Skip to main content

sva_engine/render/
mod.rs

1// Concern: holds a composition's decided types, the buffers a reading needed and each label | Non-concern: ordering the work (schedule.rs), a sampled shader (sampled.rs) | IO: (&Graph, target) -> Render
2
3mod answer;
4mod pointwise;
5mod sampled;
6mod silent;
7mod slots;
8mod stream;
9mod volatile;
10
11use std::collections::BTreeMap;
12
13use sva_ast::Graph;
14use sva_formula::{Held, NodeId, SpectralSum, hash_closed_form};
15use sva_samples::{
16    AliasScore, Buffer, FilterTrace, Frames, Horizon, Label, PSYCHOACOUSTIC_V1, Profile, stft,
17};
18
19use crate::bindings::Binding;
20use crate::cache::{
21    CacheStats, Cost, Expected, Payload, PayloadKind, Recording, Slots, frames_key, symbolic_key,
22};
23use crate::cast::Cast;
24use crate::error::{Diagnostic, EngineError, Located};
25use crate::instantiate;
26use crate::query::Ask;
27use crate::refs;
28use crate::schedule::{self, Schedule};
29use crate::typing::{self, Typing, Value};
30
31#[derive(Clone, Debug, PartialEq)]
32pub struct RenderConfig {
33    pub rate: u32,
34    pub horizon: Horizon,
35    pub profile: Profile,
36    /// What the caller means to read. An empty list is audio out, which collapses the root.
37    pub asks: Vec<Ask>,
38    /// The operation count this render may pay; the profile's own until a caller raises it.
39    pub flop_budget: u128,
40    /// What reads one of these is kept in a slot, never in a store.
41    pub volatile: Vec<String>,
42}
43
44impl RenderConfig {
45    pub fn seconds(rate: u32, secs: f64) -> RenderConfig {
46        RenderConfig {
47            rate,
48            horizon: Horizon::secs(0.0, secs),
49            profile: PSYCHOACOUSTIC_V1,
50            asks: Vec::new(),
51            flop_budget: PSYCHOACOUSTIC_V1.flop_budget,
52            volatile: Vec::new(),
53        }
54    }
55
56    pub fn asking(mut self, asks: Vec<Ask>) -> RenderConfig {
57        self.asks = asks;
58        self
59    }
60}
61
62pub use answer::{answer, answer_buffer, ledger_over, sketch_atom};
63pub use silent::{Silent, render_until_silent};
64pub use stream::{Block, Checkpoint, STREAMED, Stream, StreamConfig};
65
66pub struct Render {
67    pub root: NodeId,
68    pub tys: Typing,
69    pub buffers: BTreeMap<NodeId, Buffer>,
70    pub frames: BTreeMap<NodeId, Frames>,
71    pub symbolic: BTreeMap<NodeId, SpectralSum>,
72    pub labels: BTreeMap<NodeId, Label>,
73    pub traces: Vec<FilterTrace>,
74    pub config: RenderConfig,
75    pub schedule: Schedule,
76    pub bindings: BTreeMap<NodeId, Vec<Binding>>,
77    pub cache_stats: Option<CacheStats>,
78}
79
80impl Render {
81    pub fn buffer(&self, node: NodeId) -> Option<&Buffer> {
82        self.buffers.get(&node)
83    }
84
85    pub fn id(&self, path: &str) -> Option<NodeId> {
86        self.tys.id(path)
87    }
88
89    /// The node a reading names, refusing by name where a file expanded into several.
90    pub fn node(&self, path: &str) -> Result<NodeId, EngineError> {
91        self.tys.resolve(path)
92    }
93
94    /// The multiple a reading asks this node's score against, where one asks for a score at all.
95    pub fn alias_oversample(&self, node: NodeId) -> Option<u32> {
96        self.config
97            .asks
98            .iter()
99            .find_map(|ask| match ask.representation {
100                crate::query::Representation::Alias { oversample }
101                    if self.node(&ask.node).is_ok_and(|asked| asked == node) =>
102                {
103                    Some(oversample)
104                }
105                _ => None,
106            })
107    }
108
109    pub fn alias_score(&self, node: NodeId) -> AliasScore {
110        match self.alias_oversample(node) {
111            Some(_) => AliasScore::Asked,
112            None => AliasScore::NotAsked,
113        }
114    }
115}
116
117pub fn render(
118    graph: &Graph,
119    target: &str,
120    config: RenderConfig,
121    cache: Option<&dyn crate::cache::Cache>,
122) -> Result<Render, EngineError> {
123    render_with_slots(graph, target, config, cache, None)
124}
125
126/// Nothing is materialized that no reading asked for: a closed form answered off its spectral sum
127/// allocates no buffer at all.
128pub fn render_with_slots(
129    graph: &Graph,
130    target: &str,
131    config: RenderConfig,
132    cache: Option<&dyn crate::cache::Cache>,
133    slots: Option<&Slots>,
134) -> Result<Render, EngineError> {
135    run(prepared(graph, target)?, config, cache, slots, None)
136}
137
138/// A composition resolved and typed under one target, before any horizon is chosen.
139pub(crate) struct Prepared<'g> {
140    pub(crate) instances: instantiate::Instances<'g>,
141    pub(crate) order: schedule::Order,
142    pub(crate) tys: Typing,
143    pub(crate) root: NodeId,
144    pub(crate) target: String,
145}
146
147impl Prepared<'_> {
148    /// The root's content address, and whether a reading asks it for an alias score.
149    pub(crate) fn identity(&self, asks: &[Ask]) -> Result<(sva_formula::Hash, bool), EngineError> {
150        let scored = asks.iter().any(|ask| {
151            matches!(
152                ask.representation,
153                crate::query::Representation::Alias { .. }
154            ) && self.tys.resolve(&ask.node).is_ok_and(|id| id == self.root)
155        });
156        Ok((refs::identity(&self.tys, self.root)?, scored))
157    }
158}
159
160pub(crate) fn prepared<'g>(graph: &'g Graph, target: &str) -> Result<Prepared<'g>, EngineError> {
161    let instances = instantiate::instantiate(graph, target)?;
162    let held = instances.instance_of(target)?;
163    let order = schedule::schedule_from(&instances, std::slice::from_ref(&held))?;
164    let tys = typing::infer_all(&instances, &order)?;
165    let root = tys
166        .id(&held)
167        .ok_or_else(|| EngineError::UnknownNode(held.clone()))?;
168    Ok(Prepared {
169        instances,
170        order,
171        tys,
172        root,
173        target: target.to_string(),
174    })
175}
176
177/// The root's value already held, where `known` carries it: only what another reading
178/// names is materialized beside it.
179pub(crate) fn run(
180    prepared: Prepared,
181    config: RenderConfig,
182    cache: Option<&dyn crate::cache::Cache>,
183    slots: Option<&Slots>,
184    known: Option<(Buffer, Label)>,
185) -> Result<Render, EngineError> {
186    let Prepared {
187        instances,
188        order,
189        tys,
190        root,
191        target,
192    } = prepared;
193    let mut schedule = schedule::plan(&tys, &order, root, &config.asks);
194    if known.is_some() {
195        match only_the_root(&tys, root, &config.asks) {
196            true => schedule.materialize.clear(),
197            false => schedule.materialize.retain(|id| *id != root),
198        }
199    }
200
201    let bindings = tys
202        .paths()
203        .filter_map(|(path, id)| Some((id, resolved(&instances, path)?)))
204        .collect();
205    let mut held = Render {
206        root,
207        tys,
208        buffers: BTreeMap::new(),
209        frames: BTreeMap::new(),
210        symbolic: BTreeMap::new(),
211        labels: BTreeMap::new(),
212        traces: Vec::new(),
213        config,
214        schedule,
215        bindings,
216        cache_stats: None,
217    };
218    if let Some((buffer, label)) = known {
219        held.buffers.insert(root, buffer);
220        held.labels.insert(root, label);
221    }
222    let volatile = volatile::mark(&instances, &held.tys, &held.config, &target)?;
223    affordable(&held)?;
224    let recording = cache.map(|c| Recording::over(c, slots));
225    for id in held.schedule.materialize.clone() {
226        let lens = recording.as_ref().map(|r| r.at(volatile.slot(id)));
227        materialize(
228            &mut held,
229            id,
230            lens.as_ref().map(|l| l as &dyn crate::cache::Cache),
231        )?;
232    }
233    held.cache_stats = recording.map(Recording::finish);
234    compose_read(&mut held);
235    stamp(&mut held);
236    Ok(held)
237}
238
239/// Whether every reading is one of the root's own samples, so a held root answers them all.
240fn only_the_root(tys: &Typing, root: NodeId, asks: &[Ask]) -> bool {
241    asks.iter().all(|ask| {
242        tys.resolve(&ask.node).is_ok_and(|id| id == root)
243            && !matches!(
244                ask.representation,
245                crate::query::Representation::Ledger { .. }
246            )
247    })
248}
249
250/// A reading that materializes nothing is never refused for cost: asking what a render
251/// would cost is not paying for it. Anything that runs is counted first, and a count past
252/// the budget refuses before a sample is computed.
253fn affordable(held: &Render) -> Result<(), EngineError> {
254    if held.schedule.materialize.is_empty() {
255        return Ok(());
256    }
257    let total = crate::flops::total(held);
258    if total <= held.config.flop_budget {
259        return Ok(());
260    }
261    let counted = crate::flops::tree(held);
262    let over = crate::flops::dominating(&counted).expect("a counted tree holds its root");
263    Err(EngineError::refused(Diagnostic {
264        code: "collapse.over_budget".to_string(),
265        message: format!(
266            "this render counts {} operations, over the budget of {}; `{}` dominates it at \
267             {} by {}",
268            counted.total, counted.budget, over.node, over.subtree, over.route
269        ),
270        location: Located::at(held.tys.name(held.root), None),
271        help: format!("pass --flop-budget {} to render it anyway", counted.total),
272    }))
273}
274
275/// FORMAT 9.3: the render's own label says what it cost and what it was allowed.
276fn stamp(held: &mut Render) {
277    let root = held.root;
278    let Some(label) = held.labels.remove(&root) else {
279        return;
280    };
281    let counted = crate::flops::total(held);
282    held.labels
283        .insert(root, label.costing(counted, held.config.flop_budget));
284}
285
286/// One render, every reading: a closed form a reading asks for is composed once here, and each
287/// `--as` answers off that one form rather than walking the graph again. A form that
288/// refuses is left for the reading itself to raise, in its own words.
289fn compose_read(held: &mut Render) {
290    for id in held.schedule.compose.clone() {
291        if held.symbolic.contains_key(&id) {
292            continue;
293        }
294        if let Ok(sum) = refs::spectral_sum_of(&held.tys, id, held.tys.var(id)) {
295            held.symbolic.insert(id, sum);
296        }
297    }
298}
299
300fn materialize(
301    held: &mut Render,
302    id: NodeId,
303    cache: Option<&dyn crate::cache::Cache>,
304) -> Result<(), EngineError> {
305    match held.tys.ty(id).held {
306        Held::Frames => frames_of(held, id, cache),
307        Held::Sampled => sampled::run(held, id, cache),
308        _ => collapse_closed_form(held, id, cache),
309    }
310}
311
312/// The one place a closed form becomes samples: `sample(...)` and the render root, and nothing else.
313fn collapse_closed_form(
314    held: &mut Render,
315    id: NodeId,
316    cache: Option<&dyn crate::cache::Cache>,
317) -> Result<(), EngineError> {
318    let var = held.tys.var(id);
319    let written = match refs::resolve(&held.tys, id, 0, held.tys.ty(id).held) {
320        Ok(refs::Read::Substitute(form)) => Some(*form),
321        _ => None,
322    };
323    let symbolic = written.as_ref().map(|t| symbolic_key(hash_closed_form(t)));
324    let sum = remembered(held, id, symbolic, cache)
325        .map(Ok)
326        .unwrap_or_else(|| {
327            let began = Cost::begun();
328            let found = refs::spectral_sum_of(&held.tys, id, var);
329            if let (Ok(sum), Some(key), Some(cache)) = (&found, symbolic, cache) {
330                let payload = Payload::Symbolic(Box::new(sum.clone()));
331                if cache.worth_storing(began.elapsed(), payload.bytes(), PayloadKind::Symbolic) {
332                    cache.store(key, &payload, &[], None);
333                }
334            }
335            found
336        });
337    let identity = match refs::closed_form_identity(&sum, written.as_ref()) {
338        Ok(identity) => identity,
339        Err(_) if var == sva_formula::Var::T => refs::identity(&held.tys, id)?,
340        Err(e) => return Err(e),
341    };
342    let score = held.alias_score(id);
343    let samples = length(held, id)?;
344    let key = crate::cache::buffer_key(
345        identity,
346        held.config.rate,
347        held.config.horizon.start_secs,
348        samples,
349        held.tys.ty(id).width as usize,
350        score,
351    );
352    if let Ok(sum) = &sum {
353        held.symbolic.insert(id, sum.clone());
354    }
355    // FORMAT 9.3: the label belongs to the value, so an entry without one is no value.
356    if let Some((hit, label)) = warm(held, id, key, samples, cache) {
357        held.buffers.insert(id, hit);
358        held.labels.insert(id, label);
359        return Ok(());
360    }
361    let began = Cost::begun();
362    let (buffer, label) = match (&sum, &written) {
363        (Err(_), None) => pointwise::point_sample(held, id, score)?,
364        _ => sampled_form(held, &sum, written.as_ref(), score)
365            .map_err(|e| collapse_refused(held, id, &e))?,
366    };
367    store(key, &buffer, &label, began.elapsed(), cache);
368    held.buffers.insert(id, buffer);
369    held.labels.insert(id, label);
370    Ok(())
371}
372
373fn collapse_refused(held: &Render, id: NodeId, e: &sva_samples::CollapseError) -> EngineError {
374    EngineError::refused(Diagnostic {
375        code: e.code().to_string(),
376        message: e.to_string(),
377        location: Located::at(held.tys.name(id), None),
378        help: e.help().to_string(),
379    })
380}
381
382fn length(held: &Render, id: NodeId) -> Result<usize, EngineError> {
383    held.config
384        .horizon
385        .len(held.config.rate)
386        .map_err(|e| collapse_refused(held, id, &e))
387}
388
389/// A closed form with a spectral sum takes the six rows over it; one without takes the point-sampled
390/// row over the written closed form itself, which is what FORMAT 9.1's no-dual row is.
391fn sampled_form(
392    held: &Render,
393    sum: &Result<SpectralSum, EngineError>,
394    written: Option<&sva_formula::ClosedForm>,
395    score: AliasScore,
396) -> Result<(Buffer, Label), sva_samples::CollapseError> {
397    let (rate, horizon, profile) = (held.config.rate, held.config.horizon, &held.config.profile);
398    match (sum, written) {
399        (Ok(sum), written) => {
400            sva_samples::of_spectral_sum_or_point(sum, written, rate, horizon, profile, score)
401        }
402        (Err(_), Some(form)) => sva_samples::render(form, rate, horizon, profile, score),
403        (Err(_), None) => unreachable!("a closed form with neither view is point-sampled above"),
404    }
405}
406
407/// A spectral sum is rate-free, so one entry answers every rate the same closed form is read at.
408fn remembered(
409    held: &Render,
410    id: NodeId,
411    key: Option<sva_formula::Hash>,
412    cache: Option<&dyn crate::cache::Cache>,
413) -> Option<SpectralSum> {
414    let entry = cache?.load(key?, held.tys.name(id), Expected::Symbolic)?;
415    entry.payload.symbolic().cloned()
416}
417
418/// A warm entry is the same bytes the cold run would write, so a hit is returned as it
419/// stands rather than checked against a second collapse.
420fn warm(
421    held: &Render,
422    id: NodeId,
423    key: sva_formula::Hash,
424    samples: usize,
425    cache: Option<&dyn crate::cache::Cache>,
426) -> Option<(Buffer, Label)> {
427    let expected = Expected::Samples {
428        rate: held.config.rate,
429        width: held.tys.ty(id).width as usize,
430        samples,
431    };
432    let entry = cache?.load(key, held.tys.name(id), expected)?;
433    Some((entry.payload.samples().cloned()?, entry.label?))
434}
435
436fn store(
437    key: sva_formula::Hash,
438    buffer: &Buffer,
439    label: &Label,
440    cost: std::time::Duration,
441    cache: Option<&dyn crate::cache::Cache>,
442) {
443    let Some(cache) = cache else { return };
444    let payload = Payload::Samples(Box::new(buffer.clone()));
445    if cache.worth_storing(cost, payload.bytes(), PayloadKind::Samples) {
446        cache.store(key, &payload, &[], Some(label));
447    }
448}
449
450fn frames_of(
451    held: &mut Render,
452    id: NodeId,
453    cache: Option<&dyn crate::cache::Cache>,
454) -> Result<(), EngineError> {
455    let Value::Cast(Cast::Stft { window, hop }, source) = *held.tys.value(id) else {
456        return Err(not_frames(held, id));
457    };
458    let buffer = held
459        .buffers
460        .get(&source)
461        .ok_or_else(|| not_frames(held, id))?;
462    let key = frames_key(
463        crate::cache::buffer_key(
464            refs::identity(&held.tys, source)?,
465            buffer.rate,
466            buffer.origin_secs,
467            buffer.len(),
468            buffer.width,
469            AliasScore::NotAsked,
470        ),
471        window,
472        hop,
473    );
474    if let Some(entry) = cache.and_then(|c| c.load(key, held.tys.name(id), Expected::Frames))
475        && let Payload::Frames(frames) = entry.payload
476    {
477        held.frames.insert(id, *frames);
478        return Ok(());
479    }
480    let began = Cost::begun();
481    let frames = stft::forward(buffer, window, hop).map_err(|e| sampled::refused(held, id, &e))?;
482    if let Some(cache) = cache {
483        let payload = Payload::Frames(Box::new(frames.clone()));
484        if cache.worth_storing(began.elapsed(), payload.bytes(), PayloadKind::Frames) {
485            cache.store(key, &payload, &[], None);
486        }
487    }
488    held.frames.insert(id, frames);
489    Ok(())
490}
491
492fn not_frames(held: &Render, id: NodeId) -> EngineError {
493    EngineError::refused(Diagnostic {
494        code: "cast.stft_needs_samples".to_string(),
495        message: format!("`{}` holds no frames to read", held.tys.name(id)),
496        location: Located::at(held.tys.name(id), None),
497        help: "write stft(sample(x), window=, hop=)".to_string(),
498    })
499}
500
501/// One instance's resolved parameters, as the composer wrote them at the call site.
502fn resolved(instances: &instantiate::Instances, path: &str) -> Option<Vec<Binding>> {
503    Some(
504        instances
505            .bindings(path)?
506            .into_iter()
507            .map(|(name, expr, cx)| Binding {
508                name: name.to_string(),
509                source: instances.render(expr, cx),
510            })
511            .collect(),
512    )
513}