1mod 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#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)]
92#[serde(deny_unknown_fields)]
93pub struct Tolerance {
94 #[serde(default)]
96 pub atol: Option<f64>,
97 #[serde(default)]
99 pub rtol: Option<f64>,
100 #[serde(default)]
102 pub factor: Option<f64>,
103}
104
105impl Tolerance {
106 pub fn is_set(&self) -> bool {
108 self.atol.is_some() || self.rtol.is_some() || self.factor.is_some()
109 }
110
111 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 #[serde(default)]
160 #[allow(dead_code, reason = "documentation of the specification, not used to check")]
161 why: String,
162 #[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 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#[derive(Clone, Debug)]
203pub struct Expectation {
204 spec: ExpectSpec,
205}
206
207#[derive(Clone, Debug, PartialEq)]
209pub struct Check {
210 pub name: String,
212 pub source: String,
214 pub condition: String,
216 pub metric: String,
218 pub expected: f64,
220 pub measured: Interval,
222 pub passed: bool,
224}
225
226#[derive(Clone, Copy, Debug, PartialEq)]
228pub struct Interval {
229 pub value: f64,
231 pub lo: f64,
233 pub hi: f64,
235}
236
237impl Expectation {
238 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 fn label(&self) -> String {
293 if self.spec.name.is_empty() { format!("on {}", self.spec.metric) } else { format!("{:?}", self.spec.name) }
294 }
295
296 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 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 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
423pub 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
437pub 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
452pub(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 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#[derive(Clone, Debug)]
478pub enum Verified {
479 Run(Vec<ConditionResult>),
481 Search(Vec<crate::search::SearchResult>),
483}
484
485pub 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
504pub 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
533pub 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 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 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 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 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 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 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 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}