1#[derive(Clone, Debug)]
11pub struct RelevanceQuery {
12 normalized: String,
13 tokens: Vec<String>,
14}
15
16impl RelevanceQuery {
17 pub fn new(query: &str) -> Self {
18 let normalized = normalize(query);
19 let mut tokens = Vec::new();
20 for token in normalized
21 .split_whitespace()
22 .filter(|token| token.len() > 2)
23 {
24 if !tokens.iter().any(|existing| existing == token) {
25 tokens.push(token.to_string());
26 }
27 }
28 Self { normalized, tokens }
29 }
30
31 pub fn is_empty(&self) -> bool {
32 self.tokens.is_empty()
33 }
34
35 pub fn tokens(&self) -> &[String] {
38 &self.tokens
39 }
40
41 pub fn lexical_score(&self, text: &str) -> f32 {
47 if self.tokens.is_empty() {
48 return 0.0;
49 }
50
51 let lowercase_text = text.to_lowercase();
52 if lowercase_text.is_empty() {
53 return 0.0;
54 }
55
56 let words: Vec<&str> = lowercase_text
72 .split(|ch: char| !ch.is_alphanumeric())
73 .filter(|token| !token.is_empty())
74 .collect();
75
76 let mut matched = 0.0f32;
77 for query_token in &self.tokens {
78 let best = words
79 .iter()
80 .map(|text_token| token_similarity(query_token, text_token))
81 .fold(0.0f32, f32::max);
82 matched += best;
83 }
84
85 let coverage = matched / self.tokens.len() as f32;
86 let phrase =
87 (!self.normalized.is_empty() && lowercase_text.contains(&self.normalized)) as u8 as f32;
88 (coverage * 0.95 + phrase * 0.05).clamp(0.0, 1.0)
89 }
90
91 pub fn rank_distance(
97 &self,
98 text: &str,
99 spatial_dist_sq: f32,
100 keyword_boost: f32,
101 importance: u8,
102 ) -> f32 {
103 let lexical = self.lexical_score(text);
104 rank_distance_from_score(lexical, spatial_dist_sq, keyword_boost, importance)
105 }
106}
107
108#[inline]
112pub fn rank_distance_from_score(
113 lexical_score: f32,
114 spatial_dist_sq: f32,
115 keyword_boost: f32,
116 importance: u8,
117) -> f32 {
118 let lexical_weight = 0.8 + keyword_boost.clamp(0.0, 2.0);
119 let lexical_penalty = (1.0 - lexical_score.clamp(0.0, 1.0)) * lexical_weight;
120 let spatial_component = spatial_dist_sq.max(0.0) * 0.20;
121 let importance_prior = 1.0 + (importance.min(10) as f32 * 0.015);
122 (lexical_penalty + spatial_component) / importance_prior
123}
124
125#[inline]
127pub fn apply_boost(distance: f32, boost: f32) -> f32 {
128 distance / (1.0 + boost.max(0.0))
129}
130
131#[inline]
141pub fn apply_confidence_boost(distance: f32, confidence: u8) -> f32 {
142 distance / (1.0 + (confidence as f32 * 0.001))
143}
144
145fn normalize(text: &str) -> String {
146 let mut out = String::with_capacity(text.len());
147 let mut previous_was_space = true;
148 for ch in text.chars().flat_map(char::to_lowercase) {
149 if ch.is_alphanumeric() {
150 out.push(ch);
151 previous_was_space = false;
152 } else if !previous_was_space {
153 out.push(' ');
154 previous_was_space = true;
155 }
156 }
157 if out.ends_with(' ') {
158 out.pop();
159 }
160 out
161}
162
163fn token_similarity(query: &str, text: &str) -> f32 {
164 if query == text {
165 return 1.0;
166 }
167 let common_prefix = query
168 .chars()
169 .zip(text.chars())
170 .take_while(|(left, right)| left == right)
171 .count();
172 let shorter = query.chars().count().min(text.chars().count());
173 if shorter >= 5 && common_prefix >= 5 {
174 common_prefix as f32 / query.chars().count().max(text.chars().count()) as f32 * 0.72
175 } else {
176 0.0
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn lexical_score_reference(q: &RelevanceQuery, text: &str) -> f32 {
192 if q.tokens.is_empty() {
193 return 0.0;
194 }
195 let lowercase_text = text.to_lowercase();
196 if lowercase_text.is_empty() {
197 return 0.0;
198 }
199 let mut matched = 0.0f32;
200 for query_token in &q.tokens {
201 let best = lowercase_text
202 .split(|ch: char| !ch.is_alphanumeric())
203 .filter(|token| !token.is_empty())
204 .map(|text_token| token_similarity(query_token, text_token))
205 .fold(0.0f32, f32::max);
206 matched += best;
207 }
208 let coverage = matched / q.tokens.len() as f32;
209 let phrase =
210 (!q.normalized.is_empty() && lowercase_text.contains(&q.normalized)) as u8 as f32;
211 (coverage * 0.95 + phrase * 0.05).clamp(0.0, 1.0)
212 }
213
214 #[test]
215 fn split_once_per_block_matches_the_per_token_split() {
216 let texts = [
220 "",
221 " ",
222 "!!! ??? ---",
223 "the user has a cat named Bella",
224 "Bella, bella, BELLA; the-user/has\ta\tcat",
225 "visszakeresési minőség",
226 "A visszakeresési-minőség mérhető.",
227 "aBc DeF 123 x9 456",
228 "rebuild rollback rollback rollback",
229 "Transactional rebuild uses a rollback snapshot",
230 ];
231 let queries = [
232 "",
233 "cat",
234 "the user has a cat named Bella",
235 "rebuild rollback",
236 "visszakeresési minőség",
237 "123 x9",
238 "a",
239 ];
240 for q in &queries {
241 let query = RelevanceQuery::new(q);
242 for t in &texts {
243 let got = query.lexical_score(t);
244 let want = lexical_score_reference(&query, t);
245 assert!(
246 (got - want).abs() < 1e-6,
247 "query {q:?} text {t:?}: got {got}, reference {want}"
248 );
249 }
250 }
251 }
252
253 #[test]
254 fn complete_query_coverage_beats_partial_match() {
255 let query = RelevanceQuery::new("transactional rebuild rollback");
256 let complete = query.lexical_score("Transactional rebuild uses a rollback snapshot");
257 let partial = query.lexical_score("Rebuild completed successfully");
258 assert!(
259 complete > partial + 0.4,
260 "{complete} should dominate {partial}"
261 );
262 }
263
264 #[test]
265 fn unicode_and_punctuation_are_normalized() {
266 let query = RelevanceQuery::new("visszakeresési minőség");
267 assert!(query.lexical_score("A visszakeresési-minőség mérhető.") >= 0.95);
268 }
269
270 #[test]
271 fn continuous_distance_does_not_collapse_keyword_matches() {
272 let query = RelevanceQuery::new("octopus snapshot lifecycle");
273 let complete = query.rank_distance("Octopus snapshot lifecycle is durable", 0.5, 0.4, 5);
274 let partial = query.rank_distance("Octopus runtime", 0.01, 0.4, 5);
275 assert!(complete < partial, "complete={complete}, partial={partial}");
276 assert_ne!(complete, 0.0);
277 assert_ne!(partial, 0.0);
278 }
279
280 #[test]
281 fn boosts_preserve_existing_order() {
282 assert!(apply_boost(0.2, 0.5) < apply_boost(0.4, 0.5));
283 }
284}
285
286pub fn merge_candidates(
300 lexical: impl IntoIterator<Item = u32>,
301 semantic: impl IntoIterator<Item = usize>,
302 block_count: usize,
303) -> Vec<usize> {
304 let mut out: Vec<usize> = lexical
305 .into_iter()
306 .map(|c| c as usize)
307 .chain(semantic)
308 .filter(|&i| i < block_count)
309 .collect();
310 out.sort_unstable();
311 out.dedup();
312 out
313}
314
315#[cfg(test)]
316mod candidate_tests {
317 use super::merge_candidates;
318
319 #[test]
320 fn semantic_candidates_survive_without_any_lexical_overlap() {
321 let merged = merge_candidates(Vec::<u32>::new(), [7usize], 16);
324 assert_eq!(merged, vec![7]);
325 }
326
327 #[test]
328 fn lexical_and_semantic_are_unioned_and_deduplicated() {
329 let merged = merge_candidates([3u32, 1, 9], [9usize, 4, 1], 16);
330 assert_eq!(merged, vec![1, 3, 4, 9]);
331 }
332
333 #[test]
334 fn out_of_range_ids_are_dropped() {
335 let merged = merge_candidates([2u32, 99], [50usize, 2], 16);
337 assert_eq!(merged, vec![2]);
338 }
339
340 #[test]
341 fn empty_sources_yield_no_candidates() {
342 assert!(merge_candidates(Vec::<u32>::new(), Vec::<usize>::new(), 16).is_empty());
343 }
344}