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    /// Treatment variant that routes CSV/TSV through the columnar
47    /// `crate::core::tabular_crush` core (lossless constant-column hoisting)
48    /// instead of the line-based compaction the generic [`aggressive_compress`]
49    /// applies to delimited data. Measures tabular_crush's token savings and its
50    /// answer-preservation floor in isolation (#982).
51    TabularCrush,
52    /// Treatment variant that routes YAML through the `crate::core::yaml_crush`
53    /// core (YAML → compact JSON + lossless array factoring) instead of the
54    /// line-based compaction the generic [`aggressive_compress`] applies to YAML.
55    /// Measures yaml_crush's token savings and its answer-preservation floor in
56    /// isolation (#985).
57    YamlCrush,
58}
59
60impl Condition {
61    /// Stable label used in reports + the determinism digest.
62    pub fn label(self) -> &'static str {
63        match self {
64            Condition::Baseline => "baseline",
65            Condition::LeanCtx => "lean_ctx",
66            Condition::JsonCrush => "json_crush",
67            Condition::TabularCrush => "tabular_crush",
68            Condition::YamlCrush => "yaml_crush",
69        }
70    }
71}
72
73/// The assembled context for one (task, condition) pair.
74#[derive(Debug, Clone)]
75pub struct AssembledContext {
76    /// The exact context string placed before the task prompt.
77    pub text: String,
78    /// Token count of `text` (≤ budget).
79    pub tokens: usize,
80    /// Number of files that contributed.
81    pub files: usize,
82    /// Hex SHA-256 of `text` — the auditable context fingerprint.
83    pub digest: String,
84}
85
86/// Assembles the context for `condition` from `workspace`, honouring `budget` tokens.
87pub fn assemble(
88    condition: Condition,
89    workspace: &Path,
90    query: &str,
91    budget: usize,
92) -> Result<AssembledContext> {
93    let entries = match condition {
94        Condition::Baseline => baseline_entries(workspace),
95        Condition::LeanCtx => lean_ctx_entries(workspace, query),
96        Condition::JsonCrush => json_crush_entries(workspace, query),
97        Condition::TabularCrush => tabular_crush_entries(workspace, query),
98        Condition::YamlCrush => yaml_crush_entries(workspace, query),
99    };
100    Ok(pack(&entries, budget))
101}
102
103/// `(relpath, rendered_content)` in baseline order: every text file, path-sorted, raw.
104fn baseline_entries(workspace: &Path) -> Vec<(String, String)> {
105    let mut files = gather_text_files(workspace);
106    files.sort_by(|a, b| a.0.cmp(&b.0));
107    files
108}
109
110/// `(relpath, rendered_content)` in lean-ctx order: BM25-ranked by `query`, then compressed.
111fn lean_ctx_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
112    ranked_entries(workspace, query, |content, ext| {
113        aggressive_compress(content, ext)
114    })
115}
116
117/// Like [`lean_ctx_entries`], but JSON/JSONL files go through the deduplicating
118/// `json_crush` core (lossless) when it pays, instead of whitespace-only
119/// compaction. Every other file uses the same `aggressive_compress` path.
120fn json_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
121    ranked_entries(workspace, query, |content, ext| match ext {
122        Some("json" | "jsonl") => crate::core::json_crush::crush_text_if_beneficial(content)
123            .unwrap_or_else(|| aggressive_compress(content, ext)),
124        _ => aggressive_compress(content, ext),
125    })
126}
127
128/// Like [`lean_ctx_entries`], but CSV/TSV files go through the columnar
129/// `tabular_crush` core (lossless) when it pays, instead of line-based
130/// compaction. Every other file uses the same `aggressive_compress` path.
131fn tabular_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
132    ranked_entries(
133        workspace,
134        query,
135        |content, ext| match crate::core::compressor::tabular_delimiter(ext) {
136            Some(delim) => crate::core::tabular_crush::crush_text_if_beneficial(content, delim)
137                .unwrap_or_else(|| aggressive_compress(content, ext)),
138            None => aggressive_compress(content, ext),
139        },
140    )
141}
142
143/// Like [`lean_ctx_entries`], but YAML files go through the [`yaml_crush`] core
144/// (YAML → compact JSON + lossless array factoring) when it pays, instead of
145/// line-based compaction. Every other file uses the same `aggressive_compress`
146/// path.
147///
148/// [`yaml_crush`]: crate::core::yaml_crush
149fn yaml_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
150    ranked_entries(workspace, query, |content, ext| {
151        if crate::core::compressor::is_yaml_ext(ext) {
152            crate::core::yaml_crush::crush_text_if_beneficial(content)
153                .unwrap_or_else(|| aggressive_compress(content, ext))
154        } else {
155            aggressive_compress(content, ext)
156        }
157    })
158}
159
160/// Shared BM25-ranked assembly: rank `workspace` files by `query`, render each
161/// with `render(content, ext)`, dedup by path. Falls back to baseline ordering
162/// when retrieval is empty so a treatment condition is never accidentally empty.
163fn ranked_entries(
164    workspace: &Path,
165    query: &str,
166    render: impl Fn(&str, Option<&str>) -> String,
167) -> Vec<(String, String)> {
168    let index = BM25Index::build_from_directory(workspace);
169    let ranked = index.search(query, 256);
170    let mut out = Vec::new();
171    let mut seen = std::collections::HashSet::new();
172    for result in ranked {
173        if !seen.insert(result.file_path.clone()) {
174            continue;
175        }
176        let path = resolve(workspace, &result.file_path);
177        let Ok(content) = std::fs::read_to_string(&path) else {
178            continue;
179        };
180        let ext = path.extension().and_then(|e| e.to_str());
181        out.push((rel_label(workspace, &path), render(&content, ext)));
182    }
183    if out.is_empty() {
184        return baseline_entries(workspace);
185    }
186    out
187}
188
189/// Resolves a BM25 `file_path` (relative or absolute) against the workspace root.
190fn resolve(root: &Path, file_path: &str) -> PathBuf {
191    let p = Path::new(file_path);
192    if p.is_absolute() {
193        p.to_path_buf()
194    } else {
195        root.join(p)
196    }
197}
198
199/// Label for a file inside the workspace (relative when possible, else the file name).
200///
201/// Separators are normalized to `/` so the assembled context — and therefore
202/// every `RecordedRunner` replay key derived from it — is byte-identical on
203/// Windows and Unix (#498). Without this, a nested fixture such as
204/// `config/seed-data.json` would label as `config\seed-data.json` on Windows and
205/// never match the committed Unix-generated recording.
206fn rel_label(root: &Path, path: &Path) -> String {
207    path.strip_prefix(root)
208        .unwrap_or(path)
209        .to_string_lossy()
210        .replace('\\', "/")
211}
212
213/// Walks `root` (respecting .gitignore + hidden filters) and returns every readable UTF-8 file
214/// under [`MAX_FILE_BYTES`] as `(relpath, content)`.
215fn gather_text_files(root: &Path) -> Vec<(String, String)> {
216    let mut out = Vec::new();
217    let walker = ignore::WalkBuilder::new(root)
218        .hidden(true)
219        .git_ignore(true)
220        .require_git(false)
221        .filter_entry(crate::core::walk_filter::keep_entry)
222        .build();
223    for entry in walker.flatten() {
224        if !entry.file_type().is_some_and(|t| t.is_file()) {
225            continue;
226        }
227        let path = entry.path();
228        if entry.metadata().map_or(u64::MAX, |m| m.len()) > MAX_FILE_BYTES {
229            continue;
230        }
231        if let Ok(content) = std::fs::read_to_string(path) {
232            out.push((rel_label(root, path), content));
233        }
234    }
235    out
236}
237
238/// Packs entries into one context string, greedily filling the budget then hard-capping it so
239/// the result is always ≤ `budget` tokens.
240fn pack(entries: &[(String, String)], budget: usize) -> AssembledContext {
241    let mut text = String::new();
242    let mut running = 0usize;
243    let mut files = 0usize;
244    for (label, content) in entries {
245        let block = format!("// file: {label}\n{content}\n\n");
246        let cost = count_tokens(&block);
247        if running > 0 && running + cost > budget {
248            continue;
249        }
250        text.push_str(&block);
251        running += cost;
252        files += 1;
253        if running >= budget {
254            break;
255        }
256    }
257    let capped = truncate_to_tokens(&text, budget);
258    let tokens = count_tokens(&capped);
259    let digest = sha256_hex(capped.as_bytes());
260    AssembledContext {
261        text: capped,
262        tokens,
263        files,
264        digest,
265    }
266}
267
268/// Returns the longest char-prefix of `text` that stays within `budget` tokens.
269fn truncate_to_tokens(text: &str, budget: usize) -> String {
270    if count_tokens(text) <= budget {
271        return text.to_string();
272    }
273    let chars: Vec<char> = text.chars().collect();
274    let (mut lo, mut hi) = (0usize, chars.len());
275    while lo < hi {
276        let mid = lo + (hi - lo).div_ceil(2);
277        let candidate: String = chars[..mid].iter().collect();
278        if count_tokens(&candidate) <= budget {
279            lo = mid;
280        } else {
281            hi = mid - 1;
282        }
283    }
284    chars[..lo].iter().collect()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::fs;
291
292    fn workspace() -> tempfile::TempDir {
293        let dir = tempfile::tempdir().unwrap();
294        fs::write(
295            dir.path().join("relevant.md"),
296            "The consolidation pipeline persists to bm25, graph, knowledge and session stores.",
297        )
298        .unwrap();
299        fs::write(
300            dir.path().join("noise.md"),
301            "Lorem ipsum dolor sit amet, totally unrelated filler content about cats and weather.",
302        )
303        .unwrap();
304        dir
305    }
306
307    #[test]
308    fn conditions_produce_distinct_digests() {
309        let ws = workspace();
310        let a = assemble(Condition::Baseline, ws.path(), "consolidation stores", 4000).unwrap();
311        let b = assemble(Condition::LeanCtx, ws.path(), "consolidation stores", 4000).unwrap();
312        assert!(a.tokens > 0 && b.tokens > 0);
313        assert_ne!(a.digest, b.digest, "raw vs compressed must differ");
314    }
315
316    #[test]
317    fn assembly_is_deterministic() {
318        let ws = workspace();
319        let first = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
320        let second = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
321        assert_eq!(first.digest, second.digest);
322    }
323
324    #[test]
325    fn json_crush_condition_beats_baseline_and_is_deterministic() {
326        let dir = tempfile::tempdir().unwrap();
327        let rows: Vec<String> = (0..30)
328            .map(|i| {
329                format!(
330                    "{{\"id\":{i},\"role\":\"operator\",\"status\":\"active\",\"region\":\"emea\"}}"
331                )
332            })
333            .collect();
334        fs::write(
335            dir.path().join("roster.json"),
336            format!("[{}]", rows.join(",")),
337        )
338        .unwrap();
339
340        let crushed = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
341        let baseline = assemble(Condition::Baseline, dir.path(), "roster operator", 4000).unwrap();
342        assert!(
343            crushed.tokens < baseline.tokens,
344            "crush {} must beat baseline {}",
345            crushed.tokens,
346            baseline.tokens
347        );
348
349        let again = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
350        assert_eq!(
351            crushed.digest, again.digest,
352            "json_crush assembly is deterministic"
353        );
354    }
355
356    #[test]
357    fn tabular_crush_condition_beats_baseline_and_is_deterministic() {
358        let dir = tempfile::tempdir().unwrap();
359        let mut csv = String::from("id,name,status,region,tier\n");
360        for i in 0..40 {
361            csv.push_str(&format!("{i},user{i},active,eu-central-1,standard\n"));
362        }
363        fs::write(dir.path().join("roster.csv"), csv).unwrap();
364
365        let crushed = assemble(Condition::TabularCrush, dir.path(), "roster status", 4000).unwrap();
366        let baseline = assemble(Condition::Baseline, dir.path(), "roster status", 4000).unwrap();
367        assert!(
368            crushed.tokens < baseline.tokens,
369            "tabular crush {} must beat baseline {}",
370            crushed.tokens,
371            baseline.tokens
372        );
373
374        let again = assemble(Condition::TabularCrush, dir.path(), "roster status", 4000).unwrap();
375        assert_eq!(
376            crushed.digest, again.digest,
377            "tabular_crush assembly is deterministic"
378        );
379    }
380
381    #[test]
382    fn yaml_crush_condition_beats_baseline_and_is_deterministic() {
383        let dir = tempfile::tempdir().unwrap();
384        let mut yaml = String::from("items:\n");
385        for i in 0..40 {
386            yaml.push_str(&format!(
387                "  - apiVersion: v1\n    kind: Pod\n    namespace: prod\n    name: pod-{i}\n"
388            ));
389        }
390        fs::write(dir.path().join("pods.yaml"), yaml).unwrap();
391
392        let crushed = assemble(Condition::YamlCrush, dir.path(), "pod namespace", 4000).unwrap();
393        let baseline = assemble(Condition::Baseline, dir.path(), "pod namespace", 4000).unwrap();
394        assert!(
395            crushed.tokens < baseline.tokens,
396            "yaml crush {} must beat baseline {}",
397            crushed.tokens,
398            baseline.tokens
399        );
400
401        let again = assemble(Condition::YamlCrush, dir.path(), "pod namespace", 4000).unwrap();
402        assert_eq!(
403            crushed.digest, again.digest,
404            "yaml_crush assembly is deterministic"
405        );
406    }
407
408    #[test]
409    fn budget_is_respected() {
410        let ws = workspace();
411        let ctx = assemble(Condition::Baseline, ws.path(), "x", 12).unwrap();
412        assert!(ctx.tokens <= 12, "got {} tokens", ctx.tokens);
413    }
414
415    #[test]
416    fn truncate_caps_tokens() {
417        let long = "word ".repeat(5000);
418        let capped = truncate_to_tokens(&long, 50);
419        assert!(count_tokens(&capped) <= 50);
420    }
421}