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};
20use crate::core::ocla::registry::OclaRegistry;
21use crate::core::ocla::types::{ExperimentRequest, OclaRequestContext};
22
23pub fn cmd_eval(args: &[String]) {
25 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
93fn 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 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 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
214fn 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
271fn 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 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
397fn 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
405fn 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
568fn 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 #[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 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}