Skip to main content

lean_ctx/tools/
ctx_explore.rs

1//! `ctx_explore` — FastContext-style bounded, deterministic repo exploration.
2//!
3//! Where [`crate::tools::ctx_compose`] is a single-shot composer that returns
4//! prose plus *inlined symbol bodies*, `ctx_explore` runs a **bounded multi-turn
5//! loop** and returns compact `path:start-end` **citations** (the FastContext
6//! idea, arXiv 2606.14066): the calling agent gets a map of *where* the answer
7//! lives at a fraction of the tokens, then reads only what it needs.
8//!
9//! ## Loop
10//! 1. Query understanding — `parse_task_hints` → keywords + path hints.
11//! 2. Lexical anchor — one broad BM25 search over the resident index.
12//! 3. Structural expansion — bounded BFS over the **static** import/call graph,
13//!    grounded in the lexical hit set (only files that actually match the query
14//!    are followed). A turn that discovers no new files stops the loop early
15//!    (coverage saturation).
16//! 4. Symbol channel — exact AST definitions for each keyword (`find_symbols`).
17//! 5. Selection — submodular `greedy_max_coverage` picks the minimal,
18//!    non-redundant citation set that covers the query terms under a token budget.
19//!
20//! ## Determinism (#498)
21//! The output is a pure function of (repo content, query, options). Only
22//! side-effect-free paths are used: the BM25 index and the **static** graph.
23//! It never writes session state and never records Hebbian co-access
24//! (`cooccurrence::record_access`) — those adaptive signals would make call N+1
25//! differ from call N and are deliberately excluded from the byte-stable block.
26
27use std::collections::{BTreeMap, BTreeSet, HashSet};
28use std::path::Path;
29
30use crate::core::bm25_index::{BM25Index, ChunkKind, SearchResult};
31use crate::core::context_packing::{CoverageItem, greedy_max_coverage};
32use crate::core::graph_provider;
33use crate::core::task_relevance::parse_task_hints;
34use crate::core::tokens::count_tokens;
35use crate::tools::CrpMode;
36
37const DEFAULT_MAX_TURNS: usize = 3;
38const DEFAULT_PER_TURN_K: usize = 8;
39const DEFAULT_BUDGET_TOKENS: usize = 1200;
40/// Hard caps keep the loop bounded regardless of repo size / query breadth.
41const MAX_HITS: usize = 100;
42const MAX_CANDIDATES: usize = 60;
43const MAX_CITATIONS: usize = 15;
44const MAX_KEYWORDS: usize = 6;
45const MAX_SYMS_PER_KW: usize = 3;
46
47fn env_usize(key: &str, default: usize) -> usize {
48    std::env::var(key)
49        .ok()
50        .and_then(|v| v.parse::<usize>().ok())
51        .filter(|&v| v > 0)
52        .unwrap_or(default)
53}
54
55/// Caller-facing knobs. `max_turns` is clamped to a sane range; `citation_only`
56/// mirrors FastContext's terse mode (emit only the `<final_answer>` block).
57#[derive(Debug, Clone)]
58pub struct ExploreOptions {
59    pub max_turns: usize,
60    pub citation_only: bool,
61}
62
63impl ExploreOptions {
64    pub fn new(max_turns: Option<usize>, citation_only: bool) -> Self {
65        let mt = max_turns
66            .unwrap_or_else(|| env_usize("LEAN_CTX_EXPLORE_MAX_TURNS", DEFAULT_MAX_TURNS))
67            .clamp(1, 8);
68        Self {
69            max_turns: mt,
70            citation_only,
71        }
72    }
73}
74
75impl Default for ExploreOptions {
76    fn default() -> Self {
77        Self::new(None, false)
78    }
79}
80
81/// A single cited source span.
82#[derive(Debug, Clone)]
83pub struct Citation {
84    pub file: String,
85    pub start: usize,
86    pub end: usize,
87    pub label: String,
88}
89
90/// Result of an exploration run.
91#[derive(Debug, Clone)]
92pub struct ExploreOutcome {
93    pub text: String,
94    pub tokens: usize,
95    pub citations: Vec<Citation>,
96}
97
98/// Internal candidate span carrying selection metadata.
99#[derive(Debug, Clone)]
100struct Candidate {
101    file: String,
102    start: usize,
103    end: usize,
104    label: String,
105    score: f64,
106    cost: usize,
107    terms: HashSet<String>,
108}
109
110/// Map a kind string (BM25 `ChunkKind` debug or graph kind) to a short tag.
111fn short_kind(kind: &str) -> &str {
112    match kind.to_ascii_lowercase().as_str() {
113        "function" | "fn" | "method" => "fn",
114        "struct" => "struct",
115        "impl" => "impl",
116        "module" | "mod" => "mod",
117        "class" => "class",
118        "trait" => "trait",
119        "enum" => "enum",
120        "issue" => "issue",
121        "pullrequest" => "pr",
122        other if !other.is_empty() => "sym",
123        _ => "",
124    }
125}
126
127fn chunk_kind_tag(kind: &ChunkKind) -> &'static str {
128    match kind {
129        ChunkKind::Function | ChunkKind::Method => "fn",
130        ChunkKind::Struct => "struct",
131        ChunkKind::Impl => "impl",
132        ChunkKind::Module => "mod",
133        ChunkKind::Class => "class",
134        ChunkKind::Issue => "issue",
135        ChunkKind::PullRequest => "pr",
136        _ => "",
137    }
138}
139
140/// Repo-relative, forward-slash path for stable citations (#324).
141fn rel_path(file: &str, root: &str) -> String {
142    let stripped = file
143        .strip_prefix(root)
144        .map_or(file, |s| s.trim_start_matches(['/', '\\']));
145    crate::core::protocol::display_path(stripped)
146}
147
148/// Distinct, lowercased query terms used for coverage selection.
149fn query_terms(keywords: &[String]) -> Vec<String> {
150    let mut seen = HashSet::new();
151    let mut out = Vec::new();
152    for k in keywords {
153        let lk = k.to_ascii_lowercase();
154        if lk.len() >= 3 && seen.insert(lk.clone()) {
155            out.push(lk);
156            if out.len() >= MAX_KEYWORDS {
157                break;
158            }
159        }
160    }
161    out
162}
163
164/// Which query terms a piece of text covers (case-insensitive substring).
165fn covered_terms(text: &str, terms: &[String]) -> HashSet<String> {
166    let lower = text.to_ascii_lowercase();
167    terms
168        .iter()
169        .filter(|t| lower.contains(t.as_str()))
170        .cloned()
171        .collect()
172}
173
174/// File-level adjacency from the **static** import/call graph (bidirectional),
175/// keyed by repo-relative path. Empty when no graph is available.
176fn build_adjacency(project_root: &str) -> BTreeMap<String, BTreeSet<String>> {
177    let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
178    if let Some(open) = graph_provider::open_or_build(project_root) {
179        for e in open.provider.edges() {
180            let from = rel_path(&e.from, project_root);
181            let to = rel_path(&e.to, project_root);
182            if from == to {
183                continue;
184            }
185            adj.entry(from.clone()).or_default().insert(to.clone());
186            adj.entry(to).or_default().insert(from);
187        }
188    }
189    adj
190}
191
192/// Symbol candidates: exact AST definitions for the keywords (deterministic).
193fn symbol_candidates(project_root: &str, keywords: &[String], terms: &[String]) -> Vec<Candidate> {
194    let Some(open) = graph_provider::open_or_build(project_root) else {
195        return Vec::new();
196    };
197    let gp = &open.provider;
198    let mut out: Vec<Candidate> = Vec::new();
199    for kw in keywords.iter().take(MAX_KEYWORDS) {
200        let mut syms = gp.find_symbols(kw, None, None);
201        // Deterministic, exported-first ordering, then a small cap per keyword.
202        syms.sort_by(|a, b| {
203            b.is_exported
204                .cmp(&a.is_exported)
205                .then_with(|| a.file.cmp(&b.file))
206                .then_with(|| a.start_line.cmp(&b.start_line))
207        });
208        for sym in syms.into_iter().take(MAX_SYMS_PER_KW) {
209            let file = rel_path(&sym.file, project_root);
210            let label = format!("{} ({})", sym.name, short_kind(&sym.kind));
211            let text = format!("{} {}", sym.name, sym.kind);
212            let mut covered = covered_terms(&text, terms);
213            covered.insert(kw.to_ascii_lowercase());
214            out.push(Candidate {
215                file,
216                start: sym.start_line,
217                end: sym.end_line.max(sym.start_line),
218                label,
219                score: 0.0,
220                cost: 1,
221                terms: covered,
222            });
223        }
224    }
225    out
226}
227
228/// Convert a BM25 hit into a candidate.
229fn candidate_from_hit(hit: &SearchResult, project_root: &str, terms: &[String]) -> Candidate {
230    let file = rel_path(&hit.file_path, project_root);
231    let tag = chunk_kind_tag(&hit.kind);
232    let label = if hit.symbol_name.is_empty() {
233        if tag.is_empty() {
234            String::new()
235        } else {
236            format!("({tag})")
237        }
238    } else if tag.is_empty() {
239        hit.symbol_name.clone()
240    } else {
241        format!("{} ({})", hit.symbol_name, tag)
242    };
243    let terms_covered = covered_terms(&format!("{} {}", hit.symbol_name, hit.snippet), terms);
244    Candidate {
245        file,
246        start: hit.start_line,
247        end: hit.end_line.max(hit.start_line),
248        label,
249        score: hit.score,
250        cost: count_tokens(&hit.snippet).max(1),
251        terms: terms_covered,
252    }
253}
254
255/// Bounded BFS over the static graph, grounded in the lexical hit set. Returns
256/// the set of discovered (query-relevant) files and the number of turns run.
257fn expand_frontier(
258    seed_files: &[String],
259    relevant: &BTreeSet<String>,
260    adjacency: &BTreeMap<String, BTreeSet<String>>,
261    max_turns: usize,
262) -> (BTreeSet<String>, usize) {
263    let mut discovered: BTreeSet<String> = seed_files.iter().cloned().collect();
264    let mut frontier: Vec<String> = seed_files.to_vec();
265    let mut turns = 1usize;
266
267    while turns < max_turns && !frontier.is_empty() {
268        let mut next: BTreeSet<String> = BTreeSet::new();
269        for f in &frontier {
270            if let Some(nbrs) = adjacency.get(f) {
271                for nbr in nbrs {
272                    if !discovered.contains(nbr) && relevant.contains(nbr) {
273                        next.insert(nbr.clone());
274                    }
275                }
276            }
277        }
278        if next.is_empty() {
279            break; // coverage saturation — additional turns add nothing
280        }
281        for n in &next {
282            discovered.insert(n.clone());
283        }
284        frontier = next.into_iter().collect();
285        turns += 1;
286    }
287    (discovered, turns)
288}
289
290/// Select the citation set: coverage-first (submodular), then score-fill, under
291/// a token budget. Falls back to score order when the query has no usable terms.
292fn select_citations(mut candidates: Vec<Candidate>, budget: usize) -> Vec<Candidate> {
293    if candidates.is_empty() {
294        return Vec::new();
295    }
296    // Deterministic candidate order: (file, start, end). greedy breaks gain/cost
297    // ties by earliest index, so a stable order ⇒ stable selection.
298    candidates.sort_by(|a, b| {
299        a.file
300            .cmp(&b.file)
301            .then_with(|| a.start.cmp(&b.start))
302            .then_with(|| a.end.cmp(&b.end))
303    });
304    candidates.truncate(MAX_CANDIDATES);
305
306    let any_terms = candidates.iter().any(|c| !c.terms.is_empty());
307    let mut chosen: Vec<usize> = if any_terms {
308        let items: Vec<CoverageItem> = candidates
309            .iter()
310            .map(|c| CoverageItem {
311                terms: c.terms.clone(),
312                cost: c.cost,
313            })
314            .collect();
315        greedy_max_coverage(&items, budget, |_| 1.0)
316    } else {
317        Vec::new()
318    };
319
320    let mut spent: usize = chosen.iter().map(|&i| candidates[i].cost).sum();
321    let in_chosen: HashSet<usize> = chosen.iter().copied().collect();
322
323    // Score-fill the remaining budget with the most relevant uncovered spans.
324    let mut by_score: Vec<usize> = (0..candidates.len())
325        .filter(|i| !in_chosen.contains(i))
326        .collect();
327    by_score.sort_by(|&a, &b| {
328        candidates[b]
329            .score
330            .partial_cmp(&candidates[a].score)
331            .unwrap_or(std::cmp::Ordering::Equal)
332            .then_with(|| candidates[a].file.cmp(&candidates[b].file))
333            .then_with(|| candidates[a].start.cmp(&candidates[b].start))
334    });
335    for idx in by_score {
336        if chosen.len() >= MAX_CITATIONS {
337            break;
338        }
339        let cost = candidates[idx].cost;
340        if spent + cost <= budget {
341            chosen.push(idx);
342            spent += cost;
343        }
344    }
345
346    // Emit in stable (file, start) order for a readable, byte-stable block.
347    chosen.sort_by(|&a, &b| {
348        candidates[a]
349            .file
350            .cmp(&candidates[b].file)
351            .then_with(|| candidates[a].start.cmp(&candidates[b].start))
352    });
353    chosen.into_iter().map(|i| candidates[i].clone()).collect()
354}
355
356/// Render the final answer: a `<final_answer>` block of `path:start-end label`,
357/// optionally preceded by a short, deterministic summary.
358fn render(
359    query: &str,
360    keywords: &[String],
361    turns: usize,
362    files_examined: usize,
363    citations: &[Citation],
364    crp_mode: CrpMode,
365    citation_only: bool,
366) -> String {
367    let mut block = String::from("<final_answer>\n");
368    for c in citations {
369        if c.label.is_empty() {
370            block.push_str(&format!("{}:{}-{}\n", c.file, c.start, c.end));
371        } else {
372            block.push_str(&format!("{}:{}-{}  {}\n", c.file, c.start, c.end, c.label));
373        }
374    }
375    block.push_str("</final_answer>\n");
376
377    if citation_only {
378        return block;
379    }
380
381    let mut out = String::new();
382    if crp_mode.is_tdd() {
383        out.push_str(&format!(
384            "explore({query}) → {} citations\n\n",
385            citations.len()
386        ));
387    } else {
388        out.push_str(&format!("EXPLORE: {query}\n"));
389        if keywords.is_empty() {
390            out.push_str("keywords: (none extracted)\n");
391        } else {
392            out.push_str(&format!("keywords: {}\n", keywords.join(", ")));
393        }
394        out.push_str(&format!(
395            "turns: {turns}  files_examined: {files_examined}  citations: {}\n\n",
396            citations.len()
397        ));
398    }
399    out.push_str(&block);
400    out
401}
402
403/// Run a bounded, deterministic exploration for `query`.
404pub fn handle(
405    query: &str,
406    project_root: &str,
407    crp_mode: CrpMode,
408    opts: &ExploreOptions,
409) -> ExploreOutcome {
410    let query = query.trim();
411    if query.is_empty() {
412        return ExploreOutcome {
413            text: "ERROR: query is required".to_string(),
414            tokens: 0,
415            citations: Vec::new(),
416        };
417    }
418
419    let (_hint_files, keywords) = parse_task_hints(query);
420    let terms = query_terms(&keywords);
421
422    // Lexical anchor: one broad BM25 search over the resident index.
423    let root = Path::new(project_root);
424    let index = BM25Index::load_or_build(root);
425    let hits: Vec<SearchResult> = if index.doc_count == 0 {
426        Vec::new()
427    } else {
428        index.search(query, MAX_HITS)
429    };
430
431    // Best (highest-ranked) hit per file, in score order.
432    let mut best_hit_for_file: BTreeMap<String, Candidate> = BTreeMap::new();
433    let mut seed_order: Vec<String> = Vec::new();
434    for hit in &hits {
435        let cand = candidate_from_hit(hit, project_root, &terms);
436        if let std::collections::btree_map::Entry::Vacant(slot) =
437            best_hit_for_file.entry(cand.file.clone())
438        {
439            seed_order.push(cand.file.clone());
440            slot.insert(cand);
441        }
442    }
443    let relevant: BTreeSet<String> = best_hit_for_file.keys().cloned().collect();
444
445    // Turn 1 frontier: the top-k distinct files by lexical relevance.
446    let per_turn_k = env_usize("LEAN_CTX_EXPLORE_K", DEFAULT_PER_TURN_K);
447    let seeds: Vec<String> = seed_order.iter().take(per_turn_k).cloned().collect();
448
449    // Bounded BFS over the static graph, grounded in the lexical hit set.
450    let adjacency = build_adjacency(project_root);
451    let (discovered, turns) = expand_frontier(&seeds, &relevant, &adjacency, opts.max_turns);
452
453    // Candidates: best hit per discovered file + exact symbol definitions.
454    let mut by_loc: BTreeMap<(String, usize, usize), Candidate> = BTreeMap::new();
455    let mut insert = |c: Candidate| {
456        let key = (c.file.clone(), c.start, c.end);
457        by_loc
458            .entry(key)
459            .and_modify(|e| {
460                if c.score > e.score {
461                    e.score = c.score;
462                }
463                if e.label.is_empty() && !c.label.is_empty() {
464                    e.label.clone_from(&c.label);
465                }
466                for t in &c.terms {
467                    e.terms.insert(t.clone());
468                }
469            })
470            .or_insert(c);
471    };
472    for file in &discovered {
473        if let Some(c) = best_hit_for_file.get(file) {
474            insert(c.clone());
475        }
476    }
477    for c in symbol_candidates(project_root, &keywords, &terms) {
478        insert(c);
479    }
480
481    let files_examined = discovered.len();
482    let candidates: Vec<Candidate> = by_loc.into_values().collect();
483    let budget = env_usize("LEAN_CTX_EXPLORE_BUDGET_TOKENS", DEFAULT_BUDGET_TOKENS);
484    let selected = select_citations(candidates, budget);
485
486    let citations: Vec<Citation> = selected
487        .into_iter()
488        .map(|c| Citation {
489            file: c.file,
490            start: c.start,
491            end: c.end,
492            label: c.label,
493        })
494        .collect();
495
496    let text = render(
497        query,
498        &keywords,
499        turns,
500        files_examined,
501        &citations,
502        crp_mode,
503        opts.citation_only,
504    );
505    let tokens = count_tokens(&text);
506    ExploreOutcome {
507        text,
508        tokens,
509        citations,
510    }
511}
512
513/// Parse the `path:start-end` citations out of an explore answer. Reused by the
514/// eval harness to score the Explore arm. Tolerant of the optional trailing
515/// label and of text outside the `<final_answer>` block.
516pub fn parse_final_answer(text: &str) -> Vec<(String, usize, usize)> {
517    let mut out = Vec::new();
518    let mut inside = false;
519    for line in text.lines() {
520        let trimmed = line.trim();
521        if trimmed == "<final_answer>" {
522            inside = true;
523            continue;
524        }
525        if trimmed == "</final_answer>" {
526            break;
527        }
528        if !inside || trimmed.is_empty() {
529            continue;
530        }
531        // First whitespace-delimited token is `path:start-end`.
532        let token = trimmed.split_whitespace().next().unwrap_or("");
533        if let Some((path, range)) = token.rsplit_once(':')
534            && let Some((s, e)) = range.split_once('-')
535            && let (Ok(start), Ok(end)) = (s.parse::<usize>(), e.parse::<usize>())
536        {
537            out.push((path.to_string(), start, end));
538        }
539    }
540    out
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn empty_query_is_rejected() {
549        let outcome = handle("   ", "/tmp", CrpMode::Off, &ExploreOptions::default());
550        assert!(outcome.text.starts_with("ERROR"));
551        assert_eq!(outcome.tokens, 0);
552        assert!(outcome.citations.is_empty());
553    }
554
555    #[test]
556    fn short_kind_maps_known_kinds() {
557        assert_eq!(short_kind("Function"), "fn");
558        assert_eq!(short_kind("method"), "fn");
559        assert_eq!(short_kind("struct"), "struct");
560        assert_eq!(short_kind("Constant"), "sym");
561        assert_eq!(short_kind(""), "");
562    }
563
564    #[test]
565    fn rel_path_strips_root_and_normalizes() {
566        assert_eq!(rel_path("/repo/src/a.rs", "/repo"), "src/a.rs");
567        assert_eq!(rel_path("src/a.rs", "/repo"), "src/a.rs");
568    }
569
570    #[test]
571    fn query_terms_dedup_and_cap() {
572        let kws: Vec<String> = vec!["Cache", "cache", "Index", "ab", "Search"]
573            .into_iter()
574            .map(String::from)
575            .collect();
576        let terms = query_terms(&kws);
577        assert!(terms.contains(&"cache".to_string()));
578        assert!(terms.contains(&"index".to_string()));
579        assert!(!terms.iter().any(|t| t == "ab")); // too short
580        // "cache" appears once despite two casings.
581        assert_eq!(terms.iter().filter(|t| *t == "cache").count(), 1);
582    }
583
584    #[test]
585    fn parse_final_answer_extracts_citations() {
586        let text = "EXPLORE: foo\n\n<final_answer>\nsrc/a.rs:10-20  foo (fn)\nsrc/b.rs:5-5\n</final_answer>\n";
587        let cits = parse_final_answer(text);
588        assert_eq!(
589            cits,
590            vec![
591                ("src/a.rs".to_string(), 10, 20),
592                ("src/b.rs".to_string(), 5, 5),
593            ]
594        );
595    }
596
597    #[test]
598    fn parse_final_answer_ignores_text_outside_block() {
599        let text = "noise:1-2 should be ignored\n<final_answer>\nsrc/x.rs:1-3\n</final_answer>\ntrailing:9-9";
600        let cits = parse_final_answer(text);
601        assert_eq!(cits, vec![("src/x.rs".to_string(), 1, 3)]);
602    }
603
604    #[test]
605    fn select_citations_respects_budget_and_is_deterministic() {
606        let mk = |file: &str, start: usize, term: &str, cost: usize, score: f64| Candidate {
607            file: file.to_string(),
608            start,
609            end: start + 5,
610            label: format!("{term} (fn)"),
611            score,
612            cost,
613            terms: HashSet::from([term.to_string()]),
614        };
615        let cands = vec![
616            mk("src/a.rs", 1, "cache", 100, 2.0),
617            mk("src/b.rs", 1, "index", 100, 1.5),
618            mk("src/c.rs", 1, "cache", 100, 1.0), // redundant term, low score
619        ];
620        let a = select_citations(cands.clone(), 250);
621        let b = select_citations(cands, 250);
622        // Deterministic across calls.
623        let fa: Vec<_> = a.iter().map(|c| (c.file.clone(), c.start)).collect();
624        let fb: Vec<_> = b.iter().map(|c| (c.file.clone(), c.start)).collect();
625        assert_eq!(fa, fb);
626        // Budget 250 with cost 100 each ⇒ at most 2 spans.
627        assert!(a.len() <= 2, "budget should cap to 2: {}", a.len());
628    }
629
630    #[test]
631    fn expand_frontier_stops_on_coverage_saturation() {
632        // No neighbours ⇒ the loop saturates after the first turn regardless of
633        // the `max_turns` budget (the early-stop that bounds delegated cost).
634        let adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
635        let relevant: BTreeSet<String> = ["a.rs".to_string()].into_iter().collect();
636        let (discovered, turns) = expand_frontier(&["a.rs".to_string()], &relevant, &adj, 8);
637        assert_eq!(turns, 1, "no new files ⇒ stop after turn 1");
638        assert_eq!(discovered.len(), 1);
639    }
640
641    #[test]
642    fn expand_frontier_respects_max_turns() {
643        // Relevant chain a→b→c→d. With max_turns=2 only b is reached (turn 2);
644        // c/d lie beyond the depth budget.
645        let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
646        adj.insert("a.rs".into(), ["b.rs".to_string()].into_iter().collect());
647        adj.insert("b.rs".into(), ["c.rs".to_string()].into_iter().collect());
648        adj.insert("c.rs".into(), ["d.rs".to_string()].into_iter().collect());
649        let relevant: BTreeSet<String> = ["a.rs", "b.rs", "c.rs", "d.rs"]
650            .iter()
651            .map(ToString::to_string)
652            .collect();
653        let (discovered, turns) = expand_frontier(&["a.rs".to_string()], &relevant, &adj, 2);
654        assert_eq!(turns, 2, "max_turns caps BFS depth");
655        assert!(discovered.contains("b.rs"));
656        assert!(
657            !discovered.contains("c.rs"),
658            "depth beyond max_turns is unexplored"
659        );
660    }
661
662    #[test]
663    fn expand_frontier_follows_only_relevant_files() {
664        // `b.rs` is a graph neighbour but not in the lexical hit set, so the
665        // graph walk never follows it (grounding prevents drift into noise).
666        let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
667        adj.insert("a.rs".into(), ["b.rs".to_string()].into_iter().collect());
668        let relevant: BTreeSet<String> = ["a.rs".to_string()].into_iter().collect();
669        let (discovered, turns) = expand_frontier(&["a.rs".to_string()], &relevant, &adj, 8);
670        assert_eq!(turns, 1);
671        assert!(
672            !discovered.contains("b.rs"),
673            "irrelevant neighbours are skipped"
674        );
675    }
676
677    #[test]
678    fn handle_output_is_byte_stable_across_runs() {
679        // #498: the output is a pure function of (content, query, options). Two
680        // back-to-back runs on the same fixture must be byte-identical — no
681        // session writes, no Hebbian co-access, no timestamps.
682        let _env_lock = crate::core::data_dir::test_env_lock();
683        let dir = tempfile::tempdir().unwrap();
684        let root = dir.path();
685        std::fs::write(
686            root.join("cache.rs"),
687            "pub struct CacheStore { entries: usize }\n\
688             impl CacheStore {\n    pub fn lookup(&self, key: &str) -> Option<usize> { let _ = key; None }\n}\n",
689        )
690        .unwrap();
691        std::fs::write(
692            root.join("index.rs"),
693            "pub fn build_index(cache: &str) -> usize { cache.len() }\n",
694        )
695        .unwrap();
696        let root_str = root.to_string_lossy().to_string();
697        let opts = ExploreOptions::new(Some(3), false);
698
699        let run = || {
700            handle(
701                "how does the cache lookup work",
702                &root_str,
703                CrpMode::Off,
704                &opts,
705            )
706        };
707
708        // The fixture root is brand new, so the first call races index warm-up:
709        // it can answer from a partially-built symbol index and cite two spans
710        // where a warm call cites three (`lookup (fn)` arrives late). That is a
711        // cold-start difference, not the state accumulation #498 is about — so
712        // warm the index with a discarded call and compare two warm runs.
713        let _warm = run();
714        let a = run();
715        let b = run();
716
717        assert_eq!(a.text, b.text, "explore output must be byte-stable");
718        assert_eq!(a.tokens, b.tokens);
719        let locs = |o: &ExploreOutcome| -> Vec<(String, usize, usize)> {
720            o.citations
721                .iter()
722                .map(|c| (c.file.clone(), c.start, c.end))
723                .collect()
724        };
725        assert_eq!(locs(&a), locs(&b));
726        assert!(a.text.contains("<final_answer>"));
727        assert!(a.text.contains("</final_answer>"));
728    }
729}