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