1use std::cmp::Ordering;
7use std::collections::HashSet;
8
9use crate::core::tokens::count_tokens;
10
11use super::intent::TaskIntent;
12
13#[derive(Debug, Clone)]
15pub struct RelevanceScore {
16 pub chunk_index: usize,
18 pub score: f64,
20}
21
22pub fn intent_query_terms(intent: &TaskIntent) -> Vec<&'static str> {
25 match intent {
26 TaskIntent::Debug => vec![
27 "error", "panic", "unwrap", "stack", "trace", "fail", "crash", "assert", "expect",
28 "bug",
29 ],
30 TaskIntent::Refactor => vec![
31 "struct",
32 "trait",
33 "impl",
34 "pub",
35 "mod",
36 "type",
37 "fn",
38 "signature",
39 "interface",
40 ],
41 TaskIntent::Implement => vec![
42 "test",
43 "spec",
44 "todo",
45 "fixme",
46 "requirement",
47 "feature",
48 "api",
49 "endpoint",
50 ],
51 TaskIntent::Review => vec![
52 "unsafe",
53 "unwrap",
54 "todo",
55 "hack",
56 "fixme",
57 "security",
58 "performance",
59 "complexity",
60 ],
61 TaskIntent::Explore => vec![
62 "mod", "struct", "enum", "trait", "fn", "import", "use", "pub",
63 ],
64 TaskIntent::Unknown => vec![],
65 }
66}
67
68pub fn compute_relevance(
71 chunks: &[&str],
72 intent: &TaskIntent,
73 explicit_query: Option<&str>,
74) -> Vec<RelevanceScore> {
75 if chunks.is_empty() {
76 return Vec::new();
77 }
78
79 let mut terms: Vec<String> = intent_query_terms(intent)
80 .into_iter()
81 .map(str::to_owned)
82 .collect();
83 if let Some(query) = explicit_query {
84 terms.extend(words(query));
85 }
86 deduplicate(&mut terms);
87
88 if terms.is_empty() {
89 return chunks
90 .iter()
91 .enumerate()
92 .map(|(chunk_index, _)| RelevanceScore {
93 chunk_index,
94 score: 0.0,
95 })
96 .collect();
97 }
98
99 let chunk_words: Vec<Vec<String>> = chunks.iter().map(|chunk| words(chunk)).collect();
100 let lengths: Vec<f64> = chunks
101 .iter()
102 .map(|chunk| count_tokens(chunk).max(1) as f64)
103 .collect();
104 let average_length = lengths.iter().sum::<f64>() / lengths.len() as f64;
105 let document_count = chunks.len() as f64;
106 let document_frequencies: Vec<usize> = terms
107 .iter()
108 .map(|term| {
109 chunk_words
110 .iter()
111 .filter(|chunk| chunk.iter().any(|word| word == term))
112 .count()
113 })
114 .collect();
115
116 let mut scores: Vec<RelevanceScore> = chunk_words
117 .iter()
118 .enumerate()
119 .map(|(chunk_index, chunk)| {
120 let score = bm25_score(
121 chunk,
122 lengths[chunk_index],
123 average_length,
124 document_count,
125 &terms,
126 &document_frequencies,
127 );
128 RelevanceScore { chunk_index, score }
129 })
130 .collect();
131
132 normalize(&mut scores);
133 scores.sort_by(|left, right| {
134 right
135 .score
136 .partial_cmp(&left.score)
137 .unwrap_or(Ordering::Equal)
138 .then_with(|| left.chunk_index.cmp(&right.chunk_index))
139 });
140 scores
141}
142
143fn words(text: &str) -> Vec<String> {
144 text.split(|character: char| !character.is_alphanumeric())
145 .filter(|word| !word.is_empty())
146 .map(str::to_ascii_lowercase)
147 .collect()
148}
149
150fn deduplicate(terms: &mut Vec<String>) {
151 let mut seen = HashSet::new();
152 terms.retain(|term| !term.is_empty() && seen.insert(term.clone()));
153}
154
155fn bm25_score(
156 chunk: &[String],
157 chunk_length: f64,
158 average_length: f64,
159 document_count: f64,
160 terms: &[String],
161 document_frequencies: &[usize],
162) -> f64 {
163 const K1: f64 = 1.2;
164 const B: f64 = 0.75;
165
166 terms
167 .iter()
168 .zip(document_frequencies)
169 .filter(|(_, frequency)| **frequency > 0)
170 .map(|(term, document_frequency)| {
171 let term_frequency = chunk.iter().filter(|word| *word == term).count() as f64;
172 let inverse_document_frequency = (document_count / *document_frequency as f64).ln();
173 let length_factor = 1.0 - B + B * chunk_length / average_length;
174 inverse_document_frequency * term_frequency * (K1 + 1.0)
175 / (term_frequency + K1 * length_factor)
176 })
177 .sum()
178}
179
180fn normalize(scores: &mut [RelevanceScore]) {
181 let maximum = scores.iter().map(|score| score.score).fold(0.0, f64::max);
182 if maximum > 0.0 {
183 for score in scores {
184 score.score /= maximum;
185 }
186 }
187}
188
189#[cfg(test)]
190pub mod tests {
191 use super::{TaskIntent, compute_relevance};
192
193 #[test]
194 fn debug_intent_scores_error_lines_higher() {
195 let chunks = ["normal control flow", "panic error crash in parser"];
196 let scores = compute_relevance(&chunks, &TaskIntent::Debug, None);
197 assert_eq!(scores[0].chunk_index, 1);
198 assert!(scores[0].score > scores[1].score);
199 }
200
201 #[test]
202 fn empty_intent_returns_uniform_scores() {
203 let chunks = ["first chunk", "a very different second chunk"];
204 let scores = compute_relevance(&chunks, &TaskIntent::Unknown, None);
205 assert!(scores.iter().all(|score| score.score == scores[0].score));
206 }
207
208 #[test]
209 fn explicit_query_overrides_intent() {
210 let chunks = ["panic", "database database timeout"];
211 let scores = compute_relevance(
212 &chunks,
213 &TaskIntent::Debug,
214 Some("database timeout database"),
215 );
216 assert_eq!(scores[0].chunk_index, 1);
217 }
218
219 #[test]
220 fn scores_are_normalized() {
221 let chunks = ["panic crash error", "panic", "ordinary text"];
222 let scores = compute_relevance(&chunks, &TaskIntent::Debug, None);
223 assert!(
224 scores
225 .iter()
226 .all(|score| (0.0..=1.0).contains(&score.score))
227 );
228 assert!((scores[0].score - 1.0).abs() < f64::EPSILON);
229 }
230}