Skip to main content

lean_ctx_server/tools/
ctx_compose.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_compose` — task composer (Phase 2 of the efficiency epic).
3//!
4//! The biggest agent win is a single "rich per call" tool that returns ranked
5//! files *with* inline bodies, replacing the typical search → read → outline →
6//! read chain (3-5 calls) with one.
7//!
8//! lean-ctx already has the building blocks as separate tools; this composes
9//! them into one response for a natural-language task:
10//!   1. extracted keywords,
11//!   2. locally ranked files (admitted BM25),
12//!   3. exact match locations (index-backed `ctx_search`),
13//!   4. the body of the most relevant symbol, inline.
14
15use std::sync::mpsc;
16use std::time::Duration;
17
18use crate::tools::CrpMode;
19use lean_ctx::core::tokens::count_tokens;
20
21/// Wall-time budget for the semantic-ranking stage. The exact-match and symbol
22/// stages are index-backed and cheap; only semantic ranking can hit a cold
23/// `O(corpus)` BM25 build. We never let that block the agent loop: past the
24/// budget (4s, tuned for cold-start coverage #902) we return the independently
25/// admitted sections. Late ranking output is discarded; no legacy index is used
26/// as a fallback. Override via `LEAN_CTX_COMPOSE_BUDGET_MS`.
27const DEFAULT_SEMANTIC_BUDGET_MS: u64 = 4000;
28/// A one-shot `lean-ctx call` has no agent loop to keep responsive, and its
29/// caller asked for this result: discarding a ranking after 4s only wasted it
30/// (#2049). Its own watchdog bounds the whole call.
31const ONE_SHOT_SEMANTIC_BUDGET_MS: u64 = 120_000;
32// A timed-out worker retains its permit until it actually finishes. Repeated
33// requests cannot accumulate an unbounded set of fresh corpus scans.
34static RANKING_WORKERS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
35
36fn semantic_budget() -> Duration {
37    let ms = std::env::var("LEAN_CTX_COMPOSE_BUDGET_MS")
38        .ok()
39        .and_then(|v| v.parse::<u64>().ok())
40        .filter(|&v| v > 0)
41        .unwrap_or(if lean_ctx::core::conversation::is_one_shot_process() {
42            ONE_SHOT_SEMANTIC_BUDGET_MS
43        } else {
44            DEFAULT_SEMANTIC_BUDGET_MS
45        });
46    Duration::from_millis(ms)
47}
48
49/// Token budget for the inlined symbol bodies. Submodular selection fills it
50/// with the most coverage-effective, non-redundant set of symbols.
51/// Override via `LEAN_CTX_COMPOSE_SYMBOL_TOKENS`.
52const DEFAULT_SYMBOL_BUDGET_TOKENS: usize = 600;
53
54fn symbol_budget_tokens() -> usize {
55    std::env::var("LEAN_CTX_COMPOSE_SYMBOL_TOKENS")
56        .ok()
57        .and_then(|v| v.parse::<usize>().ok())
58        .filter(|&v| v > 0)
59        .unwrap_or(DEFAULT_SYMBOL_BUDGET_TOKENS)
60}
61
62pub(crate) fn kernel_supplement_budget(project_root: &str) -> usize {
63    let config = lean_ctx::core::context_kernel::activation::load_config(project_root);
64    (symbol_budget_tokens() / 5)
65        .min(lean_ctx::core::context_kernel::activation::supplement_budget(&config))
66}
67
68/// Words that carry no retrieval signal — dropped from keyword extraction.
69const STOPWORDS: &[&str] = &[
70    "the",
71    "and",
72    "for",
73    "with",
74    "that",
75    "this",
76    "from",
77    "into",
78    "how",
79    "where",
80    "what",
81    "does",
82    "are",
83    "was",
84    "use",
85    "used",
86    "uses",
87    "add",
88    "all",
89    "any",
90    "can",
91    "get",
92    "set",
93    "via",
94    "out",
95    "its",
96    "his",
97    "her",
98    "you",
99    "your",
100    "our",
101    "find",
102    "show",
103    "list",
104    "make",
105    "when",
106    "then",
107    "has",
108    "have",
109    "had",
110    "not",
111    "but",
112    "see",
113    "function",
114    "method",
115    "class",
116    "code",
117    "file",
118    "files",
119    "implement",
120    "implementation",
121];
122
123/// Extract up to `max` distinct identifier-ish keywords from a task, preserving
124/// original case (symbol lookups are case-sensitive) and first-seen order.
125fn extract_keywords(task: &str, max: usize) -> Vec<String> {
126    let mut seen = std::collections::HashSet::new();
127    let mut out = Vec::new();
128    for raw in task.split(|c: char| !(c.is_alphanumeric() || c == '_')) {
129        if raw.len() < 3 {
130            continue;
131        }
132        if STOPWORDS.contains(&raw.to_ascii_lowercase().as_str()) {
133            continue;
134        }
135        if seen.insert(raw.to_string()) {
136            out.push(raw.to_string());
137            if out.len() >= max {
138                break;
139            }
140        }
141    }
142    out
143}
144
145/// Order `keywords` from most to least specific using the resident BM25 index's
146/// per-token document frequency (how many chunks contain the token). Rarer =
147/// more specific = better as the "exact matches" seed. A token absent from the
148/// corpus (df 0) sinks to the end — grepping it yields nothing useful.
149///
150/// Non-blocking and best-effort: if the resident index isn't warm yet we return
151/// the keywords in their original first-seen order (the previous behaviour), so
152/// this can only improve the seed, never stall the call to build an index.
153fn order_by_specificity(keywords: &[String], project_root: &str) -> Vec<String> {
154    let Some(index) = resident_index(project_root) else {
155        return keywords.to_vec();
156    };
157    rank_by_doc_freq(keywords, &index.doc_freqs)
158}
159
160/// Pure ranking core: choose the exact-match seed keyword.
161///
162/// The seed feeds a case-sensitive regex grep, so raw rarity is the wrong sort:
163/// the rarest task token is often a lowercase prose word (`measurand`) that the
164/// index counts case-insensitively but the grep then misses against `Measurand`
165/// — 0 hits, worse than before. Instead prefer *code identifiers* (camelCase or
166/// snake_case: `GetMaxCurrent`, `CurrentGetter`), which grep straight to code,
167/// over acronyms (`OCPP`) and prose (`current`) that also match READMEs. Within
168/// each class, rarer (lower document frequency) wins; absent tokens (df 0) sink.
169/// Keys are lowercased in `doc_freqs`; a stable sort keeps first-seen order on
170/// ties, so a task with no identifiers degrades to the previous rarity order.
171fn rank_by_doc_freq(
172    keywords: &[String],
173    doc_freqs: &std::collections::HashMap<String, usize>,
174) -> Vec<String> {
175    let df = |kw: &String| match doc_freqs.get(&kw.to_ascii_lowercase()) {
176        Some(&n) if n > 0 => n,
177        _ => usize::MAX,
178    };
179    // Class 0 = code identifier (grep-friendly), class 1 = acronym/prose.
180    let rank_key = |kw: &String| (u8::from(!is_code_identifier(kw)), df(kw));
181    let mut ranked = keywords.to_vec();
182    ranked.sort_by_key(rank_key);
183    ranked
184}
185
186/// True for tokens that read as code identifiers — snake_case (`get_max_current`)
187/// or camelCase/PascalCase with an internal capital (`GetMaxCurrent`). A leading
188/// capital alone (`Current`) or an all-caps acronym (`OCPP`) does not qualify:
189/// those match prose and file boilerplate as readily as code.
190fn is_code_identifier(kw: &str) -> bool {
191    if kw.contains('_') {
192        return true;
193    }
194    let has_lower = kw.chars().any(|c| c.is_ascii_lowercase());
195    let internal_upper = kw.chars().skip(1).any(|c| c.is_ascii_uppercase());
196    has_lower && internal_upper
197}
198
199/// Fetch the already-resident BM25 index for `project_root` without triggering a
200/// build. Returns `None` when nothing is cached yet (cold start).
201fn resident_index(
202    project_root: &str,
203) -> Option<std::sync::Arc<lean_ctx::core::bm25_index::BM25Index>> {
204    let cache = crate::tools::ctx_semantic_search::get_thread_cache()?;
205    lean_ctx::core::bm25_cache::get_or_background(&cache, std::path::Path::new(project_root))
206}
207
208/// Run admitted local ranking under the caller's source authority and deadline.
209/// The inherited view closes with the owning request, so a late worker cannot
210/// switch to an unprotected policy or role while finishing.
211fn ranked_files_budgeted(task: &str, project_root: &str, crp_mode: CrpMode) -> String {
212    ranked_files_with_slots(task, project_root, crp_mode, &RANKING_WORKERS)
213}
214
215fn ranked_files_with_slots(
216    task: &str,
217    project_root: &str,
218    crp_mode: CrpMode,
219    slots: &'static tokio::sync::Semaphore,
220) -> String {
221    // Admit in the caller's scope: a role that denies ctx_search must not
222    // obtain ranked source through compose, whatever the worker inherits.
223    let role = lean_ctx::core::roles::active_role();
224    if role
225        .tools
226        .denied
227        .iter()
228        .any(|denied| denied == "ctx_search")
229    {
230        return "ERR: source search is not authorized".into();
231    }
232    let Ok(slot) = slots.try_acquire() else {
233        return "(local BM25 ranking busy: retry after active requests finish. Other sections are independently admitted.)".into();
234    };
235    let (tx, rx) = mpsc::channel::<String>();
236    let task_owned = task.to_string();
237    let root_owned = project_root.to_string();
238    // The resident BM25 cache is thread-local to the request thread. Without
239    // it the worker deserialized the whole persisted index on every call
240    // (seconds on a mid-size repo), regularly overran the budget and made the
241    // composed output depend on timing (focus analysis 2026-10-15).
242    let resident_cache = crate::tools::ctx_semantic_search::get_thread_cache();
243
244    lean_ctx::core::task_spine::TaskSpine::spawn_thread(move || {
245        if let Some(cache) = resident_cache {
246            crate::tools::ctx_semantic_search::set_thread_cache(cache);
247        }
248        let _slot = slot;
249        let ranked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
250            crate::tools::ctx_semantic_search::handle_for_tool(
251                "ctx_compose",
252                &task_owned,
253                &root_owned,
254                8,
255                crp_mode,
256                None,
257                None,
258                Some("bm25"),
259                Some(false),
260                Some(false),
261            )
262        }))
263        .unwrap_or_else(|_| {
264            tracing::warn!("[ctx_compose: semantic ranking panicked; omitting section]");
265            String::new()
266        });
267        // A timed-out receiver drops the result. This fresh view is never
268        // persisted or substituted with a legacy shared index.
269        let _ = tx.send(ranked);
270    });
271
272    match rx.recv_timeout(semantic_budget()) {
273        Ok(ranked) => ranked.trim().to_string(),
274        Err(_) => deferred_ranking_note().to_string(),
275    }
276}
277
278/// Do not replay unadmitted index diagnostics or promise a warmed cache: each
279/// local ranking view is freshly admitted, including after a timeout.
280fn deferred_ranking_note() -> &'static str {
281    "(local BM25 ranking deferred: source admission did not finish within this call's budget; \
282     late ranking output is discarded. Other sections are independently admitted. \
283     LEAN_CTX_COMPOSE_BUDGET_MS raises the budget.)"
284}
285
286/// Prose and documentation formats. An identifier's mentions there rank below
287/// its definition and its uses in code.
288const PROSE_GLOBS: &str = "*.{md,mdx,markdown,rst,txt,adoc}";
289
290/// Exact-match evidence for `primary`, best first: definitions, then uses in
291/// code, and prose mentions only when code has none. A single capped search in
292/// walk order used to fill all ten slots with whatever came first — root-level
293/// docs and agent rule files — so the code the task asked about never showed.
294fn exact_matches_section(
295    primary: &str,
296    project_root: &str,
297    crp_mode: CrpMode,
298    allow_secret: bool,
299) -> String {
300    let search = |pattern: &str, max: usize, exclude: Option<&str>| {
301        crate::tools::ctx_search::handle_filtered(
302            pattern,
303            project_root,
304            None,
305            max,
306            crp_mode,
307            true,
308            allow_secret,
309            false,
310            exclude,
311            None,
312        )
313        .text
314    };
315    let found = |text: &str| {
316        let t = text.trim_start();
317        !(t.is_empty() || t.starts_with("0 matches") || t.starts_with("ERR"))
318    };
319    let mut out = String::new();
320    let definition = format!(
321        r"\b(?:fn|struct|enum|trait|type|const|static|mod|class|interface|def|func|function)\s+{}\b",
322        regex::escape(primary)
323    );
324    let defs = search(&definition, 5, None);
325    if found(&defs) {
326        out.push_str("Definitions:\n");
327        out.push_str(defs.trim());
328        out.push('\n');
329    }
330    let code = search(primary, 10, Some(PROSE_GLOBS));
331    if found(&code) {
332        out.push_str("Uses in code:\n");
333        out.push_str(code.trim());
334        out.push('\n');
335    } else {
336        out.push_str(search(primary, 10, None).trim());
337        out.push('\n');
338    }
339    out
340}
341
342/// Small-file inlining (owner decision 2026-10-31). Benchmark transcripts
343/// (Claude Code + Haiku, ledgerlite + Dispatch edit tasks): after an
344/// orientation call the agent spent its next model step reading the files the
345/// overview had named — every full read there was a file of at most 61 lines /
346/// ~520 tokens, at most four per step, ~1.6k tokens together; larger files were
347/// read by symbol or line range instead. Those small files are delivered here
348/// in full, verbatim, so the overview call already carries them.
349const SMALL_FILE_MAX_LINES: usize = 150;
350const SMALL_FILE_MAX_TOKENS: usize = 800;
351const SMALL_FILES_MAX: usize = 4;
352/// Token budget of the whole section. `LEAN_CTX_COMPOSE_INLINE_TOKENS`
353/// overrides it; `0` turns inlining off.
354const DEFAULT_SMALL_FILES_BUDGET_TOKENS: usize = 2_000;
355
356fn small_files_budget_tokens() -> usize {
357    std::env::var("LEAN_CTX_COMPOSE_INLINE_TOKENS")
358        .ok()
359        .and_then(|v| v.parse::<usize>().ok())
360        .unwrap_or(DEFAULT_SMALL_FILES_BUDGET_TOKENS)
361}
362
363/// Files the sections above name, in the order they name them: ranked
364/// results (`path :: symbol`), exact matches (`path:line …`) and symbol
365/// bodies (`File: path (…)`).
366fn named_files(composed: &str) -> Vec<String> {
367    let mut seen = std::collections::HashSet::new();
368    let mut files = Vec::new();
369    for line in composed.lines() {
370        let candidate = if let Some((path, _)) = line.split_once(" :: ") {
371            path.trim()
372        } else if let Some(rest) = line.strip_prefix("File: ") {
373            rest.split(" (").next().unwrap_or("").trim()
374        } else {
375            let Some((path, rest)) = line.split_once(':') else {
376                continue;
377            };
378            if !rest.starts_with(|c: char| c.is_ascii_digit()) {
379                continue;
380            }
381            path.trim()
382        };
383        if candidate.is_empty()
384            || candidate.contains(char::is_whitespace)
385            || !candidate.contains('.')
386            || candidate.starts_with('[')
387        {
388            continue;
389        }
390        if seen.insert(candidate.to_string()) {
391            files.push(candidate.to_string());
392        }
393    }
394    files
395}
396
397/// The named files that are small, admitted exactly as `ctx_read` admits a
398/// read (path jail, binary and policy checks, secret-like paths only for a
399/// role that may read them), in full and byte-exact inside a fence. Empty when
400/// none qualifies. Deterministic: order and selection depend only on the
401/// sections above and the file contents.
402fn small_files_section(composed: &str, project_root: &str, allow_secret: bool) -> String {
403    let budget = small_files_budget_tokens();
404    if budget == 0 {
405        return String::new();
406    }
407    let mut used = 0usize;
408    let mut inlined = 0usize;
409    let mut section = String::new();
410    let mut remaining_bytes = lean_ctx::core::limits::max_read_bytes();
411    for rel in named_files(composed) {
412        if inlined >= SMALL_FILES_MAX {
413            break;
414        }
415        let path = std::path::Path::new(project_root).join(&rel);
416        if !allow_secret && lean_ctx::core::io_boundary::is_secret_like(&path).is_some() {
417            continue;
418        }
419        let Ok(meta) = std::fs::metadata(&path) else {
420            continue;
421        };
422        // Cheap pre-filter before reading: 150 lines of code stay far below this.
423        if !meta.is_file() || meta.len() > 16 * 1024 {
424            continue;
425        }
426        let Ok(content) = crate::tools::ctx_read::read_file_for_tool_rooted_budgeted(
427            &path.to_string_lossy(),
428            project_root,
429            "ctx_read",
430            &mut remaining_bytes,
431        ) else {
432            continue;
433        };
434        let lines = content.lines().count();
435        let tokens = count_tokens(&content);
436        if lines == 0
437            || lines > SMALL_FILE_MAX_LINES
438            || tokens > SMALL_FILE_MAX_TOKENS
439            || used + tokens > budget
440        {
441            continue;
442        }
443        let longest_run = content.split(|c| c != '`').map(str::len).max().unwrap_or(0);
444        let fence = "`".repeat(longest_run.max(2) + 1);
445        if section.is_empty() {
446            section.push_str("\n## Small files (full, verbatim)\n");
447        }
448        section.push_str(&format!("File: {rel} ({lines} lines)\n{fence}\n{content}"));
449        if !content.ends_with('\n') {
450            section.push('\n');
451        }
452        section.push_str(&fence);
453        section.push('\n');
454        used += tokens;
455        inlined += 1;
456    }
457    section
458}
459
460/// Compose a single rich response for `task`.
461pub fn handle(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
462    lean_ctx::core::policy::runtime::with_project_source_view(project_root, || {
463        handle_in_view(task, project_root, crp_mode)
464    })
465    .unwrap_or_else(|reason| {
466        (
467            format!(
468                "ERROR: context withheld: source authority changed or could not be verified \
469                 ({reason})"
470            ),
471            0,
472        )
473    })
474}
475
476fn handle_in_view(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
477    let task = task.trim();
478    if task.is_empty() {
479        return ("ERROR: task is required".to_string(), 0);
480    }
481
482    let protected = lean_ctx::core::policy::runtime::is_active();
483    let keywords = extract_keywords(task, 6);
484    let allow_secret = lean_ctx::core::roles::active_role().io.allow_secret_paths;
485
486    let mut out = String::new();
487    out.push_str(&format!("TASK: {task}\n"));
488    if keywords.is_empty() {
489        out.push_str("KEYWORDS: (none extracted — using full task for ranking)\n");
490    } else {
491        out.push_str(&format!("KEYWORDS: {}\n", keywords.join(", ")));
492    }
493
494    // 1. Fresh source admission precedes ranking under the same pinned view as
495    //    the rest of this response, including in the bounded worker.
496    out.push_str("\n## Ranked files (local BM25)\n");
497    {
498        let _phase = lean_ctx::core::phase_timing::enter("ctx_compose: ranking");
499        out.push_str(&ranked_files_budgeted(task, project_root, crp_mode));
500    }
501    if protected {
502        // These legacy stores do not retain enough original-source provenance
503        // to reauthorize derived records after a policy or rights change.
504        out.push_str("\nStored graph and memory enrichment withheld: source authorization is unavailable. Fresh exact matches and symbols follow.");
505    }
506    out.push('\n');
507
508    // 2. Exact match locations for the most specific identifier-shaped keyword.
509    // Broad prose words and acronyms create repository-wide README/Dockerfile
510    // noise. Within identifiers, the resident index ranks the rarest one first.
511    let exact_phase = lean_ctx::core::phase_timing::enter("ctx_compose: exact matches");
512    let ranked_keywords = if protected {
513        keywords.clone()
514    } else {
515        order_by_specificity(&keywords, project_root)
516    };
517    if let Some(primary) = ranked_keywords
518        .iter()
519        .find(|keyword| is_code_identifier(keyword))
520    {
521        out.push_str(&format!("\n## Exact matches: '{primary}'\n"));
522        out.push_str(&exact_matches_section(
523            primary,
524            project_root,
525            crp_mode,
526            allow_secret,
527        ));
528    }
529
530    // 3. Inline the symbol bodies that best cover the task keywords. Rather
531    //    than just the first match, select the non-redundant *set* of symbols
532    //    with maximal keyword coverage under a token budget via submodular
533    //    greedy (1−1/e optimal). Two keywords resolving to the same symbol, or
534    //    a symbol whose body adds no new keyword, are naturally pruned.
535    drop(exact_phase);
536    let symbols_phase = lean_ctx::core::phase_timing::enter("ctx_compose: symbol bodies");
537    use lean_ctx::core::context_packing::CoverageItem;
538    let mut snippets: Vec<String> = Vec::new();
539    let mut items: Vec<CoverageItem> = Vec::new();
540    for kw in &keywords {
541        if let Some((rendered, toks)) =
542            crate::tools::ctx_symbol::best_symbol_snippet_for_task(kw, task, project_root)
543        {
544            // The snippet always covers its triggering keyword, plus any other
545            // task keyword its body textually surfaces (a more central symbol).
546            let mut terms: std::collections::HashSet<String> =
547                std::collections::HashSet::from([kw.clone()]);
548            for other in &keywords {
549                if other != kw && rendered.contains(other.as_str()) {
550                    terms.insert(other.clone());
551                }
552            }
553            if let Some(index) = snippets.iter().position(|snippet| snippet == &rendered) {
554                items[index].terms.extend(terms);
555                continue;
556            }
557            items.push(CoverageItem {
558                terms,
559                cost: toks.max(1),
560            });
561            snippets.push(rendered);
562        }
563    }
564    if !items.is_empty() {
565        let selected = lean_ctx::core::context_packing::greedy_max_coverage(
566            &items,
567            symbol_budget_tokens(),
568            |_| 1.0,
569        );
570        let mut seen = std::collections::HashSet::new();
571        let mut header_written = false;
572        for idx in selected {
573            let rendered = snippets[idx].trim();
574            if rendered.is_empty() || !seen.insert(rendered.to_string()) {
575                continue;
576            }
577            if !header_written {
578                out.push_str("\n## Top symbols (bodies)\n");
579                header_written = true;
580            }
581            out.push_str(rendered);
582            out.push('\n');
583        }
584    }
585
586    // 4. (No associative "Related" section: measured 2026-10-16 over 882
587    //    replies it added no relevant file the ranked list had missed, at
588    //    ~10 % precision and ~110 tokens when shown.)
589    drop(symbols_phase);
590
591    // 4b. Small files in full: the bodies the agent would read next anyway.
592    {
593        let _phase = lean_ctx::core::phase_timing::enter("ctx_compose: small files");
594        out.push_str(&small_files_section(&out, project_root, allow_secret));
595    }
596
597    // 5. Context Kernel enrichment — cross-store context from knowledge,
598    //    session state and the indexes that the lexical pipeline misses.
599    //    Budget: 20% of symbol budget. Graceful no-op if kernel returns None.
600    if !protected {
601        use lean_ctx::core::context_kernel::context_dedup::dedup_kernel_blocks;
602        let _phase = lean_ctx::core::phase_timing::enter("ctx_compose: kernel enrichment");
603
604        let budget = kernel_supplement_budget(project_root);
605        if let Some(enrichment) =
606            lean_ctx::core::context_kernel::bridge::kernel_enrich(task, project_root, budget)
607                .filter(|enrichment| !enrichment.blocks.is_empty())
608        {
609            let blocks =
610                dedup_kernel_blocks(&enrichment.blocks, &mut std::collections::HashSet::new());
611            if !blocks.is_empty() {
612                out.push_str("\n## Context Kernel\n");
613                out.push_str(&blocks);
614                out.push('\n');
615            }
616        }
617    }
618
619    let sent = count_tokens(&out);
620    (out, sent)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn ranking_worker_keeps_original_source_policy_and_full_role() {
629        let _env = lean_ctx::core::data_dir::test_env_lock();
630        let policy = lean_ctx::core::policy::load(
631            "name = \"compose-worker\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
632             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n\
633             [redaction]\ncustomer = \"ACC-1234\"\n",
634        )
635        .unwrap();
636        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
637        let dir = tempfile::tempdir().unwrap();
638        std::fs::write(
639            dir.path().join("public.rs"),
640            "fn authenticate() { let customer = \"ACC-1234\"; }\n",
641        )
642        .unwrap();
643        std::fs::write(
644            dir.path().join("private.rs"),
645            "// CONFIDENTIAL\nfn authenticate_private() { let hidden = \"CLASSIFIED_CANARY\"; }\n",
646        )
647        .unwrap();
648        let root = dir.path().to_str().unwrap();
649        let rank = || {
650            lean_ctx::core::policy::runtime::with_project_source_view(root, || {
651                ranked_files_budgeted("authenticate", root, CrpMode::Off)
652            })
653            .unwrap()
654        };
655        let role = lean_ctx::core::roles::load_role("coder").unwrap();
656        lean_ctx::core::roles::with_test_active_role(role.clone(), || {
657            let ranked = rank();
658            assert!(ranked.contains("public.rs"), "{ranked}");
659            assert!(ranked.contains("REDACTED"), "{ranked}");
660            assert!(!ranked.contains("ACC-1234"));
661            assert!(!ranked.contains("private.rs"));
662            assert!(!ranked.contains("CLASSIFIED_CANARY"));
663            static ONE_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
664            let occupied = ONE_SLOT.try_acquire().unwrap();
665            let rank_with_limit = || {
666                lean_ctx::core::policy::runtime::with_project_source_view(root, || {
667                    ranked_files_with_slots("authenticate", root, CrpMode::Off, &ONE_SLOT)
668                })
669                .unwrap()
670            };
671            let busy = rank_with_limit();
672            assert!(busy.contains("ranking busy"));
673            assert!(!busy.contains("public.rs"));
674            drop(occupied);
675            let recovered = rank_with_limit();
676            assert!(recovered.contains("public.rs"));
677            assert!(recovered.contains("REDACTED"));
678            assert!(!recovered.contains("CLASSIFIED_CANARY"));
679        });
680        let mut denied = role;
681        denied.tools.denied.push("ctx_search".into());
682        lean_ctx::core::roles::with_test_active_role(denied, || {
683            let ranked = rank();
684            assert!(ranked.starts_with("ERR:"), "{ranked}");
685            assert!(!ranked.contains("public.rs"));
686        });
687    }
688
689    #[test]
690    fn rank_by_doc_freq_puts_rare_identifier_first() {
691        // The evcc/#993 shape: an "OCPP … GetMaxCurrent" task. `Current` and
692        // `OCPP` are common tokens; `GetMaxCurrent` is rare. The rare one must
693        // seed the exact-match grep so it lands on code, not README/Dockerfile.
694        let keywords = vec![
695            "OCPP".to_string(),
696            "GetMaxCurrent".to_string(),
697            "Current".to_string(),
698        ];
699        let doc_freqs = std::collections::HashMap::from([
700            ("ocpp".to_string(), 120),
701            ("current".to_string(), 400),
702            ("getmaxcurrent".to_string(), 3),
703        ]);
704        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
705        assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
706        assert_eq!(ranked.last().unwrap(), "Current");
707    }
708
709    /// Benchmark replay (feature-export-csv): after `ctx_compose` the agent's
710    /// next step read cli.py and test_core.py in full. Named small files now
711    /// come with the overview, verbatim; a large one and a secret-like one
712    /// named by the same sections do not.
713    #[test]
714    fn named_small_files_are_inlined_verbatim_within_budget() {
715        let _env = lean_ctx::core::data_dir::test_env_lock();
716        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
717        let dir = tempfile::tempdir().unwrap();
718        std::fs::write(
719            dir.path().join("pyproject.toml"),
720            "[project]\nname = \"x\"\n",
721        )
722        .unwrap();
723        std::fs::create_dir(dir.path().join("pkg")).unwrap();
724        let cli = "import argparse\n\n\ndef main():\n    return \"```\"\n";
725        std::fs::write(dir.path().join("pkg/cli.py"), cli).unwrap();
726        let big: String = (0..400).fold(String::new(), |mut acc, i| {
727            use std::fmt::Write as _;
728            let _ = writeln!(acc, "value_{i} = {i}");
729            acc
730        });
731        std::fs::write(dir.path().join("pkg/big.py"), &big).unwrap();
732        std::fs::write(dir.path().join(".env"), "TOKEN=abc\n").unwrap();
733        let composed = "## Ranked files (local BM25)\n\
734                        pkg/big.py :: value_1 [Variable] (L2-2)\n\
735                        pkg/cli.py :: main [Function] (L4-5)\n\
736                        ## Exact matches: 'main'\n\
737                        .env:1 TOKEN=abc\n\
738                        pkg/cli.py:4 def main():\n";
739        let root = dir.path().to_str().unwrap();
740        let section = lean_ctx::core::policy::runtime::with_project_source_view(root, || {
741            small_files_section(composed, root, false)
742        })
743        .unwrap();
744        assert!(
745            section.contains(&format!("File: pkg/cli.py (5 lines)\n````\n{cli}````\n")),
746            "{section}"
747        );
748        assert!(!section.contains("value_399"), "{section}");
749        assert!(!section.contains("TOKEN=abc"), "{section}");
750        assert_eq!(section.matches("File: ").count(), 1, "{section}");
751    }
752
753    #[test]
754    fn rank_by_doc_freq_sinks_absent_tokens_and_is_stable() {
755        // A token absent from the corpus (df 0) is useless as a grep seed and
756        // must sort last; equal-df tokens keep their original order.
757        let keywords = vec![
758            "absent".to_string(),
759            "alpha".to_string(),
760            "beta".to_string(),
761        ];
762        let doc_freqs =
763            std::collections::HashMap::from([("alpha".to_string(), 5), ("beta".to_string(), 5)]);
764        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
765        assert_eq!(ranked, vec!["alpha", "beta", "absent"]);
766    }
767
768    #[test]
769    fn rank_prefers_code_identifier_over_rarer_prose_word() {
770        // The regression the case-sensitive grep exposed: a rarer lowercase prose
771        // token (`measurand`, df 4) must NOT beat a camelCase identifier
772        // (`GetMaxCurrent`, df 30) as the seed — the identifier greps to code,
773        // the prose word whiffs against `Measurand`.
774        let keywords = vec!["measurand".to_string(), "GetMaxCurrent".to_string()];
775        let doc_freqs = std::collections::HashMap::from([
776            ("measurand".to_string(), 4),
777            ("getmaxcurrent".to_string(), 30),
778        ]);
779        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
780        assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
781    }
782
783    #[test]
784    fn is_code_identifier_classifies_camel_snake_vs_prose_and_acronym() {
785        assert!(is_code_identifier("GetMaxCurrent"));
786        assert!(is_code_identifier("CurrentGetter"));
787        assert!(is_code_identifier("get_max_current"));
788        // Leading-cap word and all-caps acronym are not code identifiers.
789        assert!(!is_code_identifier("Current"));
790        assert!(!is_code_identifier("OCPP"));
791        assert!(!is_code_identifier("charger"));
792    }
793
794    #[test]
795    fn extract_keywords_drops_stopwords_and_short_tokens() {
796        let kw = extract_keywords("How does the BM25Index cache work for ctx_search?", 6);
797        assert!(kw.contains(&"BM25Index".to_string()));
798        assert!(kw.contains(&"cache".to_string()));
799        assert!(kw.contains(&"ctx_search".to_string()));
800        assert!(!kw.iter().any(|k| k == "the" || k == "How" || k == "for"));
801    }
802
803    #[test]
804    fn extract_keywords_dedups_and_caps() {
805        let kw = extract_keywords("alpha alpha beta gamma delta epsilon zeta eta", 3);
806        assert_eq!(kw.len(), 3);
807        assert_eq!(kw[0], "alpha");
808    }
809
810    #[test]
811    fn exact_matches_choose_specific_identifier_not_first_broad_keyword() {
812        let keywords = extract_keywords(
813            "OCPP charger GetMaxCurrent Current.Offered measurand CurrentGetter",
814            6,
815        );
816        assert!(keywords.iter().any(|keyword| keyword == "GetMaxCurrent"));
817        assert!(keywords.iter().any(|keyword| is_code_identifier(keyword)));
818        assert!(!is_code_identifier("OCPP"));
819
820        let prose = extract_keywords("Fix semantic ranking exact matches", 6);
821        assert!(prose.iter().all(|keyword| !is_code_identifier(keyword)));
822    }
823
824    /// Regression: root-level docs that mention the identifier came first in
825    /// walk order and filled every slot; the definition in code never showed.
826    #[test]
827    fn exact_matches_rank_definition_and_code_above_prose() {
828        // The source view re-verifies policy and role after the search; a
829        // parallel test switching either (Windows CI) would void it.
830        let _env = lean_ctx::core::data_dir::test_env_lock();
831        let dir = tempfile::tempdir().unwrap();
832        let root = dir.path();
833        std::fs::write(root.join("AGENTS.md"), "use widget_factory\n".repeat(30)).unwrap();
834        std::fs::create_dir_all(root.join("src")).unwrap();
835        std::fs::write(
836            root.join("src/lib.rs"),
837            "pub fn widget_factory() -> u8 { 1 }\n",
838        )
839        .unwrap();
840        std::fs::write(
841            root.join("src/main.rs"),
842            "fn main() { lib::widget_factory(); }\n",
843        )
844        .unwrap();
845        let root_str = root.to_str().unwrap();
846        let out = lean_ctx::core::policy::runtime::with_project_source_view(root_str, || {
847            exact_matches_section("widget_factory", root_str, CrpMode::Off, false)
848        })
849        .expect("source view");
850
851        let defs = out.find("Definitions:").expect("definition section");
852        let uses = out.find("Uses in code:").expect("code section");
853        assert!(defs < uses, "{out}");
854        assert!(out.contains("lib.rs:1"), "definition found: {out}");
855        assert!(out.contains("main.rs:1"), "use found: {out}");
856        assert!(
857            !out.contains("AGENTS.md"),
858            "prose only when code has none: {out}"
859        );
860
861        let prose_only =
862            lean_ctx::core::policy::runtime::with_project_source_view(root_str, || {
863                exact_matches_section("use", root_str, CrpMode::Off, false)
864            })
865            .expect("source view");
866        assert!(prose_only.contains("AGENTS.md"), "{prose_only}");
867    }
868
869    #[test]
870    fn empty_task_is_rejected() {
871        let (out, tok) = handle("   ", "/tmp", CrpMode::Off);
872        assert!(out.starts_with("ERROR"));
873        assert_eq!(tok, 0);
874    }
875
876    #[test]
877    fn handle_withholds_context_when_project_authority_cannot_be_verified() {
878        let root = tempfile::tempdir().unwrap();
879        let missing = root.path().join("missing-project");
880        let (output, tokens) = handle(
881            "find authentication bugs",
882            missing.to_str().unwrap(),
883            CrpMode::Tdd,
884        );
885        assert_eq!(tokens, 0);
886        assert!(output.starts_with("ERROR"));
887        assert!(output.contains("source authority"));
888        assert!(!output.contains("TASK:"));
889    }
890
891    #[test]
892    fn deferred_ranking_note_is_deterministic_and_has_no_timing() {
893        // Issue #498 / #1366: elapsed_ms must never appear in the note — it
894        // varies between calls and defeats provider prompt caching.
895        let a = deferred_ranking_note();
896        let b = deferred_ranking_note();
897        assert_eq!(a, b, "deferred note must be byte-stable across calls");
898        assert!(
899            !a.contains("elapsed"),
900            "deferred note must not embed timing data: {a}"
901        );
902    }
903
904    #[test]
905    fn deferred_note_does_not_promise_cached_or_late_delivery() {
906        let note = deferred_ranking_note();
907        assert!(note.contains("late ranking output is discarded"));
908        assert!(note.contains("independently admitted"));
909        assert!(!note.contains("warming"));
910        assert!(!note.contains("next call"));
911    }
912}