Skip to main content

lean_ctx/core/ib/
relevance.rs

1//! BM25 relevance scoring for information-bottleneck chunk selection.
2//!
3//! Ranks text chunks against intent-specific query terms as a lightweight
4//! proxy for mutual information between task intent and source content.
5
6use std::cmp::Ordering;
7use std::collections::HashSet;
8
9use crate::core::tokens::count_tokens;
10
11use super::intent::TaskIntent;
12
13/// Relevance assigned to one input chunk.
14#[derive(Debug, Clone)]
15pub struct RelevanceScore {
16    /// Zero-based position of the chunk in the input slice.
17    pub chunk_index: usize,
18    /// BM25-derived score normalized to the inclusive range `[0, 1]`.
19    pub score: f64,
20}
21
22/// Generate query terms for IB compression based on task intent.
23/// Each intent has domain-specific terms that maximize mutual information.
24pub 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
68/// Score chunks by relevance to the current task intent.
69/// Uses BM25-style IDF-weighted term overlap as a lightweight mutual information proxy.
70pub 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}