Skip to main content

sva_engine/refs/
mod.rs

1// Concern: what reading another node yields, per the representation it holds | Non-concern: ordering the reads (schedule.rs), collapsing a closed form (sva-samples) | IO: (NodeId, Var) -> SpectralSum
2
3use std::borrow::Cow;
4use std::cell::RefCell;
5use std::collections::BTreeMap;
6
7use sva_formula::closed_form::children;
8use sva_formula::spectral_sum::atom::Indicator;
9use sva_formula::spectral_sum::build::{multiply_lanes_read, sole_constant};
10use sva_formula::spectral_sum::image;
11use sva_formula::spectral_sum::merge::simplify;
12use sva_formula::{
13    Body, C64, ClosedForm, Lane, Left, NodeId, Opaque, Part, Reads, SpectralSum, Through, Var,
14    dual_read, inverse_read, normalize_closed_form, normalize_read,
15};
16
17use crate::cast::Cast;
18use crate::error::{Diagnostic, EngineError, Located};
19use crate::typing::{Typing, Value};
20
21mod identity;
22mod prefix;
23
24pub use identity::identity;
25pub(crate) use identity::{passes, shape, subterm_identity};
26pub(crate) use prefix::switches;
27
28/// The spectral sum of one node read on `want`'s axis, with every ref it holds already
29/// composed in. A pair answers on either axis; anything else answers on its own.
30pub fn spectral_sum_of(
31    typing: &Typing,
32    node: NodeId,
33    want: Var,
34) -> Result<SpectralSum, EngineError> {
35    let held = |id: NodeId| {
36        let var = typing.var(id);
37        let key = identity(typing, id).ok();
38        typing.folds().refused((id, var)).is_some()
39            || key.is_some_and(|key| typing.folds().composed((key, var)).is_some())
40    };
41    for read in typing.unfolded_over(node, |id| composes(typing, id), held) {
42        if read != node {
43            let _ = composed(typing, read, typing.var(read), &mut Open::default());
44        }
45    }
46    composed(typing, node, want, &mut Open::default())
47}
48
49fn composes(typing: &Typing, id: NodeId) -> Vec<NodeId> {
50    match typing.value(id) {
51        Value::ClosedForm(form) => nodes_in(&form.body),
52        Value::Cast(Cast::Fourier | Cast::IFourier, source) => vec![*source],
53        _ => Vec::new(),
54    }
55}
56
57/// Whether a composition met a ref still being composed: that refusal depends on where the
58/// walk entered the loop.
59#[derive(Default)]
60struct Open {
61    chain: Vec<NodeId>,
62    cut: bool,
63}
64
65/// Kept under the node's identity, unlocated, and answered as written where `node` is: two
66/// nodes that are one value compose once. A refusal is kept per node, unless a loop of refs
67/// made it.
68fn composed(
69    typing: &Typing,
70    node: NodeId,
71    want: Var,
72    open: &mut Open,
73) -> Result<SpectralSum, EngineError> {
74    let here = written_at(typing, node);
75    let key = identity(typing, node).ok().map(|held| (held, want));
76    if let Some(held) = key.and_then(|key| typing.folds().composed(key)) {
77        return Ok(held.located(here));
78    }
79    if let Some(refused) = typing.folds().refused((node, want)) {
80        return Err(refused);
81    }
82    let outer = std::mem::take(&mut open.cut);
83    let found = composing(typing, node, want, open);
84    match (&found, key) {
85        (Ok(found), Some(key)) => {
86            let unlocated = found.clone().located(sva_formula::Origin::UNKNOWN);
87            typing.folds().keep_composed(key, unlocated);
88        }
89        (Err(refused), _) if !open.cut => {
90            typing.folds().keep_refused((node, want), refused.clone());
91        }
92        _ => {}
93    }
94    open.cut |= outer;
95    Ok(found?.located(here))
96}
97
98fn written_at(typing: &Typing, node: NodeId) -> sva_formula::Origin {
99    match typing.value(node) {
100        Value::ClosedForm(form) => form.origin,
101        Value::Cast(_, source) => written_at(typing, *source),
102        _ => sva_formula::Origin::UNKNOWN,
103    }
104}
105
106/// A form reaching itself through a ref `open` still composes is a loop no substitution
107/// closes.
108fn composing(
109    typing: &Typing,
110    node: NodeId,
111    want: Var,
112    open: &mut Open,
113) -> Result<SpectralSum, EngineError> {
114    #[cfg(test)]
115    typing
116        .folds()
117        .composings
118        .set(typing.folds().composings.get() + 1);
119    if open.chain.contains(&node) {
120        open.cut = true;
121        return Err(cyclic(typing, node));
122    }
123    open.chain.push(node);
124    let held = match typing.value(node) {
125        Value::ClosedForm(form) => {
126            let body = fold_constants(typing, &form.body);
127            compose(typing, &body, form.var, open)?
128        }
129        Value::Cast(Cast::Fourier, source) => {
130            let inner = composed(typing, *source, Var::T, open)?;
131            turn(typing, node, read_through(typing, |t| dual_read(&inner, t)))?
132        }
133        Value::Cast(Cast::IFourier, source) => {
134            let inner = composed(typing, *source, Var::F, open)?;
135            turn(
136                typing,
137                node,
138                read_through(typing, |t| inverse_read(&inner, t)),
139            )?
140        }
141        Value::Op { name, .. } if typing.ty(node).is_closed_form() => {
142            return Err(across(typing, node, name));
143        }
144        _ => return Err(no_closed_form(typing, node)),
145    };
146    open.chain.pop();
147    on_axis(typing, node, held, typing.var(node), want)
148}
149
150pub(crate) fn cyclic(typing: &Typing, node: NodeId) -> EngineError {
151    EngineError::refused(Diagnostic {
152        code: "engine.cyclic_substitution".to_string(),
153        message: format!(
154            "`{}` reads itself around a loop of refs.",
155            typing.name(node)
156        ),
157        location: Located::at(typing.name(node), None),
158        help: "write the loop with self(...), which the engine classifies".to_string(),
159    })
160}
161
162fn on_axis(
163    typing: &Typing,
164    node: NodeId,
165    held: SpectralSum,
166    axis: Var,
167    want: Var,
168) -> Result<SpectralSum, EngineError> {
169    if axis == want {
170        return Ok(held);
171    }
172    let turned = read_through(typing, |through| match want {
173        Var::F => dual_read(&held, through),
174        Var::T => inverse_read(&held, through),
175    });
176    turn(typing, node, turned)
177}
178
179fn turn(
180    typing: &Typing,
181    node: NodeId,
182    turned: Result<SpectralSum, Left>,
183) -> Result<SpectralSum, EngineError> {
184    turned.map_err(|left| {
185        EngineError::of_closed_form(
186            &left.refusal(),
187            typing.locate(left.origin),
188            format!(
189                "write `{}` inside sample(...) to leave A deliberately",
190                typing.name(node)
191            ),
192        )
193    })
194}
195
196/// A form whose operands crossed a cast is held as an operation over values, and only
197/// the written form itself has atoms to compose.
198pub(crate) fn across(typing: &Typing, node: NodeId, call: &str) -> EngineError {
199    no_spectral_sum(typing.name(node), call)
200}
201
202/// An exact reading answers off a spectral sum, so a term that reaches none says which
203/// subterm blocked it rather than which reading asked.
204fn no_spectral_sum(node: &str, blocking: &str) -> EngineError {
205    EngineError::refused(Diagnostic {
206        code: "read.no_spectral_sum".to_string(),
207        message: format!(
208            "`{node}` has no spectral sum to read: `{blocking}` composes no value across a ref."
209        ),
210        location: Located::at(node, None),
211        help: "write the construct inside the node it reads, or read it off sample(...)"
212            .to_string(),
213    })
214}
215
216fn no_closed_form(typing: &Typing, node: NodeId) -> EngineError {
217    EngineError::refused(Diagnostic {
218        code: "type.samples_in_closed_form".to_string(),
219        message: format!(
220            "`{}` is samples; nothing returns from samples to a closed form",
221            typing.name(node)
222        ),
223        location: Located::at(typing.name(node), None),
224        help: "read it as samples, or build the closed form without it".to_string(),
225    })
226}
227
228/// Every ref naming one number, replaced by that number. A constant is the same value on
229/// either axis and under every construct, so it folds where no form would substitute.
230pub(crate) fn fold_constants<'a>(typing: &Typing, f: &'a Body) -> Cow<'a, Body> {
231    let fold = &mut Folding::default();
232    folded_by(f, &mut |id| number(typing, id, fold))
233}
234
235/// `f` with every ref `number` answers replaced by its number.
236fn folded_by<'a>(f: &'a Body, number: &mut dyn FnMut(NodeId) -> Option<C64>) -> Cow<'a, Body> {
237    match names_number(f, number) {
238        true => Cow::Owned(folded(f, number)),
239        false => Cow::Borrowed(f),
240    }
241}
242
243fn names_number(f: &Body, number: &mut dyn FnMut(NodeId) -> Option<C64>) -> bool {
244    match f {
245        Body::Node(id) => number(*id).is_some(),
246        _ => children(f)
247            .into_iter()
248            .any(|p| names_number(&p.body, number)),
249    }
250}
251
252/// The chain of refs still being folded, and whether a fold met one of them again: a number
253/// that loop cut short is not the node's own, so it is not kept.
254#[derive(Default)]
255struct Folding {
256    open: Vec<NodeId>,
257    cut: bool,
258}
259
260fn folded(f: &Body, number: &mut dyn FnMut(NodeId) -> Option<C64>) -> Body {
261    let Body::Node(id) = f else {
262        return sva_formula::closed_form::map_children(f, |p| {
263            Part::new(p.origin, folded(&p.body, number))
264        });
265    };
266    match number(*id) {
267        Some(c) => Body::Const(c),
268        None => f.clone(),
269    }
270}
271
272/// The one number a node holds, or `None` where it holds a form, samples or a ref loop.
273fn number_of(typing: &Typing, node: NodeId) -> Option<C64> {
274    number(typing, node, &mut Folding::default())
275}
276
277fn number(typing: &Typing, node: NodeId, fold: &mut Folding) -> Option<C64> {
278    if let Some(held) = typing.folds().number(node) {
279        return held;
280    }
281    if fold.open.contains(&node) {
282        fold.cut = true;
283        return None;
284    }
285    let Value::ClosedForm(form) = typing.value(node) else {
286        return None;
287    };
288    if let Body::Const(c) = form.body {
289        return Some(c);
290    }
291    let outer = std::mem::take(&mut fold.cut);
292    fold.open.push(node);
293    let number = match folded_by(&form.body, &mut |id| number(typing, id, fold)) {
294        Cow::Owned(body) => sole_number(&ClosedForm { body, ..*form }),
295        Cow::Borrowed(_) => sole_number(form),
296    };
297    fold.open.pop();
298    if !fold.cut {
299        typing.folds().keep_number(node, number);
300    }
301    fold.cut |= outer;
302    number
303}
304
305/// The one number a form with no ref left in it holds.
306fn sole_number(form: &ClosedForm) -> Option<C64> {
307    normalize_closed_form(form)
308        .ok()
309        .and_then(|sum| sole_constant(&sum))
310}
311
312/// A node reference is not a `Body`, so a term holding one is normalized by composing
313/// the pieces around it rather than by handing the whole tree to `normalize`.
314fn compose(
315    typing: &Typing,
316    body: &Body,
317    var: Var,
318    open: &mut Open,
319) -> Result<SpectralSum, EngineError> {
320    let here = *open
321        .chain
322        .last()
323        .expect("compose runs inside the node it composes");
324    if !holds_node(body) {
325        return normalize_here(typing, body, var);
326    }
327    match body {
328        Body::Node(id) => composed(typing, *id, var, open),
329        Body::Add(parts) => {
330            let mut lanes: Vec<Lane> = Vec::new();
331            for part in parts {
332                add_into(&mut lanes, compose(typing, &part.body, var, open)?);
333            }
334            Ok(sum(var, lanes))
335        }
336        Body::Mul(parts) => {
337            let mut acc: Option<SpectralSum> = None;
338            for part in parts {
339                let next = compose(typing, &part.body, var, open)?;
340                acc = Some(match acc {
341                    None => next,
342                    Some(held) => multiply(typing, &held, &next, var)?,
343                });
344            }
345            Ok(acc.unwrap_or_else(|| sum(var, Vec::new())))
346        }
347        Body::Shift { by, of } => {
348            let held = compose(typing, &of.body, var, open)?;
349            image::shift(held, *by).map_err(|left| left_of(typing, left))
350        }
351        Body::Crop {
352            of,
353            l,
354            r,
355            rise,
356            fall,
357        } if *rise > 0.0 || *fall > 0.0 => {
358            let held = compose(typing, &of.body, var, open)?;
359            let window = image::crop_window(*l, *r, *rise, *fall, of.origin, var);
360            multiply(typing, &held, &window, var)
361        }
362        Body::Crop { of, l, r, .. } => {
363            let held = compose(typing, &of.body, var, open)?;
364            image::crop(held, Indicator { l: *l, r: *r }).map_err(|left| left_of(typing, left))
365        }
366        Body::Div(num, den) => {
367            let over = compose(typing, &den.body, var, open)?;
368            let numerator = compose(typing, &num.body, var, open)?;
369            multiply(typing, &numerator, &reciprocal(typing, here, &over)?, var)
370        }
371        Body::Join(parts) => {
372            let mut lanes = Vec::new();
373            for part in parts {
374                lanes.extend(compose(typing, &part.body, var, open)?.lanes);
375            }
376            Ok(sum(var, lanes))
377        }
378        Body::Channel(of, k) => {
379            let held = compose(typing, &of.body, var, open)?;
380            match held.lanes.into_iter().nth(usize::from(*k)) {
381                Some(lane) => Ok(sum(var, vec![lane])),
382                None => Err(unsubstituted(typing, here, body)),
383            }
384        }
385        // Every other construct is what it was written as, each ref read as its own form.
386        other if reads_through(typing, other, var) => read_through(typing, |through| {
387            normalize_with(typing, other, var, through)
388        }),
389        other => Err(unsubstituted(typing, here, other)),
390    }
391}
392
393/// A divisor a ref reaches has to be one number: a reciprocal is not an atom sum.
394fn reciprocal(
395    typing: &Typing,
396    node: NodeId,
397    over: &SpectralSum,
398) -> Result<SpectralSum, EngineError> {
399    let divided = || no_spectral_sum(typing.name(node), "a division by a closed form");
400    let [lane] = over.lanes.as_slice() else {
401        return Err(divided());
402    };
403    match lane.atoms.as_slice() {
404        [atom] if atom.is_bare() => Ok(SpectralSum::mono(
405            over.var,
406            vec![sva_formula::spectral_sum::atom::SpectralAtom::constant(
407                atom.c.inv(),
408                atom.origin,
409            )],
410        )),
411        _ => Err(divided()),
412    }
413}
414
415fn left_of(typing: &Typing, left: Left) -> EngineError {
416    EngineError::of_closed_form(
417        &left.refusal(),
418        typing.locate(left.origin),
419        "write the subterm inside sample(...) to leave A deliberately",
420    )
421}
422
423fn normalize_here(typing: &Typing, body: &Body, var: Var) -> Result<SpectralSum, EngineError> {
424    normalize_with(typing, body, var, &Opaque)
425}
426
427fn normalize_with(
428    typing: &Typing,
429    body: &Body,
430    var: Var,
431    reads: &dyn Reads,
432) -> Result<SpectralSum, EngineError> {
433    normalize_read(body, var, reads).map_err(|left| {
434        EngineError::of_closed_form(
435            &left.refusal(),
436            typing.locate(left.origin),
437            "write the subterm inside sample(...) to leave A deliberately",
438        )
439    })
440}
441
442/// A ref reaching a value no substitution inlines, under a construct with no lane rule of
443/// its own: the reading has a name and nothing to read it off.
444fn unsubstituted(typing: &Typing, node: NodeId, body: &Body) -> EngineError {
445    no_spectral_sum(typing.name(node), named(body))
446}
447
448fn named(body: &Body) -> &'static str {
449    match body {
450        Body::Apply(op, _) => op.name(),
451        Body::Pow(..) => "pow",
452        Body::Fold(..) => "max, min or mod",
453        Body::Join(_) => "join",
454        Body::Channel(..) => "ch",
455        Body::Series(_) => "sum",
456        Body::Delta { .. } => "delta",
457        Body::Pv(_) => "pv",
458        Body::Deriv { .. } => "a derivative",
459        Body::Warp { .. } => "a warped time",
460        _ => "a construct",
461    }
462}
463
464/// Every node a written form names, in written order, so a caller answers each one.
465pub fn nodes_in(f: &Body) -> Vec<NodeId> {
466    let mut out = Vec::new();
467    collect_nodes(f, &mut out);
468    out
469}
470
471fn collect_nodes(f: &Body, out: &mut Vec<NodeId>) {
472    if let Body::Node(id) = f {
473        if !out.contains(id) {
474            out.push(*id);
475        }
476        return;
477    }
478    for part in children(f) {
479        collect_nodes(&part.body, out);
480    }
481}
482
483fn holds_node(f: &Body) -> bool {
484    matches!(f, Body::Node(_)) || children(f).iter().any(|p| holds_node(&p.body))
485}
486
487fn sum(var: Var, mut lanes: Vec<Lane>) -> SpectralSum {
488    for lane in &mut lanes {
489        simplify(lane);
490    }
491    SpectralSum::of(var, lanes)
492}
493
494/// A width-1 operand broadcasts into every lane of the wider one, at the operator.
495fn add_into(lanes: &mut Vec<Lane>, other: SpectralSum) {
496    if other.lanes.is_empty() {
497        return;
498    }
499    let width = lanes.len().max(other.lanes.len());
500    if lanes.len() == 1 {
501        let held = lanes[0].clone();
502        lanes.resize(width, held);
503    }
504    for at in 0..width {
505        let lane = lane_at(&other, at).clone();
506        match lanes.get_mut(at) {
507            Some(held) => {
508                held.atoms.extend(lane.atoms);
509                held.series.extend(lane.series);
510                held.modal.extend(lane.modal);
511            }
512            None => lanes.push(lane),
513        }
514    }
515}
516
517fn multiply(
518    typing: &Typing,
519    a: &SpectralSum,
520    b: &SpectralSum,
521    var: Var,
522) -> Result<SpectralSum, EngineError> {
523    let width = a.lanes.len().max(b.lanes.len());
524    let mut lanes = Vec::with_capacity(width);
525    for at in 0..width {
526        let (x, y) = (lane_at(a, at).clone(), lane_at(b, at).clone());
527        let held = read_through(typing, |through| multiply_lanes_read(x, y, through));
528        lanes.push(held.map_err(|left| {
529            EngineError::of_closed_form(
530                &left.refusal(),
531                typing.locate(left.origin),
532                "write one of the factors inside sample(...)",
533            )
534        })?);
535    }
536    Ok(sum(var, lanes))
537}
538
539fn lane_at(n: &SpectralSum, at: usize) -> &Lane {
540    n.lanes.get(at).unwrap_or(&n.lanes[0])
541}
542
543/// Whether each ref `body` holds names a form on `var`, as do the refs each of those holds,
544/// with no loop among them: only then is a ref read as the form it names.
545pub(crate) fn reads_through(typing: &Typing, body: &Body, var: Var) -> bool {
546    let open = &mut Vec::new();
547    nodes_in(body)
548        .into_iter()
549        .all(|id| inlinable(typing, id, var, open))
550}
551
552/// A form that is one series without end whose term reads refs at a time its index moves, as
553/// a closed loop's does, each ref read through and with a sum of its own: no sample of it sums
554/// those refs read as values, so it is read as one sum.
555pub(crate) fn sums_through(typing: &Typing, form: &ClosedForm) -> bool {
556    let Body::Series(s) = &form.body else {
557        return false;
558    };
559    let refs = nodes_in(&form.body);
560    form.var == Var::T
561        && s.hi == sva_formula::Bound::Infinite
562        && moves_a_ref(&s.term.body, s.index)
563        && reads_through(typing, &form.body, Var::T)
564        && refs
565            .iter()
566            .all(|id| spectral_sum_of(typing, *id, Var::T).is_ok())
567}
568
569fn moves_a_ref(f: &Body, k: sva_formula::IndexId) -> bool {
570    match f {
571        Body::Warp { at, of } if matches!(*of.body, Body::Node(_)) => {
572            sva_formula::series::mentions(&at.body, k)
573        }
574        other => children(other).iter().any(|p| moves_a_ref(&p.body, k)),
575    }
576}
577
578/// A loop met is a loop the node is on or reaches, wherever the walk entered it.
579fn inlinable(typing: &Typing, id: NodeId, var: Var, open: &mut Vec<NodeId>) -> bool {
580    if let Some(held) = typing.folds().inlinable((id, var)) {
581        return held;
582    }
583    if open.contains(&id) {
584        return false;
585    }
586    let found = match typing.value(id) {
587        Value::ClosedForm(form) if form.var == var => {
588            open.push(id);
589            let held = nodes_in(&form.body)
590                .into_iter()
591                .all(|n| inlinable(typing, n, var, open));
592            open.pop();
593            held
594        }
595        _ => false,
596    };
597    typing.folds().keep_inlinable((id, var), found);
598    found
599}
600
601/// One reading of written forms, found once per node for as long as it lasts.
602pub(crate) struct PerNode<T>(RefCell<BTreeMap<NodeId, T>>);
603
604impl<T: Clone> PerNode<T> {
605    pub(crate) fn new() -> PerNode<T> {
606        PerNode(RefCell::default())
607    }
608
609    pub(crate) fn of(&self, id: NodeId, read: impl FnOnce() -> T) -> T {
610        if let Some(held) = self.0.borrow().get(&id) {
611            return held.clone();
612        }
613        let found = read();
614        self.0.borrow_mut().insert(id, found.clone());
615        found
616    }
617
618    /// The same, keeping only a reading that did not refuse.
619    pub(crate) fn try_of<E>(
620        &self,
621        id: NodeId,
622        read: impl FnOnce() -> Result<T, E>,
623    ) -> Result<T, E> {
624        if let Some(held) = self.0.borrow().get(&id) {
625            return Ok(held.clone());
626        }
627        let found = read()?;
628        self.0.borrow_mut().insert(id, found.clone());
629        Ok(found)
630    }
631}
632
633/// The written form a ref `reads_through` passed names.
634pub(crate) fn written_form(typing: &Typing, id: NodeId) -> &Body {
635    match typing.value(id) {
636        Value::ClosedForm(form) => &form.body,
637        _ => unreachable!("a ref read through names a closed form"),
638    }
639}
640
641/// `with` handed every ref as the form it names, each node's readings kept on the typing.
642pub(crate) fn read_through<R>(typing: &Typing, with: impl FnOnce(&Through) -> R) -> R {
643    let written = |id: NodeId| match typing.value(id) {
644        Value::ClosedForm(form) => Some((&form.body, form.origin)),
645        _ => None,
646    };
647    with(&Through::new(&written, typing.folds().written()))
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn typed(name: &str, files: &[(&str, String)], root: &str) -> Typing {
655        let dir = std::env::temp_dir().join(format!("sva-refs-{name}-{}", std::process::id()));
656        let _ = std::fs::remove_dir_all(&dir);
657        std::fs::create_dir_all(&dir).expect("a directory");
658        for (file, body) in files {
659            std::fs::write(dir.join(file), body).expect("a node file");
660        }
661        let graph = sva_ast::parse_composition(&dir).expect("a composition");
662        crate::types(&graph, root).expect("typed")
663    }
664
665    fn composings(tys: &Typing) -> usize {
666        tys.folds().composings.get()
667    }
668
669    /// Each level reads the one below three times: one composition per node, however many
670    /// paths reach it, and none on asking again.
671    #[test]
672    fn a_chain_composes_each_node_once() {
673        let names: Vec<String> = (0..=8).map(|k| format!("n{k}")).collect();
674        let mut files = vec![(names[0].as_str(), "sin(2*pi*220*t)\n".to_string())];
675        for pair in names.windows(2) {
676            let p = format!("@{}(t)", pair[0]);
677            files.push((&pair[1], format!("{p}*0.5 + {p}*0.3 + {p}*0.2\n")));
678        }
679        let tys = typed("chain", &files, "n8");
680        let root = tys.id("n8").expect("the root");
681        spectral_sum_of(&tys, root, Var::T).expect("a sum");
682        assert_eq!(composings(&tys), 9);
683        spectral_sum_of(&tys, root, Var::T).expect("a sum");
684        assert_eq!(composings(&tys), 9);
685    }
686
687    /// Twenty filters deep, the poles pass what a sum holds and every level above refuses:
688    /// each refusal is kept, so asking any level again composes nothing.
689    #[test]
690    fn a_refusal_a_chain_meets_is_composed_once_per_node() {
691        let names: Vec<String> = (0..=20).map(|k| format!("n{k}")).collect();
692        let tone = "crop(sin(2*pi*220*t), 0s, 0.1s)\n".to_string();
693        let mut files = vec![(names[0].as_str(), tone)];
694        for pair in names.windows(2) {
695            files.push((&pair[1], format!("lowpass(@{}(t), 1000)\n", pair[0])));
696        }
697        let tys = typed("refused", &files, "n20");
698        let at = |k: usize| tys.id(&names[k]).expect("a level");
699        assert!(
700            spectral_sum_of(&tys, at(20), Var::T).is_err(),
701            "the poles refuse"
702        );
703        let once = composings(&tys);
704        for k in (0..=20).rev() {
705            let _ = spectral_sum_of(&tys, at(k), Var::T);
706        }
707        assert_eq!(composings(&tys), once, "a kept refusal composes nothing");
708    }
709
710    /// Lowering one node and its reader anew lets go of what they folded to, and only that:
711    /// a node beside them composes from what it kept.
712    #[test]
713    fn an_edit_lets_go_of_the_folds_of_what_it_changed_alone() {
714        let dir = std::env::temp_dir().join(format!("sva-refs-narrow-{}", std::process::id()));
715        let _ = std::fs::remove_dir_all(&dir);
716        std::fs::create_dir_all(&dir).expect("a directory");
717        let files = [
718            ("a", "sin(2*pi*220*t)*0.5\n"),
719            ("b", "sin(2*pi*330*t)*0.5\n"),
720            ("mix", "@a(t) + @b(t)\n"),
721        ];
722        for (file, body) in files {
723            std::fs::write(dir.join(file), body).expect("a node file");
724        }
725        let graph = sva_ast::parse_composition(&dir).expect("a composition");
726        let held = crate::render::prepared(&graph, "mix", 8_000).expect("typed");
727        let (inst, mut tys) = (held.instances, held.tys);
728        let a = tys.id("a").expect("a");
729        spectral_sum_of(&tys, a, Var::T).expect("a sum");
730        let before = composings(&tys);
731        let changed = [vec!["b".to_string()], vec!["mix".to_string()]];
732        tys.lower(&inst, &changed).expect("lowered anew");
733        tys.commit(&inst);
734        spectral_sum_of(&tys, a, Var::T).expect("a sum");
735        assert_eq!(composings(&tys), before, "`a` composes from what it kept");
736        let mix = tys.id("mix").expect("mix");
737        spectral_sum_of(&tys, mix, Var::T).expect("a sum");
738        assert!(composings(&tys) > before, "what changed composes anew");
739        let _ = std::fs::remove_dir_all(&dir);
740    }
741
742    /// Two differently named nodes holding one body are one identity, so one composition.
743    #[test]
744    fn two_names_for_one_body_compose_once() {
745        let files = [
746            ("tone", "sin(2*pi*3*t)\n".to_string()),
747            ("a", "@tone(t)*0.5\n".to_string()),
748            ("b", "@tone(t)*0.5\n".to_string()),
749            ("mix", "@a(t) + @b(t)\n".to_string()),
750        ];
751        let tys = typed("twins", &files, "mix");
752        let [a, b] = ["a", "b"].map(|n| tys.id(n).expect("a node"));
753        assert_ne!(a, b);
754        assert_eq!(identity(&tys, a).ok(), identity(&tys, b).ok());
755        spectral_sum_of(&tys, tys.id("mix").expect("mix"), Var::T).expect("a sum");
756        assert_eq!(composings(&tys), 3);
757    }
758}