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