Skip to main content

sva_core/solve/
mod.rs

1// Concern: one solve, each point every cost target rendered through render's path and read | Non-concern: argv, signals, files (sva-cli) | IO: (Problem, Tier) -> Solved or CliError
2
3mod cost;
4mod load;
5mod residual;
6mod search;
7mod vary;
8
9pub use cost::{
10    Cost, From, Held, LOAD_JSON, Leaf, Load, OPERATORS, Op, REDUCERS, REPR, SUBTRACTS, Target,
11    Tree, built, cost, takes,
12};
13pub use load::{NoFiles, Texts};
14pub use residual::{FLOOR_DB, MISSED_NOTE_CENTS, MISSED_ONSET_SECS, RULES};
15pub use search::{
16    DEFAULT_COST_TOL, DEFAULT_MAX_RENDERS, DEFAULT_STEP_TOL, FIRST_STEP_OF_SPAN, Stopped, Stops,
17};
18pub use vary::{Precision, Vary, vary};
19
20use std::borrow::Cow;
21use std::num::NonZeroUsize;
22use std::sync::Arc;
23
24use sva_ast::{Listing, Recording, Source};
25use sva_engine::{Abandon, Backend, EngineError, Output, Resampled, Session, Tier};
26
27use crate::cli_error::CliError;
28use crate::json::{escape, list, num};
29use crate::output::{Diagnostic, Severity};
30use crate::query::{Asked, asked, call};
31use crate::{Job, execute_over, overlaid};
32use cost::Node;
33use search::{Next, Search};
34
35pub struct Problem<'a> {
36    pub source: &'a dyn Source,
37    /// Where each `load_json` file is read from.
38    pub texts: &'a dyn Texts,
39    pub cost: Cost,
40    pub vary: Vec<Vary>,
41    /// Variables held at these texts throughout.
42    pub sets: Vec<(String, String)>,
43    pub rate: Option<u32>,
44    pub threads: Option<NonZeroUsize>,
45    pub stops: Stops,
46}
47
48/// One render of every target at one point, and what the cost made of it.
49#[derive(Clone, Debug, PartialEq)]
50pub struct Point {
51    pub render: usize,
52    pub values: Vec<(String, f64, String)>,
53    pub total: f64,
54    /// The least cost of this render and every one before it.
55    pub best: f64,
56    pub terms: Vec<f64>,
57    /// Each target's samples computed this render, beside the target: none where every value
58    /// was read back from the store.
59    pub computed: Vec<(String, u64)>,
60}
61
62pub struct Solved {
63    pub cost: String,
64    pub terms: Vec<String>,
65    pub start: Vec<String>,
66    pub best: Point,
67    pub on_bound: Vec<bool>,
68    pub renders: usize,
69    pub stopped_by: Stopped,
70    pub trace: Vec<Point>,
71    pub resampled: Vec<Resampled>,
72}
73
74/// The help a node or a render target earns where it writes a cost's own leaf.
75pub const SOLVE_ONLY: &str = "`repr.<reading>(...)` and `load_json(...)` are leaves of a \
76     `sva-cli solve` cost, and only a cost takes them: write the cost on solve's command line \
77     or in `fits/<name>`, as `sva-cli solve @fits/<name> --vary ...`; a node renders, and \
78     `render --representation` reads it";
79
80/// Whether `text` calls `load_json` on a path ending at `byte`, which the node grammar
81/// refuses as a misplaced path.
82fn loads_at(text: &str, byte: usize) -> bool {
83    let before = text[..byte.min(text.len())].trim_end();
84    let before = before.strip_suffix('(').map(str::trim_end);
85    before.is_some_and(|b| b.ends_with(LOAD_JSON))
86}
87
88/// `refused` with [`SOLVE_ONLY`] beside it where a node or `target` wrote `load_json(...)`; an
89/// engine refusal of `repr` or `load_json` earns it where it becomes a diagnostic.
90pub(crate) fn hinted(refused: CliError, source: &dyn Source, target: Option<&str>) -> CliError {
91    let solve_only = match &refused {
92        CliError::Refusals(rs) => rs.iter().any(|r| {
93            r.code == sva_ast::DiagCode::MisplacedPath
94                && source
95                    .get(&r.at.path)
96                    .ok()
97                    .flatten()
98                    .is_some_and(|text| loads_at(&text, r.at.span.start))
99        }),
100        CliError::BadProbe(_) => {
101            target.is_some_and(|t| t.match_indices('"').any(|(at, _)| loads_at(t, at)))
102        }
103        _ => false,
104    };
105    match solve_only {
106        true => CliError::Hinted {
107            error: Box::new(refused),
108            help: SOLVE_ONLY,
109        },
110        false => refused,
111    }
112}
113
114/// The directory of a composition a solve reads `@fits/<name>` costs from; no node lives in it.
115pub const FITS: &str = "fits";
116
117/// `@fits/<name>` is the cost that file of the composition holds; any other text is the cost.
118pub fn cost_of(source: &dyn Source, written: &str) -> Result<Cost, CliError> {
119    let named = written
120        .trim()
121        .strip_prefix('@')
122        .filter(|path| path.starts_with(&format!("{FITS}/")) && sva_ast::whole_ref_path(path));
123    let Some(path) = named else {
124        return cost::cost(written);
125    };
126    let text = source.get(path).map_err(CliError::Io)?.ok_or_else(|| {
127        CliError::NotFound(format!(
128            "`{written}` names no file `{path}` in {}; write the cost there, under an \
129             annotation line, as a node is written",
130            source.name()
131        ))
132    })?;
133    cost::cost_file(path, &text)
134}
135
136/// `under` beside the recordings the cost's leaves hold as values, each under its leaf's key.
137struct Recordings<'a> {
138    under: &'a dyn Source,
139    held: Vec<(String, Arc<Recording>)>,
140}
141
142impl Source for Recordings<'_> {
143    fn paths(&self) -> Result<Listing, String> {
144        self.under.paths()
145    }
146
147    fn get(&self, path: &str) -> Result<Option<Cow<'_, str>>, String> {
148        self.under.get(path)
149    }
150
151    fn generation(&self, path: &str) -> Option<u64> {
152        self.under.generation(path)
153    }
154
155    fn name(&self) -> String {
156        self.under.name()
157    }
158
159    fn recording(&self, path: &str) -> Result<Option<Arc<Recording>>, String> {
160        match self.held.iter().find(|(key, _)| key == path) {
161            Some((_, held)) => Ok(Some(Arc::clone(held))),
162            None => self.under.recording(path),
163        }
164    }
165}
166
167fn recorded<'a>(cost: &Cost, under: &'a dyn Source) -> Recordings<'a> {
168    let held = cost
169        .leaves
170        .iter()
171        .filter_map(|leaf| leaf.recording.clone())
172        .map(|held| (held.path.clone(), held))
173        .collect();
174    Recordings { under, held }
175}
176
177/// Every check the cost and the variables need, before anything renders: the variables' start
178/// values, and each load's value.
179pub fn checked(problem: &Problem) -> Result<(Vec<f64>, Vec<Held>), CliError> {
180    let base = recorded(&problem.cost, problem.source);
181    checked_over(problem, &base)
182}
183
184fn checked_over(problem: &Problem, base: &dyn Source) -> Result<(Vec<f64>, Vec<Held>), CliError> {
185    for one in &problem.vary {
186        if let Some((name, _)) = problem.sets.iter().find(|(n, _)| *n == one.name) {
187            return Err(CliError::usage(
188                "solve.set_and_vary",
189                format!(
190                    "`{name}` is both held by `--set` and varied by `--vary`; a variable is one \
191                     or the other"
192                ),
193            ));
194        }
195        if problem.vary.iter().filter(|v| v.name == one.name).count() > 1 {
196            return Err(CliError::usage(
197                "solve.vary_twice",
198                format!("`{}` is varied twice; vary each variable once", one.name),
199            ));
200        }
201    }
202    let held = overlaid(base, &problem.sets)?;
203    let start = problem
204        .vary
205        .iter()
206        .map(|one| one.start(&held))
207        .collect::<Result<Vec<_>, _>>()?;
208    for one in &problem.vary {
209        reaches(problem, base, one)?;
210    }
211    let mut sets = problem.sets.clone();
212    sets.extend(
213        problem
214            .vary
215            .iter()
216            .zip(&start)
217            .map(|(one, v)| (one.name.clone(), one.text(*v, Precision::Shortest))),
218    );
219    let at_start = overlaid(base, &sets)?;
220    let side = load::Side {
221        source: &at_start,
222        rate: problem.rate,
223    };
224    let loads = load::loaded(&problem.cost, problem.texts, &side)?;
225    problem.cost.typed(Some(&loads))?;
226    Ok((start, loads))
227}
228
229/// A variable no target's value or interval moves with refuses: no render could fit it.
230fn reaches(problem: &Problem, base: &dyn Source, one: &Vary) -> Result<(), CliError> {
231    let planned = |value: f64| -> Result<Vec<String>, CliError> {
232        let mut sets = problem.sets.clone();
233        sets.push((one.name.clone(), one.text(value, Precision::Shortest)));
234        let source = overlaid(base, &sets)?;
235        targets(&problem.cost)
236            .iter()
237            .map(|target| {
238                let job = Job {
239                    rate: problem.rate,
240                    ..Job::over(&source, target)
241                };
242                let (render, _) = crate::plan(&job)?;
243                let identity = sva_engine::identity(&render.tys, render.root)?;
244                Ok(format!("{identity:?} {:?}", render.config.range))
245            })
246            .collect()
247    };
248    if planned(one.lo)? == planned(one.hi)? {
249        return Err(CliError::usage(
250            "solve.reaches_nothing",
251            format!(
252                "`{}` reaches no target of the cost: every target renders the same at `{}` as \
253                 at `{}`; vary a variable a target reads, as `@variables/{}` in its node, or add \
254                 a target that reads it",
255                one.name,
256                one.text(one.lo, Precision::Shortest),
257                one.text(one.hi, Precision::Shortest),
258                one.name
259            ),
260        ));
261    }
262    Ok(())
263}
264
265fn targets(cost: &Cost) -> Vec<String> {
266    let mut out: Vec<String> = Vec::new();
267    for leaf in &cost.leaves {
268        if !out.contains(&leaf.target) {
269            out.push(leaf.target.clone());
270        }
271    }
272    out
273}
274
275/// `heard` hears each point as it is rendered; `abandon` stops the search between renders
276/// and the render in flight.
277pub async fn solve<B: Backend>(
278    problem: &Problem<'_>,
279    tier: &Tier<B>,
280    heard: &mut dyn FnMut(&Point),
281    abandon: &dyn Abandon,
282) -> Result<Solved, CliError> {
283    let base = recorded(&problem.cost, problem.source);
284    let (start, loads) = checked_over(problem, &base)?;
285    let mut search = Search::new(&problem.vary, &start, problem.stops);
286    let targets = targets(&problem.cost);
287    let mut sessions: Vec<Session> = targets.iter().map(|_| Session::default()).collect();
288    let samples = asked(&call("samples")?)?;
289    let mut trace: Vec<Point> = Vec::new();
290    let mut resampled: Vec<Resampled> = Vec::new();
291    let stopped_by = loop {
292        let values = match search.next() {
293            Next::Done(stopped) => break stopped,
294            Next::Render(values) => values,
295        };
296        if abandon.abandoned().await {
297            search.cancel();
298            continue;
299        }
300        let digits = search.digits();
301        let written: Vec<(String, f64, String)> = problem
302            .vary
303            .iter()
304            .zip(values.iter().zip(digits))
305            .map(|(one, (v, d))| {
306                let text = one.text(*v, d);
307                (one.name.clone(), one.number(&text), text)
308            })
309            .collect();
310        let point = Rendering {
311            problem,
312            base: &base,
313            loads: &loads,
314            targets: &targets,
315            samples: &samples,
316            abandon,
317        };
318        let evaluated = point
319            .evaluated(&written, tier, &mut sessions, &mut resampled)
320            .await;
321        let (total, terms, computed) = match evaluated {
322            Err(CliError::Engine(EngineError::Abandoned)) => {
323                search.cancel();
324                continue;
325            }
326            other => other?,
327        };
328        let point = Point {
329            render: trace.len() + 1,
330            values: written,
331            total,
332            best: trace
333                .last()
334                .map_or(total, |last: &Point| last.best.min(total)),
335            terms,
336            computed,
337        };
338        heard(&point);
339        trace.push(point);
340        search.tell(total);
341    };
342    let best = trace
343        .iter()
344        .reduce(|held, p| if p.total < held.total { p } else { held })
345        .cloned()
346        .ok_or_else(|| {
347            CliError::usage(
348                "solve.no_render",
349                "the solve was stopped before its first render finished, so it holds no point",
350            )
351        })?;
352    Ok(Solved {
353        cost: problem.cost.text.clone(),
354        terms: problem.cost.terms.iter().map(|t| t.text.clone()).collect(),
355        start: problem
356            .vary
357            .iter()
358            .zip(&start)
359            .map(|(one, v)| one.text(*v, Precision::Shortest))
360            .collect(),
361        on_bound: best
362            .values
363            .iter()
364            .zip(&problem.vary)
365            .map(|((_, v, _), one)| *v <= one.lo || *v >= one.hi)
366            .collect(),
367        best,
368        renders: trace.len(),
369        stopped_by,
370        trace,
371        resampled,
372    })
373}
374
375struct Rendering<'p, 'a> {
376    problem: &'p Problem<'a>,
377    base: &'p dyn Source,
378    loads: &'p [Held],
379    targets: &'p [String],
380    samples: &'p Asked,
381    abandon: &'p dyn Abandon,
382}
383
384type Evaluated = (f64, Vec<f64>, Vec<(String, u64)>);
385
386impl Rendering<'_, '_> {
387    /// Every target rendered once at `written`, then every leaf read off its target's samples.
388    async fn evaluated<B: Backend>(
389        &self,
390        written: &[(String, f64, String)],
391        tier: &Tier<B>,
392        sessions: &mut [Session],
393        resampled: &mut Vec<Resampled>,
394    ) -> Result<Evaluated, CliError> {
395        let problem = self.problem;
396        let mut sets = problem.sets.clone();
397        sets.extend(written.iter().map(|(n, _, t)| (n.clone(), t.clone())));
398        let source = overlaid(self.base, &sets)?;
399        let mut readings: Vec<Option<Output>> = vec![None; problem.cost.leaves.len()];
400        let mut computed = Vec::new();
401        for (target, session) in self.targets.iter().zip(sessions.iter_mut()) {
402            let job = Job {
403                rate: problem.rate,
404                threads: problem.threads,
405                asked: std::slice::from_ref(self.samples),
406                abandon: self.abandon,
407                ..Job::over(&source, target)
408            };
409            let rendered = execute_over(job, tier, session).await?;
410            computed.push((target.clone(), rendered.render.work().computed_samples));
411            if let Some(label) = rendered.label() {
412                for one in &label.resampled {
413                    if !resampled.contains(one) {
414                        resampled.push(*one);
415                    }
416                }
417            }
418            let Output::Samples(buffer) = rendered.answered(self.samples)?.value else {
419                unreachable!("`samples` answers samples");
420            };
421            let per_bar = rendered.graph.tempo().map(|tempo| tempo.seconds_per_bar());
422            let profile = rendered.config.profile.name;
423            for (at, leaf) in problem.cost.leaves.iter().enumerate() {
424                if leaf.target != *target {
425                    continue;
426                }
427                let representation = leaf.asked.representation(rendered.config.rate, per_bar)?;
428                let answer =
429                    sva_engine::answer_buffer(&leaf.written, &buffer, representation, profile)?;
430                readings[at] = Some(answer.value);
431            }
432        }
433        let readings: Vec<Output> = readings
434            .into_iter()
435            .map(|r| r.expect("every leaf's target rendered"))
436            .collect();
437        let mut terms = Vec::new();
438        for term in &problem.cost.terms {
439            terms.push(term.sign * scalar(&value(&term.node, &readings, self.loads)?));
440        }
441        Ok((terms.iter().sum(), terms, computed))
442    }
443}
444
445#[derive(Clone, Debug)]
446enum Value {
447    Scalar(f64),
448    Vector(Vec<f64>),
449}
450
451fn scalar(v: &Value) -> f64 {
452    match v {
453        Value::Scalar(x) => *x,
454        Value::Vector(_) => unreachable!("the cost typed every term to a number"),
455    }
456}
457
458fn lengths(a: usize, b: usize, what: &str) -> CliError {
459    CliError::usage(
460        "solve.length_mismatch",
461        format!(
462            "{what} meets a vector of {a} values with one of {b}; give a weight vector one \
463             weight per value of the residual it weighs"
464        ),
465    )
466}
467
468/// One side of a subtraction of readings: a leaf's, or the reading a load held.
469fn side<'r>(node: &Node, readings: &'r [Output], loads: &'r [Held]) -> Option<&'r Output> {
470    match node {
471        Node::Leaf(at) => Some(&readings[*at]),
472        Node::Loaded(at) => match &loads[*at] {
473            Held::Reading(reading) => Some(reading),
474            Held::Number(_) | Held::Vector(_) => None,
475        },
476        _ => None,
477    }
478}
479
480fn value(node: &Node, readings: &[Output], loads: &[Held]) -> Result<Value, CliError> {
481    let value = |node: &Node| value(node, readings, loads);
482    Ok(match node {
483        Node::Num(n) => Value::Scalar(*n),
484        Node::Vector(v) => Value::Vector(v.clone()),
485        Node::Loaded(at) => match &loads[*at] {
486            Held::Number(n) => Value::Scalar(*n),
487            Held::Vector(v) => Value::Vector(v.clone()),
488            Held::Reading(_) => {
489                unreachable!("the cost typed every held reading into a subtraction")
490            }
491        },
492        Node::Leaf(_) => unreachable!("the cost typed every leaf into a subtraction"),
493        Node::Neg(x) => match value(x)? {
494            Value::Scalar(s) => Value::Scalar(-s),
495            Value::Vector(v) => Value::Vector(v.iter().map(|x| -x).collect()),
496        },
497        Node::Bin(Op::Sub, a, b)
498            if side(a, readings, loads).is_some() && side(b, readings, loads).is_some() =>
499        {
500            let (Some(x), Some(y)) = (side(a, readings, loads), side(b, readings, loads)) else {
501                unreachable!("matched above");
502            };
503            let vector =
504                residual::residual(x, y).expect("the cost typed both sides to one reading");
505            Value::Vector(vector)
506        }
507        Node::Bin(op, a, b) => {
508            let (a, b) = (value(a)?, value(b)?);
509            let apply = |x: f64, y: f64| match op {
510                Op::Add => x + y,
511                Op::Sub => x - y,
512                Op::Mul => x * y,
513                Op::Div => x / y,
514            };
515            match (a, b) {
516                (Value::Scalar(x), Value::Scalar(y)) => Value::Scalar(apply(x, y)),
517                (Value::Vector(v), Value::Scalar(y)) => {
518                    Value::Vector(v.iter().map(|x| apply(*x, y)).collect())
519                }
520                (Value::Scalar(x), Value::Vector(v)) => {
521                    Value::Vector(v.iter().map(|y| apply(x, *y)).collect())
522                }
523                (Value::Vector(v), Value::Vector(w)) if v.len() == w.len() => {
524                    Value::Vector(v.iter().zip(&w).map(|(x, y)| apply(*x, *y)).collect())
525                }
526                (Value::Vector(v), Value::Vector(w)) => {
527                    return Err(lengths(v.len(), w.len(), "a sum of two vectors"));
528                }
529            }
530        }
531        Node::Reduce(name, args) => {
532            let args = args.iter().map(value).collect::<Result<Vec<_>, _>>()?;
533            match (*name, args.as_slice()) {
534                ("abs", [Value::Scalar(x)]) => Value::Scalar(x.abs()),
535                ("abs", [Value::Vector(v)]) => Value::Vector(v.iter().map(|x| x.abs()).collect()),
536                ("norm", [Value::Vector(v)]) => {
537                    Value::Scalar(v.iter().map(|x| x * x).sum::<f64>().sqrt())
538                }
539                ("sum", [Value::Vector(v)]) => Value::Scalar(v.iter().sum()),
540                ("max", [Value::Vector(v)]) => {
541                    Value::Scalar(v.iter().copied().reduce(f64::max).unwrap_or(0.0))
542                }
543                ("dot", [Value::Vector(w), Value::Vector(v)]) if w.len() == v.len() => {
544                    Value::Scalar(w.iter().zip(v).map(|(a, b)| a * b).sum())
545                }
546                ("dot", [Value::Vector(w), Value::Vector(v)]) => {
547                    return Err(lengths(w.len(), v.len(), "`dot`"));
548                }
549                _ => unreachable!("the cost typed every reducer's arguments"),
550            }
551        }
552    })
553}
554
555/// One render's progress line, as `sva-cli` writes it to stderr.
556pub fn point_json(point: &Point) -> String {
557    let values: Vec<String> = point
558        .values
559        .iter()
560        .map(|(name, _, text)| format!("\"{}\": \"{}\"", escape(name), escape(text)))
561        .collect();
562    format!(
563        "{{\"render\": {}, \"values\": {{{}}}, \"cost\": {}, \"best\": {}}}",
564        point.render,
565        values.join(", "),
566        num(point.total),
567        num(point.best)
568    )
569}
570
571/// The answer's `data`, as both front ends print it.
572pub fn solved_data(solved: &Solved, vary: &[Vary], extra: &str) -> String {
573    let point_values = |p: &Point| {
574        let fields: Vec<String> = p
575            .values
576            .iter()
577            .map(|(n, v, t)| {
578                format!(
579                    "\"{}\": {{ \"value\": {}, \"text\": \"{}\" }}",
580                    escape(n),
581                    num(*v),
582                    escape(t)
583                )
584            })
585            .collect();
586        format!("{{ {} }}", fields.join(", "))
587    };
588    let values: Vec<String> = solved
589        .best
590        .values
591        .iter()
592        .zip(vary)
593        .zip(solved.start.iter().zip(&solved.on_bound))
594        .map(|(((n, v, t), one), (start, bound))| {
595            format!(
596                "\"{}\": {{ \"value\": {}, \"text\": \"{}\", \"unit\": \"{}\", \"start\": \"{}\", \
597                 \"on_bound\": {bound} }}",
598                escape(n),
599                num(*v),
600                escape(t),
601                one.unit,
602                escape(start)
603            )
604        })
605        .collect();
606    let terms: Vec<(String, f64)> = solved
607        .terms
608        .iter()
609        .cloned()
610        .zip(solved.best.terms.iter().copied())
611        .collect();
612    let terms = list(&terms, |(text, v)| {
613        format!(
614            "{{ \"term\": \"{}\", \"value\": {} }}",
615            escape(text),
616            num(*v)
617        )
618    });
619    let trace = list(&solved.trace, |p| {
620        let computed: Vec<String> = p
621            .computed
622            .iter()
623            .map(|(t, n)| format!("\"{}\": {n}", escape(t)))
624            .collect();
625        format!(
626            "{{ \"render\": {}, \"values\": {}, \"cost\": {}, \"computed_samples\": {{ {} }} }}",
627            p.render,
628            point_values(p),
629            num(p.total),
630            computed.join(", ")
631        )
632    });
633    let resampled = list(&solved.resampled, crate::answer::resampled_json);
634    format!(
635        "{{\"cost_expression\": \"{}\", \"values\": {{ {} }}, \"cost\": {{ \"total\": {}, \
636         \"terms\": {terms} }}, \"renders\": {}, \"stopped_by\": \"{}\", \"resampled\": \
637         {resampled}{extra}, \"trace\": {trace}}}",
638        escape(&solved.cost),
639        values.join(", "),
640        num(solved.best.total),
641        solved.renders,
642        solved.stopped_by.name()
643    )
644}
645
646/// What a fit says beside its values: a variable on a bound, a spent budget, a resampling.
647pub fn findings(solved: &Solved, vary: &[Vary]) -> Vec<Diagnostic> {
648    let mut out = Vec::new();
649    for ((name, _, text), (bound, one)) in solved
650        .best
651        .values
652        .iter()
653        .zip(solved.on_bound.iter().zip(vary))
654    {
655        if *bound {
656            out.push(
657                Diagnostic::new(
658                    "solve.on_bound",
659                    format!(
660                        "`{name}` ended on a bound of `{}..{}`, at `{text}`: the fit wants past it",
661                        one.text(one.lo, Precision::Shortest),
662                        one.text(one.hi, Precision::Shortest)
663                    ),
664                )
665                .with_severity(Severity::Warning)
666                .helped("widen the bound, or the model lacks what the reference holds"),
667            );
668        }
669    }
670    if solved.stopped_by == Stopped::Budget {
671        out.push(
672            Diagnostic::new(
673                "solve.budget",
674                format!(
675                    "the solve spent `--max-renders` ({}) before its steps or cost converged",
676                    solved.renders
677                ),
678            )
679            .with_severity(Severity::Warning)
680            .helped("raise `--max-renders`, narrow the bounds, or vary fewer variables at once"),
681        );
682    }
683    for r in &solved.resampled {
684        out.push(
685            Diagnostic::new(
686                "solve.resampled",
687                format!(
688                    "a recording at {} Hz was read at {} Hz through a windowed sinc: within \
689                     {:.1e} dB up to {} Hz, at least {:.1} dB down from {} Hz",
690                    r.from_hz, r.to_hz, r.ripple_db, r.passband_hz, r.stopband_db, r.stopband_hz
691                ),
692            )
693            .with_severity(Severity::Warning)
694            .helped("pass `--rate` at the recording's own rate to read its samples unchanged"),
695        );
696    }
697    out
698}