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::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
22pub fn cmd_eval(args: &[String]) {
24 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
92fn 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 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 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
213fn 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
275fn 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 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
401fn 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
409fn 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
572fn 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 #[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 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}