1use 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
28pub 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#[derive(Default)]
60struct Open {
61 chain: Vec<NodeId>,
62 cut: bool,
63}
64
65fn 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
106fn 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
196pub(crate) fn across(typing: &Typing, node: NodeId, call: &str) -> EngineError {
199 no_spectral_sum(typing.name(node), call)
200}
201
202fn 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
228pub(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
235fn 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#[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
272fn 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
305fn sole_number(form: &ClosedForm) -> Option<C64> {
307 normalize_closed_form(form)
308 .ok()
309 .and_then(|sum| sole_constant(&sum))
310}
311
312fn 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 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
393fn 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
442fn 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
464pub 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
494fn 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
543pub(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
552pub(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
578fn 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
601pub(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 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
633pub(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
641pub(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 #[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 #[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 #[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 #[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}