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