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 .build();
138 for entry in walker.flatten() {
139 if !entry.file_type().is_some_and(|t| t.is_file()) {
140 continue;
141 }
142 let path = entry.path();
143 if entry.metadata().map_or(u64::MAX, |m| m.len()) > MAX_FILE_BYTES {
144 continue;
145 }
146 if let Ok(content) = std::fs::read_to_string(path) {
147 out.push((rel_label(root, path), content));
148 }
149 }
150 out
151}
152
153fn pack(entries: &[(String, String)], budget: usize) -> AssembledContext {
156 let mut text = String::new();
157 let mut running = 0usize;
158 let mut files = 0usize;
159 for (label, content) in entries {
160 let block = format!("// file: {label}\n{content}\n\n");
161 let cost = count_tokens(&block);
162 if running > 0 && running + cost > budget {
163 continue;
164 }
165 text.push_str(&block);
166 running += cost;
167 files += 1;
168 if running >= budget {
169 break;
170 }
171 }
172 let capped = truncate_to_tokens(&text, budget);
173 let tokens = count_tokens(&capped);
174 let digest = sha256_hex(capped.as_bytes());
175 AssembledContext {
176 text: capped,
177 tokens,
178 files,
179 digest,
180 }
181}
182
183fn truncate_to_tokens(text: &str, budget: usize) -> String {
185 if count_tokens(text) <= budget {
186 return text.to_string();
187 }
188 let chars: Vec<char> = text.chars().collect();
189 let (mut lo, mut hi) = (0usize, chars.len());
190 while lo < hi {
191 let mid = lo + (hi - lo).div_ceil(2);
192 let candidate: String = chars[..mid].iter().collect();
193 if count_tokens(&candidate) <= budget {
194 lo = mid;
195 } else {
196 hi = mid - 1;
197 }
198 }
199 chars[..lo].iter().collect()
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::fs;
206
207 fn workspace() -> tempfile::TempDir {
208 let dir = tempfile::tempdir().unwrap();
209 fs::write(
210 dir.path().join("relevant.md"),
211 "The consolidation pipeline persists to bm25, graph, knowledge and session stores.",
212 )
213 .unwrap();
214 fs::write(
215 dir.path().join("noise.md"),
216 "Lorem ipsum dolor sit amet, totally unrelated filler content about cats and weather.",
217 )
218 .unwrap();
219 dir
220 }
221
222 #[test]
223 fn conditions_produce_distinct_digests() {
224 let ws = workspace();
225 let a = assemble(Condition::Baseline, ws.path(), "consolidation stores", 4000).unwrap();
226 let b = assemble(Condition::LeanCtx, ws.path(), "consolidation stores", 4000).unwrap();
227 assert!(a.tokens > 0 && b.tokens > 0);
228 assert_ne!(a.digest, b.digest, "raw vs compressed must differ");
229 }
230
231 #[test]
232 fn assembly_is_deterministic() {
233 let ws = workspace();
234 let first = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
235 let second = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
236 assert_eq!(first.digest, second.digest);
237 }
238
239 #[test]
240 fn budget_is_respected() {
241 let ws = workspace();
242 let ctx = assemble(Condition::Baseline, ws.path(), "x", 12).unwrap();
243 assert!(ctx.tokens <= 12, "got {} tokens", ctx.tokens);
244 }
245
246 #[test]
247 fn truncate_caps_tokens() {
248 let long = "word ".repeat(5000);
249 let capped = truncate_to_tokens(&long, 50);
250 assert!(count_tokens(&capped) <= 50);
251 }
252}