1use 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
21pub fn cmd_eval(args: &[String]) {
23 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
83fn 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 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 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
204fn 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 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
330fn 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
338fn 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
501fn 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 #[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 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}