Skip to main content

comnum_spec/cli/
agent.rs

1//! The output of `comnum --json` for programs and agents: one compact JSON document per
2//! specification on standard output, results with their confidence intervals and their
3//! provenance, errors with a stable kind, a hint and a suggestion, and the time budget
4//! of `--max-seconds`.
5//!
6//! A run is a line
7//!
8//! ```text
9//! {"ok": true, "command": "run", "spec": NAME, "provenance": {...}, "budget_s": S,
10//!  "conditions": [{"condition": {...}, "status": ..., "trials": N, "wall_s": T,
11//!                  "time_limited": false, "metrics": {"ber": {"value": ..., "lo": ...,
12//!                  "hi": ..., "count": ..., "total": ...}}}], "wall_s": T}
13//! ```
14//!
15//! and an error a line `{"ok": false, "command": ..., "file": ..., "error": {"kind": ...,
16//! "message": ..., "hint": ..., "suggestion": ...}}`, the kind one of `usage`, `config`,
17//! `shape`, `numerical`, `condition` and `io`.
18
19use crate::blocks;
20use crate::search::SearchResult;
21use crate::verify::Check;
22use crate::{RunOptions, Spec};
23use comnum_core::Error;
24use comnum_sim::loggers::value_json;
25use comnum_sim::runner::ConditionResult;
26use comnum_sim::stopping::{Progress, Stop};
27use serde_json::{Map, Value as Json, json};
28use std::time::Duration;
29
30/// The time budget of a run in JSON mode when `--max-seconds` is not given (s).
31pub const DEFAULT_BUDGET_S: f64 = 300.0;
32
33/// The budget of a run (s): `--max-seconds` (0 for none), [`DEFAULT_BUDGET_S`] in JSON
34/// mode, none otherwise.
35pub fn budget(max_seconds: Option<f64>, json: bool) -> Option<f64> {
36    match max_seconds {
37        Some(s) if s > 0.0 => Some(s),
38        Some(_) => None,
39        None => json.then_some(DEFAULT_BUDGET_S),
40    }
41}
42
43/// Spread a budget of `seconds` over the conditions (and the evaluations of a search) of
44/// `spec`: its stopping rule becomes the rule of the specification or the share of the
45/// budget of every condition, whichever comes first. Returns the rule of the
46/// specification, to tell afterwards which conditions the budget cut short.
47pub fn apply_budget(spec: &mut Spec, seconds: f64) -> Stop {
48    let original = spec.stop.clone();
49    let evaluations = spec.search.as_ref().map_or(1, |s| s.max_evaluations);
50    let n = (spec.conditions().len() * evaluations).max(1);
51    spec.stop = original.clone().or(Stop::max_time(Duration::from_secs_f64(seconds / n as f64)));
52    original
53}
54
55/// The provenance of a run: the version of comnum, the seed, the threads, the
56/// confidence, a fingerprint of the specification and the reports written.
57pub fn provenance(spec: &Spec, opts: &RunOptions) -> Json {
58    json!({
59        "comnum": env!("CARGO_PKG_VERSION"),
60        "seed": spec.seed,
61        "threads": opts.threads.unwrap_or(spec.threads),
62        "confidence": spec.confidence,
63        "fingerprint": fingerprint(&spec.source.to_string()),
64        "reports": {
65            "csv": opts.csv.as_ref().map(|p| p.display().to_string()),
66            "json": opts.json.as_ref().map(|p| p.display().to_string()),
67        },
68    })
69}
70
71/// FNV-1a (64 bits) of a text, in hexadecimal: the same specification gives the same
72/// fingerprint on every machine.
73pub fn fingerprint(text: &str) -> String {
74    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
75    for b in text.bytes() {
76        h ^= u64::from(b);
77        h = h.wrapping_mul(0x0100_0000_01b3);
78    }
79    format!("{h:016x}")
80}
81
82/// One condition of a run: its values, how it ended, its metrics with their intervals,
83/// and whether the budget stopped it before the rule of the specification (`original`)
84/// was met.
85pub fn condition(r: &ConditionResult, confidence: f64, original: Option<&Stop>) -> Json {
86    let values: Map<String, Json> = r
87        .condition
88        .entries()
89        .iter()
90        .map(|(k, v)| (k.clone(), serde_json::from_str(&value_json(v)).unwrap_or(Json::Null)))
91        .collect();
92    let metrics: Map<String, Json> = r
93        .record
94        .iter()
95        .map(|(k, e)| {
96            let (lo, hi) = e.interval(confidence);
97            (k.clone(), json!({"value": finite(e.value()), "lo": finite(lo), "hi": finite(hi), "count": e.count(), "total": e.total()}))
98        })
99        .collect();
100    let time_limited = original.is_some_and(|s| {
101        let p = Progress {
102            condition: r.index,
103            trials: r.trials,
104            record: &r.record,
105            elapsed: Duration::from_secs_f64(r.wall_s),
106        };
107        r.status.as_str() == "completed" && !s.is_met(&p)
108    });
109    json!({
110        "condition": values,
111        "status": r.status.as_str(),
112        "trials": r.trials,
113        "wall_s": r.wall_s,
114        "time_limited": time_limited,
115        "metrics": metrics,
116    })
117}
118
119/// A number, or null when it is not finite (JSON has no infinity).
120fn finite(x: f64) -> Json {
121    if x.is_finite() { json!(x) } else { Json::Null }
122}
123
124/// One operating point of a search.
125pub fn operating_point(r: &SearchResult) -> Json {
126    let values: Map<String, Json> = r
127        .condition
128        .entries()
129        .iter()
130        .map(|(k, v)| (k.clone(), serde_json::from_str(&value_json(v)).unwrap_or(Json::Null)))
131        .collect();
132    json!({
133        "condition": values,
134        "along": r.along,
135        "metric": r.metric,
136        "target": r.target,
137        "value": finite(r.value),
138        "lo": finite(r.lo),
139        "hi": finite(r.hi),
140        "converged": r.converged,
141        "evaluations": r.evaluations.len(),
142        "trials": r.evaluations.iter().map(|e| e.trials).sum::<u64>(),
143    })
144}
145
146/// One check of `verify`.
147pub fn check_json(c: &Check) -> Json {
148    json!({
149        "name": c.name,
150        "source": c.source,
151        "condition": c.condition,
152        "metric": c.metric,
153        "expected": finite(c.expected),
154        "measured": finite(c.measured.value),
155        "lo": finite(c.measured.lo),
156        "hi": finite(c.measured.hi),
157        "passed": c.passed,
158    })
159}
160
161/// The kind of an error: `config`, `shape`, `numerical`, `condition`, `io` (`other` for a
162/// kind added later).
163pub fn error_kind(e: &Error) -> &'static str {
164    match e {
165        Error::Config(_) => "config",
166        Error::Shape(_) => "shape",
167        Error::Numerical(_) => "numerical",
168        Error::Condition(_) => "condition",
169        Error::Io(_) => "io",
170        _ => "other",
171    }
172}
173
174/// An error as JSON: its kind, its message, a hint (the command that documents what was
175/// wrong) and, for a misspelt block or parameter, the closest name.
176pub fn error(kind: &str, message: &str) -> Json {
177    let (hint, suggestion) = advice(message);
178    json!({"kind": kind, "message": message, "hint": hint, "suggestion": suggestion})
179}
180
181/// The hint and the suggestion for an error message.
182fn advice(message: &str) -> (String, Option<String>) {
183    let all = blocks::registry();
184    if let Some(name) = quoted_after(message, "unknown block \"", '"') {
185        let names: Vec<&str> = all.iter().map(|b| b.name).collect();
186        let best = closest(&name, &names).map(|b| format!("type: {b}"));
187        return ("`comnum blocks --json` lists the blocks and their parameters".into(), best);
188    }
189    if let (Some(block), Some(field)) =
190        (quoted_after(message, "block \"", '"'), quoted_after(message, "unknown field `", '`'))
191    {
192        let hint = format!("`comnum blocks {block} --json` documents the parameters of {block}");
193        let own: Vec<&str> =
194            all.iter().find(|b| b.name == block).map(|b| b.params.iter().map(|p| p.name).collect()).unwrap_or_default();
195        if let Some(best) = closest(&field, &own) {
196            return (hint, Some(format!("{best} (a parameter of {block})")));
197        }
198        // the parameter of another block: the block meant may be that one
199        let others: Vec<&str> = all
200            .iter()
201            .filter(|b| b.name != block && b.params.iter().any(|p| p.name == field))
202            .map(|b| b.name)
203            .collect();
204        return (hint, others.first().map(|b| format!("type: {b} (it takes {field})")));
205    }
206    if ["YAML:", "JSON:", "TOML:"].iter().any(|f| message.contains(f)) {
207        return (
208            "a specification is a mapping with name, grid, link (a list of blocks), metrics and stop; `comnum schema` gives the JSON Schema of a block".into(),
209            None,
210        );
211    }
212    if ["unknown option", "unknown command", "no specification given", "needs a", "is not a number", "must be a number"]
213        .iter()
214        .any(|m| message.contains(m))
215    {
216        return ("`comnum --help` prints the usage".into(), None);
217    }
218    if message.contains("No such file") || message.contains("not found") {
219        return ("check the path of the specification, or pass `-` to read it from standard input".into(), None);
220    }
221    ("`comnum check SPEC` builds every condition and runs one trial, without writing anything".into(), None)
222}
223
224/// The text between `prefix` and the next `end` in `message`.
225fn quoted_after(message: &str, prefix: &str, end: char) -> Option<String> {
226    let start = message.find(prefix)? + prefix.len();
227    let rest = &message[start..];
228    Some(rest[..rest.find(end)?].to_string())
229}
230
231/// The name of `names` closest to `word` by edit distance, if within a third of its
232/// length (at least 2).
233fn closest<'a>(word: &str, names: &[&'a str]) -> Option<&'a str> {
234    let limit = (word.chars().count() / 3).max(2);
235    names.iter().map(|n| (edit_distance(word, n), *n)).filter(|(d, _)| *d <= limit).min().map(|(_, n)| n)
236}
237
238/// The Levenshtein distance between two words.
239fn edit_distance(a: &str, b: &str) -> usize {
240    let b: Vec<char> = b.chars().collect();
241    let mut row: Vec<usize> = (0..=b.len()).collect();
242    for (i, ca) in a.chars().enumerate() {
243        let mut prev = row[0];
244        row[0] = i + 1;
245        for (j, cb) in b.iter().enumerate() {
246            let cur = row[j + 1];
247            row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
248            prev = cur;
249        }
250    }
251    row[b.len()]
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn a_misspelt_block_or_parameter_gets_the_closest_name() {
260        let e = error("config", "link block 2: unknown block \"awgnn\": one of source, awgn");
261        assert_eq!(e["suggestion"], "type: awgn");
262        // a parameter of another block: the block that takes it
263        let e = error("config", "link block 2: block \"awgn\": unknown field `ebn0_db`, expected one of `snr_db`");
264        assert_eq!(e["suggestion"], "type: awgn_ebn0 (it takes ebn0_db)");
265        let e = error("config", "link block 2: block \"awgn\": unknown field `snr_bd`, expected one of `snr_db`");
266        assert_eq!(e["suggestion"], "snr_db (a parameter of awgn)");
267        let e = error("config", "b.yaml: YAML: did not find expected node content");
268        assert!(e["hint"].as_str().unwrap().contains("comnum schema") && e["suggestion"].is_null());
269        assert!(error("usage", "unknown option --bogus")["hint"].as_str().unwrap().contains("--help"));
270    }
271
272    #[test]
273    fn the_budget_is_300_s_in_json_mode_and_0_removes_it() {
274        assert_eq!(budget(None, true), Some(DEFAULT_BUDGET_S));
275        assert_eq!(budget(None, false), None);
276        assert_eq!(budget(Some(12.0), false), Some(12.0));
277        assert_eq!(budget(Some(0.0), true), None);
278    }
279
280    #[test]
281    fn the_fingerprint_is_fnv_1a() {
282        // the test vectors of FNV-1a 64
283        assert_eq!(fingerprint(""), "cbf29ce484222325");
284        assert_eq!(fingerprint("a"), "af63dc4c8601ec8c");
285        assert_eq!(edit_distance("kitten", "sitting"), 3);
286    }
287}