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