Skip to main content

ctx/
smart.rs

1//! Smart context selection for LLM workflows.
2//!
3//! This module provides intelligent file selection based on:
4//! - Semantic search using embeddings
5//! - Call graph expansion (callers and callees)
6//! - Token budget management
7//!
8//! # Usage
9//!
10//! ```ignore
11//! let config = SmartConfig::default();
12//! let result = smart_context(&db, &analytics, &provider, "add caching", config)?;
13//! for file in result.selected_files {
14//!     println!("{}: {} tokens", file.path, file.token_count);
15//! }
16//! ```
17
18use std::collections::HashMap;
19use std::path::Path;
20
21use crate::analytics::{Analytics, CallGraphNode, ImpactNode};
22use crate::db::Database;
23use crate::embeddings::{semantic_search, Embedding, EmbeddingProvider, SearchResult};
24use crate::error::{CtxError, Result};
25use crate::tokens::{count_file_tokens, select_by_token_budget, Encoding, HasTokenCount};
26
27/// Configuration for smart context selection.
28#[derive(Debug, Clone)]
29pub struct SmartConfig {
30    /// Maximum tokens in output
31    pub max_tokens: usize,
32    /// Call graph expansion depth
33    pub depth: i32,
34    /// Number of initial semantic matches to find
35    pub top: usize,
36    /// Tokenizer encoding to use
37    pub encoding: Encoding,
38}
39
40impl Default for SmartConfig {
41    fn default() -> Self {
42        Self {
43            max_tokens: 8000,
44            depth: 2,
45            top: 10,
46            encoding: Encoding::default(),
47        }
48    }
49}
50
51/// Reason why a file was selected.
52#[derive(Debug, Clone)]
53pub enum SelectionReason {
54    /// Direct semantic match to task embedding
55    SemanticMatch {
56        /// The symbol that matched
57        symbol: String,
58        /// Similarity score (0.0-1.0)
59        score: f32,
60    },
61    /// Called by a matched symbol
62    CalledBy {
63        /// The symbol that calls this file's code
64        caller: String,
65        /// Depth in call graph
66        depth: i32,
67    },
68    /// Calls a matched symbol
69    Calls {
70        /// The symbol that this file calls
71        callee: String,
72        /// Depth in call graph
73        depth: i32,
74    },
75    /// In the same module as a matched symbol
76    #[allow(dead_code)] // Used in tests and pattern matching
77    SameModule {
78        /// The related symbol
79        symbol: String,
80    },
81    /// User explicitly requested this file
82    #[allow(dead_code)] // Used in tests and pattern matching
83    Explicit,
84}
85
86impl SelectionReason {
87    /// Get a human-readable description of the reason.
88    pub fn description(&self) -> String {
89        match self {
90            SelectionReason::SemanticMatch { symbol, score } => {
91                format!("SemanticMatch: \"{}\" (score: {:.2})", symbol, score)
92            }
93            SelectionReason::CalledBy { caller, depth } => {
94                format!("CalledBy: {} (depth {})", caller, depth)
95            }
96            SelectionReason::Calls { callee, depth } => {
97                format!("Calls: {} (depth {})", callee, depth)
98            }
99            SelectionReason::SameModule { symbol } => {
100                format!("SameModule: shares context with {}", symbol)
101            }
102            SelectionReason::Explicit => "Explicit: user requested".to_string(),
103        }
104    }
105
106    /// Get the relevance weight for this reason type.
107    fn weight(&self) -> f32 {
108        match self {
109            SelectionReason::SemanticMatch { score, .. } => *score,
110            SelectionReason::CalledBy { depth, .. } => 0.8 / (*depth as f32).max(1.0),
111            SelectionReason::Calls { depth, .. } => 0.7 / (*depth as f32).max(1.0),
112            SelectionReason::SameModule { .. } => 0.5,
113            SelectionReason::Explicit => 1.0,
114        }
115    }
116}
117
118/// A selected file with relevance information.
119#[derive(Debug, Clone)]
120pub struct FileSelection {
121    /// File path (relative to project root)
122    pub path: String,
123    /// Relevance score (0.0-1.0)
124    pub relevance_score: f32,
125    /// Why this file was selected
126    pub reasons: Vec<SelectionReason>,
127    /// Token count for this file
128    pub token_count: usize,
129}
130
131impl HasTokenCount for FileSelection {
132    fn token_count(&self) -> usize {
133        self.token_count
134    }
135}
136
137impl FileSelection {
138    /// Create a new file selection.
139    fn new(path: String, reason: SelectionReason) -> Self {
140        let score = reason.weight();
141        Self {
142            path,
143            relevance_score: score,
144            reasons: vec![reason],
145            token_count: 0,
146        }
147    }
148
149    /// Add another reason for selection, updating the relevance score.
150    fn add_reason(&mut self, reason: SelectionReason) {
151        let new_weight = reason.weight();
152        // Take the maximum weight as the relevance score
153        if new_weight > self.relevance_score {
154            self.relevance_score = new_weight;
155        }
156        self.reasons.push(reason);
157    }
158
159    /// The best (maximum) direct semantic-match score among this file's reasons,
160    /// or `None` if the file was pulled in only through call-graph expansion.
161    ///
162    /// Files that directly match the task embedding are ranked ahead of graph-only
163    /// files (see [`rank_files`]), so this must be read independently of
164    /// `relevance_score`, which `add_reason` collapses to the max weight across
165    /// reasons (and which the flat 0.7/0.8 call-graph constants would otherwise
166    /// dominate over a compressed semantic score).
167    fn best_semantic_score(&self) -> Option<f32> {
168        self.reasons
169            .iter()
170            .filter_map(|r| match r {
171                SelectionReason::SemanticMatch { score, .. } => Some(*score),
172                _ => None,
173            })
174            .reduce(f32::max)
175    }
176}
177
178/// Result of smart context selection.
179#[derive(Debug, Clone)]
180pub struct SmartContext {
181    /// The task description used for selection
182    #[allow(dead_code)] // Part of public API
183    pub task: String,
184    /// Selected files with their relevance information
185    pub selected_files: Vec<FileSelection>,
186    /// Total token count of selected files
187    pub total_tokens: usize,
188    /// Whether selection was truncated by token limit
189    pub truncated: bool,
190    /// Number of files omitted due to token limit
191    pub omitted_count: usize,
192}
193
194/// Select files relevant to a task using semantic search and call graph analysis.
195///
196/// # Algorithm
197///
198/// 1. Embed the task description
199/// 2. Find top-N symbols matching the embedding
200/// 3. For each matched symbol:
201///    - Add the file containing the symbol
202///    - Expand call graph (callers + callees) to specified depth
203///    - Add files from expanded symbols
204/// 4. Deduplicate files, keeping highest relevance
205/// 5. Count tokens for each file
206/// 6. Sort by relevance score descending
207/// 7. Include files until token limit is reached
208pub fn smart_context(
209    db: &Database,
210    analytics: &Analytics,
211    provider: &dyn EmbeddingProvider,
212    task: &str,
213    config: SmartConfig,
214) -> Result<SmartContext> {
215    // 1. Embed the task description
216    let task_embedding = provider.embed(task)?;
217
218    smart_context_with_embedding(db, analytics, task, &task_embedding, config)
219}
220
221/// Select files relevant to a task using a pre-computed embedding.
222///
223/// This variant is useful when the embedding has been computed asynchronously
224/// (e.g., in the MCP server with OpenAI's async API) to avoid blocking the async runtime.
225///
226/// See [`smart_context`] for the full algorithm description.
227pub fn smart_context_with_embedding(
228    db: &Database,
229    analytics: &Analytics,
230    task: &str,
231    task_embedding: &Embedding,
232    config: SmartConfig,
233) -> Result<SmartContext> {
234    // 2. Semantic search for matching symbols
235    let matches = semantic_search(db, task_embedding, config.top)?;
236
237    if matches.is_empty() {
238        return Err(CtxError::NoMatches);
239    }
240
241    // 3. Build file selection map
242    let mut files: HashMap<String, FileSelection> = HashMap::new();
243
244    for result in &matches {
245        // Add the file containing the matched symbol
246        add_file(
247            &mut files,
248            &result.file_path,
249            SelectionReason::SemanticMatch {
250                symbol: result.name.clone(),
251                score: result.score,
252            },
253        );
254
255        // Expand call graph
256        expand_symbol(&mut files, analytics, result, config.depth)?;
257    }
258
259    // 4. Convert to vector and count tokens
260    let mut selections: Vec<FileSelection> = files.into_values().collect();
261
262    // Count tokens for each file
263    for selection in &mut selections {
264        selection.token_count = count_file_token_safe(&selection.path, config.encoding);
265    }
266
267    // 5. Rank files by relevance
268    rank_files(&mut selections);
269
270    // 6. Apply token limit
271    let (selected, total_tokens, omitted) = select_by_token_budget(selections, config.max_tokens);
272
273    Ok(SmartContext {
274        task: task.to_string(),
275        selected_files: selected,
276        total_tokens,
277        truncated: omitted > 0,
278        omitted_count: omitted,
279    })
280}
281
282/// Add a file to the selection map, merging reasons if already present.
283fn add_file(files: &mut HashMap<String, FileSelection>, path: &str, reason: SelectionReason) {
284    if let Some(existing) = files.get_mut(path) {
285        existing.add_reason(reason);
286    } else {
287        files.insert(
288            path.to_string(),
289            FileSelection::new(path.to_string(), reason),
290        );
291    }
292}
293
294/// Expand a symbol's call graph and add related files.
295fn expand_symbol(
296    files: &mut HashMap<String, FileSelection>,
297    analytics: &Analytics,
298    result: &SearchResult,
299    depth: i32,
300) -> Result<()> {
301    // Expand callers (who calls this symbol - impact analysis)
302    if let Ok(callers) = analytics.impact_analysis(&result.symbol_id, depth) {
303        for caller in callers {
304            add_file_from_impact(files, &caller, &result.name);
305        }
306    }
307
308    // Expand callees (what does this symbol call - call graph)
309    if let Ok(callees) = analytics.call_graph(&result.symbol_id, depth) {
310        for callee in callees {
311            add_file_from_call_graph(files, &callee, &result.name);
312        }
313    }
314
315    Ok(())
316}
317
318/// Add a file from impact analysis result.
319fn add_file_from_impact(
320    files: &mut HashMap<String, FileSelection>,
321    node: &ImpactNode,
322    callee_name: &str,
323) {
324    add_file(
325        files,
326        &node.file_path,
327        SelectionReason::CalledBy {
328            caller: node.name.clone(),
329            depth: node.distance,
330        },
331    );
332
333    // If in the same file as the callee, also add SameModule reason
334    if let Some(existing) = files.get(&node.file_path) {
335        if existing
336            .reasons
337            .iter()
338            .any(|r| matches!(r, SelectionReason::SemanticMatch { .. }))
339        {
340            // Already has a semantic match, skip SameModule
341        } else {
342            // Check if this is the same module (we'll add SameModule reason separately if needed)
343            let _ = callee_name; // Suppress unused warning
344        }
345    }
346}
347
348/// Add a file from call graph result.
349fn add_file_from_call_graph(
350    files: &mut HashMap<String, FileSelection>,
351    node: &CallGraphNode,
352    caller_name: &str,
353) {
354    add_file(
355        files,
356        &node.file_path,
357        SelectionReason::Calls {
358            callee: node.name.clone(),
359            depth: node.depth,
360        },
361    );
362
363    let _ = caller_name; // Suppress unused warning
364}
365
366/// Rank files for selection. The ordering is tiered and fully deterministic:
367///
368/// 1. Files with a direct semantic match rank above files pulled in only through
369///    call-graph expansion. Without this, a graph neighbour's flat weight (0.8 for
370///    `CalledBy`, 0.7 for `Calls`) outranks the compressed semantic score (~0.4–0.6)
371///    of the very seed that expanded it, so generic hubs displace the on-topic file.
372/// 2. Within the semantic tier, higher semantic score first; within the graph-only
373///    tier, higher `relevance_score` first.
374/// 3. Ties break by `path` ascending. Because the input is collected from a
375///    `HashMap`, this tie-break is what makes the command deterministic across runs
376///    (many files share the same 0.5/0.7/0.8 weight).
377fn rank_files(files: &mut [FileSelection]) {
378    files.sort_by(|a, b| {
379        let a_sem = a.best_semantic_score();
380        let b_sem = b.best_semantic_score();
381        // Tier: semantic matches (Some) sort before graph-only (None).
382        // Score compared within a tier only: semantic score for the semantic tier,
383        // relevance_score for the graph-only tier — never across scales.
384        let a_key = (a_sem.is_none(), a_sem.unwrap_or(a.relevance_score));
385        let b_key = (b_sem.is_none(), b_sem.unwrap_or(b.relevance_score));
386        a_key
387            .0
388            .cmp(&b_key.0)
389            .then_with(|| {
390                b_key
391                    .1
392                    .partial_cmp(&a_key.1)
393                    .unwrap_or(std::cmp::Ordering::Equal)
394            })
395            .then_with(|| a.path.cmp(&b.path))
396    });
397}
398
399/// Count tokens in a file, returning 0 on error.
400fn count_file_token_safe(path: &str, encoding: Encoding) -> usize {
401    count_file_tokens(Path::new(path), encoding)
402        .map(|tc| tc.count)
403        .unwrap_or(0)
404}
405
406/// Format the smart context result for display.
407pub fn format_explain(result: &SmartContext) -> String {
408    let mut output = String::new();
409
410    output.push_str(&format!(
411        "Selected {} files ({} tokens):\n\n",
412        result.selected_files.len(),
413        result.total_tokens
414    ));
415
416    for (i, file) in result.selected_files.iter().enumerate() {
417        output.push_str(&format!(
418            "{}. {} ({} tokens)\n",
419            i + 1,
420            file.path,
421            file.token_count
422        ));
423
424        for reason in &file.reasons {
425            output.push_str(&format!("   - {}\n", reason.description()));
426        }
427        output.push('\n');
428    }
429
430    if result.truncated {
431        output.push_str(&format!(
432            "({} files omitted due to token limit)\n",
433            result.omitted_count
434        ));
435    }
436
437    output
438}
439
440/// Format the smart context result for dry-run mode.
441pub fn format_dry_run(result: &SmartContext) -> String {
442    let mut output = String::new();
443
444    output.push_str(&format!(
445        "Would select {} files ({} tokens):\n",
446        result.selected_files.len(),
447        result.total_tokens
448    ));
449
450    for file in &result.selected_files {
451        let primary_reason = file
452            .reasons
453            .first()
454            .map(|r| match r {
455                SelectionReason::SemanticMatch { .. } => "SemanticMatch",
456                SelectionReason::CalledBy { .. } => "CalledBy",
457                SelectionReason::Calls { .. } => "Calls",
458                SelectionReason::SameModule { .. } => "SameModule",
459                SelectionReason::Explicit => "Explicit",
460            })
461            .unwrap_or("Unknown");
462
463        output.push_str(&format!(
464            "  {} ({} tokens) - {}\n",
465            file.path, file.token_count, primary_reason
466        ));
467    }
468
469    if result.truncated {
470        output.push_str(&format!(
471            "\n({} files would be omitted due to token limit)\n",
472            result.omitted_count
473        ));
474    }
475
476    output
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn test_selection_reason_weight() {
485        let semantic = SelectionReason::SemanticMatch {
486            symbol: "test".to_string(),
487            score: 0.9,
488        };
489        assert!((semantic.weight() - 0.9).abs() < 0.001);
490
491        let called_by = SelectionReason::CalledBy {
492            caller: "main".to_string(),
493            depth: 1,
494        };
495        assert!((called_by.weight() - 0.8).abs() < 0.001);
496
497        let called_by_depth2 = SelectionReason::CalledBy {
498            caller: "main".to_string(),
499            depth: 2,
500        };
501        assert!((called_by_depth2.weight() - 0.4).abs() < 0.001);
502
503        let calls = SelectionReason::Calls {
504            callee: "helper".to_string(),
505            depth: 1,
506        };
507        assert!((calls.weight() - 0.7).abs() < 0.001);
508
509        let same_module = SelectionReason::SameModule {
510            symbol: "related".to_string(),
511        };
512        assert!((same_module.weight() - 0.5).abs() < 0.001);
513
514        let explicit = SelectionReason::Explicit;
515        assert!((explicit.weight() - 1.0).abs() < 0.001);
516    }
517
518    #[test]
519    fn test_file_selection_add_reason() {
520        let mut selection = FileSelection::new(
521            "src/main.rs".to_string(),
522            SelectionReason::Calls {
523                callee: "helper".to_string(),
524                depth: 1,
525            },
526        );
527
528        assert!((selection.relevance_score - 0.7).abs() < 0.001);
529        assert_eq!(selection.reasons.len(), 1);
530
531        // Add a higher-weight reason
532        selection.add_reason(SelectionReason::SemanticMatch {
533            symbol: "main".to_string(),
534            score: 0.95,
535        });
536
537        assert!((selection.relevance_score - 0.95).abs() < 0.001);
538        assert_eq!(selection.reasons.len(), 2);
539    }
540
541    #[test]
542    fn test_select_by_token_budget() {
543        let files = vec![
544            FileSelection {
545                path: "a.rs".to_string(),
546                relevance_score: 1.0,
547                reasons: vec![SelectionReason::Explicit],
548                token_count: 100,
549            },
550            FileSelection {
551                path: "b.rs".to_string(),
552                relevance_score: 0.8,
553                reasons: vec![SelectionReason::Explicit],
554                token_count: 200,
555            },
556            FileSelection {
557                path: "c.rs".to_string(),
558                relevance_score: 0.5,
559                reasons: vec![SelectionReason::Explicit],
560                token_count: 150,
561            },
562        ];
563
564        // All fit
565        let (selected, total, omitted) = select_by_token_budget(files.clone(), 500);
566        assert_eq!(selected.len(), 3);
567        assert_eq!(total, 450);
568        assert_eq!(omitted, 0);
569
570        // Only first two fit
571        let (selected, total, omitted) = select_by_token_budget(files.clone(), 300);
572        assert_eq!(selected.len(), 2);
573        assert_eq!(total, 300);
574        assert_eq!(omitted, 1);
575
576        // Only first fits
577        let (selected, total, omitted) = select_by_token_budget(files, 150);
578        assert_eq!(selected.len(), 1);
579        assert_eq!(total, 100);
580        assert_eq!(omitted, 2);
581    }
582
583    #[test]
584    fn test_format_dry_run() {
585        let result = SmartContext {
586            task: "add caching".to_string(),
587            selected_files: vec![
588                FileSelection {
589                    path: "src/main.rs".to_string(),
590                    relevance_score: 0.9,
591                    reasons: vec![SelectionReason::SemanticMatch {
592                        symbol: "main".to_string(),
593                        score: 0.9,
594                    }],
595                    token_count: 500,
596                },
597                FileSelection {
598                    path: "src/lib.rs".to_string(),
599                    relevance_score: 0.7,
600                    reasons: vec![SelectionReason::Calls {
601                        callee: "helper".to_string(),
602                        depth: 1,
603                    }],
604                    token_count: 300,
605                },
606            ],
607            total_tokens: 800,
608            truncated: false,
609            omitted_count: 0,
610        };
611
612        let output = format_dry_run(&result);
613        assert!(output.contains("Would select 2 files"));
614        assert!(output.contains("src/main.rs"));
615        assert!(output.contains("SemanticMatch"));
616    }
617
618    #[test]
619    fn test_smart_config_default() {
620        let config = SmartConfig::default();
621        assert_eq!(config.max_tokens, 8000);
622        assert_eq!(config.depth, 2);
623        assert_eq!(config.top, 10);
624    }
625
626    #[test]
627    fn test_add_file_merges_reasons() {
628        let mut files: HashMap<String, FileSelection> = HashMap::new();
629
630        // First add
631        add_file(
632            &mut files,
633            "src/main.rs",
634            SelectionReason::SemanticMatch {
635                symbol: "main".to_string(),
636                score: 0.9,
637            },
638        );
639        assert_eq!(files.len(), 1);
640        assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
641
642        // Second add to same file - should merge
643        add_file(
644            &mut files,
645            "src/main.rs",
646            SelectionReason::CalledBy {
647                caller: "run".to_string(),
648                depth: 1,
649            },
650        );
651        assert_eq!(files.len(), 1); // Still only 1 file
652        assert_eq!(files.get("src/main.rs").unwrap().reasons.len(), 2);
653        // Score should be max of the two
654        assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
655    }
656
657    #[test]
658    fn test_rank_files_sorts_by_relevance() {
659        let mut files = vec![
660            FileSelection {
661                path: "low.rs".to_string(),
662                relevance_score: 0.3,
663                reasons: vec![SelectionReason::Explicit],
664                token_count: 100,
665            },
666            FileSelection {
667                path: "high.rs".to_string(),
668                relevance_score: 0.9,
669                reasons: vec![SelectionReason::Explicit],
670                token_count: 100,
671            },
672            FileSelection {
673                path: "mid.rs".to_string(),
674                relevance_score: 0.5,
675                reasons: vec![SelectionReason::Explicit],
676                token_count: 100,
677            },
678        ];
679
680        rank_files(&mut files);
681
682        assert_eq!(files[0].path, "high.rs");
683        assert_eq!(files[1].path, "mid.rs");
684        assert_eq!(files[2].path, "low.rs");
685    }
686
687    /// A file that directly matches the task embedding must rank above a file that
688    /// was only pulled in through call-graph expansion, even though the graph
689    /// neighbour's flat weight (0.8) is numerically larger than the compressed
690    /// semantic score (0.5). This is the core relevance regression.
691    #[test]
692    fn test_semantic_match_ranks_above_graph_only() {
693        let mut files = vec![
694            FileSelection {
695                path: "graph_hub.rs".to_string(),
696                relevance_score: 0.8, // CalledBy depth 1
697                reasons: vec![SelectionReason::CalledBy {
698                    caller: "run".to_string(),
699                    depth: 1,
700                }],
701                token_count: 100,
702            },
703            FileSelection {
704                path: "on_topic.rs".to_string(),
705                relevance_score: 0.5,
706                reasons: vec![SelectionReason::SemanticMatch {
707                    symbol: "run_sql".to_string(),
708                    score: 0.5,
709                }],
710                token_count: 100,
711            },
712        ];
713
714        rank_files(&mut files);
715
716        assert_eq!(
717            files[0].path, "on_topic.rs",
718            "semantic match must outrank a higher-weighted graph-only file"
719        );
720        assert_eq!(files[1].path, "graph_hub.rs");
721    }
722
723    /// Within the semantic tier, higher score wins; equal scores break by path so
724    /// the order is stable.
725    #[test]
726    fn test_semantic_tier_orders_by_score_then_path() {
727        let mk = |path: &str, score: f32| FileSelection {
728            path: path.to_string(),
729            relevance_score: score,
730            reasons: vec![SelectionReason::SemanticMatch {
731                symbol: "s".to_string(),
732                score,
733            }],
734            token_count: 100,
735        };
736        // Two files tie at 0.6; "a.rs" must precede "b.rs" by path.
737        let mut files = vec![mk("b.rs", 0.6), mk("c.rs", 0.4), mk("a.rs", 0.6)];
738
739        rank_files(&mut files);
740
741        assert_eq!(
742            files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
743            vec!["a.rs", "b.rs", "c.rs"]
744        );
745    }
746
747    /// Ranking must not depend on the (HashMap-derived) input order: the same set of
748    /// selections presented in any order yields the same ranked paths. This is what
749    /// makes `ctx smart` deterministic across runs.
750    #[test]
751    fn test_rank_files_is_deterministic() {
752        let make_set = || {
753            vec![
754                FileSelection {
755                    path: "z_graph.rs".to_string(),
756                    relevance_score: 0.8,
757                    reasons: vec![SelectionReason::CalledBy {
758                        caller: "run".to_string(),
759                        depth: 1,
760                    }],
761                    token_count: 100,
762                },
763                FileSelection {
764                    path: "a_graph.rs".to_string(),
765                    relevance_score: 0.8, // ties with z_graph.rs -> path breaks it
766                    reasons: vec![SelectionReason::CalledBy {
767                        caller: "run".to_string(),
768                        depth: 1,
769                    }],
770                    token_count: 100,
771                },
772                FileSelection {
773                    path: "seed.rs".to_string(),
774                    relevance_score: 0.5,
775                    reasons: vec![SelectionReason::SemanticMatch {
776                        symbol: "seed".to_string(),
777                        score: 0.5,
778                    }],
779                    token_count: 100,
780                },
781            ]
782        };
783
784        let mut a = make_set();
785        rank_files(&mut a);
786        let order_a: Vec<String> = a.iter().map(|f| f.path.clone()).collect();
787
788        // Present the same set reversed; ranking must produce the identical order.
789        let mut b = make_set();
790        b.reverse();
791        rank_files(&mut b);
792        let order_b: Vec<String> = b.iter().map(|f| f.path.clone()).collect();
793
794        assert_eq!(order_a, order_b);
795        // Semantic seed first, then graph files tie-broken by path.
796        assert_eq!(order_a, vec!["seed.rs", "a_graph.rs", "z_graph.rs"]);
797    }
798
799    #[test]
800    fn test_selection_reason_description() {
801        let semantic = SelectionReason::SemanticMatch {
802            symbol: "test".to_string(),
803            score: 0.9,
804        };
805        assert!(semantic.description().contains("SemanticMatch"));
806        assert!(semantic.description().contains("test"));
807
808        let called_by = SelectionReason::CalledBy {
809            caller: "main".to_string(),
810            depth: 2,
811        };
812        assert!(called_by.description().contains("CalledBy"));
813        assert!(called_by.description().contains("main"));
814        assert!(called_by.description().contains("2"));
815    }
816}