Skip to main content

lean_ctx/cli/
eval_cmd.rs

1//! `lean-ctx eval` — deterministic with/without output-quality eval CLI (#238).
2//!
3//! Subcommands:
4//! * `eval init <dir>`     — scaffold a runnable starter suite (one QA + one code task).
5//! * `eval ab --suite P`   — run the A/B comparison and write a signed, reproducible artifact.
6//! * `eval verify <file>`  — verify an artifact's signature + determinism digest offline.
7
8use std::path::{Path, PathBuf};
9
10use crate::core::eval_ab::artifact::{self, SignedAbReportV1};
11use crate::core::eval_ab::footprint::{
12    Footprint, FootprintConfig, FootprintReport, run_footprint_ab,
13};
14use crate::core::eval_ab::model::{ModelRunner, OpenAiRunner, RecordedRunner, RecordingRunner};
15use crate::core::eval_ab::report::ReportConfig;
16use crate::core::eval_ab::routing_eval::{RoutingEvalConfig, run_routing_eval};
17use crate::core::eval_ab::suite::EvalSuite;
18use crate::core::eval_ab::testbench::lockfile::TestbenchLock;
19use crate::core::eval_ab::testbench::{self, TestbenchConfig, TestbenchReport, findings};
20use crate::core::eval_ab::{AbRunConfig, run_ab};
21
22/// Entry point dispatched from `cli::dispatch`.
23pub fn cmd_eval(args: &[String]) {
24    // `eval --delta [opts]` is sugar for the footprint subcommand.
25    if args.iter().any(|a| a == "--delta") {
26        let rest: Vec<String> = args.iter().filter(|a| *a != "--delta").cloned().collect();
27        return cmd_footprint(&rest);
28    }
29    match args.first().map(String::as_str) {
30        Some("ab") => cmd_ab(&args[1..]),
31        Some("footprint" | "delta") => cmd_footprint(&args[1..]),
32        Some("routing") => cmd_routing(&args[1..]),
33        Some("testbench") => cmd_testbench(&args[1..]),
34        Some("verify") => cmd_verify(&args[1..]),
35        Some("init") => cmd_init(&args[1..]),
36        Some("-h" | "--help") | None => print_help(),
37        Some(other) => {
38            eprintln!("eval: unknown subcommand '{other}'\n");
39            print_help();
40            std::process::exit(2);
41        }
42    }
43}
44
45fn print_help() {
46    println!(
47        "lean-ctx eval — deterministic with/without output-quality proof\n\n\
48USAGE:\n\
49  lean-ctx eval init <dir>                 Scaffold a runnable starter suite\n\
50  lean-ctx eval ab --suite <file> [opts]   Run the A/B quality comparison\n\
51  lean-ctx eval footprint --suite <f> [o]  Ablate lean-ctx's OWN injected context (#959)\n\
52  lean-ctx eval routing --suite <f> [o]    Router off-vs-on rate-card savings proof\n\
53  lean-ctx eval testbench --lock <f> [o]   Off-vs-on across pinned real repos (#611)\n\
54  lean-ctx eval verify <artifact.json>     Verify signature + determinism digest\n\n\
55ab OPTIONS:\n\
56  --suite <file>     NDJSON suite (required)\n\
57  --budget <n>       Token budget per condition (default 4000)\n\
58  --margin <f>       Non-inferiority margin for the gate (default 0.0)\n\
59  --out <file>       Artifact path (default: data dir)\n\
60  --replay <file>    Replay a recording instead of calling a live model (deterministic CI)\n\
61  --record <file>    Call the live model and save responses to a recording\n\
62  --gate             Exit non-zero if the verdict is a regression\n\n\
63footprint OPTIONS (also: `eval --delta`):\n\
64  --suite <file>     Footprint-sensitive NDJSON suite (required)\n\
65  --margin <f>       Non-inferiority margin for the per-element gate (default 0.0)\n\
66  --floor <n>        Min marginal tokens before flagging an element to prune (default 50)\n\
67  --replay <file>    Replay a recording (deterministic); --record to capture live\n\
68  --json             Emit the full JSON report instead of the side-by-side table\n\
69  --gate             Exit non-zero if any injected element is actively harmful\n\n\
70routing OPTIONS:\n\
71  --suite <file>     NDJSON suite of real task prompts (required)\n\
72  --requested <m>    Model the off-arm assumes (default: [proxy.baseline].reference_model)\n\
73  --json             Emit the full JSON report instead of the table\n\
74  --gate             Exit non-zero if routing downgraded premium work\n\
75  Rules come from [proxy.routing] in config.toml — the deployment's live rule set.\n\n\
76testbench OPTIONS:\n\
77  --lock <file>      Pinned-repo lockfile (default eval/testbench/testbench.lock.json)\n\
78  --out <dir>        Output dir for FINDINGS.md + regressions.json (default testbench-out)\n\
79  --cache <dir>      Clone cache for remote repos (default <out>/cache)\n\
80  --budget <n>       Token budget per condition (default 4000)\n\
81  --margin <f>       Non-inferiority margin for the per-repo gate (default 0.0)\n\
82  --replay <file>    Replay a recording (deterministic CI); --record to capture live\n\
83  --gate             Exit non-zero if any repo regressed\n\n\
84LIVE MODEL (when not replaying) is read from the environment:\n\
85  LEAN_CTX_EVAL_MODEL_URL   OpenAI-compatible base URL (e.g. https://api.openai.com/v1)\n\
86  LEAN_CTX_EVAL_MODEL       Model id (e.g. gpt-4o-mini)\n\
87  LEAN_CTX_EVAL_MODEL_KEY   API key (optional for local servers)\n\
88  LEAN_CTX_EVAL_SEED        Decoding seed (default 7)"
89    );
90}
91
92/// Returns the value following `flag` in `args`, if present.
93fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
94    args.iter()
95        .position(|a| a == flag)
96        .and_then(|i| args.get(i + 1))
97        .map(String::as_str)
98}
99
100fn has_flag(args: &[String], flag: &str) -> bool {
101    args.iter().any(|a| a == flag)
102}
103
104fn cmd_ab(args: &[String]) {
105    let Some(suite_path) = flag_value(args, "--suite") else {
106        eprintln!("eval ab: --suite <file> is required");
107        std::process::exit(2);
108    };
109    let suite_path = PathBuf::from(suite_path);
110    let suite = match EvalSuite::load(&suite_path) {
111        Ok(s) => s,
112        Err(e) => {
113            eprintln!("eval ab: {e:#}");
114            std::process::exit(1);
115        }
116    };
117    let suite_name = suite_path
118        .file_name()
119        .map_or_else(|| "suite".to_string(), |s| s.to_string_lossy().into_owned());
120
121    let mut cfg = AbRunConfig::default();
122    if let Some(b) = flag_value(args, "--budget").and_then(|v| v.parse().ok()) {
123        cfg.budget_tokens = b;
124    }
125    cfg.report = ReportConfig {
126        noninferiority_margin: flag_value(args, "--margin")
127            .and_then(|v| v.parse().ok())
128            .unwrap_or(0.0),
129        ..ReportConfig::default()
130    };
131
132    // Runner selection: replay (deterministic) > live + record > live.
133    let report = if let Some(replay) = flag_value(args, "--replay") {
134        let runner = match RecordedRunner::from_file(Path::new(replay)) {
135            Ok(r) => r,
136            Err(e) => {
137                eprintln!("eval ab: {e:#}");
138                std::process::exit(1);
139            }
140        };
141        run_or_exit(&suite, &suite_name, &runner, &cfg)
142    } else {
143        let live = match OpenAiRunner::from_env() {
144            Ok(r) => r,
145            Err(e) => {
146                eprintln!(
147                    "eval ab: no live model configured: {e:#}\n(use --replay <file> for an offline run)"
148                );
149                std::process::exit(1);
150            }
151        };
152        if let Some(record_path) = flag_value(args, "--record") {
153            let recorder = RecordingRunner::new(live);
154            let report = run_or_exit(&suite, &suite_name, &recorder, &cfg);
155            if let Err(e) = recorder.into_recording().save(Path::new(record_path)) {
156                eprintln!("eval ab: failed to save recording: {e:#}");
157                std::process::exit(1);
158            }
159            println!("Recording saved → {record_path}");
160            report
161        } else {
162            run_or_exit(&suite, &suite_name, &live, &cfg)
163        }
164    };
165
166    // Sign + persist the artifact.
167    let agent_id = crate::core::agent_identity::current_agent_id().to_string();
168    let mut signed = SignedAbReportV1::from_report(report, &agent_id);
169    if let Err(e) = signed.sign(&agent_id) {
170        eprintln!("eval ab: signing failed: {e}");
171        std::process::exit(1);
172    }
173    let out = match flag_value(args, "--out") {
174        Some(p) => PathBuf::from(p),
175        None => match artifact::default_artifact_path() {
176            Ok(p) => p,
177            Err(e) => {
178                eprintln!("eval ab: {e}");
179                std::process::exit(1);
180            }
181        },
182    };
183    if let Err(e) = artifact::write_artifact(&signed, &out) {
184        eprintln!("eval ab: {e}");
185        std::process::exit(1);
186    }
187
188    println!("{}", signed.report.render());
189    println!("determinism digest: {}", signed.determinism_digest);
190    println!("artifact:           {}", out.display());
191
192    if has_flag(args, "--gate") && !signed.verdict.gate_passes() {
193        eprintln!("\nquality gate FAILED: {}", signed.verdict.label());
194        std::process::exit(1);
195    }
196}
197
198fn run_or_exit(
199    suite: &EvalSuite,
200    suite_name: &str,
201    runner: &dyn crate::core::eval_ab::model::ModelRunner,
202    cfg: &AbRunConfig,
203) -> crate::core::eval_ab::report::AbReport {
204    match run_ab(suite, suite_name, runner, cfg) {
205        Ok(r) => r,
206        Err(e) => {
207            eprintln!("eval ab: run failed: {e:#}");
208            std::process::exit(1);
209        }
210    }
211}
212
213/// `eval routing`: off-vs-on rate-card savings proof for the active router
214/// (enterprise#13/#21). Runs the deployment's `[proxy.routing]` rules and the
215/// production intent classifier over a suite's real prompts, priced from the
216/// shared pricing table; gates on "premium is never downgraded".
217fn cmd_routing(args: &[String]) {
218    let Some(suite_path) = flag_value(args, "--suite") else {
219        eprintln!("eval routing: --suite <file> is required");
220        std::process::exit(2);
221    };
222    let suite_path = PathBuf::from(suite_path);
223    let suite = match EvalSuite::load(&suite_path) {
224        Ok(s) => s,
225        Err(e) => {
226            eprintln!("eval routing: {e:#}");
227            std::process::exit(1);
228        }
229    };
230    let suite_name = suite_path
231        .file_name()
232        .map_or_else(|| "suite".to_string(), |s| s.to_string_lossy().into_owned());
233
234    let config = crate::core::config::Config::load();
235    let requested_model = flag_value(args, "--requested")
236        .map(str::to_string)
237        .or_else(|| config.proxy.baseline.reference_model.clone());
238    let Some(requested_model) = requested_model else {
239        eprintln!(
240            "eval routing: no requested model — pass --requested <model> or set \
241             [proxy.baseline].reference_model in config.toml"
242        );
243        std::process::exit(2);
244    };
245
246    let cfg = RoutingEvalConfig {
247        requested_model,
248        rules: config.proxy.routing.clone(),
249    };
250    let pricing = crate::core::gain::model_pricing::ModelPricing::load();
251    let report = match run_routing_eval(&suite, &suite_name, &pricing, &cfg) {
252        Ok(r) => r,
253        Err(e) => {
254            eprintln!("eval routing: {e:#}");
255            std::process::exit(1);
256        }
257    };
258
259    if has_flag(args, "--json") {
260        println!("{}", report.to_json());
261    } else {
262        println!("{}", report.render());
263    }
264    println!("determinism digest: {}", report.determinism_digest());
265
266    if has_flag(args, "--gate") && !report.gate_passes() {
267        eprintln!(
268            "\nrouting gate FAILED: {} premium task(s) downgraded",
269            report.premium_downgrades
270        );
271        std::process::exit(1);
272    }
273}
274
275/// `eval footprint` (alias `eval --delta`): ablate each element of lean-ctx's own
276/// injected context (rules / tool schemas / wakeup) and report per-element
277/// pass-rate Δ + token Δ with a prune recommendation (#959).
278fn cmd_footprint(args: &[String]) {
279    let Some(suite_path) = flag_value(args, "--suite") else {
280        eprintln!("eval footprint: --suite <file> is required");
281        std::process::exit(2);
282    };
283    let suite_path = PathBuf::from(suite_path);
284    let suite = match EvalSuite::load(&suite_path) {
285        Ok(s) => s,
286        Err(e) => {
287            eprintln!("eval footprint: {e:#}");
288            std::process::exit(1);
289        }
290    };
291    let suite_name = suite_path.file_name().map_or_else(
292        || "footprint".to_string(),
293        |s| s.to_string_lossy().into_owned(),
294    );
295
296    let margin = flag_value(args, "--margin")
297        .and_then(|v| v.parse().ok())
298        .unwrap_or(0.0);
299    let token_floor = flag_value(args, "--floor")
300        .and_then(|v| v.parse().ok())
301        .unwrap_or_else(|| FootprintConfig::default().token_floor);
302    let cfg = FootprintConfig {
303        report: ReportConfig {
304            noninferiority_margin: margin,
305            ..ReportConfig::default()
306        },
307        token_floor,
308    };
309
310    // The footprint under test is what this install actually injects.
311    let project_root = std::env::current_dir()
312        .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().into_owned());
313    let footprint = Footprint::live(&project_root);
314
315    let mut report = if let Some(replay) = flag_value(args, "--replay") {
316        let runner = match RecordedRunner::from_file(Path::new(replay)) {
317            Ok(r) => r,
318            Err(e) => {
319                eprintln!("eval footprint: {e:#}");
320                std::process::exit(1);
321            }
322        };
323        run_footprint_or_exit(&suite, &suite_name, &footprint, &runner, &cfg)
324    } else {
325        let live = match OpenAiRunner::from_env() {
326            Ok(r) => r,
327            Err(e) => {
328                eprintln!(
329                    "eval footprint: no live model configured: {e:#}\n(use --replay <file> for an offline run)"
330                );
331                std::process::exit(1);
332            }
333        };
334        if let Some(record_path) = flag_value(args, "--record") {
335            let recorder = RecordingRunner::new(live);
336            let report = run_footprint_or_exit(&suite, &suite_name, &footprint, &recorder, &cfg);
337            if let Err(e) = recorder.into_recording().save(Path::new(record_path)) {
338                eprintln!("eval footprint: failed to save recording: {e:#}");
339                std::process::exit(1);
340            }
341            println!("Recording saved → {record_path}");
342            report
343        } else {
344            run_footprint_or_exit(&suite, &suite_name, &footprint, &live, &cfg)
345        }
346    };
347
348    let agent_id = crate::core::agent_identity::current_agent_id().to_string();
349    if let Err(e) = report.sign(&agent_id) {
350        eprintln!("eval footprint: signing failed: {e}");
351        std::process::exit(1);
352    }
353
354    let out = match flag_value(args, "--out") {
355        Some(p) => PathBuf::from(p),
356        None => match default_footprint_path() {
357            Ok(p) => p,
358            Err(e) => {
359                eprintln!("eval footprint: {e}");
360                std::process::exit(1);
361            }
362        },
363    };
364    if let Some(parent) = out.parent() {
365        let _ = std::fs::create_dir_all(parent);
366    }
367    if let Err(e) = std::fs::write(&out, report.to_json()) {
368        eprintln!("eval footprint: write {}: {e}", out.display());
369        std::process::exit(1);
370    }
371
372    if has_flag(args, "--json") {
373        println!("{}", report.to_json());
374    } else {
375        println!("{}", report.render());
376        println!("artifact:           {}", out.display());
377    }
378
379    if has_flag(args, "--gate") && !report.gate_passes() {
380        eprintln!("\nfootprint gate FAILED: a harmful injected element is present");
381        std::process::exit(1);
382    }
383}
384
385fn run_footprint_or_exit(
386    suite: &EvalSuite,
387    suite_name: &str,
388    footprint: &Footprint,
389    runner: &dyn ModelRunner,
390    cfg: &FootprintConfig,
391) -> FootprintReport {
392    match run_footprint_ab(suite, suite_name, footprint, runner, cfg) {
393        Ok(r) => r,
394        Err(e) => {
395            eprintln!("eval footprint: run failed: {e:#}");
396            std::process::exit(1);
397        }
398    }
399}
400
401/// Default footprint artifact location: `<data_dir>/eval/footprint-report-v1_<utc>.json`.
402fn default_footprint_path() -> Result<PathBuf, String> {
403    let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("eval");
404    std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir eval: {e}"))?;
405    let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
406    Ok(dir.join(format!("footprint-report-v1_{stamp}.json")))
407}
408
409/// `eval testbench`: run the off-vs-on answer-quality benchmark across every pinned
410/// repo in a lockfile, write `FINDINGS.md` + `regressions.json`, and (with `--gate`)
411/// fail the build if any repo regressed (#611).
412fn cmd_testbench(args: &[String]) {
413    let lock_path = flag_value(args, "--lock").map_or_else(
414        || PathBuf::from("eval/testbench/testbench.lock.json"),
415        PathBuf::from,
416    );
417    let lock = match TestbenchLock::load(&lock_path) {
418        Ok(l) => l,
419        Err(e) => {
420            eprintln!("eval testbench: {e:#}\n(use --lock <file> to point at a lockfile)");
421            std::process::exit(1);
422        }
423    };
424
425    let out_dir =
426        flag_value(args, "--out").map_or_else(|| PathBuf::from("testbench-out"), PathBuf::from);
427    let cache_dir =
428        flag_value(args, "--cache").map_or_else(|| out_dir.join("cache"), PathBuf::from);
429
430    let mut cfg = TestbenchConfig::default();
431    if let Some(b) = flag_value(args, "--budget").and_then(|v| v.parse().ok()) {
432        cfg.run.budget_tokens = b;
433    }
434    cfg.run.report = ReportConfig {
435        noninferiority_margin: flag_value(args, "--margin")
436            .and_then(|v| v.parse().ok())
437            .unwrap_or(0.0),
438        ..ReportConfig::default()
439    };
440
441    let report = if let Some(replay) = flag_value(args, "--replay") {
442        let runner = match RecordedRunner::from_file(Path::new(replay)) {
443            Ok(r) => r,
444            Err(e) => {
445                eprintln!("eval testbench: {e:#}");
446                std::process::exit(1);
447            }
448        };
449        run_testbench_or_exit(&lock, &cache_dir, &runner, &cfg)
450    } else {
451        let live = match OpenAiRunner::from_env() {
452            Ok(r) => r,
453            Err(e) => {
454                eprintln!(
455                    "eval testbench: no live model configured: {e:#}\n(use --replay <file> for an offline run)"
456                );
457                std::process::exit(1);
458            }
459        };
460        if let Some(record_path) = flag_value(args, "--record") {
461            let recorder = RecordingRunner::new(live);
462            let report = run_testbench_or_exit(&lock, &cache_dir, &recorder, &cfg);
463            if let Err(e) = recorder.into_recording().save(Path::new(record_path)) {
464                eprintln!("eval testbench: failed to save recording: {e:#}");
465                std::process::exit(1);
466            }
467            println!("Recording saved → {record_path}");
468            report
469        } else {
470            run_testbench_or_exit(&lock, &cache_dir, &live, &cfg)
471        }
472    };
473
474    let (findings_path, regressions_path) = match findings::write(&report, &out_dir) {
475        Ok(paths) => paths,
476        Err(e) => {
477            eprintln!("eval testbench: {e:#}");
478            std::process::exit(1);
479        }
480    };
481
482    print!("{}", findings::render_findings(&report));
483    println!("\nFINDINGS:     {}", findings_path.display());
484    println!("regressions:  {}", regressions_path.display());
485
486    if has_flag(args, "--gate") && !report.gate_passes() {
487        eprintln!("\ntestbench gate FAILED: {}", report.verdict.label());
488        std::process::exit(1);
489    }
490}
491
492fn run_testbench_or_exit(
493    lock: &TestbenchLock,
494    cache_dir: &Path,
495    runner: &dyn ModelRunner,
496    cfg: &TestbenchConfig,
497) -> TestbenchReport {
498    match testbench::run_testbench(lock, cache_dir, runner, cfg) {
499        Ok(r) => r,
500        Err(e) => {
501            eprintln!("eval testbench: run failed: {e:#}");
502            std::process::exit(1);
503        }
504    }
505}
506
507fn cmd_verify(args: &[String]) {
508    let Some(path) = args.first() else {
509        eprintln!("eval verify: <artifact.json> is required");
510        std::process::exit(2);
511    };
512    let artifact = match artifact::load_artifact(Path::new(path)) {
513        Ok(a) => a,
514        Err(e) => {
515            eprintln!("eval verify: {e}");
516            std::process::exit(1);
517        }
518    };
519    let result = artifact.verify();
520    println!("Artifact:           {path}");
521    println!("Verdict:            {}", artifact.verdict.label());
522    println!("Determinism digest: {}", artifact.determinism_digest);
523    println!(
524        "Digest matches:     {}",
525        if result.digest_matches { "yes" } else { "NO" }
526    );
527    println!(
528        "Signature valid:    {}",
529        if result.signature_valid { "yes" } else { "NO" }
530    );
531    if let Some(pk) = &result.signer_public_key {
532        println!("Signer public key:  {pk}");
533    }
534    if let Some(err) = &result.error {
535        println!("Error:              {err}");
536    }
537    if result.ok() {
538        println!("\nOK — artifact is authentic and internally consistent.");
539    } else {
540        eprintln!("\nFAILED — artifact could not be verified.");
541        std::process::exit(1);
542    }
543}
544
545fn cmd_init(args: &[String]) {
546    let dir = PathBuf::from(args.first().map_or("eval-suite", |s| s.as_str()));
547    match write_starter_suite(&dir) {
548        Ok(suite) => {
549            println!("Starter suite written to {}", dir.display());
550            println!("Suite file: {}", suite.display());
551            println!("\nNext:");
552            println!("  # 1) record real model answers once (needs a live model in env)");
553            println!(
554                "  lean-ctx eval ab --suite {} --record {}/recording.json",
555                suite.display(),
556                dir.display()
557            );
558            println!("  # 2) replay deterministically anywhere (CI)");
559            println!(
560                "  lean-ctx eval ab --suite {} --replay {}/recording.json --gate",
561                suite.display(),
562                dir.display()
563            );
564        }
565        Err(e) => {
566            eprintln!("eval init: {e:#}");
567            std::process::exit(1);
568        }
569    }
570}
571
572/// Materializes a small, runnable starter suite: one RAG/QA task whose answer lives in the
573/// corpus, and one POSIX-shell code task with a failing stub + unit test.
574fn write_starter_suite(dir: &Path) -> anyhow::Result<PathBuf> {
575    use anyhow::Context;
576    let corpus = dir.join("corpus");
577    let code = dir.join("code");
578    std::fs::create_dir_all(&corpus).context("creating corpus dir")?;
579    std::fs::create_dir_all(&code).context("creating code dir")?;
580
581    std::fs::write(
582        corpus.join("architecture.md"),
583        "# Consolidation pipeline\n\n\
584Provider data flows through one consolidation pipeline. Artifacts are persisted to four \
585stores: the BM25 index, the Graph index, ProjectKnowledge, and the Session cache. This is \
586what lets semantic search, knowledge recall, and cross-source hints share one source of truth.\n",
587    )
588    .context("writing corpus/architecture.md")?;
589    std::fs::write(
590        corpus.join("overview.md"),
591        "# Overview\n\nlean-ctx is a context runtime for AI agents. This file is general \
592background and intentionally does not list the consolidation stores.\n",
593    )
594    .context("writing corpus/overview.md")?;
595
596    std::fs::write(
597        code.join("test.sh"),
598        "#!/bin/sh\n. ./solution.sh\n[ \"$(add 2 3)\" = \"5\" ] || exit 1\n[ \"$(add 10 20)\" = \"30\" ] || exit 1\n",
599    )
600    .context("writing code/test.sh")?;
601    std::fs::write(
602        code.join("solution.sh"),
603        "# TODO: implement add() so that `add a b` prints a+b\nadd() { echo 0; }\n",
604    )
605    .context("writing code/solution.sh")?;
606
607    let suite = dir.join("suite.ndjson");
608    let lines = [
609        r#"{"id":"qa-consolidation-stores","domain":"qa","prompt":"Which four stores does the consolidation pipeline persist artifacts to?","workspace":"corpus","answers":["bm25 index, graph index, projectknowledge, session cache","bm25, graph, knowledge, session"]}"#,
610        r#"{"id":"code-add","domain":"code","prompt":"Implement the POSIX shell function add in solution.sh so that `add a b` prints the sum a+b. Output only the file contents.","workspace":"code","target_file":"solution.sh","test_cmd":"sh test.sh"}"#,
611    ];
612    std::fs::write(
613        &suite,
614        format!("# lean-ctx eval starter suite\n{}\n", lines.join("\n")),
615    )
616    .context("writing suite.ndjson")?;
617    Ok(suite)
618}
619
620#[cfg(test)]
621mod recording_guard_tests {
622    use super::*;
623
624    /// The committed recording (`rust/eval/recording.json`) is what flips the CI
625    /// quality-gate from "skipped" to "enforced" (#361 Phase 3). Guard it
626    /// **in-process** so a suite/corpus/prompt change that invalidates the
627    /// recording (a replay key miss) or a captured regression fails here in
628    /// `cargo test` — i.e. during `dev-install` — not only in CI.
629    #[test]
630    fn committed_recording_replays_and_passes_gate() {
631        let dir = tempfile::tempdir().unwrap();
632        let suite_path = write_starter_suite(dir.path()).expect("scaffold starter suite");
633        let suite = EvalSuite::load(&suite_path).expect("load starter suite");
634
635        let rec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("eval/recording.json");
636        assert!(
637            rec_path.exists(),
638            "committed recording missing at {} — CI quality-gate would silently skip",
639            rec_path.display()
640        );
641        let runner = RecordedRunner::from_file(&rec_path).expect("load committed recording");
642
643        // Every (task × condition) request must hit a recorded key, else the
644        // recording drifted from the suite/corpus/prompt and must be re-captured.
645        let report = run_ab(&suite, "suite.ndjson", &runner, &AbRunConfig::default())
646            .expect("committed recording must cover every replay key");
647        assert!(
648            report.verdict.gate_passes(),
649            "committed recording must not encode a regression, got: {}",
650            report.verdict.label()
651        );
652    }
653}