Skip to main content

greplm_core/
context.rs

1//! Task-driven context packs.
2//!
3//! Given a natural-language task and a token budget, greplm assembles the most
4//! relevant slice of the codebase an agent needs to act — ranked by lexical
5//! relevance, call-graph centrality, and (when built with the `semantic`
6//! feature) meaning — and packs it to fit the budget. This is the "give me
7//! exactly the code for this task" surface that keeps agents off the
8//! grep-then-read-whole-files treadmill.
9//!
10//! The ranking helpers here are pure; [`crate::search::Searcher::context_pack`]
11//! drives them over the index.
12
13use serde::{Deserialize, Serialize};
14
15use crate::search::SnippetLine;
16
17/// One unit of packed context: a symbol, its signature, and a code snippet.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PackItem {
20    pub path: String,
21    pub lang: String,
22    pub name: String,
23    pub kind: String,
24    pub line_start: u32,
25    pub line_end: u32,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub signature: Option<String>,
28    pub snippet: Vec<SnippetLine>,
29    /// Why this item was included (e.g. "match", "central", "callee of X").
30    pub reason: String,
31    pub score: f32,
32}
33
34/// A budget-bounded bundle of context for a task.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ContextPack {
37    pub task: String,
38    pub budget_tokens: u64,
39    pub used_tokens: u64,
40    /// True if relevant items were dropped to stay within budget.
41    pub truncated: bool,
42    pub items: Vec<PackItem>,
43}
44
45/// Conservative chars-per-token estimate (matches the savings accounting).
46pub const CHARS_PER_TOKEN: u64 = 4;
47
48/// Estimate the token cost of a string.
49pub fn est_tokens(chars: u64) -> u64 {
50    chars / CHARS_PER_TOKEN
51}
52
53/// Tokenize a free-form task into lowercased search terms: split on
54/// non-identifier characters, then split camelCase/snake_case, dropping
55/// stopwords and 1-character noise.
56pub fn tokenize(task: &str) -> Vec<String> {
57    let mut terms: Vec<String> = Vec::new();
58    let mut seen = std::collections::HashSet::new();
59    for raw in task.split(|c: char| !(c.is_alphanumeric() || c == '_')) {
60        if raw.is_empty() {
61            continue;
62        }
63        for tok in split_identifier(raw) {
64            if tok.len() < 2 || is_stopword(&tok) {
65                continue;
66            }
67            if seen.insert(tok.clone()) {
68                terms.push(tok);
69            }
70        }
71    }
72    terms
73}
74
75fn is_stopword(t: &str) -> bool {
76    matches!(
77        t,
78        "the"
79            | "a"
80            | "an"
81            | "of"
82            | "to"
83            | "in"
84            | "is"
85            | "for"
86            | "and"
87            | "or"
88            | "how"
89            | "where"
90            | "what"
91            | "does"
92            | "do"
93            | "with"
94            | "on"
95            | "by"
96            | "this"
97            | "that"
98            | "it"
99            | "be"
100            | "as"
101            | "at"
102            | "we"
103            | "i"
104            | "add"
105            | "fix"
106            | "use"
107            | "using"
108            | "make"
109            | "get"
110            | "set"
111            | "all"
112            | "when"
113            | "from"
114            | "into"
115            | "via"
116            | "can"
117            | "should"
118            | "code"
119            | "function"
120            | "method"
121    )
122}
123
124/// Lexical relevance of a symbol to the task terms.
125pub fn lexical_score(
126    name: &str,
127    kind: &str,
128    signature: Option<&str>,
129    container: Option<&str>,
130    path: &str,
131    terms: &[String],
132) -> f32 {
133    if terms.is_empty() {
134        return 0.0;
135    }
136    let name_lower = name.to_ascii_lowercase();
137    let name_tokens = split_identifier(name);
138    let sig_lower = signature.map(|s| s.to_ascii_lowercase());
139    let cont_tokens = container.map(split_identifier).unwrap_or_default();
140    let path_lower = path.to_ascii_lowercase();
141
142    let mut score = 0.0f32;
143    for term in terms {
144        if name_lower == *term {
145            score += 20.0;
146        } else if name_tokens.iter().any(|t| t == term) {
147            score += 12.0;
148        } else if name_lower.contains(term.as_str()) {
149            score += 6.0;
150        }
151        if cont_tokens.iter().any(|t| t == term) {
152            score += 4.0;
153        }
154        if let Some(sig) = &sig_lower {
155            if sig.contains(term.as_str()) {
156                score += 3.0;
157            }
158        }
159        if path_lower.contains(term.as_str()) {
160            score += 2.0;
161        }
162    }
163    if score > 0.0 && is_priority_kind(kind) {
164        score += 2.0;
165    }
166    score
167}
168
169fn is_priority_kind(kind: &str) -> bool {
170    matches!(
171        kind,
172        "function"
173            | "method"
174            | "struct"
175            | "class"
176            | "trait"
177            | "interface"
178            | "enum"
179            | "type"
180            | "constructor"
181            | "module"
182    )
183}
184
185/// Split an identifier into lowercase tokens on camelCase and snake/kebab.
186pub fn split_identifier(s: &str) -> Vec<String> {
187    let mut tokens = Vec::new();
188    let mut cur = String::new();
189    let mut prev_lower = false;
190    for ch in s.chars() {
191        if ch == '_' || ch == '-' || ch == ' ' {
192            if !cur.is_empty() {
193                tokens.push(std::mem::take(&mut cur));
194            }
195            prev_lower = false;
196            continue;
197        }
198        if ch.is_uppercase() && prev_lower && !cur.is_empty() {
199            tokens.push(std::mem::take(&mut cur));
200        }
201        cur.extend(ch.to_lowercase());
202        prev_lower = ch.is_lowercase() || ch.is_numeric();
203    }
204    if !cur.is_empty() {
205        tokens.push(cur);
206    }
207    tokens
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn tokenizes_and_drops_stopwords() {
216        let t = tokenize("How does the SegmentWriter flush to disk?");
217        assert!(t.contains(&"segment".to_string()));
218        assert!(t.contains(&"writer".to_string()));
219        assert!(t.contains(&"flush".to_string()));
220        assert!(t.contains(&"disk".to_string()));
221        assert!(!t.contains(&"the".to_string()));
222        assert!(!t.contains(&"how".to_string()));
223    }
224
225    #[test]
226    fn scores_name_matches_highest() {
227        let terms = tokenize("flush segment writer");
228        let exact = lexical_score("flush", "function", None, None, "src/a.rs", &terms);
229        let unrelated = lexical_score("zebra", "function", None, None, "src/a.rs", &terms);
230        assert!(exact > unrelated);
231        assert_eq!(unrelated, 0.0);
232    }
233}