Skip to main content

lean_ctx/core/eval_ab/
conditions.rs

1//! A/B context conditions (#235): the two context layers a task is run under.
2//!
3//! Both conditions are handed the **same token budget**, so any quality difference is
4//! attributable to *what* lean-ctx put in the window, not *how much*:
5//!
6//! * [`Condition::Baseline`] — "without lean-ctx": raw files in deterministic path order,
7//!   packed until the budget is full (the naive "dump the repo" approach).
8//! * [`Condition::LeanCtx`] — "with lean-ctx": BM25 relevance-ranks files against the task
9//!   query, then packs them through [`aggressive_compress`] so more *relevant* signal fits in
10//!   the same budget.
11//!
12//! Every assembled context carries a `digest` (hex SHA-256 of the exact bytes) so the report
13//! can prove which window each answer was produced from.
14
15use 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
26/// Default budget both conditions respect. Small enough to force selection pressure.
27pub const DEFAULT_BUDGET_TOKENS: usize = 4000;
28
29/// Largest single file (bytes) considered for context — skips vendored blobs / binaries.
30const MAX_FILE_BYTES: u64 = 256 * 1024;
31
32/// Which context layer the model receives for a task.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Condition {
36    /// Baseline — "without lean-ctx".
37    Baseline,
38    /// Treatment — "with lean-ctx".
39    LeanCtx,
40}
41
42impl Condition {
43    /// Stable label used in reports + the determinism digest.
44    pub fn label(self) -> &'static str {
45        match self {
46            Condition::Baseline => "baseline",
47            Condition::LeanCtx => "lean_ctx",
48        }
49    }
50}
51
52/// The assembled context for one (task, condition) pair.
53#[derive(Debug, Clone)]
54pub struct AssembledContext {
55    /// The exact context string placed before the task prompt.
56    pub text: String,
57    /// Token count of `text` (≤ budget).
58    pub tokens: usize,
59    /// Number of files that contributed.
60    pub files: usize,
61    /// Hex SHA-256 of `text` — the auditable context fingerprint.
62    pub digest: String,
63}
64
65/// Assembles the context for `condition` from `workspace`, honouring `budget` tokens.
66pub 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
79/// `(relpath, rendered_content)` in baseline order: every text file, path-sorted, raw.
80fn 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
86/// `(relpath, rendered_content)` in lean-ctx order: BM25-ranked by `query`, then compressed.
87fn 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 retrieval found nothing (e.g. empty index), fall back to the baseline ordering so the
105    // treatment condition is never empty by accident.
106    if out.is_empty() {
107        return baseline_entries(workspace);
108    }
109    out
110}
111
112/// Resolves a BM25 `file_path` (relative or absolute) against the workspace root.
113fn 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
122/// Label for a file inside the workspace (relative when possible, else the file name).
123fn 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
130/// Walks `root` (respecting .gitignore + hidden filters) and returns every readable UTF-8 file
131/// under [`MAX_FILE_BYTES`] as `(relpath, content)`.
132fn 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
153/// Packs entries into one context string, greedily filling the budget then hard-capping it so
154/// the result is always ≤ `budget` tokens.
155fn 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
183/// Returns the longest char-prefix of `text` that stays within `budget` tokens.
184fn 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}