Skip to main content

comnum_spec/
verify.rs

1//! Expected results: a specification that checks itself against the literature.
2//!
3//! The `expect` list of a specification states what the results must be, where they
4//! come from, and how close they must be. `comnum verify` (or [`verify`]) runs the
5//! specification and checks every expectation on every condition it applies to:
6//!
7//! ```yaml
8//! expect:
9//!   - name: BER of Gray 16-QAM over AWGN
10//!     source: Proakis & Salehi, Digital Communications, 5th ed., eq. (4.3-30)
11//!     where: {m: 16}                     # the conditions it applies to (default: all)
12//!     metric: ber
13//!     theory: {function: ber, kind: qam, m: $m, ebn0_db: $ebn0_db}
14//!     interval: 0.999                    # the expected value is inside the 99.9 % interval
15//!     rtol: 0.05                         # … or within 5 % of the estimate
16//!     min_count: 1000                    # skip the conditions with fewer errors
17//!   - name: Effective SNR of linear equalisation, Fig. 5 of Häger & Pfister (2021)
18//!     where: {receiver: cdc}
19//!     metric: snr_eff_db
20//!     curve: {along: power_dbm, x: [-8, -6, -4], y: [17.40, 16.96, 14.50]}
21//!     atol: 0.5
22//! ```
23//!
24//! The expected value is one of:
25//!
26//! - `value`: a number;
27//! - `curve`: a published curve `y(x)` along a variable of the grid, interpolated
28//!   linearly; the conditions outside the curve, or on a missing point (`null`), are
29//!   not checked;
30//! - `reference`: another metric of the same condition (a theoretical value that a block
31//!   records, for instance `ce_ofdm_rx.ser_impulsive`);
32//! - `theory`: a closed form evaluated on the condition (`$name` substitutes a grid
33//!   variable): `ber`, `ser` (with `kind`, `m`, and `ebn0_db` or `snr_db`), `mi`, `gmi`
34//!   (with `kind`, `m`, `snr_db`, and `mb_lambda` for the Maxwell-Boltzmann priors of a
35//!   shaped source), and for a search the inverses `ebn0_for_ber`,
36//!   `ebn0_for_ser`, `snr_for_ber`, `snr_for_ser` (with `kind`, `m`, `target`; see
37//!   `comnum_mapping::theory::ebn0_for_ber`), and
38//!   `gn_snr_db`, the SNR of the centre channel of a WDM link by the GN model (with
39//!   `fiber`, merged over the `fiber` of the specification as for the fibre blocks,
40//!   `n_channels`, `spacing_hz`, `symbol_rate`, `rolloff`, `power_dbm` and
41//!   `coherent`, see `comnum_channel::optical::gn`; a `raman` mapping in `fiber` gives the
42//!   GN model with the Raman power profiles; `format`, a constellation with
43//!   `mb_lambda` for shaped symbols, gives the EGN model), and `ber_rayleigh`, the BER of BPSK
44//!   or QPSK with maximal-ratio combining of `branches` Rayleigh branches whose mean
45//!   $E_{b}/N_{0}$ is `ebn0_db` plus `offset_db` (with `m`, `kind`; see
46//!   `comnum_mapping::theory::ber_rayleigh_mrc`), and `dvbs2_esn0_db`,
47//!   the ideal $E_{s}/N_{0}$ of a DVB-S2 MODCOD at a PER of $10^{-7}$ (Table 13 of EN
48//!   302 307-1, with `modulation`, `rate`) plus `offset_db`.
49//!
50//! On a specification with a `search`, the expectations are about the searched
51//! variable (`metric: ebn0_db` for a search along `ebn0_db`) and are checked against its
52//! value and confidence interval ([`Expectation::check_search`]).
53//!
54//! Every `function` takes its own parameters, and an unknown one is an error (`branches`
55//! on `ber`, say); `kind` is read without case.
56//!
57//! An expectation passes when the measured value $v$ satisfies
58//! $|v - e| \le a + r |e|$ (`atol: a`, `rtol: r`), or, with `factor: f`, when
59//! $e/f \le v \le f e$ (error rates over several decades), or, with `interval: c`, when
60//! $e$ lies in the confidence interval of level $c$ of the estimate; at least one of
61//! `atol`, `rtol`, `factor` and `interval` is required. `min_count` skips the conditions whose estimate counts fewer errors (or
62//! values), and `measured_range: [lo, hi]` those whose measured value is outside the range
63//! (the domain of validity of an approximation, or the values precise enough).
64//!
65//! An expectation that checks no condition is an error, not a pass: a `where` that
66//! matches no condition (a misspelt variable or value), a curve outside the grid, or
67//! conditions all skipped by `min_count` or `measured_range` verify nothing, and the
68//! message names the expectation and the step that left no condition.
69//!
70//! [`report`] is the verification report that `comnum verify` writes to
71//! `DIR/<name>.verify.json` and that the Python `verify_spec` returns: the header of
72//! the reports of the runs (the seed, the confidence, the version and commit of the
73//! library, the specification) and every check.
74
75mod theory;
76
77use crate::params::{parse, substitute};
78use crate::{RunOptions, Spec};
79use comnum_core::{Error, Result, ensure};
80use comnum_sim::grid::{Condition, Value};
81use comnum_sim::json;
82use comnum_sim::loggers::{run_header, write_json};
83use comnum_sim::runner::ConditionResult;
84use serde::Deserialize;
85use serde_json::{Map, Value as Json};
86use theory::Theory;
87
88/// The acceptance rule of a measured value $v$ against an expected value $e$:
89/// $|v - e| \le a + r |e|$ (with `atol` $a$ or `rtol` $r$), or $e/f \le v \le f e$ (with
90/// `factor` $f$, for error rates over several decades); either rule suffices.
91#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)]
92#[serde(deny_unknown_fields)]
93pub struct Tolerance {
94    /// Absolute tolerance.
95    #[serde(default)]
96    pub atol: Option<f64>,
97    /// Tolerance relative to the expected value.
98    #[serde(default)]
99    pub rtol: Option<f64>,
100    /// Factor, at least 1.
101    #[serde(default)]
102    pub factor: Option<f64>,
103}
104
105impl Tolerance {
106    /// Whether a rule is given.
107    pub fn is_set(&self) -> bool {
108        self.atol.is_some() || self.rtol.is_some() || self.factor.is_some()
109    }
110
111    /// Whether `v` is accepted for `e`.
112    ///
113    /// # Examples
114    /// ```
115    /// use comnum_spec::verify::Tolerance;
116    /// let t = Tolerance { factor: Some(2.0), ..Tolerance::default() };
117    /// assert!(t.accepts(1.9e-3, 1e-3) && !t.accepts(2.1e-3, 1e-3));
118    /// ```
119    pub fn accepts(&self, v: f64, e: f64) -> bool {
120        let by_tol = (self.atol.is_some() || self.rtol.is_some())
121            && (v - e).abs() <= self.atol.unwrap_or(0.0) + self.rtol.unwrap_or(0.0) * e.abs();
122        let by_factor = self.factor.is_some_and(|f| e > 0.0 && v >= e / f && v <= e * f);
123        by_tol || by_factor
124    }
125}
126
127#[derive(Deserialize, Clone, Debug)]
128#[serde(deny_unknown_fields)]
129struct ExpectSpec {
130    #[serde(default)]
131    name: String,
132    #[serde(default)]
133    source: String,
134    #[serde(default, rename = "where")]
135    filter: Map<String, Json>,
136    metric: String,
137    #[serde(default)]
138    value: Option<f64>,
139    #[serde(default)]
140    curve: Option<CurveSpec>,
141    #[serde(default)]
142    theory: Option<Json>,
143    #[serde(default)]
144    reference: Option<String>,
145    #[serde(default)]
146    atol: Option<f64>,
147    #[serde(default)]
148    factor: Option<f64>,
149    #[serde(default)]
150    rtol: Option<f64>,
151    #[serde(default)]
152    interval: Option<f64>,
153    #[serde(default)]
154    min_count: u64,
155    #[serde(default)]
156    measured_range: Option<[f64; 2]>,
157    /// Why the tolerance is what it is (the precision of the published value, the
158    /// sampling error), for the reader.
159    #[serde(default)]
160    #[allow(dead_code, reason = "documentation of the specification, not used to check")]
161    why: String,
162    /// A comment, for the reader.
163    #[serde(default)]
164    #[allow(dead_code, reason = "documentation of the specification, not used to check")]
165    note: String,
166}
167
168impl ExpectSpec {
169    fn tolerance(&self) -> Tolerance {
170        Tolerance { atol: self.atol, rtol: self.rtol, factor: self.factor }
171    }
172}
173
174#[derive(Deserialize, Clone, Debug)]
175#[serde(deny_unknown_fields)]
176struct CurveSpec {
177    along: String,
178    x: Vec<f64>,
179    y: Vec<Option<f64>>,
180}
181
182impl CurveSpec {
183    /// The curve at `x`, interpolated linearly; `None` outside the curve or next to a
184    /// missing point.
185    fn at(&self, x: f64) -> Option<f64> {
186        let n = self.x.len();
187        if x < self.x[0] - 1e-12 || x > self.x[n - 1] + 1e-12 {
188            return None;
189        }
190        let i = self.x.iter().position(|&xi| xi >= x - 1e-12).unwrap_or(n - 1);
191        if (self.x[i] - x).abs() <= 1e-12 {
192            return self.y[i];
193        }
194        match (self.y[i - 1], self.y[i]) {
195            (Some(a), Some(b)) => Some(a + (b - a) * (x - self.x[i - 1]) / (self.x[i] - self.x[i - 1])),
196            _ => None,
197        }
198    }
199}
200
201/// An expectation of a specification, validated.
202#[derive(Clone, Debug)]
203pub struct Expectation {
204    spec: ExpectSpec,
205}
206
207/// The outcome of one expectation on one condition.
208#[derive(Clone, Debug, PartialEq)]
209pub struct Check {
210    /// Name of the expectation.
211    pub name: String,
212    /// Where the expected value comes from.
213    pub source: String,
214    /// The condition.
215    pub condition: String,
216    /// The metric.
217    pub metric: String,
218    /// Expected value.
219    pub expected: f64,
220    /// Measured value and its interval at the confidence of the specification.
221    pub measured: Interval,
222    /// Whether the check passed.
223    pub passed: bool,
224}
225
226/// A measured value and its confidence interval.
227#[derive(Clone, Copy, Debug, PartialEq)]
228pub struct Interval {
229    /// The estimate.
230    pub value: f64,
231    /// Lower bound of its confidence interval.
232    pub lo: f64,
233    /// Upper bound of its confidence interval.
234    pub hi: f64,
235}
236
237impl Expectation {
238    /// Parse and validate an expectation.
239    ///
240    /// # Errors
241    /// [`Error::Config`] for a missing expected value or tolerance.
242    pub fn from_json(v: &Json) -> Result<Expectation> {
243        let spec: ExpectSpec = parse("expect", v.clone())?;
244        let sources = [spec.value.is_some(), spec.curve.is_some(), spec.theory.is_some(), spec.reference.is_some()];
245        ensure(sources.iter().filter(|&&b| b).count() == 1, || {
246            format!("expectation {:?}: give exactly one of value, curve, theory and reference", spec.name)
247        })?;
248        ensure(spec.tolerance().is_set() || spec.interval.is_some(), || {
249            format!("expectation {:?}: give atol, rtol, factor or interval", spec.name)
250        })?;
251        ensure(!matches!(spec.factor, Some(f) if f < 1.0), || {
252            format!("expectation {:?}: factor must be at least 1", spec.name)
253        })?;
254        if let Some(c) = &spec.curve {
255            ensure(c.x.len() == c.y.len() && !c.x.is_empty(), || {
256                format!("expectation {:?}: the curve needs as many x as y", spec.name)
257            })?;
258            ensure(c.x.windows(2).all(|w| w[0] < w[1]), || {
259                format!("expectation {:?}: the x of the curve must increase", spec.name)
260            })?;
261        }
262        Ok(Expectation { spec })
263    }
264
265    fn applies(&self, cond: &Condition) -> bool {
266        self.spec.filter.iter().all(|(k, want)| match (cond.get(k), want) {
267            (Some(v), Json::Number(n)) => {
268                v.as_f64().zip(n.as_f64()).is_some_and(|(a, b)| (a - b).abs() <= 1e-9 * b.abs().max(1.0))
269            }
270            (Some(Value::Str(s)), Json::String(w)) => s == w,
271            (Some(Value::Bool(b)), Json::Bool(w)) => b == w,
272            _ => false,
273        })
274    }
275
276    fn expected(&self, cond: &Condition) -> Result<Option<f64>> {
277        let s = &self.spec;
278        ensure(s.reference.is_none(), || {
279            format!("expectation {:?}: a reference metric needs the results of a run", s.name)
280        })?;
281        if let Some(v) = s.value {
282            return Ok(Some(v));
283        }
284        if let Some(c) = &s.curve {
285            return Ok(c.at(cond.f64(&c.along)?));
286        }
287        let t: Theory = parse("expect theory", substitute(s.theory.as_ref().unwrap_or(&Json::Null), cond)?)?;
288        t.value(&s.name).map(Some)
289    }
290
291    /// The name of the expectation in a message: its name, or its metric.
292    fn label(&self) -> String {
293        if self.spec.name.is_empty() { format!("on {}", self.spec.metric) } else { format!("{:?}", self.spec.name) }
294    }
295
296    /// The error of an expectation that checked no condition: `funnel` counts the
297    /// conditions left after each step, with the step's name.
298    fn checked_nothing(&self, funnel: &[(&str, usize)]) -> Error {
299        let steps: Vec<String> = funnel.iter().map(|(what, n)| format!("{n} {what}")).collect();
300        let filter = if self.spec.filter.is_empty() {
301            String::new()
302        } else {
303            format!(" (where: {})", Json::Object(self.spec.filter.clone()))
304        };
305        Error::Config(format!("expectation {} checks no condition{filter}: {}", self.label(), steps.join(", ")))
306    }
307
308    /// Check the expectation on the results of a run.
309    ///
310    /// # Errors
311    /// [`Error::Config`] for a missing metric or variable, or an expectation that checks
312    /// no condition (see the module documentation).
313    pub fn check(&self, results: &[ConditionResult], confidence: f64) -> Result<Vec<Check>> {
314        let s = &self.spec;
315        let mut out = Vec::new();
316        let run = results.iter().filter(|r| r.trials > 0).count();
317        let (mut matched, mut expected, mut counted) = (0, 0, 0);
318        for r in results.iter().filter(|r| r.trials > 0 && self.applies(&r.condition)) {
319            matched += 1;
320            let e = match &s.reference {
321                Some(m) => Some(
322                    r.metric(m)
323                        .ok_or_else(|| {
324                            Error::Config(format!(
325                                "expectation {:?}: no reference metric {m:?} in ({})",
326                                s.name,
327                                r.condition.label()
328                            ))
329                        })?
330                        .value(),
331                ),
332                None => self.expected(&r.condition)?,
333            };
334            let Some(e) = e else { continue };
335            expected += 1;
336            let est = r.metric(&s.metric).ok_or_else(|| {
337                Error::Config(format!(
338                    "expectation {:?}: no metric {:?} in ({})",
339                    s.name,
340                    s.metric,
341                    r.condition.label()
342                ))
343            })?;
344            if est.count() < s.min_count {
345                continue;
346            }
347            counted += 1;
348            let v = est.value();
349            if s.measured_range.is_some_and(|[a, b]| !(a <= v && v <= b)) {
350                continue;
351            }
352            let (lo, hi) = est.interval(confidence);
353            let by_interval = s.interval.is_some_and(|c| {
354                let (a, b) = est.interval(c);
355                a <= e && e <= b
356            });
357            out.push(Check {
358                name: s.name.clone(),
359                source: s.source.clone(),
360                condition: r.condition.label(),
361                metric: s.metric.clone(),
362                expected: e,
363                measured: Interval { value: v, lo, hi },
364                passed: s.tolerance().accepts(v, e) || by_interval,
365            });
366        }
367        if out.is_empty() {
368            let mut funnel =
369                vec![("conditions run", run), ("match `where`", matched), ("have an expected value", expected)];
370            if s.min_count > 0 {
371                funnel.push(("reach min_count", counted));
372            }
373            if s.measured_range.is_some() {
374                funnel.push(("are within measured_range", 0));
375            }
376            return Err(self.checked_nothing(&funnel));
377        }
378        Ok(out)
379    }
380}
381
382impl Expectation {
383    /// Check the expectation on the operating points of a search: the expected value of
384    /// the searched variable must lie within `atol + rtol |e|` of the estimate, or (with
385    /// `interval`) inside the confidence interval of the search widened by `atol`.
386    ///
387    /// # Errors
388    /// [`Error::Config`] if the expectation is not about the searched variable, or checks
389    /// no operating point.
390    pub fn check_search(&self, results: &[crate::search::SearchResult]) -> Result<Vec<Check>> {
391        let s = &self.spec;
392        let mut out = Vec::new();
393        let mut matched = 0;
394        for r in results.iter().filter(|r| self.applies(&r.condition)) {
395            matched += 1;
396            ensure(s.metric == r.along, || {
397                format!("expectation {:?}: a search checks its variable {:?}, not {:?}", s.name, r.along, s.metric)
398            })?;
399            let Some(e) = self.expected(&r.condition)? else { continue };
400            let atol = s.atol.unwrap_or(0.0);
401            let by_interval = s.interval.is_some() && r.lo - atol <= e && e <= r.hi + atol;
402            out.push(Check {
403                name: s.name.clone(),
404                source: s.source.clone(),
405                condition: r.condition.label(),
406                metric: s.metric.clone(),
407                expected: e,
408                measured: Interval { value: r.value, lo: r.lo, hi: r.hi },
409                passed: s.tolerance().accepts(r.value, e) || by_interval,
410            });
411        }
412        if out.is_empty() {
413            return Err(self.checked_nothing(&[
414                ("operating points", results.len()),
415                ("match `where`", matched),
416                ("have an expected value", 0),
417            ]));
418        }
419        Ok(out)
420    }
421}
422
423/// Check every expectation of a list on the operating points of a search.
424///
425/// # Errors
426/// [`Error::Config`] for an empty list, an invalid expectation, one that is not about
427/// the searched variable or one that checks no operating point.
428pub fn verify_search(expect: &[Json], results: &[crate::search::SearchResult]) -> Result<Vec<Check>> {
429    ensure(!expect.is_empty(), || "no expectation to verify".to_string())?;
430    let mut out = Vec::new();
431    for e in expect {
432        out.extend(Expectation::from_json(e)?.check_search(results)?);
433    }
434    Ok(out)
435}
436
437/// Check every expectation of a list on the results of a run.
438///
439/// # Errors
440/// [`Error::Config`] for an empty list, an invalid expectation, a missing metric or
441/// variable, or an expectation that checks no condition: a verification never passes
442/// with nothing checked.
443pub fn verify(expect: &[Json], results: &[ConditionResult], confidence: f64) -> Result<Vec<Check>> {
444    ensure(!expect.is_empty(), || "no expectation to verify".to_string())?;
445    let mut out = Vec::new();
446    for e in expect {
447        out.extend(Expectation::from_json(e)?.check(results, confidence)?);
448    }
449    Ok(out)
450}
451
452/// An expectation with the fibre of its specification: the `fiber` of a `gn_snr_db`
453/// theory merged over `fiber` (the `fiber` of the specification), as the fibre blocks
454/// merge theirs ([`BuildContext::fiber`](crate::blocks::BuildContext::fiber));
455/// [`Spec::from_json`] resolves its expectations so.
456///
457/// # Errors
458/// [`Error::Config`] for a `fiber` that is not a mapping.
459pub(crate) fn resolve_fiber(mut e: Json, fiber: Option<&Json>) -> Result<Json> {
460    let Some(fiber) = fiber.filter(|f| !f.is_null()) else { return Ok(e) };
461    let Some(t) = e.get_mut("theory").and_then(Json::as_object_mut) else { return Ok(e) };
462    if t.get("function").and_then(Json::as_str) != Some("gn_snr_db") {
463        return Ok(e);
464    }
465    let own = t.remove("fiber");
466    if own.as_ref().is_some_and(|f| !(f.is_object() || f.is_null())) {
467        // a variable of the grid: substituted later, not merged
468        t.insert("fiber".into(), own.unwrap_or(Json::Null));
469        return Ok(e);
470    }
471    let merged = crate::blocks::merge_fiber(Some(fiber), own.as_ref())?;
472    t.insert("fiber".into(), Json::Object(merged));
473    Ok(e)
474}
475
476/// What [`verify_spec`] ran: the conditions of the grid, or the search.
477#[derive(Clone, Debug)]
478pub enum Verified {
479    /// The result of every condition.
480    Run(Vec<ConditionResult>),
481    /// The operating points of the search.
482    Search(Vec<crate::search::SearchResult>),
483}
484
485/// Run a specification (its search, when it has one) and check its expectations: what
486/// `comnum verify` and the Python `verify_spec` do.
487///
488/// # Errors
489/// [`Error::Config`] for a specification without expectations (before anything runs);
490/// the errors of the run or the search and those of [`verify`] or [`verify_search`].
491pub fn verify_spec(spec: &crate::Spec, opts: &crate::RunOptions) -> Result<(Verified, Vec<Check>)> {
492    ensure(!spec.expect.is_empty(), || format!("{}: no expectation to verify", spec.name))?;
493    Ok(if spec.search.is_some() {
494        let found = crate::search::search(spec, opts)?;
495        let checks = verify_search(&spec.expect, &found)?;
496        (Verified::Search(found), checks)
497    } else {
498        let results = crate::run(spec, opts)?;
499        let checks = verify(&spec.expect, &results, spec.confidence)?;
500        (Verified::Run(results), checks)
501    })
502}
503
504/// The verification report of a specification: the header of the reports of its runs
505/// ([`comnum_sim::loggers::run_header`]: the name, the seed, the batch, the threads, the
506/// stopping rule and the confidence of `spec` with the options `opts`, the version and
507/// commit of the library, the specification), `passed` (every check passed) and
508/// `checks` (one object per check: `name`, `source`, `condition`, `metric`,
509/// `expected`, `value`, `lo`, `hi`, `passed`), as JSON text.
510pub fn report(spec: &Spec, opts: &RunOptions, checks: &[Check]) -> String {
511    let items: Vec<String> = checks
512        .iter()
513        .map(|c| {
514            json::object(&[
515                ("name", json::string(&c.name)),
516                ("source", json::string(&c.source)),
517                ("condition", json::string(&c.condition)),
518                ("metric", json::string(&c.metric)),
519                ("expected", json::number(c.expected)),
520                ("value", json::number(c.measured.value)),
521                ("lo", json::number(c.measured.lo)),
522                ("hi", json::number(c.measured.hi)),
523                ("passed", c.passed.to_string()),
524            ])
525        })
526        .collect();
527    let mut fields = run_header(&spec.bare_runner(opts).run_info(&spec.name, spec.grid.len()));
528    fields.push(("passed", checks.iter().all(|c| c.passed).to_string()));
529    fields.push(("checks", json::array(&items)));
530    json::object(&fields)
531}
532
533/// Write the verification report of a specification ([`report`]) to `path`, its
534/// parent directories created.
535///
536/// # Errors
537/// [`Error::Io`] if the file cannot be written.
538pub fn write_report(path: &std::path::Path, spec: &Spec, opts: &RunOptions, checks: &[Check]) -> Result<()> {
539    write_json(path, &report(spec, opts, checks))
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use comnum_sim::scenario::Record;
546    use serde_json::json;
547
548    fn result(cond: Condition, errors: u64, total: u64) -> ConditionResult {
549        let mut record = Record::new();
550        record.errors("ber", errors, total);
551        record.value("snr", 10.0);
552        record.value("snr", 10.2);
553        ConditionResult {
554            index: 0,
555            condition: cond,
556            status: comnum_sim::runner::Status::Completed,
557            trials: 1,
558            wall_s: 0.0,
559            record,
560        }
561    }
562
563    #[test]
564    fn values_curves_and_theory() {
565        let r = vec![
566            result(Condition::new().with("m", 4usize).with("ebn0_db", 6.0), 2388, 1_000_000),
567            result(Condition::new().with("m", 4usize).with("ebn0_db", 7.0), 100, 1_000_000),
568        ];
569        let theory = json!({"name": "qpsk", "metric": "ber", "theory": {"function": "ber", "m": "$m", "ebn0_db": "$ebn0_db"}, "rtol": 0.05});
570        let c = verify(&[theory], &r, 0.95).unwrap();
571        assert_eq!((c.len(), c[0].passed, c[1].passed), (2, true, false));
572        let curve = json!({"metric": "snr", "curve": {"along": "ebn0_db", "x": [5.0, 6.5, 8.0], "y": [9.8, 10.2, null]}, "atol": 0.05});
573        let c = verify(&[curve], &r, 0.95).unwrap();
574        // 6 dB interpolates 9.8 → 10.2 to 10.067; 7 dB falls next to a missing point
575        assert_eq!(c.len(), 1);
576        assert!((c[0].expected - 10.0667).abs() < 1e-3 && c[0].passed);
577        let filtered = json!({"where": {"ebn0_db": 7}, "metric": "ber", "value": 1e-4, "interval": 0.95});
578        let c = verify(&[filtered], &r, 0.95).unwrap();
579        assert_eq!((c.len(), c[0].passed), (1, true));
580        let few = json!({"metric": "ber", "value": 1e-4, "rtol": 0.1, "min_count": 1000});
581        assert_eq!(verify(&[few], &r, 0.95).unwrap().len(), 1);
582    }
583
584    #[test]
585    fn an_expectation_that_checks_no_condition_is_an_error() {
586        let r = vec![
587            result(Condition::new().with("m", 4usize).with("ebn0_db", 6.0), 2388, 1_000_000),
588            result(Condition::new().with("m", 4usize).with("ebn0_db", 7.0), 100, 1_000_000),
589        ];
590        // a misspelt variable, a value that is not in the grid
591        for filter in [json!({"M": 4}), json!({"m": 16})] {
592            let e = json!({"name": "typo", "where": filter, "metric": "ber", "value": 1e-3, "factor": 2});
593            let msg = verify(&[e], &r, 0.95).unwrap_err().to_string();
594            assert!(msg.contains("\"typo\" checks no condition") && msg.contains("0 match `where`"), "{msg}");
595        }
596        // a curve outside the grid
597        let e = json!({"metric": "snr", "curve": {"along": "ebn0_db", "x": [10.0, 12.0], "y": [1.0, 2.0]}, "atol": 1});
598        let msg = verify(&[e], &r, 0.95).unwrap_err().to_string();
599        assert!(msg.contains("on snr checks no condition") && msg.contains("0 have an expected value"), "{msg}");
600        // every condition skipped
601        let e = json!({"metric": "ber", "value": 1e-3, "factor": 2, "min_count": 1_000_000});
602        let msg = verify(&[e], &r, 0.95).unwrap_err().to_string();
603        assert!(msg.contains("0 reach min_count"), "{msg}");
604        // one empty expectation fails the whole verification, and so does an empty list
605        let good = json!({"metric": "ber", "value": 1e-3, "factor": 10});
606        let bad = json!({"where": {"m": 64}, "metric": "ber", "value": 1e-3, "factor": 10});
607        assert_eq!(verify(std::slice::from_ref(&good), &r, 0.95).unwrap().len(), 2);
608        assert!(verify(&[good, bad], &r, 0.95).is_err());
609        assert!(verify(&[], &r, 0.95).is_err());
610    }
611
612    #[test]
613    fn invalid_expectations_are_errors() {
614        for bad in [
615            json!({"metric": "ber", "value": 1.0}),
616            json!({"metric": "ber", "rtol": 0.1}),
617            json!({"metric": "ber", "value": 1.0, "theory": {"function": "ber", "m": 4}, "rtol": 0.1}),
618            json!({"metric": "ber", "curve": {"along": "x", "x": [2, 1], "y": [0, 0]}, "rtol": 0.1}),
619        ] {
620            assert!(Expectation::from_json(&bad).is_err(), "{bad}");
621        }
622        let r = vec![result(Condition::new().with("x", 1.0), 1, 10)];
623        let e = verify(&[json!({"metric": "evm", "value": 1.0, "atol": 1})], &r, 0.95).unwrap_err();
624        assert!(e.to_string().contains("evm"));
625    }
626
627    #[test]
628    fn every_theory_takes_its_own_parameters() {
629        let r = vec![result(Condition::new().with("ebn0_db", 6.0), 2388, 1_000_000)];
630        let check = |theory: Json| {
631            verify(&[json!({"metric": "ber", "theory": theory, "rtol": 0.05})], &r, 0.95).map(|c| c[0].passed)
632        };
633        assert!(check(json!({"function": "ber", "kind": "QAM", "m": 4, "ebn0_db": "$ebn0_db"})).unwrap());
634        // a parameter of another function, an unknown function, the kind without case
635        let e = check(json!({"function": "ber", "m": 4, "ebn0_db": 6, "branches": 2})).unwrap_err().to_string();
636        assert!(e.contains("unknown field `branches`"), "{e}");
637        let e = check(json!({"function": "bler", "m": 4})).unwrap_err().to_string();
638        assert!(e.contains("unknown variant `bler`") && e.contains("gn_snr_db"), "{e}");
639        let rayleigh =
640            |kind: &str| check(json!({"function": "ber_rayleigh", "kind": kind, "ebn0_db": 6, "branches": 1}));
641        assert_eq!(rayleigh("QAM").unwrap(), rayleigh("qam").unwrap());
642        assert!(rayleigh("pam").is_err());
643    }
644
645    #[test]
646    fn the_gn_theory_sees_the_fibre_of_the_specification() {
647        // the same link, its fibre given once by the specification, once by the theory
648        let theory = |fiber: &str| {
649            format!(
650                "{{metric: snr_db, theory: {{function: gn_snr_db, {fiber}n_channels: 3, spacing_hz: 50e9, \
651                 symbol_rate: 32e9, rolloff: 0.1, power_dbm: 0}}, atol: 0.1}}"
652            )
653        };
654        let spec = |top: &str, fiber: &str| {
655            Spec::from_yaml(&format!("name: g\n{top}link: [source]\nexpect: [{}]", theory(fiber))).unwrap()
656        };
657        let expected =
658            |s: &Spec| Expectation::from_json(&s.expect[0]).unwrap().expected(&Condition::new()).unwrap().unwrap();
659        let by_spec = expected(&spec("fiber: {n_spans: 10, span_km: 100}\n", ""));
660        let by_theory = expected(&spec("", "fiber: {n_spans: 10, span_km: 100}, "));
661        let merged = expected(&spec("fiber: {n_spans: 10}\n", "fiber: {span_km: 100}, "));
662        let default = expected(&spec("", ""));
663        assert_eq!(by_spec, by_theory);
664        assert_eq!(merged, by_theory);
665        assert!((default - by_theory).abs() > 1.0, "{default} {by_theory}");
666    }
667}