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.
131///
132/// Convenience wrapper for one-off scoring. The full-repo scan in
133/// [`crate::search::Searcher::context_pack`] instead uses
134/// [`path_term_bonus`] + [`lexical_score_with`], which hoist the per-document
135/// work out of the per-symbol loop and reuse their scratch buffers.
136pub fn lexical_score(
137    name: &str,
138    kind: &str,
139    signature: Option<&str>,
140    container: Option<&str>,
141    path: &str,
142    terms: &[String],
143) -> f32 {
144    if terms.is_empty() {
145        return 0.0;
146    }
147    let mut scratch = ScoreScratch::default();
148    let path_bonus = path_term_bonus(path, terms, &mut scratch);
149    lexical_score_with(
150        name,
151        kind,
152        signature,
153        container,
154        path_bonus,
155        terms,
156        &mut scratch,
157    )
158}
159
160/// Reusable buffers for [`lexical_score_with`].
161///
162/// Scoring one symbol needs a lowercased name, a lowercased signature, and the
163/// identifier tokens of the name and container. Allocating those per symbol
164/// dominated `context_pack` on large trees (one scan touches every symbol in
165/// the repository), so the buffers are hoisted into a struct the caller keeps
166/// across the whole scan. The values computed are identical to the naive
167/// version; only the allocations are amortized.
168///
169/// The path and name buffers are separate on purpose. Sharing one would work
170/// today — [`path_term_bonus`] returns its result before a name overwrites the
171/// buffer — but it would break silently if a caller ever interleaved the two.
172#[derive(Default)]
173pub struct ScoreScratch {
174    path_lower: String,
175    name_lower: String,
176    sig_lower: String,
177    name_tokens: Vec<String>,
178    cont_tokens: Vec<String>,
179}
180
181/// Overwrite `buf` with the ASCII-lowercased form of `s`, reusing its capacity.
182fn ascii_lower_into(s: &str, buf: &mut String) {
183    buf.clear();
184    buf.push_str(s);
185    buf.make_ascii_lowercase();
186}
187
188/// The path component of a symbol's lexical score: `2.0` per task term that
189/// appears in the path. This depends only on the document, so `context_pack`
190/// computes it once per file rather than once per symbol.
191pub fn path_term_bonus(path: &str, terms: &[String], scratch: &mut ScoreScratch) -> f32 {
192    ascii_lower_into(path, &mut scratch.path_lower);
193    let mut bonus = 0.0f32;
194    for term in terms {
195        if scratch.path_lower.contains(term.as_str()) {
196            bonus += 2.0;
197        }
198    }
199    bonus
200}
201
202/// Lexical relevance of a symbol, given its document's precomputed
203/// [`path_term_bonus`]. Allocation-free once `scratch`'s buffers are warm.
204pub fn lexical_score_with(
205    name: &str,
206    kind: &str,
207    signature: Option<&str>,
208    container: Option<&str>,
209    path_bonus: f32,
210    terms: &[String],
211    scratch: &mut ScoreScratch,
212) -> f32 {
213    if terms.is_empty() {
214        return 0.0;
215    }
216    ascii_lower_into(name, &mut scratch.name_lower);
217    let n_name = split_identifier_into(name, &mut scratch.name_tokens);
218    match signature {
219        Some(s) => ascii_lower_into(s, &mut scratch.sig_lower),
220        None => scratch.sig_lower.clear(),
221    }
222    let n_cont = match container {
223        Some(c) => split_identifier_into(c, &mut scratch.cont_tokens),
224        None => 0,
225    };
226
227    let name_lower = &scratch.name_lower;
228    let mut score = path_bonus;
229    for term in terms {
230        if name_lower == term {
231            score += 20.0;
232        } else if scratch.name_tokens[..n_name].iter().any(|t| t == term) {
233            score += 12.0;
234        } else if name_lower.contains(term.as_str()) {
235            score += 6.0;
236        }
237        if scratch.cont_tokens[..n_cont].iter().any(|t| t == term) {
238            score += 4.0;
239        }
240        if signature.is_some() && scratch.sig_lower.contains(term.as_str()) {
241            score += 3.0;
242        }
243    }
244    if score > 0.0 && is_priority_kind(kind) {
245        score += 2.0;
246    }
247    score
248}
249
250fn is_priority_kind(kind: &str) -> bool {
251    matches!(
252        kind,
253        "function"
254            | "method"
255            | "struct"
256            | "class"
257            | "trait"
258            | "interface"
259            | "enum"
260            | "type"
261            | "constructor"
262            | "module"
263    )
264}
265
266/// Split an identifier into lowercase tokens on camelCase and snake/kebab.
267pub fn split_identifier(s: &str) -> Vec<String> {
268    let mut tokens = Vec::new();
269    let n = split_identifier_into(s, &mut tokens);
270    tokens.truncate(n);
271    tokens
272}
273
274/// [`split_identifier`] into a caller-owned buffer: writes the tokens to
275/// `out[..n]` and returns `n`, reusing the `String` allocations already there.
276///
277/// `out` may come back longer than `n` (stale tokens from a previous call sit
278/// past the end); only the returned prefix is valid. This is what lets the
279/// per-symbol scan paths run without allocating.
280fn split_identifier_into(s: &str, out: &mut Vec<String>) -> usize {
281    let mut n = 0usize;
282    let mut prev_lower = false;
283    // Whether a token is currently being built at `out[n]`. Mirrors the
284    // `!cur.is_empty()` guards of the allocating version: a token becomes
285    // non-empty as soon as its first character is pushed.
286    let mut open = false;
287    for ch in s.chars() {
288        if ch == '_' || ch == '-' || ch == ' ' {
289            if open {
290                n += 1;
291                open = false;
292            }
293            prev_lower = false;
294            continue;
295        }
296        if ch.is_uppercase() && prev_lower && open {
297            n += 1;
298            open = false;
299        }
300        if !open {
301            if out.len() == n {
302                out.push(String::new());
303            } else {
304                out[n].clear();
305            }
306            open = true;
307        }
308        out[n].extend(ch.to_lowercase());
309        prev_lower = ch.is_lowercase() || ch.is_numeric();
310    }
311    if open {
312        n += 1;
313    }
314    n
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn tokenizes_and_drops_stopwords() {
323        let t = tokenize("How does the SegmentWriter flush to disk?");
324        assert!(t.contains(&"segment".to_string()));
325        assert!(t.contains(&"writer".to_string()));
326        assert!(t.contains(&"flush".to_string()));
327        assert!(t.contains(&"disk".to_string()));
328        assert!(!t.contains(&"the".to_string()));
329        assert!(!t.contains(&"how".to_string()));
330    }
331
332    #[test]
333    fn scores_name_matches_highest() {
334        let terms = tokenize("flush segment writer");
335        let exact = lexical_score("flush", "function", None, None, "src/a.rs", &terms);
336        let unrelated = lexical_score("zebra", "function", None, None, "src/a.rs", &terms);
337        assert!(exact > unrelated);
338        assert_eq!(unrelated, 0.0);
339    }
340
341    /// Inputs exercising every branch of the tokenizer: separators, camelCase
342    /// boundaries, digits, leading/trailing/repeated separators, non-ASCII
343    /// (whose `to_lowercase` may expand), and the empty string.
344    const IDENTS: &[&str] = &[
345        "",
346        "x",
347        "flush",
348        "loadConfig",
349        "load_config",
350        "kebab-case-name",
351        "with space",
352        "__leading",
353        "trailing__",
354        "a__b",
355        "HTTPServer",
356        "parseHTTP2Frame",
357        "v2Handler",
358        "snake_And_Camel",
359        "ÄÖÜ_grüß",
360        "İstanbul",
361        "ALLCAPS",
362    ];
363
364    /// The reusable-buffer tokenizer must agree with the allocating one, and
365    /// must not leak stale tokens from a previous, longer call into the next
366    /// result.
367    #[test]
368    fn split_into_matches_allocating_version_and_reuses_buffer() {
369        let mut buf: Vec<String> = Vec::new();
370        for s in IDENTS {
371            let want = split_identifier(s);
372            let n = split_identifier_into(s, &mut buf);
373            assert_eq!(n, want.len(), "token count for {s:?}");
374            assert_eq!(&buf[..n], &want[..], "tokens for {s:?}");
375        }
376        // Same sequence again in reverse, so every call inherits a buffer left
377        // over from a different (often longer) input.
378        for s in IDENTS.iter().rev() {
379            let want = split_identifier(s);
380            let n = split_identifier_into(s, &mut buf);
381            assert_eq!(&buf[..n], &want[..], "tokens for {s:?} after reuse");
382        }
383    }
384
385    /// A `ScoreScratch` carried across many symbols must produce exactly the
386    /// scores a fresh one would — the property `context_pack` depends on.
387    #[test]
388    fn scratch_reuse_does_not_change_scores() {
389        let terms = tokenize("write back page cache to disk");
390        /// `(name, kind, signature, container, path)`.
391        type Case<'a> = (&'a str, &'a str, Option<&'a str>, Option<&'a str>, &'a str);
392        let cases: &[Case] = &[
393            ("writeback", "function", None, None, "mm/page-writeback.c"),
394            (
395                "write_back_pages",
396                "function",
397                Some("int write_back_pages(struct page *p)"),
398                Some("PageCache"),
399                "fs/read_write.c",
400            ),
401            ("zebra", "struct", None, None, "drivers/zoo.c"),
402            (
403                "cache",
404                "method",
405                Some("void cache(void)"),
406                None,
407                "mm/cache.c",
408            ),
409            (
410                "İstanbul",
411                "function",
412                None,
413                Some("ÄÖÜ_grüß"),
414                "i18n/ünicode.c",
415            ),
416            ("x", "field", Some("disk"), Some("page"), "a.c"),
417            ("PageWriteback", "class", None, None, "include/linux/page.h"),
418        ];
419        let mut shared = ScoreScratch::default();
420        for (name, kind, sig, cont, path) in cases {
421            // Fresh scratch per call: the reference behavior.
422            let mut fresh = ScoreScratch::default();
423            let want = lexical_score_with(
424                name,
425                kind,
426                *sig,
427                *cont,
428                path_term_bonus(path, &terms, &mut fresh),
429                &terms,
430                &mut fresh,
431            );
432            let got = lexical_score_with(
433                name,
434                kind,
435                *sig,
436                *cont,
437                path_term_bonus(path, &terms, &mut shared),
438                &terms,
439                &mut shared,
440            );
441            assert_eq!(got, want, "score for {name:?} in {path:?}");
442            // And the public one-shot wrapper must agree too.
443            assert_eq!(
444                lexical_score(name, kind, *sig, *cont, path, &terms),
445                want,
446                "wrapper score for {name:?}"
447            );
448        }
449    }
450
451    /// `Some("")` must behave like the original: an empty signature still takes
452    /// the `signature.is_some()` branch, and an empty container tokenizes to
453    /// nothing. Also checks that a stale longer value in a reused buffer cannot
454    /// leak into a later empty one.
455    #[test]
456    fn empty_and_none_signature_container_are_distinguished() {
457        let terms = tokenize("alpha beta");
458        let mut sh = ScoreScratch::default();
459        // Prime the buffers with long values so any leak would show up.
460        let _ = lexical_score_with(
461            "alpha_beta_gamma",
462            "function",
463            Some("fn alpha(beta: Beta) -> Gamma"),
464            Some("AlphaContainer"),
465            0.0,
466            &terms,
467            &mut sh,
468        );
469        let cases: &[(Option<&str>, Option<&str>)] = &[
470            (None, None),
471            (Some(""), None),
472            (None, Some("")),
473            (Some(""), Some("")),
474            (Some("alpha"), Some("beta")),
475        ];
476        for (sig, cont) in cases {
477            let mut fresh = ScoreScratch::default();
478            let want = lexical_score_with("zzz", "other", *sig, *cont, 0.0, &terms, &mut fresh);
479            let got = lexical_score_with("zzz", "other", *sig, *cont, 0.0, &terms, &mut sh);
480            assert_eq!(got, want, "sig={sig:?} cont={cont:?}");
481            assert_eq!(
482                lexical_score("zzz", "other", *sig, *cont, "x.rs", &terms),
483                want,
484                "wrapper disagrees for sig={sig:?} cont={cont:?}"
485            );
486        }
487    }
488
489    /// The path contribution is 2.0 per matching term and must be independent
490    /// of the symbol, since `context_pack` hoists it per document.
491    #[test]
492    fn path_bonus_counts_each_matching_term_once() {
493        let terms = tokenize("page cache writeback");
494        let mut s = ScoreScratch::default();
495        assert_eq!(path_term_bonus("mm/nothing.c", &terms, &mut s), 0.0);
496        assert_eq!(path_term_bonus("mm/PAGE.c", &terms, &mut s), 2.0);
497        assert_eq!(path_term_bonus("mm/page-writeback.c", &terms, &mut s), 4.0);
498        // A path match alone lifts an otherwise-unrelated symbol above zero,
499        // which is why the scan cannot prune on the name column alone.
500        assert!(lexical_score("zebra", "other", None, None, "mm/page.c", &terms) > 0.0);
501    }
502}