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::{AbRunConfig, run_ab};
18
19pub fn cmd_eval(args: &[String]) {
21 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
71fn 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 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 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
192fn 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 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
318fn 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
391fn 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 #[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 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}