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, symbolic_hash};
25pub(crate) use identity::{passes, 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    match names_number(typing, f, fold) {
233        true => Cow::Owned(folded(typing, f, fold)),
234        false => Cow::Borrowed(f),
235    }
236}
237
238fn names_number(typing: &Typing, f: &Body, fold: &mut Folding) -> bool {
239    match f {
240        Body::Node(id) => number(typing, *id, fold).is_some(),
241        _ => children(f)
242            .into_iter()
243            .any(|p| names_number(typing, &p.body, fold)),
244    }
245}
246
247/// The chain of refs still being folded, and whether a fold met one of them again: a number
248/// that loop cut short is not the node's own, so it is not kept.
249#[derive(Default)]
250struct Folding {
251    open: Vec<NodeId>,
252    cut: bool,
253}
254
255fn folded(typing: &Typing, f: &Body, fold: &mut Folding) -> Body {
256    let Body::Node(id) = f else {
257        return sva_formula::closed_form::map_children(f, |p| {
258            Part::new(p.origin, folded(typing, &p.body, fold))
259        });
260    };
261    match number(typing, *id, fold) {
262        Some(c) => Body::Const(c),
263        None => f.clone(),
264    }
265}
266
267/// The one number a node holds, or `None` where it holds a form, samples or a ref loop.
268fn number(typing: &Typing, node: NodeId, fold: &mut Folding) -> Option<C64> {
269    if let Some(held) = typing.folds().number(node) {
270        return held;
271    }
272    if fold.open.contains(&node) {
273        fold.cut = true;
274        return None;
275    }
276    let Value::ClosedForm(form) = typing.value(node) else {
277        return None;
278    };
279    if let Body::Const(c) = form.body {
280        return Some(c);
281    }
282    let outer = std::mem::take(&mut fold.cut);
283    fold.open.push(node);
284    let folded = names_number(typing, &form.body, fold).then(|| ClosedForm {
285        body: folded(typing, &form.body, fold),
286        ..*form
287    });
288    fold.open.pop();
289    let number = normalize_closed_form(folded.as_ref().unwrap_or(form))
290        .ok()
291        .and_then(|sum| sole_constant(&sum));
292    if !fold.cut {
293        typing.folds().keep_number(node, number);
294    }
295    fold.cut |= outer;
296    number
297}
298
299/// A node reference is not a `Body`, so a term holding one is normalized by composing
300/// the pieces around it rather than by handing the whole tree to `normalize`.
301fn compose(
302    typing: &Typing,
303    body: &Body,
304    var: Var,
305    open: &mut Open,
306) -> Result<SpectralSum, EngineError> {
307    let here = *open
308        .chain
309        .last()
310        .expect("compose runs inside the node it composes");
311    if !holds_node(body) {
312        return normalize_here(typing, body, var);
313    }
314    match body {
315        Body::Node(id) => composed(typing, *id, var, open),
316        Body::Add(parts) => {
317            let mut lanes: Vec<Lane> = Vec::new();
318            for part in parts {
319                add_into(&mut lanes, compose(typing, &part.body, var, open)?);
320            }
321            Ok(sum(var, lanes))
322        }
323        Body::Mul(parts) => {
324            let mut acc: Option<SpectralSum> = None;
325            for part in parts {
326                let next = compose(typing, &part.body, var, open)?;
327                acc = Some(match acc {
328                    None => next,
329                    Some(held) => multiply(typing, &held, &next, var)?,
330                });
331            }
332            Ok(acc.unwrap_or_else(|| sum(var, Vec::new())))
333        }
334        Body::Shift { by, of } => {
335            let held = compose(typing, &of.body, var, open)?;
336            image::shift(held, *by).map_err(|left| left_of(typing, left))
337        }
338        Body::Crop {
339            of,
340            l,
341            r,
342            rise,
343            fall,
344        } if *rise > 0.0 || *fall > 0.0 => {
345            let held = compose(typing, &of.body, var, open)?;
346            let window = image::crop_window(*l, *r, *rise, *fall, of.origin, var);
347            multiply(typing, &held, &window, var)
348        }
349        Body::Crop { of, l, r, .. } => {
350            let held = compose(typing, &of.body, var, open)?;
351            image::crop(held, Indicator { l: *l, r: *r }).map_err(|left| left_of(typing, left))
352        }
353        Body::Div(num, den) => {
354            let over = compose(typing, &den.body, var, open)?;
355            let numerator = compose(typing, &num.body, var, open)?;
356            multiply(typing, &numerator, &reciprocal(typing, here, &over)?, var)
357        }
358        Body::Join(parts) => {
359            let mut lanes = Vec::new();
360            for part in parts {
361                lanes.extend(compose(typing, &part.body, var, open)?.lanes);
362            }
363            Ok(sum(var, lanes))
364        }
365        Body::Channel(of, k) => {
366            let held = compose(typing, &of.body, var, open)?;
367            match held.lanes.into_iter().nth(usize::from(*k)) {
368                Some(lane) => Ok(sum(var, vec![lane])),
369                None => Err(unsubstituted(typing, here, body)),
370            }
371        }
372        // Every other construct is what it was written as, each ref read as its own form.
373        other if reads_through(typing, other, var) => read_through(typing, |through| {
374            normalize_with(typing, other, var, through)
375        }),
376        other => Err(unsubstituted(typing, here, other)),
377    }
378}
379
380/// A divisor a ref reaches has to be one number: a reciprocal is not an atom sum.
381fn reciprocal(
382    typing: &Typing,
383    node: NodeId,
384    over: &SpectralSum,
385) -> Result<SpectralSum, EngineError> {
386    let divided = || no_spectral_sum(typing.name(node), "a division by a closed form");
387    let [lane] = over.lanes.as_slice() else {
388        return Err(divided());
389    };
390    match lane.atoms.as_slice() {
391        [atom] if atom.is_bare() => Ok(SpectralSum::mono(
392            over.var,
393            vec![sva_formula::spectral_sum::atom::SpectralAtom::constant(
394                atom.c.inv(),
395                atom.origin,
396            )],
397        )),
398        _ => Err(divided()),
399    }
400}
401
402fn left_of(typing: &Typing, left: Left) -> EngineError {
403    EngineError::of_closed_form(
404        &left.refusal(),
405        typing.locate(left.origin),
406        "write the subterm inside sample(...) to leave A deliberately",
407    )
408}
409
410fn normalize_here(typing: &Typing, body: &Body, var: Var) -> Result<SpectralSum, EngineError> {
411    normalize_with(typing, body, var, &Opaque)
412}
413
414fn normalize_with(
415    typing: &Typing,
416    body: &Body,
417    var: Var,
418    reads: &dyn Reads,
419) -> Result<SpectralSum, EngineError> {
420    normalize_read(body, var, reads).map_err(|left| {
421        EngineError::of_closed_form(
422            &left.refusal(),
423            typing.locate(left.origin),
424            "write the subterm inside sample(...) to leave A deliberately",
425        )
426    })
427}
428
429/// A ref reaching a value no substitution inlines, under a construct with no lane rule of
430/// its own: the reading has a name and nothing to read it off.
431fn unsubstituted(typing: &Typing, node: NodeId, body: &Body) -> EngineError {
432    no_spectral_sum(typing.name(node), named(body))
433}
434
435fn named(body: &Body) -> &'static str {
436    match body {
437        Body::Apply(op, _) => op.name(),
438        Body::Pow(..) => "pow",
439        Body::Fold(..) => "max, min or mod",
440        Body::Join(_) => "join",
441        Body::Channel(..) => "ch",
442        Body::Series(_) => "sum",
443        Body::Delta { .. } => "delta",
444        Body::Pv(_) => "pv",
445        Body::Deriv { .. } => "a derivative",
446        Body::Warp { .. } => "a warped time",
447        _ => "a construct",
448    }
449}
450
451/// Every node a written form names, in written order, so a caller answers each one.
452pub fn nodes_in(f: &Body) -> Vec<NodeId> {
453    let mut out = Vec::new();
454    collect_nodes(f, &mut out);
455    out
456}
457
458fn collect_nodes(f: &Body, out: &mut Vec<NodeId>) {
459    if let Body::Node(id) = f {
460        if !out.contains(id) {
461            out.push(*id);
462        }
463        return;
464    }
465    for part in children(f) {
466        collect_nodes(&part.body, out);
467    }
468}
469
470fn holds_node(f: &Body) -> bool {
471    matches!(f, Body::Node(_)) || children(f).iter().any(|p| holds_node(&p.body))
472}
473
474fn sum(var: Var, mut lanes: Vec<Lane>) -> SpectralSum {
475    for lane in &mut lanes {
476        simplify(lane);
477    }
478    SpectralSum::of(var, lanes)
479}
480
481/// A width-1 operand broadcasts into every lane of the wider one, at the operator.
482fn add_into(lanes: &mut Vec<Lane>, other: SpectralSum) {
483    if other.lanes.is_empty() {
484        return;
485    }
486    let width = lanes.len().max(other.lanes.len());
487    if lanes.len() == 1 {
488        let held = lanes[0].clone();
489        lanes.resize(width, held);
490    }
491    for at in 0..width {
492        let lane = lane_at(&other, at).clone();
493        match lanes.get_mut(at) {
494            Some(held) => {
495                held.atoms.extend(lane.atoms);
496                held.series.extend(lane.series);
497                held.modal.extend(lane.modal);
498            }
499            None => lanes.push(lane),
500        }
501    }
502}
503
504fn multiply(
505    typing: &Typing,
506    a: &SpectralSum,
507    b: &SpectralSum,
508    var: Var,
509) -> Result<SpectralSum, EngineError> {
510    let width = a.lanes.len().max(b.lanes.len());
511    let mut lanes = Vec::with_capacity(width);
512    for at in 0..width {
513        let (x, y) = (lane_at(a, at).clone(), lane_at(b, at).clone());
514        let held = read_through(typing, |through| multiply_lanes_read(x, y, through));
515        lanes.push(held.map_err(|left| {
516            EngineError::of_closed_form(
517                &left.refusal(),
518                typing.locate(left.origin),
519                "write one of the factors inside sample(...)",
520            )
521        })?);
522    }
523    Ok(sum(var, lanes))
524}
525
526fn lane_at(n: &SpectralSum, at: usize) -> &Lane {
527    n.lanes.get(at).unwrap_or(&n.lanes[0])
528}
529
530/// Whether each ref `body` holds names a form on `var`, as do the refs each of those holds,
531/// with no loop among them: only then is a ref read as the form it names.
532pub(crate) fn reads_through(typing: &Typing, body: &Body, var: Var) -> bool {
533    let open = &mut Vec::new();
534    nodes_in(body)
535        .into_iter()
536        .all(|id| inlinable(typing, id, var, open))
537}
538
539/// A form that is one series without end whose term reads refs at a time its index moves, as
540/// a closed loop's does, each ref read through and with a sum of its own: no sample of it sums
541/// those refs read as values, so it is read as one sum.
542pub(crate) fn sums_through(typing: &Typing, form: &ClosedForm) -> bool {
543    let Body::Series(s) = &form.body else {
544        return false;
545    };
546    let refs = nodes_in(&form.body);
547    form.var == Var::T
548        && s.hi == sva_formula::Bound::Infinite
549        && moves_a_ref(&s.term.body, s.index)
550        && reads_through(typing, &form.body, Var::T)
551        && refs
552            .iter()
553            .all(|id| spectral_sum_of(typing, *id, Var::T).is_ok())
554}
555
556fn moves_a_ref(f: &Body, k: sva_formula::IndexId) -> bool {
557    match f {
558        Body::Warp { at, of } if matches!(*of.body, Body::Node(_)) => {
559            sva_formula::series::mentions(&at.body, k)
560        }
561        other => children(other).iter().any(|p| moves_a_ref(&p.body, k)),
562    }
563}
564
565/// A loop met is a loop the node is on or reaches, wherever the walk entered it.
566fn inlinable(typing: &Typing, id: NodeId, var: Var, open: &mut Vec<NodeId>) -> bool {
567    if let Some(held) = typing.folds().inlinable((id, var)) {
568        return held;
569    }
570    if open.contains(&id) {
571        return false;
572    }
573    let found = match typing.value(id) {
574        Value::ClosedForm(form) if form.var == var => {
575            open.push(id);
576            let held = nodes_in(&form.body)
577                .into_iter()
578                .all(|n| inlinable(typing, n, var, open));
579            open.pop();
580            held
581        }
582        _ => false,
583    };
584    typing.folds().keep_inlinable((id, var), found);
585    found
586}
587
588/// One reading of written forms, found once per node for as long as it lasts.
589pub(crate) struct PerNode<T>(RefCell<BTreeMap<NodeId, T>>);
590
591impl<T: Clone> PerNode<T> {
592    pub(crate) fn new() -> PerNode<T> {
593        PerNode(RefCell::default())
594    }
595
596    pub(crate) fn of(&self, id: NodeId, read: impl FnOnce() -> T) -> T {
597        if let Some(held) = self.0.borrow().get(&id) {
598            return held.clone();
599        }
600        let found = read();
601        self.0.borrow_mut().insert(id, found.clone());
602        found
603    }
604
605    /// The same, keeping only a reading that did not refuse.
606    pub(crate) fn try_of<E>(
607        &self,
608        id: NodeId,
609        read: impl FnOnce() -> Result<T, E>,
610    ) -> Result<T, E> {
611        if let Some(held) = self.0.borrow().get(&id) {
612            return Ok(held.clone());
613        }
614        let found = read()?;
615        self.0.borrow_mut().insert(id, found.clone());
616        Ok(found)
617    }
618}
619
620/// The written form a ref `reads_through` passed names.
621pub(crate) fn written_form(typing: &Typing, id: NodeId) -> &Body {
622    match typing.value(id) {
623        Value::ClosedForm(form) => &form.body,
624        _ => unreachable!("a ref read through names a closed form"),
625    }
626}
627
628/// `with` handed every ref as the form it names, each node's readings kept on the typing.
629pub(crate) fn read_through<R>(typing: &Typing, with: impl FnOnce(&Through) -> R) -> R {
630    let written = |id: NodeId| match typing.value(id) {
631        Value::ClosedForm(form) => Some((&form.body, form.origin)),
632        _ => None,
633    };
634    with(&Through::new(&written, typing.folds().written()))
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    fn typed(name: &str, files: &[(&str, String)], root: &str) -> Typing {
642        let dir = std::env::temp_dir().join(format!("sva-refs-{name}-{}", std::process::id()));
643        let _ = std::fs::remove_dir_all(&dir);
644        std::fs::create_dir_all(&dir).expect("a directory");
645        for (file, body) in files {
646            std::fs::write(dir.join(file), body).expect("a node file");
647        }
648        let graph = sva_ast::parse_composition(&dir).expect("a composition");
649        crate::types(&graph, root).expect("typed")
650    }
651
652    fn composings(tys: &Typing) -> usize {
653        tys.folds().composings.get()
654    }
655
656    /// Each level reads the one below three times: one composition per node, however many
657    /// paths reach it, and none on asking again.
658    #[test]
659    fn a_chain_composes_each_node_once() {
660        let names: Vec<String> = (0..=8).map(|k| format!("n{k}")).collect();
661        let mut files = vec![(names[0].as_str(), "sin(2*pi*220*t)\n".to_string())];
662        for pair in names.windows(2) {
663            let p = format!("@{}(t)", pair[0]);
664            files.push((&pair[1], format!("{p}*0.5 + {p}*0.3 + {p}*0.2\n")));
665        }
666        let tys = typed("chain", &files, "n8");
667        let root = tys.id("n8").expect("the root");
668        spectral_sum_of(&tys, root, Var::T).expect("a sum");
669        assert_eq!(composings(&tys), 9);
670        spectral_sum_of(&tys, root, Var::T).expect("a sum");
671        assert_eq!(composings(&tys), 9);
672    }
673
674    /// Twenty filters deep, the poles pass what a sum holds and every level above refuses:
675    /// each refusal is kept, so asking any level again composes nothing.
676    #[test]
677    fn a_refusal_a_chain_meets_is_composed_once_per_node() {
678        let names: Vec<String> = (0..=20).map(|k| format!("n{k}")).collect();
679        let tone = "crop(sin(2*pi*220*t), 0s, 0.1s)\n".to_string();
680        let mut files = vec![(names[0].as_str(), tone)];
681        for pair in names.windows(2) {
682            files.push((&pair[1], format!("lowpass(@{}(t), 1000)\n", pair[0])));
683        }
684        let tys = typed("refused", &files, "n20");
685        let at = |k: usize| tys.id(&names[k]).expect("a level");
686        assert!(
687            spectral_sum_of(&tys, at(20), Var::T).is_err(),
688            "the poles refuse"
689        );
690        let once = composings(&tys);
691        for k in (0..=20).rev() {
692            let _ = spectral_sum_of(&tys, at(k), Var::T);
693        }
694        assert_eq!(composings(&tys), once, "a kept refusal composes nothing");
695    }
696
697    /// Lowering one node and its reader anew lets go of what they folded to, and only that:
698    /// a node beside them composes from what it kept.
699    #[test]
700    fn an_edit_lets_go_of_the_folds_of_what_it_changed_alone() {
701        let dir = std::env::temp_dir().join(format!("sva-refs-narrow-{}", std::process::id()));
702        let _ = std::fs::remove_dir_all(&dir);
703        std::fs::create_dir_all(&dir).expect("a directory");
704        let files = [
705            ("a", "sin(2*pi*220*t)*0.5\n"),
706            ("b", "sin(2*pi*330*t)*0.5\n"),
707            ("mix", "@a(t) + @b(t)\n"),
708        ];
709        for (file, body) in files {
710            std::fs::write(dir.join(file), body).expect("a node file");
711        }
712        let graph = sva_ast::parse_composition(&dir).expect("a composition");
713        let held = crate::render::prepared(&graph, "mix", 8_000).expect("typed");
714        let (inst, mut tys) = (held.instances, held.tys);
715        let a = tys.id("a").expect("a");
716        spectral_sum_of(&tys, a, Var::T).expect("a sum");
717        let before = composings(&tys);
718        let changed = [vec!["b".to_string()], vec!["mix".to_string()]];
719        tys.lower(&inst, &changed).expect("lowered anew");
720        tys.commit(&inst);
721        spectral_sum_of(&tys, a, Var::T).expect("a sum");
722        assert_eq!(composings(&tys), before, "`a` composes from what it kept");
723        let mix = tys.id("mix").expect("mix");
724        spectral_sum_of(&tys, mix, Var::T).expect("a sum");
725        assert!(composings(&tys) > before, "what changed composes anew");
726        let _ = std::fs::remove_dir_all(&dir);
727    }
728
729    /// Two differently named nodes holding one body are one identity, so one composition.
730    #[test]
731    fn two_names_for_one_body_compose_once() {
732        let files = [
733            ("tone", "sin(2*pi*3*t)\n".to_string()),
734            ("a", "@tone(t)*0.5\n".to_string()),
735            ("b", "@tone(t)*0.5\n".to_string()),
736            ("mix", "@a(t) + @b(t)\n".to_string()),
737        ];
738        let tys = typed("twins", &files, "mix");
739        let [a, b] = ["a", "b"].map(|n| tys.id(n).expect("a node"));
740        assert_ne!(a, b);
741        assert_eq!(identity(&tys, a).ok(), identity(&tys, b).ok());
742        spectral_sum_of(&tys, tys.id("mix").expect("mix"), Var::T).expect("a sum");
743        assert_eq!(composings(&tys), 3);
744    }
745}