1use crate::{Format, RunOptions, Spec, blocks, run, search, verify};
43use comnum_sim::stopping::Stop;
44use serde_json::json;
45use std::path::PathBuf;
46use std::process::ExitCode;
47
48pub mod agent;
49
50const USAGE: &str = "usage:
51 comnum run SPEC... [--out DIR] [--threads N] [--fast-dev-run] [--profile] [--quiet] [--json] [--max-seconds S]
52 comnum check SPEC... [--json]
53 comnum verify SPEC... [--out DIR] [--threads N] [--quiet] [--json] [--max-seconds S]
54 comnum conditions SPEC
55 comnum blocks [NAME...] [--json]
56 comnum schema
57 comnum guide
58
59SPEC is a YAML, JSON or TOML specification (see the documentation of the crate comnum-spec), or -
60for standard input. --json: one JSON document per specification on standard output, for programs
61and agents (a time budget of 300 s unless --max-seconds is given; --max-seconds 0 for none).";
62
63#[allow(clippy::struct_excessive_bools, reason = "the flags of the command line")]
64struct Args {
65 command: String,
66 files: Vec<PathBuf>,
67 out: PathBuf,
68 threads: Option<usize>,
69 fast_dev_run: bool,
70 profile: bool,
71 quiet: bool,
72 json: bool,
73 max_seconds: Option<f64>,
74}
75
76fn parse_args(args: impl IntoIterator<Item = String>) -> Result<Args, String> {
77 let mut it = args.into_iter();
78 let command = it.next().ok_or_else(|| USAGE.to_string())?;
79 let mut a = Args {
80 command,
81 files: Vec::new(),
82 out: PathBuf::from("results"),
83 threads: None,
84 fast_dev_run: false,
85 profile: false,
86 quiet: false,
87 json: false,
88 max_seconds: None,
89 };
90 while let Some(arg) = it.next() {
91 match arg.as_str() {
92 "--out" => a.out = PathBuf::from(it.next().ok_or("--out needs a directory")?),
93 "--threads" => {
94 let n = it.next().ok_or("--threads needs a number")?;
95 a.threads = Some(n.parse().map_err(|_| format!("--threads: {n:?} is not a number"))?);
96 }
97 "--fast-dev-run" => a.fast_dev_run = true,
98 "--profile" => a.profile = true,
99 "--quiet" => a.quiet = true,
100 "--json" => a.json = true,
101 "--max-seconds" => {
102 let s = it.next().ok_or("--max-seconds needs a number of seconds")?;
103 let v: f64 = s.parse().map_err(|_| format!("--max-seconds: {s:?} is not a number"))?;
104 if !(v.is_finite() && v >= 0.0) {
105 return Err(format!("--max-seconds: {s} must be a number of seconds >= 0"));
106 }
107 a.max_seconds = Some(v);
108 }
109 "-h" | "--help" => return Err(USAGE.to_string()),
110 s if s.starts_with("--") => return Err(format!("unknown option {s}\n\n{USAGE}")),
111 "-" => a.files.push(PathBuf::from("-")),
112 s => a.files.push(PathBuf::from(s)),
113 }
114 }
115 Ok(a)
116}
117
118fn report_options(spec: &Spec, a: &Args) -> RunOptions {
121 RunOptions {
122 threads: a.threads,
123 progress: Some(!a.quiet && !a.json),
124 csv: Some(spec.csv.clone().unwrap_or_else(|| a.out.join(format!("{}.csv", spec.name)))),
125 json: Some(spec.json.clone().unwrap_or_else(|| a.out.join(format!("{}.json", spec.name)))),
126 ..RunOptions::default()
127 }
128}
129
130fn written(opts: &RunOptions, a: &Args) {
132 if let (false, false, Some(csv), Some(json)) = (a.quiet, a.json, &opts.csv, &opts.json) {
133 eprintln!("written {} and {}", csv.display(), json.display());
134 }
135}
136
137fn load(path: &PathBuf) -> comnum_core::Result<Spec> {
139 if path.as_os_str() == "-" {
140 let mut text = String::new();
141 std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)?;
142 return Spec::parse(&text, Format::Yaml).map_err(|e| e.context("standard input"));
143 }
144 Spec::from_path(path).map_err(|e| match e {
145 comnum_core::Error::Io(io) => {
146 comnum_core::Error::Io(std::io::Error::new(io.kind(), format!("{}: {io}", path.display())))
147 }
148 other => other,
149 })
150}
151
152fn budgeted(path: &PathBuf, a: &Args) -> comnum_core::Result<(Spec, Option<Stop>)> {
155 let mut spec = load(path)?;
156 let original = agent::budget(a.max_seconds, a.json).map(|s| agent::apply_budget(&mut spec, s));
157 Ok((spec, original))
158}
159
160fn run_one(path: &PathBuf, a: &Args) -> comnum_core::Result<()> {
161 let (spec, original) = budgeted(path, a)?;
162 let opts =
163 RunOptions { fast_dev_run: a.fast_dev_run, profile: a.profile.then_some(true), ..report_options(&spec, a) };
164 let started = std::time::Instant::now();
165 if a.json {
166 let mut doc = json!({
167 "ok": true,
168 "command": "run",
169 "spec": spec.name,
170 "description": spec.description,
171 "provenance": agent::provenance(&spec, &opts),
172 "budget_s": agent::budget(a.max_seconds, a.json),
173 });
174 if spec.search.is_some() {
175 let found = search::search(&spec, &opts)?;
176 doc["operating_points"] = found.iter().map(agent::operating_point).collect();
177 } else {
178 let results = run(&spec, &opts)?;
179 doc["conditions"] =
180 results.iter().map(|r| agent::condition(r, spec.confidence, original.as_ref())).collect();
181 }
182 doc["wall_s"] = json!(started.elapsed().as_secs_f64());
183 println!("{doc}");
184 return Ok(());
185 }
186 if let Some(sp) = &spec.search {
187 if !a.quiet {
188 eprintln!(
189 "{}: {} (search of {} for {} = {:e})",
190 spec.name, spec.description, sp.along, sp.metric, sp.target
191 );
192 }
193 for r in search::search(&spec, &opts)? {
194 println!(
195 "{} | {} = {:.4} [{:.4}, {:.4}] | {} evaluations{}",
196 r.condition.label(),
197 r.along,
198 r.value,
199 r.lo,
200 r.hi,
201 r.evaluations.len(),
202 if r.converged { "" } else { " (not converged)" }
203 );
204 }
205 written(&opts, a);
206 return Ok(());
207 }
208 if !a.quiet {
209 eprintln!("{}: {} ({} conditions)", spec.name, spec.description, spec.conditions().len());
210 }
211 let results = run(&spec, &opts)?;
212 for r in &results {
213 let metrics: Vec<String> = r
214 .record
215 .iter()
216 .map(|(k, e)| {
217 let (lo, hi) = e.interval(spec.confidence);
218 format!("{k}={:.4e} [{lo:.3e}, {hi:.3e}]", e.value())
219 })
220 .collect();
221 println!("{} | {} | {} trials | {}", r.condition.label(), r.status.as_str(), r.trials, metrics.join(" "));
222 }
223 let profile = crate::profile_of(&spec, &results)?;
224 if !profile.is_empty() {
225 println!("\nprofile: mean time per trial over {} conditions", results.len());
226 for row in &profile {
227 println!("{:>24} {:>10.3} ms {:>5.1} %", row.label, row.time_ms, 100.0 * row.share);
228 }
229 }
230 written(&opts, a);
231 Ok(())
232}
233
234fn verify_one(path: &PathBuf, a: &Args) -> comnum_core::Result<usize> {
236 let (spec, _) = budgeted(path, a)?;
237 let opts = report_options(&spec, a);
239 let (_, checks) = verify::verify_spec(&spec, &opts)?;
240 let failed = checks.iter().filter(|c| !c.passed).count();
241 let report = a.out.join(format!("{}.verify.json", spec.name));
242 verify::write_report(&report, &spec, &opts, &checks)?;
243 if a.json {
244 let doc = json!({
245 "ok": failed == 0,
246 "command": "verify",
247 "spec": spec.name,
248 "provenance": agent::provenance(&spec, &opts),
249 "budget_s": agent::budget(a.max_seconds, a.json),
250 "checks": checks.iter().map(agent::check_json).collect::<Vec<_>>(),
251 "failed": failed,
252 "report": report.display().to_string(),
253 });
254 println!("{doc}");
255 return Ok(failed);
256 }
257 println!("{}: {} checks, {} failed", spec.name, checks.len(), failed);
258 for c in &checks {
259 println!(
260 " {} {} | {} | {}: expected {:.4e}, measured {:.4e} [{:.3e}, {:.3e}]",
261 if c.passed { "ok " } else { "FAIL" },
262 c.name,
263 c.condition,
264 c.metric,
265 c.expected,
266 c.measured.value,
267 c.measured.lo,
268 c.measured.hi
269 );
270 }
271 Ok(failed)
272}
273
274fn check_one(path: &PathBuf, json_out: bool) -> comnum_core::Result<()> {
275 let spec = load(path)?;
276 let mut spec = search::first_points(&spec).unwrap_or(spec);
278 (spec.csv, spec.json) = (None, None);
280 let opts = RunOptions { fast_dev_run: true, batch: Some(1), progress: Some(false), ..RunOptions::default() };
281 let r = run(&spec, &opts)?;
282 let names: Vec<&String> = r.first().map(|c| c.record.iter().map(|(k, _)| k).collect()).unwrap_or_default();
283 if json_out {
284 let doc = json!({
285 "ok": true,
286 "command": "check",
287 "spec": spec.name,
288 "conditions": r.len(),
289 "blocks": spec.link.len(),
290 "metrics": names,
291 });
292 println!("{doc}");
293 } else {
294 println!("{}: ok, {} conditions, {} blocks, metrics {:?}", path.display(), r.len(), spec.link.len(), names);
295 }
296 Ok(())
297}
298
299fn to_json(v: &impl serde::Serialize) -> comnum_core::Result<String> {
301 serde_json::to_string_pretty(v).map_err(|e| comnum_core::Error::Config(format!("cannot serialise the report: {e}")))
302}
303
304pub fn main(args: impl IntoIterator<Item = String>) -> ExitCode {
307 let args: Vec<String> = args.into_iter().collect();
308 let json_out = args.iter().any(|s| s == "--json");
309 let usage = |msg: &str| {
310 if json_out {
311 let first = msg.lines().next().unwrap_or(msg);
312 println!("{}", json!({"ok": false, "command": args.first(), "error": agent::error("usage", first)}));
313 } else {
314 eprintln!("{msg}");
315 }
316 ExitCode::from(2)
317 };
318 let a = match parse_args(args.clone()) {
319 Ok(a) => a,
320 Err(msg) => return usage(&msg),
321 };
322 if ["run", "check", "verify", "conditions"].contains(&a.command.as_str()) && a.files.is_empty() {
323 return usage(&format!("no specification given\n\n{USAGE}"));
324 }
325 if a.json && ["run", "check", "verify"].contains(&a.command.as_str()) {
326 return main_json(&a);
327 }
328 let result: comnum_core::Result<()> = match a.command.as_str() {
329 "blocks" => {
330 let all = blocks::registry();
331 let wanted: Vec<String> = a.files.iter().map(|f| f.display().to_string()).collect();
332 let shown: Vec<_> =
333 all.iter().filter(|b| wanted.is_empty() || wanted.iter().any(|w| w == b.name)).collect();
334 if shown.is_empty() {
335 Err(comnum_core::Error::Config(format!("no block {wanted:?}")))
336 } else if a.json {
337 let all: Vec<_> = shown.iter().map(|b| b.to_json()).collect();
338 to_json(&all).map(|j| println!("{j}"))
339 } else {
340 for b in shown {
341 println!("{}", b.describe());
342 }
343 Ok(())
344 }
345 }
346 "schema" => to_json(&blocks::json_schema()).map(|j| println!("{j}")),
347 "guide" => {
348 print!("{}", include_str!("../../GUIDE_AGENTS.md"));
349 Ok(())
350 }
351 "conditions" => a.files.iter().try_for_each(|f| {
352 let spec = load(f)?;
353 for (i, c) in spec.conditions().iter().enumerate() {
354 println!("{i}: {}", c.label());
355 }
356 Ok(())
357 }),
358 "check" => a.files.iter().try_for_each(|f| check_one(f, a.json)),
359 "run" => a.files.iter().try_for_each(|f| run_one(f, &a)),
360 "verify" => {
361 let mut failed = 0;
362 let r = a.files.iter().try_for_each(|f| verify_one(f, &a).map(|n| failed += n));
363 if r.is_ok() && failed > 0 {
364 eprintln!("{failed} checks failed");
365 return ExitCode::FAILURE;
366 }
367 r
368 }
369 other => return usage(&format!("unknown command {other:?}\n\n{USAGE}")),
370 };
371 match result {
372 Ok(()) => ExitCode::SUCCESS,
373 Err(e) => {
374 eprintln!("error: {e}");
375 ExitCode::FAILURE
376 }
377 }
378}
379
380fn main_json(a: &Args) -> ExitCode {
384 let mut failed = false;
385 for f in &a.files {
386 let r = match a.command.as_str() {
387 "run" => run_one(f, a).map(|()| 0),
388 "check" => check_one(f, true).map(|()| 0),
389 _ => verify_one(f, a),
390 };
391 match r {
392 Ok(n) => failed |= n > 0,
393 Err(e) => {
394 failed = true;
395 let message = e.to_string();
396 println!(
397 "{}",
398 json!({"ok": false, "command": a.command, "file": f.display().to_string(), "error": agent::error(agent::error_kind(&e), &message)})
399 );
400 }
401 }
402 }
403 if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS }
404}