Skip to main content

newt_core/agentic/
semantic.rs

1//! Semantic repo-evidence retrieval — the `semantic` context feature (Step
2//! 26.5, #582). Embedding RAG-for-code: chunk the repo, embed each chunk, and on
3//! a query retrieve the most relevant code by cosine similarity, injected at the
4//! head of the turn (gated by the `semantic` feature, like 26.3/26.4).
5//!
6//! **Step 26.5.1 — the embeddings client.** The [`Embedder`] trait is the seam
7//! every downstream step (chunker indexing, retrieval) tests against with a
8//! DETERMINISTIC mock — the real HTTP client never enters those tests, keeping
9//! the whole subsystem in the fully-mocked unit tier. The real
10//! [`EmbeddingsClient`] (Ollama `/api/embeddings`) is wiremock-tested here.
11
12use super::display::{print_tool_call, print_tool_output};
13use async_trait::async_trait;
14use std::sync::Mutex;
15use std::time::Duration;
16
17/// Turns text into an embedding vector. The mockable seam: indexing + retrieval
18/// take `&dyn Embedder`, so they unit-test against a deterministic fake with
19/// zero network. A genuine transport/backend failure is an `Err`.
20#[async_trait]
21pub trait Embedder: Send + Sync {
22    /// Embed one text into a vector.
23    async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>>;
24}
25
26/// The real [`Embedder`], protocol-aware over the two wire APIs newt speaks:
27/// Ollama `POST /api/embeddings` (`{model, prompt}` → `{embedding: [f32]}`) and
28/// OpenAI-compatible `POST /v1/embeddings` (`{model, input}` →
29/// `{data: [{embedding: [f32]}]}`, e.g. vLLM serving an embedding model).
30/// Mirrors the summarizer's HTTP discipline — a configurable timeout, optional
31/// bearer auth, and exponential-backoff retry (embedding a whole repo is many
32/// requests; transient failures recover).
33pub struct EmbeddingsClient {
34    url: String,
35    model: String,
36    kind: crate::BackendKind,
37    api_key: Option<String>,
38    timeout_secs: u64,
39    retries: u32,
40}
41
42impl EmbeddingsClient {
43    pub fn new(
44        url: impl Into<String>,
45        model: impl Into<String>,
46        kind: crate::BackendKind,
47        api_key: Option<String>,
48        timeout_secs: u64,
49        retries: u32,
50    ) -> Self {
51        Self {
52            url: url.into(),
53            model: model.into(),
54            kind,
55            api_key,
56            timeout_secs,
57            retries,
58        }
59    }
60
61    /// Embed several texts (sequential — one request each, deterministic order).
62    /// Fails fast on the first error so the index never holds a partial set.
63    pub async fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
64        let mut out = Vec::with_capacity(texts.len());
65        for t in texts {
66            out.push(self.embed(t).await?);
67        }
68        Ok(out)
69    }
70
71    /// One embeddings request (no retry — the retry loop wraps this). The path,
72    /// request shape, and response shape follow `self.kind`'s wire protocol.
73    async fn embed_once(&self, text: &str) -> anyhow::Result<Vec<f32>> {
74        let base = self.url.trim_end_matches('/');
75        let (endpoint, body) = match self.kind {
76            crate::BackendKind::Ollama => (
77                format!("{base}/api/embeddings"),
78                serde_json::json!({ "model": self.model, "prompt": text }),
79            ),
80            crate::BackendKind::Openai => (
81                format!("{base}/v1/embeddings"),
82                serde_json::json!({ "model": self.model, "input": text }),
83            ),
84            crate::BackendKind::Embedded => anyhow::bail!(
85                "the embedded backend is chat-only and does not serve embeddings; \
86                 set `embeddings_api` to an ollama/openai backend"
87            ),
88        };
89        let client = reqwest::Client::builder()
90            .timeout(Duration::from_secs(self.timeout_secs))
91            .build()?;
92        let mut req = client.post(&endpoint).json(&body);
93        if let Some(key) = &self.api_key {
94            req = req.bearer_auth(key);
95        }
96        let resp = req.send().await?;
97        if !resp.status().is_success() {
98            anyhow::bail!("embeddings endpoint {endpoint} returned {}", resp.status());
99        }
100        let json: serde_json::Value = resp.json().await?;
101        // Ollama: `{embedding: [..]}`; OpenAI: `{data: [{embedding: [..]}]}`.
102        let arr = match self.kind {
103            crate::BackendKind::Ollama => json["embedding"].as_array(),
104            crate::BackendKind::Openai => json["data"][0]["embedding"].as_array(),
105            // Unreachable: the embedded backend bails in the request match above.
106            crate::BackendKind::Embedded => None,
107        }
108        .ok_or_else(|| anyhow::anyhow!("embeddings response missing `embedding` array"))?;
109        let vec: Vec<f32> = arr
110            .iter()
111            .map(|v| v.as_f64().unwrap_or(0.0) as f32)
112            .collect();
113        if vec.is_empty() {
114            anyhow::bail!("embeddings response had an empty vector");
115        }
116        Ok(vec)
117    }
118}
119
120#[async_trait]
121impl Embedder for EmbeddingsClient {
122    async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
123        let mut last_err = None;
124        for attempt in 0..=self.retries {
125            if attempt > 0 {
126                // Exponential backoff capped at ~4s: 250ms, 500ms, 1s, …
127                let backoff = Duration::from_millis(250u64 << (attempt - 1).min(4));
128                tokio::time::sleep(backoff).await;
129            }
130            match self.embed_once(text).await {
131                Ok(v) => return Ok(v),
132                Err(e) => last_err = Some(e),
133            }
134        }
135        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("embeddings failed")))
136    }
137}
138
139// --- Step 26.5.2: code chunker (pure string → spans) ------------------------
140
141/// One indexable unit of code: a definition (with its leading doc) or, when a
142/// file has no recognized defs, a fixed line-window (Step 26.5.2).
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct CodeChunk {
145    pub file: String,
146    /// 1-based, inclusive.
147    pub start_line: usize,
148    /// 1-based, inclusive.
149    pub end_line: usize,
150    /// `function` / `struct` / … for a def, `window` for the fallback.
151    pub kind: String,
152    pub text: String,
153}
154
155/// Max chars per chunk; oversized def bodies split into line-windows.
156pub const CHUNK_MAX_CHARS: usize = 2_000;
157/// Line-window size for the no-defs fallback (and oversized splits).
158const WINDOW_LINES: usize = 40;
159
160/// Chunk `source` (the contents of `file`) into [`CodeChunk`]s (Step 26.5.2).
161/// Pure: input strings, output Vec — the caller reads files. Reuses the
162/// build-free `symbols::extract_definitions` to LOCATE defs, then slices the
163/// span between consecutive defs (incl each def's leading doc/comment). A file
164/// with no recognized defs (or an unknown language) falls back to fixed
165/// line-windows so nothing is un-indexable.
166pub fn chunk_source(file: &str, source: &str) -> Vec<CodeChunk> {
167    let lines: Vec<&str> = source.lines().collect();
168    if lines.is_empty() {
169        return Vec::new();
170    }
171    let mut defs = crate::symbols::Lang::from_path(file)
172        .map(|lang| crate::symbols::extract_definitions(source, lang))
173        .unwrap_or_default();
174    if defs.is_empty() {
175        return window_chunks(file, &lines, 1, lines.len());
176    }
177    defs.sort_by_key(|d| d.line);
178    // Each def's block starts at its line, backed up over leading doc/comments.
179    let starts: Vec<usize> = defs.iter().map(|d| block_start(&lines, d.line)).collect();
180    let mut chunks = Vec::new();
181    for (i, d) in defs.iter().enumerate() {
182        let start = starts[i];
183        let end = if i + 1 < defs.len() {
184            starts[i + 1].saturating_sub(1).max(start)
185        } else {
186            lines.len()
187        };
188        let kind = format!("{:?}", d.kind).to_lowercase();
189        let text = join_lines(&lines, start, end);
190        if text.chars().count() > CHUNK_MAX_CHARS {
191            // Oversized body → split into windows (so no chunk blows the budget).
192            chunks.extend(window_chunks(file, &lines, start, end));
193        } else {
194            chunks.push(CodeChunk {
195                file: file.to_string(),
196                start_line: start,
197                end_line: end,
198                kind,
199                text,
200            });
201        }
202    }
203    chunks
204}
205
206/// Walk up from `def_line` (1-based) over immediately-preceding comment/doc
207/// lines to find where the def's block (incl its doc) starts.
208fn block_start(lines: &[&str], def_line: usize) -> usize {
209    let is_doc = |s: &str| {
210        let t = s.trim_start();
211        t.starts_with("///")
212            || t.starts_with("//")
213            || t.starts_with('#')
214            || t.starts_with("\"\"\"")
215            || t.starts_with("/*")
216            || t.starts_with('*')
217    };
218    let mut start = def_line;
219    while start > 1 && is_doc(lines[start - 2]) {
220        start -= 1;
221    }
222    start
223}
224
225fn join_lines(lines: &[&str], start: usize, end: usize) -> String {
226    let end = end.min(lines.len());
227    if start > end {
228        return String::new();
229    }
230    lines[start - 1..end].join("\n")
231}
232
233/// Fixed line-window chunks over `[from, to]` (1-based, inclusive).
234fn window_chunks(file: &str, lines: &[&str], from: usize, to: usize) -> Vec<CodeChunk> {
235    let mut chunks = Vec::new();
236    let mut s = from;
237    while s <= to {
238        let e = (s + WINDOW_LINES - 1).min(to);
239        chunks.push(CodeChunk {
240            file: file.to_string(),
241            start_line: s,
242            end_line: e,
243            kind: "window".to_string(),
244            text: join_lines(lines, s, e),
245        });
246        s = e + 1;
247    }
248    chunks
249}
250
251// --- Step 26.5.3: in-memory vector store + cosine top-k retrieval -----------
252
253/// Whole-block char cap for the injected `<code_evidence>` (Step 26.5.3) — the
254/// budget guard, mirroring scratchpad's `STATE_TOTAL_CAP`.
255pub(crate) const CODE_EVIDENCE_CAP: usize = 6_000;
256
257/// A vector index over [`CodeChunk`]s (Step 26.5.3). `&self` interior mutability
258/// so a single shared `&dyn SemanticIndex` serves the indexing + retrieval paths.
259pub trait SemanticIndex: Send + Sync {
260    /// Add an embedded chunk to the index.
261    fn index_chunk(&self, chunk: CodeChunk, embedding: Vec<f32>);
262    /// Top-`k` chunks by cosine similarity to `query`, highest score first.
263    fn search(&self, query: &[f32], top_k: usize) -> Vec<(f32, CodeChunk)>;
264    /// Chunks held (for `/context stats`).
265    fn chunks_indexed(&self) -> u64;
266    /// Total chars of indexed chunk text (for `/context stats`).
267    fn indexed_chars(&self) -> u64;
268    /// Drop the whole index (`/new`, or a re-index).
269    fn clear(&self);
270}
271
272/// In-memory, session-scoped [`SemanticIndex`] — pure (no fs), discarded at
273/// `/new`. A flat `Vec` + brute-force cosine: simple, deterministic, and plenty
274/// for a single repo's chunks (no ANN/vector-db dependency in v1).
275#[derive(Default)]
276pub struct SessionSemanticIndex {
277    entries: Mutex<Vec<(CodeChunk, Vec<f32>)>>,
278}
279
280impl SemanticIndex for SessionSemanticIndex {
281    fn index_chunk(&self, chunk: CodeChunk, embedding: Vec<f32>) {
282        self.entries.lock().unwrap().push((chunk, embedding));
283    }
284    fn search(&self, query: &[f32], top_k: usize) -> Vec<(f32, CodeChunk)> {
285        let entries = self.entries.lock().unwrap();
286        let mut scored: Vec<(f32, CodeChunk)> = entries
287            .iter()
288            .map(|(c, e)| (cosine(query, e), c.clone()))
289            .collect();
290        // Descending by score; a stable sort keeps index order for exact ties.
291        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
292        scored.truncate(top_k);
293        scored
294    }
295    fn chunks_indexed(&self) -> u64 {
296        self.entries.lock().unwrap().len() as u64
297    }
298    fn indexed_chars(&self) -> u64 {
299        self.entries
300            .lock()
301            .unwrap()
302            .iter()
303            .map(|(c, _)| c.text.chars().count() as u64)
304            .sum()
305    }
306    fn clear(&self) {
307        self.entries.lock().unwrap().clear();
308    }
309}
310
311/// Cosine similarity. Returns 0 for a dimension mismatch, an empty vector, or a
312/// zero vector — a defensive default (orthogonal), NEVER a panic or NaN.
313pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
314    if a.is_empty() || a.len() != b.len() {
315        return 0.0;
316    }
317    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
318    let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
319    let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
320    if na == 0.0 || nb == 0.0 {
321        0.0
322    } else {
323        dot / (na * nb)
324    }
325}
326
327/// Render a `<code_evidence>` block from already-ranked `hits` (Step 26.5.3).
328/// `None` when there are no hits — the OFF/empty bit-for-bit guarantee (mirror
329/// `scratchpad::build_state_block`). Hard-capped at `total_cap` chars so
330/// retrieval can't blow the send budget.
331fn render_hits(hits: &[(f32, CodeChunk)], total_cap: usize) -> Option<String> {
332    if hits.is_empty() {
333        return None;
334    }
335    let mut body = String::from("<code_evidence>\n");
336    for (score, chunk) in hits {
337        let piece = format!(
338            "// {}:{}-{} ({}, score {score:.2})\n{}\n\n",
339            chunk.file, chunk.start_line, chunk.end_line, chunk.kind, chunk.text
340        );
341        if body.chars().count() + piece.chars().count() + "</code_evidence>".len() > total_cap {
342            body.push_str("[… more evidence omitted to fit the budget …]\n");
343            break;
344        }
345        body.push_str(&piece);
346    }
347    body.push_str("</code_evidence>");
348    Some(body)
349}
350
351/// Render the `<code_evidence>` block for a query VECTOR (Step 26.5.3): search
352/// by cosine, render the top_k. The raw-cosine path (no rerank) behind the
353/// vector-only `code_evidence_block` entry — `retrieve_evidence` is the reranked
354/// path. `None` when the index has no hits.
355pub(crate) fn build_code_evidence_block(
356    index: &dyn SemanticIndex,
357    query: &[f32],
358    top_k: usize,
359    total_cap: usize,
360) -> Option<String> {
361    render_hits(&index.search(query, top_k), total_cap)
362}
363
364/// Render the `<code_evidence>` block with the default budget cap (Step 26.5) —
365/// the TUI-facing entry called per turn. `None` when retrieval finds nothing.
366pub fn code_evidence_block(
367    index: &dyn SemanticIndex,
368    query: &[f32],
369    top_k: usize,
370) -> Option<String> {
371    build_code_evidence_block(index, query, top_k, CODE_EVIDENCE_CAP)
372}
373
374// --- Step 26.5.4: indexing + retrieval (Embedder-driven, mockable) ----------
375
376/// Index a set of `(file, source)` pairs (Step 26.5.4): chunk each file and
377/// embed each chunk via the injected [`Embedder`], populating `index`. Returns
378/// the count indexed. Best-effort — an embed failure SKIPS that chunk (the rest
379/// still index), so a flaky/absent embedder degrades to fewer-or-no results
380/// rather than aborting. fs/net-free here: the caller supplies the files and the
381/// `Embedder` is the seam (tests inject a deterministic fake).
382pub async fn index_files(
383    files: &[(String, String)],
384    embedder: &dyn Embedder,
385    index: &dyn SemanticIndex,
386    on_failure: crate::OnEmbedFailure,
387) -> usize {
388    let mut indexed = 0;
389    for (file, source) in files {
390        for chunk in chunk_source(file, source) {
391            match embedder.embed(&chunk.text).await {
392                Ok(v) => {
393                    index.index_chunk(chunk, v);
394                    indexed += 1;
395                }
396                Err(e) => match on_failure {
397                    // A structural failure (wrong endpoint / missing model) is
398                    // total, not transient — degrading per-chunk would silently
399                    // build an empty index. Stop once with an actionable error.
400                    crate::OnEmbedFailure::Disable => {
401                        tracing::error!(
402                            error = %e,
403                            file = file.as_str(),
404                            "semantic indexing disabled: embeddings failed. Configure \
405                             [context.semantic] for a working embedder: use \
406                             embeddings_endpoint/embeddings_api for an Ollama or OpenAI \
407                             embeddings service, or embeddings_api = \"embedded\" with \
408                             embedding_model_path for local in-process embeddings. Set \
409                             on_embed_failure = \"warn\" to keep trying per-chunk. Indexed \
410                             {indexed} chunk(s) before stopping."
411                        );
412                        return indexed;
413                    }
414                    crate::OnEmbedFailure::Warn => {
415                        tracing::warn!(error = %e, file = file.as_str(), "embed failed; skipping chunk");
416                    }
417                },
418            }
419        }
420    }
421    indexed
422}
423
424// --- Step 26.5.6: rerank (cheap, deterministic re-scoring) ------------------
425
426/// Over-fetch factor: retrieval pulls `top_k * RERANK_OVERFETCH` cosine
427/// candidates so the rerank can promote a slightly-lower-cosine but
428/// structurally-better chunk into the final top_k.
429const RERANK_OVERFETCH: usize = 3;
430/// A real definition outranks a raw line-window at near-equal similarity.
431const DEF_BOOST: f32 = 0.05;
432/// A chunk whose file path contains a query term is nudged up.
433const PATH_BOOST: f32 = 0.05;
434
435/// Re-score cosine `hits` with cheap, deterministic boosts (Step 26.5.6): a
436/// definition beats a raw window, and a file path matching a query term gets a
437/// nudge — then a STABLE re-sort by `(cosine + boost)` descending. The boosts
438/// are small, so they only reorder near-ties; with no boost applicable the
439/// cosine order is preserved bit-for-bit (a stable sort on already-cosine-sorted
440/// input). Pure + deterministic — no clock, no allocation beyond the term split.
441fn rerank(query: &str, hits: &mut [(f32, CodeChunk)]) {
442    let terms: Vec<String> = query
443        .split(|c: char| !c.is_alphanumeric())
444        .filter(|t| t.len() >= 3)
445        .map(str::to_lowercase)
446        .collect();
447    let boost = |chunk: &CodeChunk| -> f32 {
448        let mut b = 0.0;
449        if chunk.kind != "window" {
450            b += DEF_BOOST;
451        }
452        let file_lc = chunk.file.to_lowercase();
453        if terms.iter().any(|t| file_lc.contains(t.as_str())) {
454            b += PATH_BOOST;
455        }
456        b
457    };
458    hits.sort_by(|a, b| {
459        let sb = b.0 + boost(&b.1);
460        let sa = a.0 + boost(&a.1);
461        sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
462    });
463}
464
465/// Retrieve a reranked `<code_evidence>` block for `query` (Step 26.5.4 +
466/// 26.5.6): embed the query, over-fetch cosine candidates, rerank with the cheap
467/// structural boosts, take the top_k, render. `None` when the query can't embed,
468/// the index is empty, or nothing matches — so an absent embedding model is a
469/// silent no-op, not a turn failure.
470pub async fn retrieve_evidence(
471    query: &str,
472    embedder: &dyn Embedder,
473    index: &dyn SemanticIndex,
474    top_k: usize,
475) -> Option<String> {
476    let qv = embedder.embed(query).await.ok()?;
477    let mut hits = index.search(&qv, top_k.saturating_mul(RERANK_OVERFETCH));
478    rerank(query, &mut hits);
479    hits.truncate(top_k);
480    render_hits(&hits, CODE_EVIDENCE_CAP)
481}
482
483/// Walk `workspace` for indexable code files (Step 26.5.4) — gitignore-aware,
484/// `.rs`/`.py` only (what the chunker understands), bounded for responsiveness.
485/// Returns `(relative-path, source)` pairs. **Runtime fs glue** (NOT unit-tier:
486/// it reads the real filesystem); the pure chunk/embed/index logic it feeds is
487/// the fully-mocked part above. Reuses the `ignore` crate (newt-core's `find`
488/// tool already depends on it).
489/// Gather up to `MAX_FILES` source files whose extension is in `extensions`
490/// (each ≤ `MAX_BYTES`), as `(relative_path, contents)`.
491///
492/// The extension allow-list is a **parameter**, not a hardcoded `rs`/`py` literal
493/// (#956): the API-surface caller derives it from the *resolved language packs*
494/// (so `bash`/`c_cpp`/`go`/`java` and any drop-in pack are actually read), while
495/// the semantic embedding index passes its own narrower set to bound how many
496/// files it embeds (blast radius). An empty `extensions` gathers nothing.
497pub fn gather_code_files(workspace: &str, extensions: &[String]) -> Vec<(String, String)> {
498    const MAX_FILES: usize = 400;
499    const MAX_BYTES: u64 = 200_000;
500    let mut out = Vec::new();
501    for entry in ignore::WalkBuilder::new(workspace).build().flatten() {
502        if out.len() >= MAX_FILES {
503            break;
504        }
505        let path = entry.path();
506        let ext = path.extension().and_then(|e| e.to_str());
507        if !ext.is_some_and(|e| extensions.iter().any(|x| x == e)) {
508            continue;
509        }
510        if path.metadata().map(|m| m.len()).unwrap_or(u64::MAX) > MAX_BYTES {
511            continue;
512        }
513        if let Ok(src) = std::fs::read_to_string(path) {
514            let rel = path
515                .strip_prefix(workspace)
516                .unwrap_or(path)
517                .to_string_lossy()
518                .to_string();
519            out.push((rel, src));
520        }
521    }
522    out
523}
524
525// --- Step 26.5.5: the code_search tool (model-callable retrieval) -----------
526
527/// The semantic searcher handed to the `code_search` tool (Step 26.5.5): an
528/// embedder + the session index + the default top_k, bundled into ONE `ChatCtx`
529/// field (both members are shared refs, so this is `Copy`).
530#[derive(Clone, Copy)]
531pub struct CodeSearch<'a> {
532    pub embedder: &'a dyn Embedder,
533    pub index: &'a dyn SemanticIndex,
534    pub top_k: usize,
535}
536
537/// The `code_search` tool definition (Step 26.5.5) — advertised only when the
538/// `semantic` feature is on and an index is present.
539pub fn code_search_tool_definition() -> serde_json::Value {
540    serde_json::json!({
541        "type": "function",
542        "function": {
543            "name": "code_search",
544            "description": "Search the indexed codebase for the most relevant code by MEANING \
545                            (semantic/embedding search, not keyword) — use it to find where \
546                            something is implemented when you don't have a file path, e.g. \
547                            'where is the retry backoff computed'. Returns the top matching \
548                            code chunks with their file:line; then read_file the ones you need.",
549            "parameters": {
550                "type": "object",
551                "properties": {
552                    "query": { "type": "string", "description": "What to find, in natural language or a symbol name." }
553                },
554                "required": ["query"]
555            }
556        }
557    })
558}
559
560/// Execute a `code_search` call (Step 26.5.5): embed the query, search the
561/// index, return the matching `<code_evidence>` (or a labelled no-match).
562pub(crate) async fn execute_code_search(
563    args: &serde_json::Value,
564    search: CodeSearch<'_>,
565    color: bool,
566    tool_output_lines: usize,
567) -> String {
568    let query = args["query"].as_str().unwrap_or("").trim();
569    print_tool_call("code_search", query, color);
570    if query.is_empty() {
571        return "error: code_search requires a non-empty `query`".to_string();
572    }
573    let out = match retrieve_evidence(query, search.embedder, search.index, search.top_k).await {
574        Some(block) => block,
575        None => "no code matched — the semantic index may be empty or the embedding model \
576                 unavailable; use read_file/find if you already know the path"
577            .to_string(),
578    };
579    print_tool_output(&out, tool_output_lines, color);
580    out
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use std::sync::atomic::{AtomicUsize, Ordering};
587    use std::sync::Arc;
588    use wiremock::matchers::{method, path};
589    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
590
591    #[test]
592    fn gather_code_files_honors_the_extension_allowlist_956() {
593        // #956: the extension allow-list is a PARAMETER, not a hardcoded rs/py.
594        // A bash pack's `.sh` files (and any drop-in pack's) must be gathered when
595        // the pack's extension is requested — they were silently dropped before,
596        // starving the API-surface block for 4 of 6 built-in languages.
597        static N: AtomicUsize = AtomicUsize::new(0);
598        let dir = std::env::temp_dir().join(format!(
599            "newt-gcf-{}-{}",
600            std::process::id(),
601            N.fetch_add(1, Ordering::Relaxed)
602        ));
603        std::fs::create_dir_all(&dir).unwrap();
604        std::fs::write(dir.join("tool.sh"), "myfunc() { echo hi; }\n").unwrap();
605        std::fs::write(dir.join("main.rs"), "pub fn open() {}\n").unwrap();
606        let ws = dir.to_string_lossy().to_string();
607
608        // Only `sh` requested → the bash file is gathered; the rs file is not.
609        let sh_only = gather_code_files(&ws, &["sh".to_string()]);
610        assert!(
611            sh_only.iter().any(|(p, _)| p.ends_with("tool.sh")),
612            "the .sh file must be gathered when `sh` is requested: {sh_only:?}"
613        );
614        assert!(
615            !sh_only.iter().any(|(p, _)| p.ends_with("main.rs")),
616            "rs was not requested: {sh_only:?}"
617        );
618
619        // Multiple extensions → the multi-language surface reads both.
620        let both = gather_code_files(&ws, &["rs".to_string(), "sh".to_string()]);
621        assert!(both.iter().any(|(p, _)| p.ends_with("tool.sh")));
622        assert!(both.iter().any(|(p, _)| p.ends_with("main.rs")));
623
624        // Empty allow-list gathers nothing.
625        assert!(gather_code_files(&ws, &[]).is_empty());
626
627        let _ = std::fs::remove_dir_all(&dir);
628    }
629
630    #[tokio::test]
631    async fn embed_parses_the_vector() {
632        let server = MockServer::start().await;
633        Mock::given(method("POST"))
634            .and(path("/api/embeddings"))
635            .respond_with(
636                ResponseTemplate::new(200)
637                    .set_body_json(serde_json::json!({ "embedding": [0.1, 0.2, 0.3] })),
638            )
639            .mount(&server)
640            .await;
641        let c = EmbeddingsClient::new(
642            server.uri(),
643            "nomic-embed-text",
644            crate::BackendKind::Ollama,
645            None,
646            30,
647            0,
648        );
649        assert_eq!(c.embed("hello").await.unwrap(), vec![0.1f32, 0.2, 0.3]);
650    }
651
652    #[tokio::test]
653    async fn embed_openai_protocol_hits_v1_and_parses_data() {
654        // An OpenAI-compatible endpoint (e.g. vLLM serving an embedding model):
655        // POST /v1/embeddings with `{input}`, response `{data:[{embedding}]}`.
656        let server = MockServer::start().await;
657        Mock::given(method("POST"))
658            .and(path("/v1/embeddings"))
659            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
660                "data": [{ "embedding": [0.5, 0.6] }]
661            })))
662            .mount(&server)
663            .await;
664        let c = EmbeddingsClient::new(
665            server.uri(),
666            "bge-m3",
667            crate::BackendKind::Openai,
668            None,
669            30,
670            0,
671        );
672        assert_eq!(c.embed("hello").await.unwrap(), vec![0.5f32, 0.6]);
673        // The request body must use OpenAI's `input` field, not Ollama's
674        // `prompt` (guards against a body-shape regression the path match alone
675        // wouldn't catch).
676        let reqs = server.received_requests().await.unwrap();
677        let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
678        assert_eq!(body["input"], "hello");
679        assert!(body.get("prompt").is_none());
680    }
681
682    /// The batch must keep input order. The mock encodes each prompt's length
683    /// into the returned vector so the assertion is exact, not incidental.
684    #[tokio::test]
685    async fn embed_batch_preserves_order() {
686        struct ByLen;
687        impl Respond for ByLen {
688            fn respond(&self, req: &Request) -> ResponseTemplate {
689                let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default();
690                let n = body["prompt"].as_str().unwrap_or("").chars().count() as f64;
691                ResponseTemplate::new(200).set_body_json(serde_json::json!({ "embedding": [n] }))
692            }
693        }
694        let server = MockServer::start().await;
695        Mock::given(method("POST"))
696            .and(path("/api/embeddings"))
697            .respond_with(ByLen)
698            .mount(&server)
699            .await;
700        let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 0);
701        let out = c
702            .embed_batch(&["a".into(), "bbb".into(), "cc".into()])
703            .await
704            .unwrap();
705        assert_eq!(out, vec![vec![1.0f32], vec![3.0], vec![2.0]]);
706    }
707
708    #[tokio::test]
709    async fn embed_retries_then_succeeds() {
710        struct FailOnce(Arc<AtomicUsize>);
711        impl Respond for FailOnce {
712            fn respond(&self, _req: &Request) -> ResponseTemplate {
713                if self.0.fetch_add(1, Ordering::SeqCst) == 0 {
714                    ResponseTemplate::new(500)
715                } else {
716                    ResponseTemplate::new(200)
717                        .set_body_json(serde_json::json!({ "embedding": [1.0, 2.0] }))
718                }
719            }
720        }
721        let calls = Arc::new(AtomicUsize::new(0));
722        let server = MockServer::start().await;
723        Mock::given(method("POST"))
724            .and(path("/api/embeddings"))
725            .respond_with(FailOnce(calls.clone()))
726            .mount(&server)
727            .await;
728        let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 2);
729        assert_eq!(c.embed("x").await.unwrap(), vec![1.0f32, 2.0]);
730        assert_eq!(calls.load(Ordering::SeqCst), 2, "one failure, one success");
731    }
732
733    #[tokio::test]
734    async fn embed_gives_up_after_retries() {
735        let server = MockServer::start().await;
736        Mock::given(method("POST"))
737            .and(path("/api/embeddings"))
738            .respond_with(ResponseTemplate::new(500))
739            .mount(&server)
740            .await;
741        let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 1);
742        let err = c.embed("x").await.unwrap_err();
743        assert!(err.to_string().contains("returned 500"), "{err}");
744    }
745
746    #[tokio::test]
747    async fn embed_rejects_missing_and_empty_vector() {
748        let server = MockServer::start().await;
749        // 200 but no `embedding` key → error, not a silent empty vector.
750        Mock::given(method("POST"))
751            .and(path("/api/embeddings"))
752            .respond_with(
753                ResponseTemplate::new(200).set_body_json(serde_json::json!({ "nope": 1 })),
754            )
755            .mount(&server)
756            .await;
757        let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 0);
758        assert!(c
759            .embed("x")
760            .await
761            .unwrap_err()
762            .to_string()
763            .contains("missing `embedding`"));
764    }
765
766    /// retries=0 (the "disable retries" config boundary) must make EXACTLY one
767    /// attempt — pins the `0..=retries` loop bound against an off-by-one mutation.
768    #[tokio::test]
769    async fn embed_retries_zero_makes_exactly_one_call() {
770        struct Count(Arc<AtomicUsize>);
771        impl Respond for Count {
772            fn respond(&self, _req: &Request) -> ResponseTemplate {
773                self.0.fetch_add(1, Ordering::SeqCst);
774                ResponseTemplate::new(500)
775            }
776        }
777        let calls = Arc::new(AtomicUsize::new(0));
778        let server = MockServer::start().await;
779        Mock::given(method("POST"))
780            .and(path("/api/embeddings"))
781            .respond_with(Count(calls.clone()))
782            .mount(&server)
783            .await;
784        let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 0);
785        assert!(c.embed("x").await.is_err());
786        assert_eq!(
787            calls.load(Ordering::SeqCst),
788            1,
789            "retries=0 → exactly one attempt"
790        );
791    }
792
793    /// The bearer-auth branch must actually emit `Authorization: Bearer <key>`
794    /// when an api_key is set (matches the codebase's auth-test convention).
795    #[tokio::test]
796    async fn embed_sends_bearer_when_api_key_set() {
797        use wiremock::matchers::header;
798        let server = MockServer::start().await;
799        Mock::given(method("POST"))
800            .and(path("/api/embeddings"))
801            .and(header("authorization", "Bearer sk-test"))
802            .respond_with(
803                ResponseTemplate::new(200).set_body_json(serde_json::json!({ "embedding": [1.0] })),
804            )
805            .mount(&server)
806            .await;
807        let c = EmbeddingsClient::new(
808            server.uri(),
809            "m",
810            crate::BackendKind::Ollama,
811            Some("sk-test".into()),
812            30,
813            0,
814        );
815        // The request only matches (and 200s) if the Authorization header is sent.
816        assert_eq!(c.embed("x").await.unwrap(), vec![1.0f32]);
817    }
818
819    // --- 26.5.2 chunker (pure, &str fixtures — no fs/net) -------------------
820
821    #[test]
822    fn chunk_rust_captures_defs_with_leading_doc() {
823        let src = "\
824//! file header
825use std::fmt;
826
827/// Adds two numbers.
828fn add(a: i32, b: i32) -> i32 {
829    a + b
830}
831
832/// A point.
833struct Point {
834    x: i32,
835}
836";
837        let chunks = chunk_source("src/lib.rs", src);
838        assert_eq!(chunks.len(), 2, "one chunk per def: {chunks:#?}");
839        // First chunk: the fn, starting at its leading doc line (line 4).
840        assert_eq!(chunks[0].kind, "function");
841        assert_eq!(chunks[0].start_line, 4);
842        assert!(chunks[0].text.contains("/// Adds two numbers."));
843        assert!(chunks[0].text.contains("fn add"));
844        assert!(
845            !chunks[0].text.contains("struct Point"),
846            "def boundary respected"
847        );
848        // Second chunk: the struct, with its doc, to EOF.
849        assert_eq!(chunks[1].kind, "struct");
850        assert!(chunks[1].text.contains("/// A point.") && chunks[1].text.contains("struct Point"));
851    }
852
853    #[test]
854    fn chunk_python_def_and_class() {
855        let src = "\
856import os
857
858def greet(name):
859    return f\"hi {name}\"
860
861class Dog:
862    def bark(self):
863        return \"woof\"
864";
865        let chunks = chunk_source("app.py", src);
866        assert!(chunks
867            .iter()
868            .any(|c| c.kind == "function" && c.text.contains("def greet")));
869        assert!(chunks
870            .iter()
871            .any(|c| c.kind == "class" && c.text.contains("class Dog")));
872    }
873
874    #[test]
875    fn chunk_unknown_language_falls_back_to_windows() {
876        let src = (1..=90)
877            .map(|i| format!("line {i}"))
878            .collect::<Vec<_>>()
879            .join("\n");
880        let chunks = chunk_source("notes.txt", &src); // unknown lang → window fallback
881        assert!(chunks.iter().all(|c| c.kind == "window"));
882        assert!(
883            chunks.len() >= 2,
884            "90 lines / 40 ⇒ 3 windows: {}",
885            chunks.len()
886        );
887        // windows are contiguous and cover the whole file
888        assert_eq!(chunks.first().unwrap().start_line, 1);
889        assert_eq!(chunks.last().unwrap().end_line, 90);
890    }
891
892    #[test]
893    fn chunk_oversized_body_splits_into_windows() {
894        // A fn whose body exceeds CHUNK_MAX_CHARS must split (no monster chunk).
895        let body = (0..400)
896            .map(|i| format!("    let v{i} = {i};"))
897            .collect::<Vec<_>>()
898            .join("\n");
899        let src = format!("fn big() {{\n{body}\n}}\n");
900        let chunks = chunk_source("src/big.rs", &src);
901        assert!(chunks.len() > 1, "oversized body split: {}", chunks.len());
902        assert!(
903            chunks
904                .iter()
905                .all(|c| c.text.chars().count() <= CHUNK_MAX_CHARS + 200),
906            "every chunk stays bounded"
907        );
908    }
909
910    #[test]
911    fn chunk_empty_source_is_empty() {
912        assert!(chunk_source("src/lib.rs", "").is_empty());
913    }
914
915    // --- 26.5.3 vector store + cosine + retrieval (literal vectors) ---------
916
917    fn chunk(file: &str, text: &str) -> CodeChunk {
918        CodeChunk {
919            file: file.into(),
920            start_line: 1,
921            end_line: 1,
922            kind: "function".into(),
923            text: text.into(),
924        }
925    }
926
927    #[test]
928    fn cosine_known_vectors() {
929        assert!(
930            (cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-6,
931            "identical"
932        );
933        assert!(
934            cosine(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-6,
935            "orthogonal → 0"
936        );
937        assert!(
938            (cosine(&[1.0, 0.0], &[-1.0, 0.0]) + 1.0).abs() < 1e-6,
939            "opposite → -1"
940        );
941        // defensive: dim mismatch / empty / zero → 0, never NaN or panic
942        assert_eq!(cosine(&[1.0, 2.0, 3.0], &[1.0, 2.0]), 0.0);
943        assert_eq!(cosine(&[], &[]), 0.0);
944        assert_eq!(cosine(&[0.0, 0.0], &[1.0, 1.0]), 0.0);
945    }
946
947    #[test]
948    fn index_search_orders_by_cosine_and_truncates() {
949        let idx = SessionSemanticIndex::default();
950        idx.index_chunk(chunk("a.rs", "alpha"), vec![1.0, 0.0]);
951        idx.index_chunk(chunk("b.rs", "beta"), vec![0.0, 1.0]);
952        idx.index_chunk(chunk("c.rs", "gamma"), vec![0.9, 0.1]);
953        assert_eq!(idx.chunks_indexed(), 3);
954        assert_eq!(idx.indexed_chars(), 5 + 4 + 5);
955        // query aligned with x-axis → a.rs (1,0) best, then c.rs (0.9,0.1), then b.rs
956        let hits = idx.search(&[1.0, 0.0], 2);
957        assert_eq!(hits.len(), 2, "top_k truncates");
958        assert_eq!(hits[0].1.file, "a.rs");
959        assert_eq!(hits[1].1.file, "c.rs");
960        assert!(hits[0].0 > hits[1].0, "descending score");
961        // top_k larger than the index → all; empty query vec → all score 0
962        assert_eq!(idx.search(&[1.0, 0.0], 99).len(), 3);
963        // empty index → no hits
964        let empty = SessionSemanticIndex::default();
965        assert!(empty.search(&[1.0, 0.0], 5).is_empty());
966        // clear empties it
967        idx.clear();
968        assert_eq!(idx.chunks_indexed(), 0);
969    }
970
971    #[test]
972    fn code_evidence_block_none_when_empty_renders_and_caps() {
973        let idx = SessionSemanticIndex::default();
974        // empty index → None (OFF/empty bit-for-bit)
975        assert_eq!(build_code_evidence_block(&idx, &[1.0, 0.0], 5, 6_000), None);
976        idx.index_chunk(chunk("src/lib.rs", "fn add() {}"), vec![1.0, 0.0]);
977        let block = build_code_evidence_block(&idx, &[1.0, 0.0], 5, 6_000).unwrap();
978        assert!(block.starts_with("<code_evidence>\n") && block.ends_with("</code_evidence>"));
979        assert!(
980            block.contains("src/lib.rs:1-1"),
981            "file:line header: {block}"
982        );
983        assert!(block.contains("fn add() {}"));
984        // total cap truncates: tiny cap → the omitted marker, bounded length
985        let capped = build_code_evidence_block(&idx, &[1.0, 0.0], 5, 30).unwrap();
986        assert!(capped.contains("omitted to fit"), "{capped}");
987    }
988
989    // --- 26.5.4 index_files + retrieve_evidence (mock Embedder, no fs/net) --
990
991    /// Deterministic fake: embed text → [count('a'), count('b'), len]. Lets the
992    /// retrieval assertions be exact without any network.
993    struct MockEmbedder;
994    #[async_trait]
995    impl Embedder for MockEmbedder {
996        async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
997            Ok(vec![
998                text.matches('a').count() as f32,
999                text.matches('b').count() as f32,
1000                text.chars().count() as f32,
1001            ])
1002        }
1003    }
1004
1005    /// An embedder that always fails — stands in for an unpulled model.
1006    struct FailEmbedder;
1007    #[async_trait]
1008    impl Embedder for FailEmbedder {
1009        async fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
1010            anyhow::bail!("embeddings model not available")
1011        }
1012    }
1013
1014    /// Always-failing embedder that counts attempts — distinguishes the
1015    /// `Disable` (stop after one) and `Warn` (try every chunk) policies.
1016    struct CountingFailEmbedder(Arc<AtomicUsize>);
1017    #[async_trait]
1018    impl Embedder for CountingFailEmbedder {
1019        async fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
1020            self.0.fetch_add(1, Ordering::SeqCst);
1021            anyhow::bail!("embeddings endpoint returned 404")
1022        }
1023    }
1024
1025    #[tokio::test]
1026    async fn index_files_disable_stops_on_first_failure() {
1027        // Two chunks; a structural failure under Disable must stop after the
1028        // FIRST embed attempt (not spam one per chunk) and index nothing.
1029        let files = vec![("a.rs".to_string(), "fn add() {}\nfn sub() {}".to_string())];
1030        let idx = SessionSemanticIndex::default();
1031        let calls = Arc::new(AtomicUsize::new(0));
1032        let n = index_files(
1033            &files,
1034            &CountingFailEmbedder(calls.clone()),
1035            &idx,
1036            crate::OnEmbedFailure::Disable,
1037        )
1038        .await;
1039        assert_eq!(n, 0);
1040        assert_eq!(idx.chunks_indexed(), 0);
1041        assert_eq!(
1042            calls.load(Ordering::SeqCst),
1043            1,
1044            "Disable must short-circuit after the first failure"
1045        );
1046
1047        // Warn, by contrast, attempts every chunk (>= 2 here).
1048        let calls2 = Arc::new(AtomicUsize::new(0));
1049        index_files(
1050            &files,
1051            &CountingFailEmbedder(calls2.clone()),
1052            &SessionSemanticIndex::default(),
1053            crate::OnEmbedFailure::Warn,
1054        )
1055        .await;
1056        assert!(
1057            calls2.load(Ordering::SeqCst) >= 2,
1058            "Warn keeps trying per-chunk, got {}",
1059            calls2.load(Ordering::SeqCst)
1060        );
1061    }
1062
1063    #[tokio::test]
1064    async fn index_files_chunks_embeds_and_skips_failures() {
1065        let files = vec![("a.rs".to_string(), "fn add() {}\nfn sub() {}".to_string())];
1066        // happy path: two fns chunked + embedded
1067        let idx = SessionSemanticIndex::default();
1068        let n = index_files(&files, &MockEmbedder, &idx, crate::OnEmbedFailure::Disable).await;
1069        assert_eq!(n, idx.chunks_indexed() as usize);
1070        assert!(n >= 2, "two fns indexed, got {n}");
1071        // Warn policy: all embeds fail → nothing indexed, no panic (it keeps
1072        // going per-chunk, the historical degrade).
1073        let empty = SessionSemanticIndex::default();
1074        assert_eq!(
1075            index_files(&files, &FailEmbedder, &empty, crate::OnEmbedFailure::Warn).await,
1076            0
1077        );
1078        assert_eq!(empty.chunks_indexed(), 0);
1079    }
1080
1081    #[tokio::test]
1082    async fn retrieve_evidence_embeds_query_ranks_and_degrades() {
1083        let files = vec![("a.rs".to_string(), "fn aaa() {}\nfn bbb() {}".to_string())];
1084        let idx = SessionSemanticIndex::default();
1085        index_files(&files, &MockEmbedder, &idx, crate::OnEmbedFailure::Disable).await;
1086        // query rich in 'a' → the aaa chunk outranks bbb (cosine on the a-axis)
1087        let block = retrieve_evidence("aaaaa", &MockEmbedder, &idx, 1)
1088            .await
1089            .unwrap();
1090        assert!(
1091            block.contains("<code_evidence>") && block.contains("aaa"),
1092            "{block}"
1093        );
1094        // a failed query embed → None (absent model = silent no-op, not a crash)
1095        assert!(retrieve_evidence("x", &FailEmbedder, &idx, 1)
1096            .await
1097            .is_none());
1098        // empty index → None
1099        let empty = SessionSemanticIndex::default();
1100        assert!(retrieve_evidence("aaa", &MockEmbedder, &empty, 1)
1101            .await
1102            .is_none());
1103    }
1104
1105    #[tokio::test]
1106    async fn code_search_tool_embeds_searches_and_coaches() {
1107        let files = vec![("a.rs".to_string(), "fn aaa() {}\nfn bbb() {}".to_string())];
1108        let idx = SessionSemanticIndex::default();
1109        index_files(&files, &MockEmbedder, &idx, crate::OnEmbedFailure::Disable).await;
1110        let search = CodeSearch {
1111            embedder: &MockEmbedder,
1112            index: &idx,
1113            top_k: 1,
1114        };
1115        // a query → the matching <code_evidence>
1116        let out =
1117            execute_code_search(&serde_json::json!({"query": "aaaaa"}), search, false, 20).await;
1118        assert!(
1119            out.contains("<code_evidence>") && out.contains("aaa"),
1120            "{out}"
1121        );
1122        // empty query → coaching, not a search
1123        assert!(
1124            execute_code_search(&serde_json::json!({}), search, false, 20)
1125                .await
1126                .starts_with("error:")
1127        );
1128        // empty index → a labelled no-match (never an empty string)
1129        let empty = SessionSemanticIndex::default();
1130        let s2 = CodeSearch {
1131            embedder: &MockEmbedder,
1132            index: &empty,
1133            top_k: 1,
1134        };
1135        assert!(
1136            execute_code_search(&serde_json::json!({"query": "x"}), s2, false, 20)
1137                .await
1138                .contains("no code matched")
1139        );
1140    }
1141
1142    #[test]
1143    fn code_search_tool_definition_shape() {
1144        let def = code_search_tool_definition();
1145        assert_eq!(def["function"]["name"], "code_search");
1146        assert!(def["function"]["parameters"]["properties"]["query"].is_object());
1147    }
1148
1149    #[test]
1150    fn rerank_boosts_defs_and_paths_and_stays_stable() {
1151        let c = |file: &str, kind: &str| CodeChunk {
1152            file: file.to_string(),
1153            start_line: 1,
1154            end_line: 2,
1155            kind: kind.to_string(),
1156            text: "x".to_string(),
1157        };
1158        // a real def beats a raw window at EQUAL cosine
1159        let mut hits = vec![(0.5, c("a.rs", "window")), (0.5, c("b.rs", "function"))];
1160        rerank("anything", &mut hits);
1161        assert_eq!(hits[0].1.kind, "function", "def promoted over window");
1162        // a file-path term match (+0.05) overtakes a 0.02 cosine gap
1163        let mut hits = vec![
1164            (0.50, c("other.rs", "window")),
1165            (0.48, c("retry.rs", "window")),
1166        ];
1167        rerank("where is retry handled", &mut hits);
1168        assert_eq!(hits[0].1.file, "retry.rs", "path-term match promoted");
1169        // a LARGE cosine gap is NOT overridden by the small boost
1170        let mut hits = vec![(0.90, c("x.rs", "window")), (0.50, c("y.rs", "function"))];
1171        rerank("anything", &mut hits);
1172        assert_eq!(
1173            hits[0].1.file, "x.rs",
1174            "strong cosine wins over a small boost"
1175        );
1176        // no applicable boost → a cosine-sorted input is preserved bit-for-bit
1177        let mut hits = vec![
1178            (0.9, c("a.rs", "window")),
1179            (0.6, c("b.rs", "window")),
1180            (0.4, c("c.rs", "window")),
1181        ];
1182        let before = hits.clone();
1183        rerank("zz", &mut hits); // "zz" < 3 chars → no terms → no boost
1184        assert_eq!(hits, before, "no boost → cosine order unchanged");
1185        // stable on ties: equal final score keeps input order
1186        let mut hits = vec![
1187            (0.5, c("first.rs", "window")),
1188            (0.5, c("second.rs", "window")),
1189        ];
1190        rerank("zz", &mut hits);
1191        assert_eq!(hits[0].1.file, "first.rs", "stable: ties keep input order");
1192    }
1193}