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    /// Treatment variant that routes JSON/JSONL through the deduplicating
41    /// [`crate::core::json_crush`] core (lossless array crush) instead of the
42    /// whitespace-only compaction the generic [`aggressive_compress`] applies to
43    /// structured data. Used to measure json_crush's token savings and its
44    /// answer-preservation floor in isolation (#942).
45    JsonCrush,
46}
47
48impl Condition {
49    /// Stable label used in reports + the determinism digest.
50    pub fn label(self) -> &'static str {
51        match self {
52            Condition::Baseline => "baseline",
53            Condition::LeanCtx => "lean_ctx",
54            Condition::JsonCrush => "json_crush",
55        }
56    }
57}
58
59/// The assembled context for one (task, condition) pair.
60#[derive(Debug, Clone)]
61pub struct AssembledContext {
62    /// The exact context string placed before the task prompt.
63    pub text: String,
64    /// Token count of `text` (≤ budget).
65    pub tokens: usize,
66    /// Number of files that contributed.
67    pub files: usize,
68    /// Hex SHA-256 of `text` — the auditable context fingerprint.
69    pub digest: String,
70}
71
72/// Assembles the context for `condition` from `workspace`, honouring `budget` tokens.
73pub fn assemble(
74    condition: Condition,
75    workspace: &Path,
76    query: &str,
77    budget: usize,
78) -> Result<AssembledContext> {
79    let entries = match condition {
80        Condition::Baseline => baseline_entries(workspace),
81        Condition::LeanCtx => lean_ctx_entries(workspace, query),
82        Condition::JsonCrush => json_crush_entries(workspace, query),
83    };
84    Ok(pack(&entries, budget))
85}
86
87/// `(relpath, rendered_content)` in baseline order: every text file, path-sorted, raw.
88fn baseline_entries(workspace: &Path) -> Vec<(String, String)> {
89    let mut files = gather_text_files(workspace);
90    files.sort_by(|a, b| a.0.cmp(&b.0));
91    files
92}
93
94/// `(relpath, rendered_content)` in lean-ctx order: BM25-ranked by `query`, then compressed.
95fn lean_ctx_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
96    ranked_entries(workspace, query, |content, ext| {
97        aggressive_compress(content, ext)
98    })
99}
100
101/// Like [`lean_ctx_entries`], but JSON/JSONL files go through the deduplicating
102/// `json_crush` core (lossless) when it pays, instead of whitespace-only
103/// compaction. Every other file uses the same `aggressive_compress` path.
104fn json_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
105    ranked_entries(workspace, query, |content, ext| match ext {
106        Some("json" | "jsonl") => crate::core::json_crush::crush_text_if_beneficial(content)
107            .unwrap_or_else(|| aggressive_compress(content, ext)),
108        _ => aggressive_compress(content, ext),
109    })
110}
111
112/// Shared BM25-ranked assembly: rank `workspace` files by `query`, render each
113/// with `render(content, ext)`, dedup by path. Falls back to baseline ordering
114/// when retrieval is empty so a treatment condition is never accidentally empty.
115fn ranked_entries(
116    workspace: &Path,
117    query: &str,
118    render: impl Fn(&str, Option<&str>) -> String,
119) -> Vec<(String, String)> {
120    let index = BM25Index::build_from_directory(workspace);
121    let ranked = index.search(query, 256);
122    let mut out = Vec::new();
123    let mut seen = std::collections::HashSet::new();
124    for result in ranked {
125        if !seen.insert(result.file_path.clone()) {
126            continue;
127        }
128        let path = resolve(workspace, &result.file_path);
129        let Ok(content) = std::fs::read_to_string(&path) else {
130            continue;
131        };
132        let ext = path.extension().and_then(|e| e.to_str());
133        out.push((rel_label(workspace, &path), render(&content, ext)));
134    }
135    if out.is_empty() {
136        return baseline_entries(workspace);
137    }
138    out
139}
140
141/// Resolves a BM25 `file_path` (relative or absolute) against the workspace root.
142fn resolve(root: &Path, file_path: &str) -> PathBuf {
143    let p = Path::new(file_path);
144    if p.is_absolute() {
145        p.to_path_buf()
146    } else {
147        root.join(p)
148    }
149}
150
151/// Label for a file inside the workspace (relative when possible, else the file name).
152fn rel_label(root: &Path, path: &Path) -> String {
153    path.strip_prefix(root)
154        .unwrap_or(path)
155        .to_string_lossy()
156        .into_owned()
157}
158
159/// Walks `root` (respecting .gitignore + hidden filters) and returns every readable UTF-8 file
160/// under [`MAX_FILE_BYTES`] as `(relpath, content)`.
161fn gather_text_files(root: &Path) -> Vec<(String, String)> {
162    let mut out = Vec::new();
163    let walker = ignore::WalkBuilder::new(root)
164        .hidden(true)
165        .git_ignore(true)
166        .require_git(false)
167        .filter_entry(crate::core::walk_filter::keep_entry)
168        .build();
169    for entry in walker.flatten() {
170        if !entry.file_type().is_some_and(|t| t.is_file()) {
171            continue;
172        }
173        let path = entry.path();
174        if entry.metadata().map_or(u64::MAX, |m| m.len()) > MAX_FILE_BYTES {
175            continue;
176        }
177        if let Ok(content) = std::fs::read_to_string(path) {
178            out.push((rel_label(root, path), content));
179        }
180    }
181    out
182}
183
184/// Packs entries into one context string, greedily filling the budget then hard-capping it so
185/// the result is always ≤ `budget` tokens.
186fn pack(entries: &[(String, String)], budget: usize) -> AssembledContext {
187    let mut text = String::new();
188    let mut running = 0usize;
189    let mut files = 0usize;
190    for (label, content) in entries {
191        let block = format!("// file: {label}\n{content}\n\n");
192        let cost = count_tokens(&block);
193        if running > 0 && running + cost > budget {
194            continue;
195        }
196        text.push_str(&block);
197        running += cost;
198        files += 1;
199        if running >= budget {
200            break;
201        }
202    }
203    let capped = truncate_to_tokens(&text, budget);
204    let tokens = count_tokens(&capped);
205    let digest = sha256_hex(capped.as_bytes());
206    AssembledContext {
207        text: capped,
208        tokens,
209        files,
210        digest,
211    }
212}
213
214/// Returns the longest char-prefix of `text` that stays within `budget` tokens.
215fn truncate_to_tokens(text: &str, budget: usize) -> String {
216    if count_tokens(text) <= budget {
217        return text.to_string();
218    }
219    let chars: Vec<char> = text.chars().collect();
220    let (mut lo, mut hi) = (0usize, chars.len());
221    while lo < hi {
222        let mid = lo + (hi - lo).div_ceil(2);
223        let candidate: String = chars[..mid].iter().collect();
224        if count_tokens(&candidate) <= budget {
225            lo = mid;
226        } else {
227            hi = mid - 1;
228        }
229    }
230    chars[..lo].iter().collect()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::fs;
237
238    fn workspace() -> tempfile::TempDir {
239        let dir = tempfile::tempdir().unwrap();
240        fs::write(
241            dir.path().join("relevant.md"),
242            "The consolidation pipeline persists to bm25, graph, knowledge and session stores.",
243        )
244        .unwrap();
245        fs::write(
246            dir.path().join("noise.md"),
247            "Lorem ipsum dolor sit amet, totally unrelated filler content about cats and weather.",
248        )
249        .unwrap();
250        dir
251    }
252
253    #[test]
254    fn conditions_produce_distinct_digests() {
255        let ws = workspace();
256        let a = assemble(Condition::Baseline, ws.path(), "consolidation stores", 4000).unwrap();
257        let b = assemble(Condition::LeanCtx, ws.path(), "consolidation stores", 4000).unwrap();
258        assert!(a.tokens > 0 && b.tokens > 0);
259        assert_ne!(a.digest, b.digest, "raw vs compressed must differ");
260    }
261
262    #[test]
263    fn assembly_is_deterministic() {
264        let ws = workspace();
265        let first = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
266        let second = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
267        assert_eq!(first.digest, second.digest);
268    }
269
270    #[test]
271    fn json_crush_condition_beats_baseline_and_is_deterministic() {
272        let dir = tempfile::tempdir().unwrap();
273        let rows: Vec<String> = (0..30)
274            .map(|i| {
275                format!(
276                    "{{\"id\":{i},\"role\":\"operator\",\"status\":\"active\",\"region\":\"emea\"}}"
277                )
278            })
279            .collect();
280        fs::write(
281            dir.path().join("roster.json"),
282            format!("[{}]", rows.join(",")),
283        )
284        .unwrap();
285
286        let crushed = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
287        let baseline = assemble(Condition::Baseline, dir.path(), "roster operator", 4000).unwrap();
288        assert!(
289            crushed.tokens < baseline.tokens,
290            "crush {} must beat baseline {}",
291            crushed.tokens,
292            baseline.tokens
293        );
294
295        let again = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
296        assert_eq!(
297            crushed.digest, again.digest,
298            "json_crush assembly is deterministic"
299        );
300    }
301
302    #[test]
303    fn budget_is_respected() {
304        let ws = workspace();
305        let ctx = assemble(Condition::Baseline, ws.path(), "x", 12).unwrap();
306        assert!(ctx.tokens <= 12, "got {} tokens", ctx.tokens);
307    }
308
309    #[test]
310    fn truncate_caps_tokens() {
311        let long = "word ".repeat(5000);
312        let capped = truncate_to_tokens(&long, 50);
313        assert!(count_tokens(&capped) <= 50);
314    }
315}