Skip to main content

comnum_spec/
search.rs

1//! Search of the operating point: the value of a variable at which a metric reaches a
2//! target, with its confidence interval.
3//!
4//! A sweep answers "what is the BER at 12 dB?"; a link budget asks the converse, "which
5//! SNR gives a BER of $10^{-3}$?". The `search` section of a specification answers it
6//! directly:
7//!
8//! ```yaml
9//! search:
10//!   along: ebn0_db          # the variable searched (a grid axis or not)
11//!   range: [4, 16]          # the bracket of the search
12//!   metric: ber
13//!   target: 1.0e-3
14//!   tolerance: 0.02         # stop when the bracket is narrower
15//!   log: true               # interpolate log10 of the metric (default)
16//! ```
17//!
18//! For every combination of the other grid axes, the metric is evaluated at both ends of
19//! the range (the target must lie between the two values), then the bracket is halved
20//! until it is narrower than `tolerance` (at most `max_evaluations` evaluations). Every
21//! evaluation is a run of one condition with the stopping rule of the specification;
22//! all of them use the same random streams (common random numbers), so the comparison
23//! of two values of the variable is not blurred by the draws, and the estimate is
24//! monotone wherever the link is.
25//!
26//! The crossing is interpolated between the two ends of the final bracket (linearly in
27//! `log10` of the metric, or in the metric). Its confidence interval comes from the
28//! confidence intervals of these two estimates: the crossings of the lower and of the
29//! upper bounds of the metric, interpolated the same way (and extrapolated when a bound
30//! does not cross the target inside the bracket).
31//!
32//! # Examples
33//! ```
34//! use comnum_spec::{search::search, RunOptions, Spec};
35//! let spec = Spec::from_yaml(r#"
36//! name: qpsk_required
37//! link:
38//!   - {type: source, constellation: QPSK, n_symbols: 4000}
39//!   - {type: awgn_ebn0, ebn0_db: $ebn0_db}
40//! metrics: {report: [ber], scale: none}
41//! stop: {min_errors: {ber: 400}, max_trials: 400}
42//! search: {along: ebn0_db, range: [2, 10], metric: ber, target: 1.0e-3, tolerance: 0.05}
43//! "#).unwrap();
44//! let r = search(&spec, &RunOptions::default()).unwrap();
45//! // Q(√(2 Eb/N0)) = 1e-3 at 6.79 dB
46//! assert!((r[0].value - 6.79).abs() < 0.15, "{}", r[0].value);
47//! assert!(r[0].lo <= r[0].value && r[0].value <= r[0].hi);
48//! ```
49
50use crate::params::parse;
51use crate::{RunOptions, Spec, SpecScenario};
52use comnum_core::{Error, Result, ensure};
53use comnum_sim::grid::{Condition, Grid};
54use comnum_sim::json;
55use comnum_sim::loggers::{create_parent_dir, csv_field, run_header, value_json, write_json as write_json_file};
56use comnum_sim::runner::RunInfo;
57use serde::Deserialize;
58use serde_json::Value as Json;
59use std::path::Path;
60
61/// The `search` section of a specification.
62#[derive(Clone, Debug, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct SearchSpec {
65    /// The variable searched.
66    pub along: String,
67    /// The bracket `[lo, hi]` of the search.
68    pub range: [f64; 2],
69    /// The metric.
70    pub metric: String,
71    /// The value of the metric to reach.
72    pub target: f64,
73    /// Stop when the bracket is narrower (in the unit of the variable).
74    #[serde(default = "d_tolerance")]
75    pub tolerance: f64,
76    /// Interpolate `log10` of the metric (rates spanning decades).
77    #[serde(default = "d_true")]
78    pub log: bool,
79    /// Largest number of evaluations per combination of the other axes.
80    #[serde(default = "d_max_eval")]
81    pub max_evaluations: usize,
82}
83
84fn d_tolerance() -> f64 {
85    0.05
86}
87fn d_true() -> bool {
88    true
89}
90fn d_max_eval() -> usize {
91    30
92}
93
94impl SearchSpec {
95    /// Parse and validate the `search` mapping.
96    ///
97    /// # Errors
98    /// [`Error::Config`] for a missing field, an empty range or a non-positive tolerance.
99    pub fn from_json(v: &Json) -> Result<SearchSpec> {
100        let s: SearchSpec = parse("search", v.clone())?;
101        ensure(s.range[0] < s.range[1], || format!("search: the range {:?} must increase", s.range))?;
102        ensure(s.tolerance > 0.0, || "search: the tolerance must be positive".into())?;
103        ensure(s.max_evaluations >= 3, || "search: at least 3 evaluations".into())?;
104        ensure(!s.log || s.target > 0.0, || "search: a log search needs a positive target".into())?;
105        Ok(s)
106    }
107}
108
109/// One evaluation of the metric during a search.
110#[derive(Clone, Debug, PartialEq)]
111pub struct Evaluation {
112    /// Value of the variable.
113    pub x: f64,
114    /// Estimate of the metric and its confidence interval.
115    pub value: f64,
116    /// Lower bound of the interval.
117    pub lo: f64,
118    /// Upper bound of the interval.
119    pub hi: f64,
120    /// Trials run.
121    pub trials: u64,
122}
123
124/// The operating point of one combination of the other axes.
125#[derive(Clone, Debug, PartialEq)]
126pub struct SearchResult {
127    /// The values of the other axes.
128    pub condition: Condition,
129    /// The variable searched.
130    pub along: String,
131    /// The metric.
132    pub metric: String,
133    /// Its target.
134    pub target: f64,
135    /// The value of the variable at which the metric reaches the target.
136    pub value: f64,
137    /// Lower bound of its confidence interval.
138    pub lo: f64,
139    /// Upper bound of its confidence interval.
140    pub hi: f64,
141    /// Whether the final bracket is narrower than the tolerance.
142    pub converged: bool,
143    /// Every evaluation, in the order of the search.
144    pub evaluations: Vec<Evaluation>,
145}
146
147/// The value of the variable at which `g` crosses `g(target)` between `(a, ga)` and
148/// `(b, gb)` (extrapolated outside; the midpoint for a flat segment).
149fn crossing(a: f64, ga: f64, b: f64, gb: f64, gt: f64) -> f64 {
150    if (gb - ga).abs() < 1e-300 || !ga.is_finite() || !gb.is_finite() {
151        return 0.5 * (a + b);
152    }
153    a + (b - a) * (gt - ga) / (gb - ga)
154}
155
156/// The conditions of the other axes: the grid without `along`, in order, unique.
157fn combinations(grid: &Grid, along: &str) -> Vec<Condition> {
158    let mut out: Vec<Condition> = Vec::new();
159    for c in grid.conditions() {
160        let mut d = Condition::new();
161        for (k, v) in c.entries() {
162            if k != along {
163                d.set(k, v.clone());
164            }
165        }
166        if !out.contains(&d) {
167            out.push(d);
168        }
169    }
170    out
171}
172
173/// The sweep of the first points of the search of `spec`: every combination of the other
174/// axes, with the searched variable at the lower end of the range (what `comnum check`
175/// runs); `None` without a `search` section.
176pub(crate) fn first_points(spec: &Spec) -> Option<Spec> {
177    let s = spec.search.as_ref()?;
178    let conditions = combinations(&spec.grid, &s.along).into_iter().map(|c| c.with(&s.along, s.range[0])).collect();
179    Some(Spec { grid: Grid::from_conditions(conditions), search: None, ..spec.clone() })
180}
181
182/// Run the search of a specification.
183///
184/// # Errors
185/// [`Error::Config`] without a `search` section, or when the target is not between the
186/// values of the metric at the ends of the range; the errors of the runs.
187pub fn search(spec: &Spec, opts: &RunOptions) -> Result<Vec<SearchResult>> {
188    let s = spec.search.as_ref().ok_or_else(|| Error::Config(format!("{}: no search section", spec.name)))?;
189    let combos = combinations(&spec.grid, &s.along);
190    let info = spec.bare_runner(opts).run_info(&spec.name, combos.len());
191    let t0 = std::time::Instant::now();
192    let mut results = Vec::new();
193    for base in combos {
194        results.push(bisect(spec, opts, s, base)?);
195    }
196    if let Some(p) = opts.csv.as_ref().or(spec.csv.as_ref()) {
197        write_csv(p, &results)?;
198    }
199    if let Some(p) = opts.json.as_ref().or(spec.json.as_ref()) {
200        write_json(p, &info, t0.elapsed().as_secs_f64(), &results)?;
201    }
202    Ok(results)
203}
204
205/// The metric of the search at `x` for the combination `base` of the other axes.
206fn evaluate(spec: &Spec, opts: &RunOptions, s: &SearchSpec, base: &Condition, x: f64) -> Result<Evaluation> {
207    let cond = base.clone().with(&s.along, x);
208    // a single condition: index 0, so every value of the variable sees the same
209    // random streams (common random numbers)
210    let grid = Grid::from_conditions(vec![cond.clone()]);
211    let r = spec.bare_runner(opts).run(&SpecScenario::new(spec), &grid)?.remove(0);
212    let est = r
213        .metric(&s.metric)
214        .ok_or_else(|| Error::Config(format!("search: no metric {:?} in ({})", s.metric, cond.label())))?;
215    let (lo, hi) = est.interval(spec.confidence);
216    if opts.progress.unwrap_or(spec.progress) {
217        eprintln!("[{}] ({}): {} = {:.4e} [{lo:.3e}, {hi:.3e}]", spec.name, cond.label(), s.metric, est.value());
218    }
219    Ok(Evaluation { x, value: est.value(), lo, hi, trials: r.trials })
220}
221
222/// The bisection of one combination `base` of the other axes: the ends of the range,
223/// then the midpoint of the bracket until it is narrower than the tolerance or the
224/// evaluations run out.
225///
226/// # Errors
227/// [`Error::Config`] when the target is not between the values at the ends of the
228/// range; the errors of the runs.
229fn bisect(spec: &Spec, opts: &RunOptions, s: &SearchSpec, base: Condition) -> Result<SearchResult> {
230    let eval = |x: f64| evaluate(spec, opts, s, &base, x);
231    let (mut a, mut b) = (eval(s.range[0])?, eval(s.range[1])?);
232    let mut evaluations = vec![a.clone(), b.clone()];
233    let side = |e: &Evaluation| (e.value - s.target).signum();
234    if side(&a) * side(&b) >= 0.0 {
235        return Err(Error::Config(format!(
236            "search ({}): the target {} of {} is not between {:.4e} at {} = {} and {:.4e} at {} = {}",
237            base.label(),
238            s.target,
239            s.metric,
240            a.value,
241            s.along,
242            a.x,
243            b.value,
244            s.along,
245            b.x
246        )));
247    }
248    while b.x - a.x > s.tolerance && evaluations.len() < s.max_evaluations {
249        let m = eval(0.5 * (a.x + b.x))?;
250        evaluations.push(m.clone());
251        if side(&m) == side(&a) {
252            a = m;
253        } else {
254            b = m;
255        }
256    }
257    Ok(operating_point(s, base, &a, &b, evaluations))
258}
259
260/// The operating point of the final bracket `[a, b]`: the crossing of the target by the
261/// estimate and by the bounds of its interval, interpolated linearly (in $\log_{10}$ of
262/// the metric for a log search).
263fn operating_point(
264    s: &SearchSpec,
265    base: Condition,
266    a: &Evaluation,
267    b: &Evaluation,
268    evaluations: Vec<Evaluation>,
269) -> SearchResult {
270    let g = |v: f64| if s.log { v.max(1e-300).log10() } else { v };
271    let gt = g(s.target);
272    let value = crossing(a.x, g(a.value), b.x, g(b.value), gt);
273    let x1 = crossing(a.x, g(a.lo), b.x, g(b.lo), gt);
274    let x2 = crossing(a.x, g(a.hi), b.x, g(b.hi), gt);
275    SearchResult {
276        condition: base,
277        along: s.along.clone(),
278        metric: s.metric.clone(),
279        target: s.target,
280        value,
281        lo: x1.min(x2).min(value),
282        hi: x1.max(x2).max(value),
283        converged: b.x - a.x <= s.tolerance,
284        evaluations,
285    }
286}
287
288/// One row per combination: its keys, then the variable at the target, its interval,
289/// the convergence and the number of evaluations.
290fn write_csv(path: &Path, results: &[SearchResult]) -> Result<()> {
291    create_parent_dir(path)?;
292    let mut out = String::new();
293    if let Some(first) = results.first() {
294        let keys: Vec<&str> = first.condition.keys().collect();
295        let mut header: Vec<String> = keys.iter().map(|k| csv_field(k)).collect();
296        let a = &first.along;
297        header.extend([
298            "metric".into(),
299            "target".into(),
300            a.clone(),
301            format!("{a}_lo"),
302            format!("{a}_hi"),
303            "converged".into(),
304            "evaluations".into(),
305        ]);
306        out.push_str(&(header.join(",") + "\n"));
307        for r in results {
308            let mut row: Vec<String> = keys
309                .iter()
310                .map(|k| r.condition.get(k).map(|v| csv_field(&v.to_string())).unwrap_or_default())
311                .collect();
312            row.extend([
313                r.metric.clone(),
314                format!("{:e}", r.target),
315                format!("{}", r.value),
316                format!("{}", r.lo),
317                format!("{}", r.hi),
318                r.converged.to_string(),
319                r.evaluations.len().to_string(),
320            ]);
321            out.push_str(&(row.join(",") + "\n"));
322        }
323    }
324    std::fs::write(path, out)?;
325    Ok(())
326}
327
328/// The JSON report of a search: the header of the reports of the runs
329/// ([`comnum_sim::loggers::run_header`]), the wall time, and every operating point with its
330/// evaluations.
331fn write_json(path: &Path, info: &RunInfo, wall_s: f64, results: &[SearchResult]) -> Result<()> {
332    let items: Vec<String> = results
333        .iter()
334        .map(|r| {
335            let cond: Vec<(&str, String)> =
336                r.condition.entries().iter().map(|(k, v)| (k.as_str(), value_json(v))).collect();
337            let evals: Vec<String> = r
338                .evaluations
339                .iter()
340                .map(|e| {
341                    json::object(&[
342                        ("x", json::number(e.x)),
343                        ("value", json::number(e.value)),
344                        ("lo", json::number(e.lo)),
345                        ("hi", json::number(e.hi)),
346                        ("trials", e.trials.to_string()),
347                    ])
348                })
349                .collect();
350            json::object(&[
351                ("condition", json::object(&cond)),
352                ("along", json::string(&r.along)),
353                ("metric", json::string(&r.metric)),
354                ("target", json::number(r.target)),
355                ("value", json::number(r.value)),
356                ("lo", json::number(r.lo)),
357                ("hi", json::number(r.hi)),
358                ("converged", r.converged.to_string()),
359                ("evaluations", json::array(&evals)),
360            ])
361        })
362        .collect();
363    let mut fields = run_header(info);
364    fields.push(("wall_s", json::number(wall_s)));
365    fields.push(("search", json::array(&items)));
366    write_json_file(path, &json::object(&fields))
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn spec(extra: &str) -> Spec {
374        Spec::from_yaml(&format!(
375            "name: s\nseed: 4\ngrid: {{m: [4, 16]}}\nlink: [{{type: source, constellation: {{kind: qam, m: $m}}, n_symbols: 4000}}, \
376             {{type: awgn_ebn0, ebn0_db: $ebn0_db}}]\nmetrics: {{report: [ber], scale: none}}\n\
377             stop: {{min_errors: {{ber: 300}}, max_trials: 300}}\n{extra}"
378        ))
379        .unwrap()
380    }
381
382    #[test]
383    fn the_operating_points_follow_the_theory() {
384        let s = spec("search: {along: ebn0_db, range: [2, 16], metric: ber, target: 1.0e-3, tolerance: 0.05}");
385        let r = search(&s, &RunOptions::default()).unwrap();
386        assert_eq!(r.len(), 2);
387        for (res, m) in r.iter().zip([4usize, 16]) {
388            let theory =
389                comnum_mapping::theory::ebn0_for_ber(comnum_mapping::constellation::ConstellationKind::Qam, m, 1e-3)
390                    .unwrap();
391            assert!(res.converged && res.evaluations.len() <= 12);
392            assert!(res.lo - 0.1 <= theory && theory <= res.hi + 0.1, "M = {m}: {res:?} against {theory}");
393            assert!(res.hi - res.lo < 0.5);
394        }
395    }
396
397    #[test]
398    fn an_unbracketed_target_is_an_error() {
399        let s = spec("search: {along: ebn0_db, range: [12, 16], metric: ber, target: 1.0e-1}");
400        let e = search(&s, &RunOptions::default()).unwrap_err().to_string();
401        assert!(e.contains("not between"), "{e}");
402        assert!(
403            Spec::from_yaml("name: x\nlink: [source]\nsearch: {along: x, range: [2, 1], metric: ber, target: 1}")
404                .is_err()
405        );
406    }
407
408    #[test]
409    fn the_first_points_of_a_search_are_a_sweep() {
410        let s = spec("search: {along: ebn0_db, range: [4, 12], metric: ber, target: 1.0e-2}");
411        // the searched variable is not in the grid: the link cannot be built as such
412        assert!(s.link_for(&s.conditions()[0]).is_err());
413        let sweep = first_points(&s).unwrap();
414        let labels: Vec<String> = sweep.conditions().iter().map(Condition::label).collect();
415        assert_eq!(labels, ["m=4, ebn0_db=4", "m=16, ebn0_db=4"]);
416        assert!(sweep.search.is_none() && sweep.link_for(&sweep.conditions()[1]).is_ok());
417        assert!(first_points(&sweep).is_none());
418    }
419
420    #[test]
421    fn reports_are_written() {
422        let dir = std::env::temp_dir().join(format!("comnum-search-{}", std::process::id()));
423        let s = spec("search: {along: ebn0_db, range: [4, 12], metric: ber, target: 1.0e-2, tolerance: 0.5}");
424        let opts = RunOptions { csv: Some(dir.join("s.csv")), json: Some(dir.join("s.json")), ..RunOptions::default() };
425        search(&s, &opts).unwrap();
426        let csv = std::fs::read_to_string(dir.join("s.csv")).unwrap();
427        assert!(csv.starts_with("m,metric,target,ebn0_db,ebn0_db_lo,ebn0_db_hi,converged,evaluations\n"), "{csv}");
428        assert_eq!(csv.lines().count(), 3);
429        let json = std::fs::read_to_string(dir.join("s.json")).unwrap();
430        // the header of the reports of the runs
431        for key in ["\"evaluations\"", "\"batch\"", "\"threads\"", "\"stop\"", "\"commit\"", "\"wall_s\""] {
432            assert!(json.contains(key), "{key} missing");
433        }
434        std::fs::remove_dir_all(&dir).ok();
435    }
436}