1use std::path::{Path, PathBuf};
9
10use crate::core::eval_ab::artifact::{self, SignedAbReportV1};
11use crate::core::eval_ab::model::{OpenAiRunner, RecordedRunner, RecordingRunner};
12use crate::core::eval_ab::report::ReportConfig;
13use crate::core::eval_ab::suite::EvalSuite;
14use crate::core::eval_ab::{AbRunConfig, run_ab};
15
16pub fn cmd_eval(args: &[String]) {
18 match args.first().map(String::as_str) {
19 Some("ab") => cmd_ab(&args[1..]),
20 Some("verify") => cmd_verify(&args[1..]),
21 Some("init") => cmd_init(&args[1..]),
22 Some("-h" | "--help") | None => print_help(),
23 Some(other) => {
24 eprintln!("eval: unknown subcommand '{other}'\n");
25 print_help();
26 std::process::exit(2);
27 }
28 }
29}
30
31fn print_help() {
32 println!(
33 "lean-ctx eval — deterministic with/without output-quality proof\n\n\
34USAGE:\n\
35 lean-ctx eval init <dir> Scaffold a runnable starter suite\n\
36 lean-ctx eval ab --suite <file> [opts] Run the A/B quality comparison\n\
37 lean-ctx eval verify <artifact.json> Verify signature + determinism digest\n\n\
38ab OPTIONS:\n\
39 --suite <file> NDJSON suite (required)\n\
40 --budget <n> Token budget per condition (default 4000)\n\
41 --margin <f> Non-inferiority margin for the gate (default 0.0)\n\
42 --out <file> Artifact path (default: data dir)\n\
43 --replay <file> Replay a recording instead of calling a live model (deterministic CI)\n\
44 --record <file> Call the live model and save responses to a recording\n\
45 --gate Exit non-zero if the verdict is a regression\n\n\
46LIVE MODEL (when not replaying) is read from the environment:\n\
47 LEAN_CTX_EVAL_MODEL_URL OpenAI-compatible base URL (e.g. https://api.openai.com/v1)\n\
48 LEAN_CTX_EVAL_MODEL Model id (e.g. gpt-4o-mini)\n\
49 LEAN_CTX_EVAL_MODEL_KEY API key (optional for local servers)\n\
50 LEAN_CTX_EVAL_SEED Decoding seed (default 7)"
51 );
52}
53
54fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
56 args.iter()
57 .position(|a| a == flag)
58 .and_then(|i| args.get(i + 1))
59 .map(String::as_str)
60}
61
62fn has_flag(args: &[String], flag: &str) -> bool {
63 args.iter().any(|a| a == flag)
64}
65
66fn cmd_ab(args: &[String]) {
67 let Some(suite_path) = flag_value(args, "--suite") else {
68 eprintln!("eval ab: --suite <file> is required");
69 std::process::exit(2);
70 };
71 let suite_path = PathBuf::from(suite_path);
72 let suite = match EvalSuite::load(&suite_path) {
73 Ok(s) => s,
74 Err(e) => {
75 eprintln!("eval ab: {e:#}");
76 std::process::exit(1);
77 }
78 };
79 let suite_name = suite_path
80 .file_name()
81 .map_or_else(|| "suite".to_string(), |s| s.to_string_lossy().into_owned());
82
83 let mut cfg = AbRunConfig::default();
84 if let Some(b) = flag_value(args, "--budget").and_then(|v| v.parse().ok()) {
85 cfg.budget_tokens = b;
86 }
87 cfg.report = ReportConfig {
88 noninferiority_margin: flag_value(args, "--margin")
89 .and_then(|v| v.parse().ok())
90 .unwrap_or(0.0),
91 ..ReportConfig::default()
92 };
93
94 let report = if let Some(replay) = flag_value(args, "--replay") {
96 let runner = match RecordedRunner::from_file(Path::new(replay)) {
97 Ok(r) => r,
98 Err(e) => {
99 eprintln!("eval ab: {e:#}");
100 std::process::exit(1);
101 }
102 };
103 run_or_exit(&suite, &suite_name, &runner, &cfg)
104 } else {
105 let live = match OpenAiRunner::from_env() {
106 Ok(r) => r,
107 Err(e) => {
108 eprintln!(
109 "eval ab: no live model configured: {e:#}\n(use --replay <file> for an offline run)"
110 );
111 std::process::exit(1);
112 }
113 };
114 if let Some(record_path) = flag_value(args, "--record") {
115 let recorder = RecordingRunner::new(live);
116 let report = run_or_exit(&suite, &suite_name, &recorder, &cfg);
117 if let Err(e) = recorder.into_recording().save(Path::new(record_path)) {
118 eprintln!("eval ab: failed to save recording: {e:#}");
119 std::process::exit(1);
120 }
121 println!("Recording saved → {record_path}");
122 report
123 } else {
124 run_or_exit(&suite, &suite_name, &live, &cfg)
125 }
126 };
127
128 let agent_id = crate::core::agent_identity::current_agent_id().to_string();
130 let mut signed = SignedAbReportV1::from_report(report, &agent_id);
131 if let Err(e) = signed.sign(&agent_id) {
132 eprintln!("eval ab: signing failed: {e}");
133 std::process::exit(1);
134 }
135 let out = match flag_value(args, "--out") {
136 Some(p) => PathBuf::from(p),
137 None => match artifact::default_artifact_path() {
138 Ok(p) => p,
139 Err(e) => {
140 eprintln!("eval ab: {e}");
141 std::process::exit(1);
142 }
143 },
144 };
145 if let Err(e) = artifact::write_artifact(&signed, &out) {
146 eprintln!("eval ab: {e}");
147 std::process::exit(1);
148 }
149
150 println!("{}", signed.report.render());
151 println!("determinism digest: {}", signed.determinism_digest);
152 println!("artifact: {}", out.display());
153
154 if has_flag(args, "--gate") && !signed.verdict.gate_passes() {
155 eprintln!("\nquality gate FAILED: {}", signed.verdict.label());
156 std::process::exit(1);
157 }
158}
159
160fn run_or_exit(
161 suite: &EvalSuite,
162 suite_name: &str,
163 runner: &dyn crate::core::eval_ab::model::ModelRunner,
164 cfg: &AbRunConfig,
165) -> crate::core::eval_ab::report::AbReport {
166 match run_ab(suite, suite_name, runner, cfg) {
167 Ok(r) => r,
168 Err(e) => {
169 eprintln!("eval ab: run failed: {e:#}");
170 std::process::exit(1);
171 }
172 }
173}
174
175fn cmd_verify(args: &[String]) {
176 let Some(path) = args.first() else {
177 eprintln!("eval verify: <artifact.json> is required");
178 std::process::exit(2);
179 };
180 let artifact = match artifact::load_artifact(Path::new(path)) {
181 Ok(a) => a,
182 Err(e) => {
183 eprintln!("eval verify: {e}");
184 std::process::exit(1);
185 }
186 };
187 let result = artifact.verify();
188 println!("Artifact: {path}");
189 println!("Verdict: {}", artifact.verdict.label());
190 println!("Determinism digest: {}", artifact.determinism_digest);
191 println!(
192 "Digest matches: {}",
193 if result.digest_matches { "yes" } else { "NO" }
194 );
195 println!(
196 "Signature valid: {}",
197 if result.signature_valid { "yes" } else { "NO" }
198 );
199 if let Some(pk) = &result.signer_public_key {
200 println!("Signer public key: {pk}");
201 }
202 if let Some(err) = &result.error {
203 println!("Error: {err}");
204 }
205 if result.ok() {
206 println!("\nOK — artifact is authentic and internally consistent.");
207 } else {
208 eprintln!("\nFAILED — artifact could not be verified.");
209 std::process::exit(1);
210 }
211}
212
213fn cmd_init(args: &[String]) {
214 let dir = PathBuf::from(args.first().map_or("eval-suite", |s| s.as_str()));
215 match write_starter_suite(&dir) {
216 Ok(suite) => {
217 println!("Starter suite written to {}", dir.display());
218 println!("Suite file: {}", suite.display());
219 println!("\nNext:");
220 println!(" # 1) record real model answers once (needs a live model in env)");
221 println!(
222 " lean-ctx eval ab --suite {} --record {}/recording.json",
223 suite.display(),
224 dir.display()
225 );
226 println!(" # 2) replay deterministically anywhere (CI)");
227 println!(
228 " lean-ctx eval ab --suite {} --replay {}/recording.json --gate",
229 suite.display(),
230 dir.display()
231 );
232 }
233 Err(e) => {
234 eprintln!("eval init: {e:#}");
235 std::process::exit(1);
236 }
237 }
238}
239
240fn write_starter_suite(dir: &Path) -> anyhow::Result<PathBuf> {
243 use anyhow::Context;
244 let corpus = dir.join("corpus");
245 let code = dir.join("code");
246 std::fs::create_dir_all(&corpus).context("creating corpus dir")?;
247 std::fs::create_dir_all(&code).context("creating code dir")?;
248
249 std::fs::write(
250 corpus.join("architecture.md"),
251 "# Consolidation pipeline\n\n\
252Provider data flows through one consolidation pipeline. Artifacts are persisted to four \
253stores: the BM25 index, the Graph index, ProjectKnowledge, and the Session cache. This is \
254what lets semantic search, knowledge recall, and cross-source hints share one source of truth.\n",
255 )
256 .context("writing corpus/architecture.md")?;
257 std::fs::write(
258 corpus.join("overview.md"),
259 "# Overview\n\nlean-ctx is a context runtime for AI agents. This file is general \
260background and intentionally does not list the consolidation stores.\n",
261 )
262 .context("writing corpus/overview.md")?;
263
264 std::fs::write(
265 code.join("test.sh"),
266 "#!/bin/sh\n. ./solution.sh\n[ \"$(add 2 3)\" = \"5\" ] || exit 1\n[ \"$(add 10 20)\" = \"30\" ] || exit 1\n",
267 )
268 .context("writing code/test.sh")?;
269 std::fs::write(
270 code.join("solution.sh"),
271 "# TODO: implement add() so that `add a b` prints a+b\nadd() { echo 0; }\n",
272 )
273 .context("writing code/solution.sh")?;
274
275 let suite = dir.join("suite.ndjson");
276 let lines = [
277 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"]}"#,
278 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"}"#,
279 ];
280 std::fs::write(
281 &suite,
282 format!("# lean-ctx eval starter suite\n{}\n", lines.join("\n")),
283 )
284 .context("writing suite.ndjson")?;
285 Ok(suite)
286}
287
288#[cfg(test)]
289mod recording_guard_tests {
290 use super::*;
291
292 #[test]
298 fn committed_recording_replays_and_passes_gate() {
299 let dir = tempfile::tempdir().unwrap();
300 let suite_path = write_starter_suite(dir.path()).expect("scaffold starter suite");
301 let suite = EvalSuite::load(&suite_path).expect("load starter suite");
302
303 let rec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("eval/recording.json");
304 assert!(
305 rec_path.exists(),
306 "committed recording missing at {} — CI quality-gate would silently skip",
307 rec_path.display()
308 );
309 let runner = RecordedRunner::from_file(&rec_path).expect("load committed recording");
310
311 let report = run_ab(&suite, "suite.ndjson", &runner, &AbRunConfig::default())
314 .expect("committed recording must cover every replay key");
315 assert!(
316 report.verdict.gate_passes(),
317 "committed recording must not encode a regression, got: {}",
318 report.verdict.label()
319 );
320 }
321}