Skip to main content

sva_engine/render/
answer.rs

1// Concern: takes one reading off the representation a node declares, a ledger edge by edge | Non-concern: naming the observations (query.rs), the arithmetic of one | IO: (&Render, node) -> Answer
2
3use sva_formula::spectral_sum::atom::{Singular, SpectralAtom};
4use sva_formula::{AUDIBLE_CEILING_HZ, Line, SpectralSum, Var, d_dt, envelope, line_atoms};
5use sva_samples::{
6    AliasScore, Buffer, Consumes, Peak, PitchFrame, Source, measure::bands, measure::crest,
7    measure::envelope, measure::formants, measure::loudness, measure::pitch, measure::spectrum,
8    measure::stereo, measure_alias,
9};
10
11use crate::error::{Diagnostic, EngineError, Located};
12use crate::query::{Answer, DEFAULT_FRAME_SECS, Output, Representation};
13use crate::refs;
14use crate::render::Render;
15use crate::typing::Value;
16
17/// A closed form answers off its spectral sum and a buffer off its samples; its own `Ty`
18/// decides which, never a flag on the observation.
19pub fn answer(
20    render: &Render,
21    node: sva_formula::NodeId,
22    representation: Representation,
23) -> Result<Answer, EngineError> {
24    let closed = render.tys.ty(node).is_closed_form();
25    let profile = render.config.profile.name;
26    if representation == Representation::Flops {
27        return Ok(Answer::whole(
28            Output::Flops(Box::new(crate::flops::tree_at(render, node))),
29            Source::Exact,
30            profile,
31            None,
32        ));
33    }
34    if representation == Representation::Bindings {
35        return Ok(Answer::whole(
36            Output::Bindings(render.bindings.get(&node).cloned().unwrap_or_default()),
37            Source::Exact,
38            profile,
39            None,
40        ));
41    }
42    match representation.consumes(closed) {
43        Consumes::ClosedForm => {
44            let found;
45            let sum = match render.symbolic.get(&node) {
46                Some(held) => held,
47                None => match refs::spectral_sum_of(&render.tys, node, render.tys.var(node)) {
48                    Ok(held) => {
49                        found = held;
50                        &found
51                    }
52                    Err(left) => return measured_instead(render, node, representation, left),
53                },
54            };
55            let (value, listed, source) = match exact(render, node, representation, sum) {
56                Err(left) => return measured_instead(render, node, representation, left),
57                Ok(held) => held,
58            };
59            Ok(Answer {
60                value,
61                source,
62                profile,
63                rate: None,
64                tail_db: listed.tail_db(),
65                dropped: listed.dropped,
66            })
67        }
68        _ => {
69            let buffer = render
70                .buffers
71                .get(&node)
72                .ok_or_else(|| unmaterialized(render, node, representation))?;
73            Ok(Answer::whole(
74                measured(render, node, representation, buffer)?,
75                Source::Measured,
76                profile,
77                Some(render.config.rate),
78            ))
79        }
80    }
81}
82
83/// A closed form no atom sum reaches answers no reading symbolically. An envelope is the one
84/// FORMAT 9.3 already names off `sample(...)`, so it is measured here rather than refused.
85fn measured_instead(
86    render: &Render,
87    node: sva_formula::NodeId,
88    representation: Representation,
89    left: EngineError,
90) -> Result<Answer, EngineError> {
91    match representation {
92        Representation::Envelope { frame_secs } if left.code() == "cast.left_algebra" => {
93            off_the_grid(render, node, frame_secs)
94        }
95        _ => Err(left),
96    }
97}
98
99/// The samples this node reaches: the buffer the render holds, or the collapse it would have run,
100/// down to FORMAT 9.1's row 4 over the written closed form.
101fn on_the_grid(render: &Render, node: sva_formula::NodeId) -> Result<Buffer, EngineError> {
102    if let Some(held) = render.buffers.get(&node) {
103        return Ok(held.clone());
104    }
105    let (rate, horizon) = (render.config.rate, render.config.horizon);
106    let profile = &render.config.profile;
107    let written = refs::substituted_closed_form(&render.tys, node);
108    let composed;
109    let sum = match render.symbolic.get(&node) {
110        Some(held) => Some(held),
111        None => match refs::spectral_sum_of(&render.tys, node, render.tys.var(node)) {
112            Ok(held) => {
113                composed = held;
114                Some(&composed)
115            }
116            Err(left) if written.is_none() => return Err(left),
117            Err(_) => None,
118        },
119    };
120    let taken = match sum {
121        Some(sum) => sva_samples::of_spectral_sum_or_point(
122            sum,
123            written.as_ref(),
124            rate,
125            horizon,
126            profile,
127            AliasScore::NotAsked,
128        ),
129        None => sva_samples::render(
130            written
131                .as_ref()
132                .expect("a node with no sum answers off the closed form written above"),
133            rate,
134            horizon,
135            profile,
136            AliasScore::NotAsked,
137        ),
138    };
139    taken
140        .map(|(buffer, _)| buffer)
141        .map_err(|e| refused(render, node, e.code(), e.to_string()))
142}
143
144fn off_the_grid(
145    render: &Render,
146    node: sva_formula::NodeId,
147    frame_secs: Option<f64>,
148) -> Result<Answer, EngineError> {
149    let rate = render.config.rate;
150    let buffer = on_the_grid(render, node)?;
151    Ok(Answer::whole(
152        Output::Envelope(envelope::trace(
153            buffer.plane(0),
154            f64::from(rate),
155            buffer.origin_secs,
156            frame_secs.unwrap_or(DEFAULT_FRAME_SECS),
157        )),
158        Source::Measured,
159        render.config.profile.name,
160        Some(rate),
161    ))
162}
163
164fn exact(
165    render: &Render,
166    node: sva_formula::NodeId,
167    representation: Representation,
168    sum: &SpectralSum,
169) -> Result<(Output, Listed, Source), EngineError> {
170    let source = Source::Exact;
171    let enumerated = || {
172        listed(
173            render,
174            node,
175            sum,
176            render.config.profile.floor(AUDIBLE_CEILING_HZ),
177        )
178    };
179    let (value, listed) = match representation {
180        Representation::Lines => {
181            let listed = enumerated()?;
182            let held = lines(render, node, sum.var, &listed.atoms)?;
183            (Output::Lines(held), listed)
184        }
185        Representation::Spectrum {
186            frame_secs: Some(_),
187            ..
188        } => {
189            return Err(refused(
190                render,
191                node,
192                "engine.observation_needs_samples",
193                "a closed form has no frames to take a spectrum across".to_string(),
194            ));
195        }
196        // FORMAT 14.1: a pair's spectrum is its whole line list; no estimate, no peaks.
197        Representation::Spectrum {
198            frame_secs: None, ..
199        } => {
200            let listed = enumerated()?;
201            let mut held = lines(render, node, sum.var, &listed.atoms)?;
202            held.sort_by(|a, b| a.hz.total_cmp(&b.hz));
203            (Output::Lines(held), listed)
204        }
205        Representation::Atoms => {
206            let listed = enumerated()?;
207            let found = listed.atoms.iter().map(sketch_atom).collect();
208            (Output::Atoms(found), listed)
209        }
210        // A pair states its partials; `max_notes` is the question, not a cut answer.
211        Representation::Pitch { max_notes, .. } => {
212            let listed = enumerated()?;
213            let held = lines(render, node, sum.var, &listed.atoms)?;
214            let mut found: Vec<Peak> = held
215                .iter()
216                .filter(|l| l.hz > 0.0)
217                .map(|l| Peak {
218                    hz: l.hz,
219                    db: 20.0 * amplitude_at(&held, l.hz).log10(),
220                })
221                .collect();
222            found.sort_by(|a, b| b.db.total_cmp(&a.db));
223            let frame = PitchFrame {
224                t_secs: render.config.horizon.start_secs,
225                notes: pitch::name_peaks(&found, max_notes),
226            };
227            (Output::Pitch(vec![frame]), listed)
228        }
229        Representation::Derivative => (Output::Symbolic(Box::new(d_dt(sum))), Listed::NONE),
230        Representation::Envelope { .. } => {
231            let held = envelope(sum).map_err(|left| {
232                EngineError::of_closed_form(
233                    &left.refusal(),
234                    render.tys.locate(left.origin),
235                    "read the envelope off sample(...) for a measured one",
236                )
237            })?;
238            (Output::Symbolic(Box::new(held.squared)), Listed::NONE)
239        }
240        other => return Err(not_a_closed_form(render, node, other)),
241    };
242    Ok((value, listed, source))
243}
244
245/// Every atom the spectral sum stands for, beside the terms its series left below the floor
246/// or above the ceiling: `atoms` alone holds only the terms already written out.
247struct Listed {
248    atoms: Vec<SpectralAtom>,
249    dropped: Vec<Line>,
250}
251
252impl Listed {
253    /// A reading that enumerates no series leaves nothing out.
254    const NONE: Listed = Listed {
255        atoms: Vec::new(),
256        dropped: Vec::new(),
257    };
258
259    /// FORMAT 9.3: the tail is the loudest line left out against the loudest one kept, so
260    /// it is one ratio over the whole answer, never a per-series one carried upward.
261    fn tail_db(&self) -> Option<f64> {
262        let loudest = |set: &mut dyn Iterator<Item = f64>| set.fold(0.0f64, f64::max);
263        let gone = loudest(&mut self.dropped.iter().map(|l| l.amp.abs()));
264        let kept = loudest(&mut self.atoms.iter().map(|a| a.c.abs()));
265        (gone > 0.0 && kept > 0.0).then(|| 20.0 * (gone / kept).log10())
266    }
267}
268
269/// Each lane's series enumerated under the band a rate-free reading is taken in. A series
270/// whose term no line closed form reads yields nothing, and nothing is not an exact empty answer.
271fn listed(
272    render: &Render,
273    node: sva_formula::NodeId,
274    sum: &SpectralSum,
275    floor_db: f64,
276) -> Result<Listed, EngineError> {
277    let mut held = Listed::NONE;
278    for lane in &sum.lanes {
279        held.atoms.extend(lane.clone().expanded().atoms);
280        for series in &lane.series {
281            let Some(found) = line_atoms(series, AUDIBLE_CEILING_HZ, floor_db) else {
282                return Err(unenumerable(render, node));
283            };
284            held.atoms.extend(found.atoms);
285            held.dropped.extend(found.dropped);
286        }
287    }
288    Ok(held)
289}
290
291/// The exact line list of a pair: in `t` a bare turning exponential, in `f` the delta it
292/// duals to. A list every other atom was dropped from answers a spectrum this node has not.
293fn lines(
294    render: &Render,
295    node: sva_formula::NodeId,
296    var: Var,
297    atoms: &[SpectralAtom],
298) -> Result<Vec<Line>, EngineError> {
299    atoms
300        .iter()
301        .map(|a| line_of(var, a).ok_or_else(|| not_a_line(render, node, var, a)))
302        .collect()
303}
304
305fn line_of(var: Var, a: &SpectralAtom) -> Option<Line> {
306    match (var, a.sing) {
307        (Var::F, Singular::Delta { at, order: 0 }) => Some(Line { hz: at, amp: a.c }),
308        (Var::T, Singular::Regular) => {
309            if a.poly > 0 || a.gauss.is_some() || a.ind.is_some() || a.pole.is_some() {
310                return None;
311            }
312            match a.exp {
313                // A constant turns at no rate, which is the line at zero hertz.
314                None => Some(Line { hz: 0.0, amp: a.c }),
315                Some(e) if e.sigma == 0.0 => Some(Line {
316                    hz: e.omega / std::f64::consts::TAU,
317                    amp: a.c,
318                }),
319                Some(_) => None,
320            }
321        }
322        _ => None,
323    }
324}
325
326/// What gave this atom a width; a turning exponential is the line itself and never among it.
327fn widening(a: &SpectralAtom) -> Vec<&'static str> {
328    let mut held = Vec::new();
329    if a.poly > 0 {
330        held.push("a polynomial");
331    }
332    if a.exp.is_some_and(|e| e.sigma != 0.0) {
333        held.push("a decaying exponential");
334    }
335    if a.gauss.is_some() {
336        held.push("a Gaussian");
337    }
338    if a.pole.is_some() {
339        held.push("a pole");
340    }
341    if held.is_empty() {
342        held.push("a delta");
343    }
344    held
345}
346
347/// Convolved into a line, each shape beside the turning exponential answers a band.
348fn not_a_line(
349    render: &Render,
350    node: sva_formula::NodeId,
351    var: Var,
352    atom: &SpectralAtom,
353) -> EngineError {
354    let mut at = render.tys.locate(atom.origin);
355    if at.node.is_empty() {
356        at = Located::at(render.tys.name(node), None);
357    }
358    let message = match atom.ind {
359        _ if var == Var::F => "this term spreads over `f` rather than standing at one \
360                               frequency, and a line in `f` is a delta"
361            .to_string(),
362        Some(window) => format!(
363            "this term is windowed to [{}s, {}s), and a windowed line is that line convolved \
364             with the window's transform, which has a width and is no line",
365            window.l.value(),
366            window.r.value()
367        ),
368        None => format!(
369            "this term carries {}, and a line is a bare turning exponential: each of those \
370             convolves the line with a shape of its own width",
371            widening(atom).join(", ")
372        ),
373    };
374    EngineError::refused(Diagnostic {
375        code: "read.lines_need_unwindowed_lines".to_string(),
376        message,
377        location: at,
378        help: "`--as atoms` states each term as it stands, and `--as lines` of the node under \
379               the window or envelope lists the lines it multiplies"
380            .to_string(),
381    })
382}
383
384/// The amplitude a partial sounds at: a real wave carries it in a conjugate pair, half in
385/// each, and a measured peak reads the pair's sum.
386fn amplitude_at(held: &[Line], hz: f64) -> f64 {
387    held.iter()
388        .filter(|l| (l.hz - hz).abs() <= f64::EPSILON * hz.abs() || l.hz == -hz)
389        .map(|l| l.amp.abs())
390        .sum()
391}
392
393/// One atom as the six factors it is present in, which is what `atoms` answers with.
394pub fn sketch_atom(a: &SpectralAtom) -> String {
395    let factors: Vec<&'static str> = a.present().iter().map(|f| f.as_str()).collect();
396    format!("{} x {}", a.c.abs(), factors.join(" times "))
397}
398
399fn measured(
400    render: &Render,
401    node: sva_formula::NodeId,
402    representation: Representation,
403    buffer: &Buffer,
404) -> Result<Output, EngineError> {
405    Ok(match representation {
406        Representation::Alias { oversample } => {
407            let reference = oversampled(render, behind(render, node)?, oversample)?;
408            Output::Alias(Box::new(worst_alias(buffer, &reference, oversample)))
409        }
410        Representation::Ledger { depth } => Output::Ledger(attributed(render, node, depth)?),
411        other => {
412            return off_buffer(buffer, other).map_err(|fault| match fault {
413                NoReading::NeedsAGraph => not_a_reading(render, node, other),
414                NoReading::TooNarrow { need, held } => too_narrow(render, node, need, held),
415            });
416        }
417    })
418}
419
420/// Why a buffer answered nothing: the reading needs the tree the node was built from, or
421/// the buffer is narrower than the reading needs.
422#[derive(Clone, Copy, Debug, PartialEq, Eq)]
423pub enum NoReading {
424    NeedsAGraph,
425    TooNarrow { need: usize, held: usize },
426}
427
428/// Every reading a buffer answers on its own, with no graph behind it.
429pub fn off_buffer(buffer: &Buffer, representation: Representation) -> Result<Output, NoReading> {
430    let sr = f64::from(buffer.rate);
431    let start = buffer.origin_secs;
432    let plane = buffer.plane(0);
433    Ok(match representation {
434        Representation::Samples => Output::Samples(Box::new(buffer.clone())),
435        Representation::Spectrum {
436            max_peaks,
437            frame_secs,
438        } => Output::Spectrum(Box::new(spectrum::analyze(
439            plane, sr, max_peaks, frame_secs,
440        ))),
441        Representation::Envelope { frame_secs } => Output::Envelope(envelope::trace(
442            plane,
443            sr,
444            start,
445            frame_secs.unwrap_or(DEFAULT_FRAME_SECS),
446        )),
447        Representation::Derivative => Output::Samples(Box::new(difference(buffer))),
448        Representation::Pitch {
449            max_notes,
450            frame_secs,
451        } => Output::Pitch(pitch::track(plane, sr, start, frame_secs, max_notes)),
452        Representation::Formants {
453            max_formants,
454            frame_secs,
455        } => Output::Formants(formants::track(
456            plane,
457            sr,
458            start,
459            frame_secs,
460            formants::default_order(sr),
461            max_formants,
462        )),
463        Representation::Stereo { frame_secs } => {
464            if buffer.width < 2 {
465                return Err(NoReading::TooNarrow {
466                    need: 2,
467                    held: buffer.width,
468                });
469            }
470            let planes: Vec<&[f64]> = (0..buffer.width).map(|c| buffer.plane(c)).collect();
471            Output::Stereo(Box::new(stereo::analyze(
472                &planes,
473                buffer.width,
474                sr,
475                start,
476                frame_secs,
477            )))
478        }
479        Representation::Bands => Output::Bands(Box::new(bands::analyze(plane, sr, start))),
480        Representation::Crest => Output::Crest(Box::new(crest::analyze(plane, sr))),
481        Representation::Loudness => {
482            let planes: Vec<&[f64]> = (0..buffer.width).map(|c| buffer.plane(c)).collect();
483            Output::Loudness(Box::new(loudness::analyze(&planes, sr, start)))
484        }
485        _ => return Err(NoReading::NeedsAGraph),
486    })
487}
488
489/// Every held buffer under the target, its own refs beside it, so the reading can share the
490/// target's energy down the tree it was built from. A ref the closed form adds stands there as what
491/// it contributed to the node reading it; one no addend isolates is left unattributed.
492fn attributed(
493    render: &Render,
494    node: sva_formula::NodeId,
495    depth: usize,
496) -> Result<Vec<sva_samples::LedgerEntry>, EngineError> {
497    let mut buffers = std::collections::BTreeMap::new();
498    let mut deps = std::collections::BTreeMap::new();
499    let mut kinds = std::collections::BTreeMap::new();
500    for (id, buffer) in &render.buffers {
501        let name = render.tys.name(*id).to_string();
502        let read = refs_read(render, *id)?
503            .into_iter()
504            .map(|op| render.tys.name(op).to_string())
505            .collect();
506        deps.insert(name.clone(), read);
507        kinds.insert(name.clone(), sva_samples::SignalKind::Audio);
508        buffers.insert(name, buffer.clone());
509    }
510    let mut contributed_by = std::collections::BTreeMap::new();
511    for (parent, child) in edges_under(render, node, depth)? {
512        if let Some(held) = contributed(render, parent, child)? {
513            contributed_by.insert(render.tys.name(child).to_string(), held);
514        }
515    }
516    let len = render.buffers.get(&node).map_or(0, |b| b.len());
517    Ok(sva_samples::measure::ledger::attribute(
518        &buffers,
519        &contributed_by,
520        &deps,
521        &kinds,
522        render.tys.name(node),
523        0..len,
524        depth,
525    ))
526}
527
528/// The tree a ledger walks; a node several read is attributed to the first to reach it.
529fn edges_under(
530    render: &Render,
531    node: sva_formula::NodeId,
532    depth: usize,
533) -> Result<Vec<(sva_formula::NodeId, sva_formula::NodeId)>, EngineError> {
534    let mut seen = std::collections::BTreeSet::from([node]);
535    let (mut level, mut out) = (vec![node], Vec::new());
536    for _ in 0..depth {
537        let mut next = Vec::new();
538        for parent in level {
539            for child in refs_read(render, parent)? {
540                if seen.insert(child) {
541                    out.push((parent, child));
542                    next.push(child);
543                }
544            }
545        }
546        level = next;
547    }
548    Ok(out)
549}
550
551/// Every ref one node reads: a closed form's own, and a sampled node's buffer slots.
552fn refs_read(
553    render: &Render,
554    node: sva_formula::NodeId,
555) -> Result<Vec<sva_formula::NodeId>, EngineError> {
556    match render.tys.ty(node).is_closed_form() {
557        true => Ok(crate::schedule::read_operands(&render.tys, node)),
558        false => crate::render::slots::refs_read(render, node),
559    }
560}
561
562/// What one ref contributed to the node reading it, at that node's own offset and window:
563/// its own addend, where the closed form adds its refs. Two under one product have no addend apiece
564/// and no share either, so that edge is left unattributed.
565fn contributed(
566    render: &Render,
567    parent: sva_formula::NodeId,
568    child: sva_formula::NodeId,
569) -> Result<Option<Buffer>, EngineError> {
570    if !render.tys.ty(parent).is_closed_form() {
571        return crate::render::slots::contributed(render, parent, child);
572    }
573    let Value::ClosedForm(form) = render.tys.value(parent) else {
574        return Ok(None);
575    };
576    Ok(separable(&form.body, child)
577        .then(|| collapsed(render, parent, &alone(&form.body, child), form.var))
578        .flatten())
579}
580
581fn collapsed(
582    render: &Render,
583    parent: sva_formula::NodeId,
584    body: &sva_formula::Body,
585    var: Var,
586) -> Option<Buffer> {
587    let sum = refs::spectral_sum_of_body(&render.tys, parent, body, var).ok()?;
588    sva_samples::collapse::of_spectral_sum(
589        &sum,
590        render.config.rate,
591        render.config.horizon,
592        &render.config.profile,
593        AliasScore::NotAsked,
594    )
595    .ok()
596    .map(|(buffer, _)| buffer)
597}
598
599fn alone(f: &sva_formula::Body, child: sva_formula::NodeId) -> sva_formula::Body {
600    match f {
601        sva_formula::Body::Node(id) if *id != child => {
602            sva_formula::Body::Const(sva_formula::C64::ZERO)
603        }
604        other => sva_formula::closed_form::map_children(other, |p| {
605            sva_formula::Part::new(p.origin, alone(&p.body, child))
606        }),
607    }
608}
609
610/// Whether silencing every other ref leaves this one's contribution standing.
611fn separable(f: &sva_formula::Body, child: sva_formula::NodeId) -> bool {
612    let parts = sva_formula::closed_form::children(f);
613    match f {
614        sva_formula::Body::Add(_) => parts.iter().all(|p| separable(&p.body, child)),
615        _ => {
616            let mut holding = parts.iter().filter(|p| !refs::nodes_in(&p.body).is_empty());
617            match (holding.next(), holding.next()) {
618                (None, _) => true,
619                (Some(only), None) => separable(&only.body, child),
620                _ => !refs::nodes_in(f).contains(&child),
621            }
622        }
623    }
624}
625
626/// The closed form an alias score oversamples: the node itself where it is one, and the operand of
627/// the `sample(...)` that collapsed it where it is not.
628fn behind(render: &Render, node: sva_formula::NodeId) -> Result<sva_formula::NodeId, EngineError> {
629    if render.tys.ty(node).is_closed_form() {
630        return Ok(node);
631    }
632    match render.tys.value(node) {
633        crate::typing::Value::Cast(crate::cast::Cast::Sample, source) => Ok(*source),
634        _ => Err(refused(
635            render,
636            node,
637            "engine.alias_needs_a_closed_form",
638            "an alias score is a render against the same closed form oversampled, and this node \
639             is samples with no closed form behind it"
640                .to_string(),
641        )),
642    }
643}
644
645/// Each component scored against its own reference, and `worst` says which one answers.
646fn worst_alias(buffer: &Buffer, reference: &Buffer, oversample: u32) -> sva_samples::Alias {
647    debug_assert_eq!(
648        buffer.width, reference.width,
649        "the reference is the same form at another rate"
650    );
651    let sr = f64::from(buffer.rate);
652    let width = buffer.width.min(reference.width);
653    sva_samples::worst_alias((0..width).map(|c| {
654        measure_alias(
655            buffer.plane(c),
656            reference.plane(c),
657            oversample as usize,
658            sr,
659            buffer.origin_secs,
660        )
661    }))
662    .expect("a buffer holds at least one component")
663}
664
665/// The same closed form read at a multiple of the rate, which is what an alias score is against.
666fn oversampled(
667    render: &Render,
668    node: sva_formula::NodeId,
669    oversample: u32,
670) -> Result<Buffer, EngineError> {
671    let rate = render.config.rate * oversample;
672    let taken = match refs::spectral_sum_of(&render.tys, node, Var::T) {
673        Ok(sum) => sva_samples::of_spectral_sum(
674            &sum,
675            rate,
676            render.config.horizon,
677            &render.config.profile,
678            AliasScore::NotAsked,
679        ),
680        Err(e) => match refs::substituted_closed_form(&render.tys, node) {
681            Some(form) => sva_samples::render(
682                &form,
683                rate,
684                render.config.horizon,
685                &render.config.profile,
686                AliasScore::NotAsked,
687            ),
688            None => return Err(e),
689        },
690    };
691    taken.map(|(buffer, _)| buffer).map_err(|e| {
692        EngineError::refused(Diagnostic {
693            code: e.code().to_string(),
694            message: e.to_string(),
695            location: Located::at(render.tys.name(node), None),
696            help: "an alias score needs a closed form to oversample".to_string(),
697        })
698    })
699}
700
701/// A first difference on the grid, which is what a derivative is once the closed form is gone.
702fn difference(buffer: &Buffer) -> Buffer {
703    let step = f64::from(buffer.rate);
704    let planes = (0..buffer.width)
705        .map(|c| {
706            let plane = buffer.plane(c);
707            plane
708                .iter()
709                .enumerate()
710                .map(|(i, x)| match i {
711                    0 => 0.0,
712                    _ => (x - plane[i - 1]) * step,
713                })
714                .collect()
715        })
716        .collect();
717    let mut out = Buffer::of_planes(buffer.rate, planes);
718    out.origin_secs = buffer.origin_secs;
719    out
720}
721
722fn refused(render: &Render, node: sva_formula::NodeId, code: &str, message: String) -> EngineError {
723    EngineError::refused(Diagnostic {
724        code: code.to_string(),
725        message,
726        location: Located::at(render.tys.name(node), None),
727        help: "ask for a reading this representation answers".to_string(),
728    })
729}
730
731fn unenumerable(render: &Render, node: sva_formula::NodeId) -> EngineError {
732    refused(
733        render,
734        node,
735        "read.series_not_enumerable",
736        "a series whose term is no line lists no lines, and an empty list would read as \
737         a node with none"
738            .to_string(),
739    )
740}
741
742fn not_a_closed_form(render: &Render, node: sva_formula::NodeId, r: Representation) -> EngineError {
743    refused(
744        render,
745        node,
746        "engine.observation_needs_samples",
747        format!(
748            "`{}` reads samples, and this node is a closed form",
749            r.name()
750        ),
751    )
752}
753
754fn not_a_reading(render: &Render, node: sva_formula::NodeId, r: Representation) -> EngineError {
755    refused(
756        render,
757        node,
758        "engine.observation_not_wired",
759        format!("`{}` takes no reading off a buffer here", r.name()),
760    )
761}
762
763fn too_narrow(render: &Render, node: sva_formula::NodeId, need: usize, held: usize) -> EngineError {
764    refused(
765        render,
766        node,
767        "type.width_mismatch",
768        format!("this reading needs {need} components and the node holds {held}"),
769    )
770}
771
772fn unmaterialized(render: &Render, node: sva_formula::NodeId, r: Representation) -> EngineError {
773    refused(
774        render,
775        node,
776        "engine.not_materialized",
777        format!("`{}` reads samples this render never held", r.name()),
778    )
779}
780
781/// One reading off a buffer nothing rendered — an external file, say — under the profile the
782/// caller names. Every representation needing the graph behind it refuses here.
783pub fn answer_buffer(
784    name: &str,
785    buffer: &Buffer,
786    representation: Representation,
787    profile: &'static str,
788) -> Result<Answer, EngineError> {
789    let refused = |code: &str, message: String| {
790        EngineError::refused(Diagnostic {
791            code: code.to_string(),
792            message,
793            location: Located::at(name, None),
794            help: "ask for a reading a buffer answers on its own".to_string(),
795        })
796    };
797    let value = off_buffer(buffer, representation).map_err(|fault| match fault {
798        NoReading::NeedsAGraph => refused(
799            "engine.observation_needs_a_graph",
800            format!(
801                "`{}` reads the tree a node was built from, and a file has none",
802                representation.name()
803            ),
804        ),
805        NoReading::TooNarrow { need, held } => refused(
806            "type.width_mismatch",
807            format!("this reading needs {need} components and the file holds {held}"),
808        ),
809    })?;
810    Ok(Answer::whole(
811        value,
812        Source::Measured,
813        profile,
814        Some(buffer.rate),
815    ))
816}