lean_ctx/core/eval_ab/
conditions.rs1use std::path::{Path, PathBuf};
16
17use anyhow::Result;
18use serde::{Deserialize, Serialize};
19
20use crate::core::bm25_index::BM25Index;
21use crate::core::compressor::aggressive_compress;
22use crate::core::tokens::count_tokens;
23
24use super::sha256_hex;
25
26pub const DEFAULT_BUDGET_TOKENS: usize = 4000;
28
29const MAX_FILE_BYTES: u64 = 256 * 1024;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Condition {
36 Baseline,
38 LeanCtx,
40}
41
42impl Condition {
43 pub fn label(self) -> &'static str {
45 match self {
46 Condition::Baseline => "baseline",
47 Condition::LeanCtx => "lean_ctx",
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct AssembledContext {
55 pub text: String,
57 pub tokens: usize,
59 pub files: usize,
61 pub digest: String,
63}
64
65pub fn assemble(
67 condition: Condition,
68 workspace: &Path,
69 query: &str,
70 budget: usize,
71) -> Result<AssembledContext> {
72 let entries = match condition {
73 Condition::Baseline => baseline_entries(workspace),
74 Condition::LeanCtx => lean_ctx_entries(workspace, query),
75 };
76 Ok(pack(&entries, budget))
77}
78
79fn baseline_entries(workspace: &Path) -> Vec<(String, String)> {
81 let mut files = gather_text_files(workspace);
82 files.sort_by(|a, b| a.0.cmp(&b.0));
83 files
84}
85
86fn lean_ctx_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
88 let index = BM25Index::build_from_directory(workspace);
89 let ranked = index.search(query, 256);
90 let mut out = Vec::new();
91 let mut seen = std::collections::HashSet::new();
92 for result in ranked {
93 if !seen.insert(result.file_path.clone()) {
94 continue;
95 }
96 let path = resolve(workspace, &result.file_path);
97 let Ok(content) = std::fs::read_to_string(&path) else {
98 continue;
99 };
100 let ext = path.extension().and_then(|e| e.to_str());
101 let compressed = aggressive_compress(&content, ext);
102 out.push((rel_label(workspace, &path), compressed));
103 }
104 if out.is_empty() {
107 return baseline_entries(workspace);
108 }
109 out
110}
111
112fn resolve(root: &Path, file_path: &str) -> PathBuf {
114 let p = Path::new(file_path);
115 if p.is_absolute() {
116 p.to_path_buf()
117 } else {
118 root.join(p)
119 }
120}
121
122fn rel_label(root: &Path, path: &Path) -> String {
124 path.strip_prefix(root)
125 .unwrap_or(path)
126 .to_string_lossy()
127 .into_owned()
128}
129
130fn gather_text_files(root: &Path) -> Vec<(String, String)> {
133 let mut out = Vec::new();
134 let walker = ignore::WalkBuilder::new(root)
135 .hidden(true)
136 .git_ignore(true)
137 .require_git(false)
138 .filter_entry(crate::core::walk_filter::keep_entry)
139 .build();
140 for entry in walker.flatten() {
141 if !entry.file_type().is_some_and(|t| t.is_file()) {
142 continue;
143 }
144 let path = entry.path();
145 if entry.metadata().map_or(u64::MAX, |m| m.len()) > MAX_FILE_BYTES {
146 continue;
147 }
148 if let Ok(content) = std::fs::read_to_string(path) {
149 out.push((rel_label(root, path), content));
150 }
151 }
152 out
153}
154
155fn pack(entries: &[(String, String)], budget: usize) -> AssembledContext {
158 let mut text = String::new();
159 let mut running = 0usize;
160 let mut files = 0usize;
161 for (label, content) in entries {
162 let block = format!("// file: {label}\n{content}\n\n");
163 let cost = count_tokens(&block);
164 if running > 0 && running + cost > budget {
165 continue;
166 }
167 text.push_str(&block);
168 running += cost;
169 files += 1;
170 if running >= budget {
171 break;
172 }
173 }
174 let capped = truncate_to_tokens(&text, budget);
175 let tokens = count_tokens(&capped);
176 let digest = sha256_hex(capped.as_bytes());
177 AssembledContext {
178 text: capped,
179 tokens,
180 files,
181 digest,
182 }
183}
184
185fn truncate_to_tokens(text: &str, budget: usize) -> String {
187 if count_tokens(text) <= budget {
188 return text.to_string();
189 }
190 let chars: Vec<char> = text.chars().collect();
191 let (mut lo, mut hi) = (0usize, chars.len());
192 while lo < hi {
193 let mid = lo + (hi - lo).div_ceil(2);
194 let candidate: String = chars[..mid].iter().collect();
195 if count_tokens(&candidate) <= budget {
196 lo = mid;
197 } else {
198 hi = mid - 1;
199 }
200 }
201 chars[..lo].iter().collect()
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use std::fs;
208
209 fn workspace() -> tempfile::TempDir {
210 let dir = tempfile::tempdir().unwrap();
211 fs::write(
212 dir.path().join("relevant.md"),
213 "The consolidation pipeline persists to bm25, graph, knowledge and session stores.",
214 )
215 .unwrap();
216 fs::write(
217 dir.path().join("noise.md"),
218 "Lorem ipsum dolor sit amet, totally unrelated filler content about cats and weather.",
219 )
220 .unwrap();
221 dir
222 }
223
224 #[test]
225 fn conditions_produce_distinct_digests() {
226 let ws = workspace();
227 let a = assemble(Condition::Baseline, ws.path(), "consolidation stores", 4000).unwrap();
228 let b = assemble(Condition::LeanCtx, ws.path(), "consolidation stores", 4000).unwrap();
229 assert!(a.tokens > 0 && b.tokens > 0);
230 assert_ne!(a.digest, b.digest, "raw vs compressed must differ");
231 }
232
233 #[test]
234 fn assembly_is_deterministic() {
235 let ws = workspace();
236 let first = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
237 let second = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
238 assert_eq!(first.digest, second.digest);
239 }
240
241 #[test]
242 fn budget_is_respected() {
243 let ws = workspace();
244 let ctx = assemble(Condition::Baseline, ws.path(), "x", 12).unwrap();
245 assert!(ctx.tokens <= 12, "got {} tokens", ctx.tokens);
246 }
247
248 #[test]
249 fn truncate_caps_tokens() {
250 let long = "word ".repeat(5000);
251 let capped = truncate_to_tokens(&long, 50);
252 assert!(count_tokens(&capped) <= 50);
253 }
254}