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