Skip to main content

gammalooprs/uv/
hedge_poset.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt::Display,
4};
5
6#[cfg(test)]
7use std::cmp::Reverse;
8
9use ahash::AHashMap;
10use eyre::{WrapErr, eyre};
11use gammaloop_tracing_filter::LogMessage;
12use idenso::{color::ColorSimplifier, shorthands::schoonschip::Schoonschip};
13use itertools::Itertools;
14use linnet::half_edge::{
15    HedgeGraph, NoData, NodeIndex,
16    algorithms::trace_unfold::{
17        HiddenData, Independence, TraceKey, TraceUnfold, UnfoldedTraceGraph,
18    },
19    involution::{EdgeIndex, Flow, HedgePair},
20    nodestore::{NodeStorageOps, NodeStorageVec},
21    subgraph::{Inclusion, InternalSubGraph, ModifySubSet, SuBitGraph, SubSetLike, SubSetOps},
22};
23use symbolica::{
24    atom::{Atom, AtomCore, FunctionBuilder},
25    function,
26};
27use tracing::debug;
28use vakint::Vakint;
29
30use crate::{
31    debug_tags,
32    graph::{
33        Graph, LMBext, LoopMomentumBasis,
34        cuts::CutSet,
35        parse::string_utils::{ToOrderedSimple, dot_attr_value},
36    },
37    utils::{GS, W_},
38    uv::{
39        ApproximationType, Integrands, RenormalizationPart, Spinney, UVgenerationSettings,
40        UltravioletGraph,
41        approx::{
42            CutStructure, ForestNodeLike, OrientationProjection, Rooted, UVCtx,
43            final_integrand::{FinalIntegrandBuilder, FinalIntegrands},
44            integrated::{Integrated, IntegratedCts},
45            local_3d::{Local3DApproximation, Local3DCts, Localizer},
46            local_4d::{self, Full4dCts, Local4dCts},
47        },
48        export::UVForestNodeExpression,
49        forest::ParametricIntegrands,
50        marker::UvMarker,
51        settings::VakintSettings,
52    },
53};
54use color_eyre::Result;
55use spenso::shadowing::symbolica_utils::{LogPrint, SpensoPrintSettings};
56
57pub struct Wood {
58    pub graph: HedgeGraph<SuBitGraph, Spinney>,
59    pub root: NodeIndex,
60    pub vakint_settings: vakint::VakintSettings,
61    cuts: CutStructure,
62}
63
64impl Independence<HiddenData<SuBitGraph, EdgeIndex>> for Wood {
65    fn independent(
66        &self,
67        a: &HiddenData<SuBitGraph, EdgeIndex>,
68        b: &HiddenData<SuBitGraph, EdgeIndex>,
69    ) -> bool {
70        !a.order.intersects(&b.order)
71    }
72}
73
74impl TraceUnfold<SuBitGraph> for Wood {
75    type EdgeData = SuBitGraph;
76    type HedgeData = NoData;
77    type NodeData = Spinney;
78    type NodeStorage = NodeStorageVec<Spinney>;
79
80    fn graph(
81        &self,
82    ) -> &HedgeGraph<Self::EdgeData, Self::NodeData, Self::HedgeData, Self::NodeStorage> {
83        &self.graph
84    }
85
86    fn key(&self, e: EdgeIndex) -> SuBitGraph {
87        self.graph[e].clone()
88    }
89
90    /// Treats a wood node as a factorized join when its incoming sink edges are exactly the
91    /// disjoint connected components of the target spinney.
92    ///
93    /// The returned `SuBitGraph`s are the required branch factors for the generic trace unfold:
94    /// their union must be the target filter, they must be pairwise disjoint, and there must be
95    /// one factor per connected component.
96    fn join_factors(&self, target: NodeIndex) -> Option<BTreeSet<SuBitGraph>> {
97        if self.graph[target].n_components() < 2 {
98            return None;
99        }
100
101        let factors = self
102            .graph
103            .iter_crown(target)
104            .filter(|hedge| self.graph.flow(*hedge) == Flow::Sink)
105            .map(|hedge| self.graph[self.graph[&hedge]].clone())
106            .collect::<BTreeSet<_>>();
107        if factors.len() != self.graph[target].n_components() {
108            return None;
109        }
110
111        let mut cover: Option<SuBitGraph> = None;
112        for factor in &factors {
113            if let Some(acc) = &mut cover {
114                if acc.intersects(factor) {
115                    return None;
116                }
117                acc.union_with(factor);
118            } else {
119                cover = Some(factor.clone());
120            }
121        }
122
123        (cover.as_ref() == Some(self.graph[target].filter())).then_some(factors)
124    }
125}
126
127impl Wood {
128    pub fn current_given_pair<'a>(
129        &'a self,
130        edge_id: EdgeIndex,
131        order: usize,
132    ) -> (ForestNode<'a>, ForestNode<'a>) {
133        let HedgePair::Paired { source, sink } = self.graph[&edge_id].1 else {
134            panic!("edge in self is not paired");
135        };
136
137        // get this hedge's forest node from the self. This is the node that has already been computed (as it is a parent to this edge)
138        let given = ForestNode {
139            spinney: &self.graph[self.graph.node_id(source)],
140            topo_order: order,
141        };
142
143        // this is the current node, which should be the same for all union edges (since they all have the same sink)
144        let current_for_h = self.graph.node_id(sink);
145
146        // this is the current node, which we want to compute with
147        let current = ForestNode {
148            spinney: &self.graph[current_for_h],
149            topo_order: order,
150        };
151
152        (current, given)
153    }
154
155    pub(crate) fn new(cuts: CutStructure, graph: &Graph, settings: &UVgenerationSettings) -> Self {
156        let mut subgraph = graph.full_filter();
157        subgraph.subtract_with(&graph.initial_state_cut.left);
158        let mut spinneys = Vec::new();
159
160        for cut in cuts.cuts.iter() {
161            let cut_sub = subgraph.subtract(&cut.union);
162            spinneys.extend(graph.classified_spinneys(
163                &cut_sub,
164                settings,
165                &graph.loop_momentum_basis,
166            ));
167        }
168
169        Self::from_spinneys(spinneys, graph, cuts, &settings.vakint)
170    }
171
172    pub(crate) fn from_spinneys<I: IntoIterator<Item = Spinney>>(
173        s: I,
174        graph: &Graph,
175        cuts: CutStructure,
176        vakint_settings: &VakintSettings,
177    ) -> Self {
178        let mut max_loops = 0;
179        let mut spinneys = BTreeMap::new();
180        for spinney in s {
181            max_loops = max_loops.max(graph.n_loops(spinney.filter()));
182            spinneys.entry(spinney.filter().clone()).or_insert(spinney);
183        }
184        let empty = Spinney::empty(graph);
185        spinneys.entry(empty.filter().clone()).or_insert(empty);
186        let mut vakint_settings = vakint_settings.true_settings();
187        // Retain enough positive epsilon powers for finite terms formed when disconnected
188        // integrated counterterms are multiplied.
189        vakint_settings.number_of_terms_in_epsilon_expansion = max_loops as i64 + 1;
190
191        let mut unions = BTreeSet::new();
192        let g: HedgeGraph<_, _> = HedgeGraph::poset(spinneys.into_values());
193        let mut poset = g.map(
194            |_, _, v| v,
195            |_, n, pair, _, e| {
196                let HedgePair::Paired { source, sink } = pair else {
197                    return e.map(|_| graph.as_ref().empty_subgraph());
198                };
199                let nsource = n.node_id_ref(source);
200                let nsink = n.node_id_ref(sink);
201
202                let source_subgraph = &n.get_node_data(nsource).subgraph;
203                let sink_subgraph = &n.get_node_data(nsink).subgraph;
204                let reduced_subgraph = sink_subgraph.subtract(source_subgraph).filter;
205                let hairy_source = graph.as_ref().full_crown(source_subgraph);
206
207                if graph.as_ref().bridges_of(&reduced_subgraph).is_empty()
208                    && !hairy_source.intersects(&reduced_subgraph)
209                {
210                    // if the reduced graph is bridgless,and has no node overlap with the source, then the source subgraph is cycle independent of reduced subgraph
211                    // if the source is not empty then this is a disjoint union
212                    if !source_subgraph.is_empty() {
213                        unions.insert(nsink);
214                    }
215                    e.map(|_| reduced_subgraph)
216                } else {
217                    e.map(|_| sink_subgraph.filter.clone())
218                }
219            },
220            |_, d| d,
221        );
222
223        let mut to_remove: SuBitGraph = poset.empty_subgraph();
224
225        // Not quite transitive closure. For disjoint unions, only keep edges that add a
226        // single connected component of the sink; those are the only ones that can be
227        // composed canonically by trace unfolding.
228        for u in unions {
229            // println!("//{u}:{}", poset[u].subgraph.string_label());
230            let mut comps: BTreeSet<_> = graph
231                .as_ref()
232                .connected_components(&poset[u].subgraph)
233                .into_iter()
234                .collect();
235            for c in poset.iter_crown(u) {
236                let Flow::Sink = poset.flow(c) else {
237                    continue;
238                };
239                let edge_id = poset[&c];
240                if comps.contains(&poset[edge_id]) {
241                    comps.remove(&poset[edge_id]);
242                } else {
243                    to_remove.add(c);
244                    to_remove.add(poset.inv(c));
245                }
246            }
247        }
248
249        poset.delete_hedges(&to_remove);
250        let root = poset
251            .iter_nodes()
252            .find(|(_, _, s)| s.subgraph.is_empty())
253            .map(|(n, _, _)| n);
254
255        Wood {
256            graph: poset,
257            root: root.expect("no empty spinney found"),
258            cuts,
259            vakint_settings,
260        }
261    }
262
263    fn unfold_with_cached_node_label_atoms(self, cache_node_label_atoms: bool) -> Forests {
264        let unfolded = self.trace_unfold::<NodeStorageVec<_>>(self.root);
265        let graph = unfolded.map(|_, _, key| OperationNode { key });
266
267        let mut cuts: Vec<(SuBitGraph, CutSet)> = Vec::new();
268        for c in &self.cuts.cuts {
269            let mut compatible: SuBitGraph = graph.empty_subgraph();
270            for (_, crown, s) in graph.iter_nodes() {
271                if s.is_compatible_with(c) {
272                    for h in crown {
273                        compatible.add(h);
274                    }
275                }
276            }
277            cuts.push((compatible, c.clone()));
278        }
279        let root = graph
280            .iter_nodes()
281            .find(|(_, _, operation)| operation.key.is_empty())
282            .map(|(node, _, _)| node)
283            .expect("no empty trace key found in unfolded hedge-poset forest");
284
285        let forests = Forests {
286            graph,
287            cuts,
288            root,
289            cached_node_label_atoms: cache_node_label_atoms.then(AHashMap::new),
290            compute_store: ComputeStore::default(),
291            wood: self,
292        };
293
294        if cache_node_label_atoms {
295            forests.with_cached_node_label_atoms()
296        } else {
297            forests
298        }
299    }
300
301    pub fn unfold(self) -> Forests {
302        self.unfold_with_cached_node_label_atoms(true)
303    }
304
305    pub fn unfold_uncached(self) -> Forests {
306        self.unfold_with_cached_node_label_atoms(false)
307    }
308}
309
310impl Display for Wood {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        self.graph.dot_impl_fmt(
313            f,
314            &self.graph.full_filter(),
315            "start=2;\n",
316            &|_| None,
317            &|a| Some(format!("label=\"{}\"", a.string_label())),
318            &|v| Some(format!("label=\"{}\"", v.subgraph.string_label())),
319        )
320    }
321}
322// pub type SpinneyGraph = HedgeGraph<SuBitGraph, Spinney>;
323
324// impl Key<&SuBitGraph> for SpinneyGraph {
325//     fn key(&self, e: linnet::half_edge::involution::EdgeIndex) -> K {
326//         &self[e]
327//     }
328// }
329
330#[derive(Default)]
331pub struct ComputeStore {
332    entries: AHashMap<OperationNode, ComputeNode>,
333}
334
335impl ComputeStore {
336    fn get(&self, key: &OperationNode) -> Option<&ComputeNode> {
337        self.entries.get(key)
338    }
339
340    fn require(&self, key: &OperationNode) -> Result<&ComputeNode> {
341        self.get(key)
342            .ok_or_else(|| eyre!("{key} not yet added to compute store"))
343    }
344
345    fn entry(
346        &mut self,
347        key: OperationNode,
348    ) -> std::collections::hash_map::Entry<'_, OperationNode, ComputeNode> {
349        self.entries.entry(key)
350    }
351}
352
353pub struct Forests {
354    pub graph: UnfoldedTraceGraph<Wood, OperationNode, NodeStorageVec<OperationNode>>,
355    pub root: NodeIndex,
356    /// Wood subgraph that has compatible
357    cuts: Vec<(SuBitGraph, CutSet)>,
358    cached_node_label_atoms: Option<AHashMap<NodeIndex, Atom>>,
359    pub compute_store: ComputeStore,
360    wood: Wood,
361}
362
363#[derive(Clone, Debug, Hash, PartialEq, Eq)]
364pub struct OperationNode {
365    pub key: TraceKey<SuBitGraph, EdgeIndex>,
366}
367
368pub struct ForestNode<'a> {
369    pub spinney: &'a Spinney,
370    pub topo_order: usize,
371}
372
373pub struct OwnedForestNode {
374    pub spinney: Spinney,
375    pub topo_order: usize,
376}
377
378#[cfg(test)]
379struct LocalLeafOperation {
380    op: HiddenData<SuBitGraph, EdgeIndex>,
381    frontier: NodeIndex,
382}
383
384struct UnionReplayState {
385    integrated: NodeIndex,
386    local_edges: Vec<EdgeIndex>,
387}
388
389#[cfg(test)]
390impl LocalLeafOperation {
391    fn new(op: &HiddenData<SuBitGraph, EdgeIndex>, frontier: NodeIndex) -> Self {
392        Self {
393            op: op.clone(),
394            frontier,
395        }
396    }
397}
398
399impl OperationNode {
400    pub fn is_compatible_with(&self, cut: &CutSet) -> bool {
401        self.covers().is_none_or(|c| !c.intersects(&cut.union))
402    }
403
404    pub fn current<'a>(&'a self, wood: &'a Wood, topo_order: usize) -> Option<Vec<ForestNode<'a>>> {
405        if self.key.is_empty() {
406            return None;
407        }
408
409        Some(
410            self.key
411                .iter_leaf_ops()
412                .map(|op| {
413                    let HedgePair::Paired { sink, .. } = wood.graph[&op.data].1 else {
414                        panic!("edge in trace key is not paired");
415                    };
416                    let spinney = &wood.graph[wood.graph.node_id(sink)];
417                    ForestNode {
418                        spinney,
419                        topo_order,
420                    }
421                })
422                .collect::<Vec<_>>(),
423        )
424    }
425}
426
427impl AsRef<TraceKey<SuBitGraph, EdgeIndex>> for OperationNode {
428    fn as_ref(&self) -> &TraceKey<SuBitGraph, EdgeIndex> {
429        &self.key
430    }
431}
432
433impl LogMessage for ForestNode<'_> {
434    fn log_display(&self) -> String {
435        format!(
436            "subgraph={}, topo_order={}, dod={}",
437            self.spinney.filter().string_label(),
438            self.topo_order,
439            self.spinney.dod
440        )
441    }
442}
443
444impl ForestNodeLike for ForestNode<'_> {
445    fn dod(&self) -> i32 {
446        self.spinney.dod
447    }
448    fn renormalization_scheme(&self) -> crate::uv::ApproximationType {
449        self.spinney.renormalization_scheme
450    }
451    fn lmb(&self) -> &LoopMomentumBasis {
452        &self.spinney.lmb
453    }
454    fn reduced_subgraph(&self, given: &Self) -> SuBitGraph {
455        self.spinney
456            .subgraph
457            .subtract(&given.spinney.subgraph)
458            .filter
459    }
460    fn subgraph(&self) -> &SuBitGraph {
461        self.spinney.filter()
462    }
463    fn topo_order(&self) -> usize {
464        self.topo_order
465    }
466}
467
468impl LogMessage for OwnedForestNode {
469    fn log_display(&self) -> String {
470        format!(
471            "subgraph={}, topo_order={}, dod={}",
472            self.spinney.filter().string_label(),
473            self.topo_order,
474            self.spinney.dod
475        )
476    }
477}
478
479impl ForestNodeLike for OwnedForestNode {
480    fn dod(&self) -> i32 {
481        self.spinney.dod
482    }
483
484    fn renormalization_scheme(&self) -> crate::uv::ApproximationType {
485        self.spinney.renormalization_scheme
486    }
487
488    fn lmb(&self) -> &LoopMomentumBasis {
489        &self.spinney.lmb
490    }
491
492    fn reduced_subgraph(&self, given: &Self) -> SuBitGraph {
493        self.spinney
494            .subgraph
495            .subtract(&given.spinney.subgraph)
496            .filter
497    }
498
499    fn subgraph(&self) -> &SuBitGraph {
500        self.spinney.filter()
501    }
502
503    fn topo_order(&self) -> usize {
504        self.topo_order
505    }
506}
507
508impl Display for OperationNode {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        if self.key.is_empty() {
511            write!(f, "∅")
512        } else {
513            self.key.write_foata_like(f, |op| op.string_label())
514        }
515    }
516}
517
518impl OperationNode {
519    fn foata_level_labels(&self) -> String {
520        self.key
521            .iter_levels_top_down()
522            .map(|level| {
523                level
524                    .iter_leaf_ops()
525                    .map(|op| op.order.string_label())
526                    .join(",")
527            })
528            .join(";")
529    }
530
531    pub fn covers(&self) -> Option<SuBitGraph> {
532        let mut acc: Option<SuBitGraph> = None;
533
534        for level in self.key.iter_levels_top_down() {
535            for op in level.iter_leaf_ops() {
536                if let Some(a) = &mut acc {
537                    a.union_with(&op.order);
538                } else {
539                    acc = Some(op.order.clone());
540                }
541            }
542        }
543
544        acc
545    }
546
547    fn forest_node(&self, graph: &Graph, topo_order: usize) -> OwnedForestNode {
548        let spinney = match self.covers() {
549            Some(cover) if !cover.is_empty() => {
550                let subgraph = InternalSubGraph::cleaned_filter_optimist(cover, graph.as_ref());
551                Spinney::new(subgraph, graph, &graph.loop_momentum_basis)
552                    .expect("operation cover should define a valid spinney")
553            }
554            _ => Spinney::empty(graph),
555        };
556
557        OwnedForestNode {
558            spinney,
559            topo_order,
560        }
561    }
562
563    pub fn to_atom(&self) -> Atom {
564        let mut acc = Atom::one();
565
566        let approx = FunctionBuilder::new(GS.uv_approx);
567        let mut levels = self.key.iter_levels_top_down();
568        let Some(first_level) = levels.next() else {
569            return acc;
570        };
571
572        let Some(first_op) = first_level.iter_leaf_ops().next() else {
573            return acc;
574        };
575
576        let mut last = SuBitGraph::empty(first_op.order.size());
577        for l in std::iter::once(first_level).chain(levels) {
578            let last_sym = if last.is_empty() {
579                Atom::Zero
580            } else {
581                last.symbol().to_atom()
582            };
583
584            let mut mul = Atom::one();
585
586            for op in l.iter_leaf_ops() {
587                let new = function!(op.order.symbol(), usize::from(op.data));
588                mul *= approx.clone().add_arg((new - &last_sym) * &acc).finish();
589                last.union_with(&op.order);
590            }
591
592            acc = mul
593        }
594
595        acc
596    }
597
598    // Four-dimensional and per-cut local terms are composed by `Forests` from typed
599    // dependency-frontier values. Empty frontiers start from the typed roots, and
600    // `Local3DApproximation::run` applies the local subtraction signs directly.
601}
602
603#[derive(Default)]
604pub struct ComputeNode {
605    local_4d: Option<Local4dCts>,
606    integrated: Option<IntegratedCts>,
607    cuts: AHashMap<CutSet, CutComputation>,
608}
609
610pub struct CutComputation {
611    local_3d: Local3DCts,
612    final_integrands: FinalIntegrands,
613}
614
615impl ComputeNode {
616    fn local_4d(&self, operation: &OperationNode) -> Result<&Local4dCts> {
617        self.local_4d
618            .as_ref()
619            .ok_or_else(|| eyre!("{operation} has no computed local 4D counterterm"))
620    }
621
622    fn integrated(&self, operation: &OperationNode) -> Result<&IntegratedCts> {
623        self.integrated
624            .as_ref()
625            .ok_or_else(|| eyre!("{operation} has no computed integrated counterterm"))
626    }
627
628    fn cut(&self, operation: &OperationNode, cutset: &CutSet) -> Result<&CutComputation> {
629        self.cuts.get(cutset).ok_or_else(|| {
630            eyre!("{operation} has no computed local counterterms for cut {cutset:?}")
631        })
632    }
633}
634
635impl Forests {
636    fn source_spinney(&self, node: NodeIndex) -> &Spinney {
637        &self.wood.graph[self.graph.source_node(node)]
638    }
639
640    fn recursion_input_4d(&self, node: NodeIndex) -> Result<Full4dCts> {
641        let operation = &self.graph[node];
642        let computed = self.compute_store.require(operation)?;
643        if self.graph.is_disjoint_union(node) {
644            return Ok(Full4dCts::from_factorized_local(
645                computed.local_4d(operation)?,
646            ));
647        }
648        Full4dCts::recursion_input(
649            computed.local_4d(operation)?,
650            computed.integrated(operation)?,
651            self.source_spinney(node).renormalization_scheme,
652            node == self.root,
653        )
654    }
655
656    fn cached_node_label_atom(&self, node: NodeIndex) -> Option<Atom> {
657        self.cached_node_label_atoms
658            .as_ref()
659            .and_then(|labels| labels.get(&node))
660            .cloned()
661    }
662
663    fn node_label_atom_factor(
664        &self,
665        frontier: NodeIndex,
666        op: &HiddenData<SuBitGraph, EdgeIndex>,
667    ) -> Atom {
668        let approx = FunctionBuilder::new(GS.uv_approx);
669        let frontier_atom = self.node_label_atom(frontier);
670
671        let current = function!(op.order.symbol(), usize::from(op.data));
672        let argument = if self.graph[frontier].covers().is_none() {
673            current
674        } else {
675            let previous = self.graph[frontier]
676                .covers()
677                .expect("non-empty frontier cover must exist")
678                .symbol();
679            (current - previous) * frontier_atom
680        };
681        approx.add_arg(argument).finish()
682    }
683
684    fn node_label_atom(&self, node: NodeIndex) -> Atom {
685        if self.graph[node].key.is_empty() {
686            return Atom::one();
687        }
688
689        if let Some(cached) = self.cached_node_label_atom(node) {
690            return cached;
691        }
692
693        self.graph
694            .leaf_op_dependency_frontiers(node, &self.wood)
695            .fold(Atom::one(), |acc, (op, frontier)| {
696                acc * self.node_label_atom_factor(frontier, op)
697            })
698    }
699
700    fn with_cached_node_label_atoms(mut self) -> Self {
701        self.cache_node_label_atoms();
702        self
703    }
704
705    fn cache_node_label_atoms(&mut self) {
706        self.cached_node_label_atoms = Some(AHashMap::new());
707        for nidx in self.graph.topo_sort_kahn().unwrap() {
708            let atom = self.node_label_atom(nidx);
709            self.cached_node_label_atoms
710                .as_mut()
711                .expect("node-label cache was initialized")
712                .insert(nidx, atom);
713        }
714    }
715
716    fn node_label(&self, node: NodeIndex) -> String {
717        let key = &self.graph[node];
718        if key.key.is_empty() {
719            return "∅".to_string();
720        }
721
722        let mut foata = String::new();
723        key.key
724            .write_foata_like(&mut foata, |op| op.string_label())
725            .expect("writing a trace key into a string must succeed");
726        let atom = self.node_label_atom(node);
727        format!("{foata}: {}", atom.to_ordered_simple())
728    }
729
730    fn dot_serialize_expr_atom() -> Atom {
731        Atom::var(GS.expr)
732    }
733
734    fn dot_serialize_node_atom_factor(
735        &self,
736        frontier: NodeIndex,
737        op: &HiddenData<SuBitGraph, EdgeIndex>,
738    ) -> Atom {
739        let frontier_depth = self.graph[frontier].key.op_count();
740        let (current, given) = self.wood.current_given_pair(op.data, frontier_depth);
741        // Structured forest-DOT approximation records use the same operation and subgraph
742        // markers as the computed UV expressions.
743        function!(
744            GS.uv_approx,
745            UvMarker::subgraph(current.subgraph(), given.subgraph())
746                * self.dot_serialize_node_atom(frontier)
747        )
748    }
749
750    fn dot_serialize_node_atom(&self, node: NodeIndex) -> Atom {
751        if self.graph[node].key.is_empty() {
752            return Self::dot_serialize_expr_atom();
753        }
754
755        self.graph
756            .leaf_op_dependency_frontiers(node, &self.wood)
757            .fold(Atom::one(), |acc, (op, frontier)| {
758                acc * self.dot_serialize_node_atom_factor(frontier, op)
759            })
760    }
761
762    fn dot_serialize_node_attrs(&self, node: NodeIndex) -> String {
763        let key = &self.graph[node];
764        let label = self
765            .dot_serialize_node_atom(node)
766            .printer(SpensoPrintSettings::typst_options())
767            .to_string();
768        let cover = key
769            .covers()
770            .unwrap_or_else(|| self.graph.empty_subgraph())
771            .string_label();
772
773        format!(
774            "label={} foata={} cover={}",
775            dot_attr_value(&label),
776            dot_attr_value(&key.foata_level_labels()),
777            dot_attr_value(&cover),
778        )
779    }
780
781    pub fn dot_serialize(&self) -> String {
782        let mut output = String::new();
783        self.dot_serialize_fmt(&mut output)
784            .expect("writing hedge-poset forest DOT into a string must succeed");
785        output
786    }
787
788    pub fn dot_serialize_fmt(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
789        let attrs: AHashMap<_, _> = self
790            .graph
791            .iter_nodes()
792            .map(|(node, _, key)| (key.clone(), self.dot_serialize_node_attrs(node)))
793            .collect();
794
795        self.graph.dot_impl_fmt(
796            writer,
797            &self.graph.full_filter(),
798            "start=2;\n",
799            &|_| None,
800            &|_| None,
801            &|v| Some(attrs[v].clone()),
802        )
803    }
804
805    fn compatible_topological_order(&self, subset: &SuBitGraph) -> Result<Vec<NodeIndex>> {
806        let mut order = self.graph.topo_sort_kahn_of(subset)?;
807
808        if let Some(root_position) = order.iter().position(|node| *node == self.root) {
809            if root_position != 0 {
810                let root = order.remove(root_position);
811                order.insert(0, root);
812            }
813        } else {
814            order.insert(0, self.root);
815        }
816
817        Ok(order)
818    }
819
820    #[cfg(test)]
821    fn local_leaf_operations(&self, node: NodeIndex) -> Vec<LocalLeafOperation> {
822        let mut leaves = self
823            .graph
824            .leaf_op_dependency_frontiers(node, &self.wood)
825            .map(|(op, frontier)| LocalLeafOperation::new(op, frontier))
826            .collect::<Vec<_>>();
827
828        leaves.sort_by_key(|leaf| {
829            (
830                Reverse(self.graph[leaf.frontier].key.op_count()),
831                leaf.op.order.clone(),
832                usize::from(leaf.op.data),
833            )
834        });
835        leaves
836    }
837
838    fn disconnected_component_nodes(&self, node: NodeIndex) -> Result<Vec<NodeIndex>> {
839        let operation = &self.graph[node];
840        let factors = self
841            .wood
842            .join_factors(self.graph.source_node(node))
843            .ok_or_else(|| eyre!("{operation} has no disconnected component factors"))?;
844        let target_ops = operation
845            .key
846            .iter_levels_top_down()
847            .flat_map(|level| level.iter_leaf_ops())
848            .cloned()
849            .collect::<Vec<_>>();
850
851        for op in &target_ops {
852            let owners = factors
853                .iter()
854                .filter(|factor| factor.includes(&op.order))
855                .count();
856            if owners != 1 {
857                return Err(eyre!(
858                    "operation {} in {operation} belongs to {owners} disconnected factors",
859                    op.order.string_label()
860                ));
861            }
862        }
863
864        factors
865            .into_iter()
866            .map(|factor| {
867                let key = target_ops
868                    .iter()
869                    .filter(|op| factor.includes(&op.order))
870                    .fold(TraceKey::empty(), |key, op| {
871                        key.push(&self.wood, op.clone())
872                    });
873                self.graph
874                    .iter_nodes()
875                    .find_map(|(component, _, candidate)| {
876                        (candidate.key == key && self.source_spinney(component).filter() == &factor)
877                            .then_some(component)
878                    })
879                    .ok_or_else(|| {
880                        eyre!(
881                            "component {} of {operation} is not in the unfolded forest",
882                            factor.string_label()
883                        )
884                    })
885            })
886            .collect()
887    }
888
889    fn operation_node_index(&self, operation: &OperationNode) -> Result<NodeIndex> {
890        let cover = operation.covers();
891        self.graph
892            .iter_nodes()
893            .find_map(|(node, _, candidate)| {
894                let source_matches = cover.as_ref().map_or_else(
895                    || self.source_spinney(node).filter().is_empty(),
896                    |cover| self.source_spinney(node).filter() == cover,
897                );
898                (candidate == operation && source_matches).then_some(node)
899            })
900            .ok_or_else(|| eyre!("{operation} is not in the unfolded forest"))
901    }
902
903    fn union_replay_states(&self, node: NodeIndex) -> Result<Vec<UnionReplayState>> {
904        let operation = &self.graph[node];
905        if operation.key.is_empty() {
906            return Ok(vec![UnionReplayState {
907                integrated: node,
908                local_edges: Vec::new(),
909            }]);
910        }
911
912        if !self.graph.is_disjoint_union(node) {
913            let (parent, edge) = self
914                .graph
915                .unique_parent(node)
916                .ok_or_else(|| eyre!("{operation} has no unique replay parent"))?;
917            let mut states = self.union_replay_states(parent)?;
918            for state in &mut states {
919                state.local_edges.push(edge);
920            }
921            states.push(UnionReplayState {
922                integrated: node,
923                local_edges: Vec::new(),
924            });
925            return Ok(states);
926        }
927
928        let component_states = self
929            .disconnected_component_nodes(node)?
930            .into_iter()
931            .map(|component| self.union_replay_states(component))
932            .collect::<Result<Vec<_>>>()?;
933        let mut states = Vec::new();
934        for components in component_states
935            .iter()
936            .map(|states| states.iter())
937            .multi_cartesian_product()
938        {
939            let key = TraceKey::try_foata_join(
940                components
941                    .iter()
942                    .map(|component| &self.graph[component.integrated].key),
943                &self.wood,
944            )
945            .ok_or_else(|| eyre!("cannot join integrated prefixes for {operation}"))?;
946            let mut local_edges = Vec::new();
947            for component in components {
948                local_edges.extend_from_slice(&component.local_edges);
949            }
950            let integrated = self.operation_node_index(&OperationNode { key })?;
951            states.push(UnionReplayState {
952                integrated,
953                local_edges,
954            });
955        }
956
957        Ok(states)
958    }
959
960    fn compute_4d_for_node(
961        &self,
962        node: NodeIndex,
963        graph: &Graph,
964        vakint: &Vakint,
965        settings: &UVgenerationSettings,
966    ) -> Result<(Local4dCts, IntegratedCts)> {
967        let operation = &self.graph[node];
968        if operation.key.is_empty() {
969            return Ok((Local4dCts::root(), IntegratedCts::root()));
970        }
971
972        if self.graph.is_disjoint_union(node) {
973            let components = self.disconnected_component_nodes(node)?;
974            let mut full_components = Vec::with_capacity(components.len());
975            let mut integrated_components = Vec::with_capacity(components.len());
976            for component in components {
977                let component_operation = &self.graph[component];
978                full_components.push(self.recursion_input_4d(component).wrap_err_with(|| {
979                    format!("while loading 4D component {component_operation} for {operation}")
980                })?);
981                integrated_components.push(
982                    self.compute_store
983                        .require(component_operation)?
984                        .integrated(component_operation)?,
985                );
986            }
987
988            let depth = graph.n_loops(
989                &operation
990                    .covers()
991                    .expect("a non-root operation has a cover"),
992            ) + 1;
993            return Ok((
994                Local4dCts::from_full_product(full_components),
995                IntegratedCts::factorized_product(integrated_components, depth)?,
996            ));
997        }
998
999        let (parent, edge) = self
1000            .graph
1001            .unique_parent(node)
1002            .ok_or_else(|| eyre!("{operation} has no unique 4D parent"))?;
1003        let full = self
1004            .recursion_input_4d(parent)
1005            .wrap_err_with(|| format!("while loading the 4D parent for {operation}"))?;
1006        let step_order = self.graph[parent].key.op_count();
1007        let (current, given) = self.wood.current_given_pair(edge, step_order);
1008        let ctx = UVCtx::new(graph, settings);
1009        let integrated_approximation = Integrated::new(vakint, &self.wood.vakint_settings);
1010        let local = local_4d::uv_limit(&full, &ctx, &current, &given, &current, &given)?;
1011        let integrated = if settings.generate_integrated {
1012            integrated_approximation.run(&local, &ctx, &current, &given, &current, &given)?
1013        } else {
1014            IntegratedCts::root()
1015        };
1016
1017        Ok((local, integrated))
1018    }
1019
1020    fn local_3d_for_node(
1021        &self,
1022        node: NodeIndex,
1023        graph: &mut Graph,
1024        cutset: &CutSet,
1025        localizer: Localizer<'_>,
1026        settings: &UVgenerationSettings,
1027    ) -> Result<CutComputation> {
1028        let operation = &self.graph[node];
1029        let local_3d = if operation.key.is_empty() {
1030            Local3DCts::root(graph, localizer)?
1031        } else if self.graph.is_disjoint_union(node) {
1032            let mut active_sectors = Vec::new();
1033            for state in self
1034                .union_replay_states(node)?
1035                .into_iter()
1036                .filter(|state| !state.local_edges.is_empty())
1037            {
1038                let integrated_operation = &self.graph[state.integrated];
1039                let mut edges = state.local_edges.into_iter().enumerate();
1040                let (offset, first_edge) = edges
1041                    .next()
1042                    .expect("a proper integrated prefix has a local suffix");
1043                let step_order = integrated_operation.key.op_count() + offset;
1044                let (current, given) = self.wood.current_given_pair(first_edge, step_order);
1045
1046                // An empty integrated prefix starts from the per-cut root integrand. Every
1047                // other prefix enters through the reduced branch of its first local operation.
1048                let mut sector = if integrated_operation.key.is_empty() {
1049                    let root = Local3DCts::root(graph, localizer)?;
1050                    Local3DApproximation::new(localizer, graph, settings)
1051                        .run_local(&root, &current, &given, &current, &given)?
1052                } else {
1053                    let integrated = self
1054                        .compute_store
1055                        .require(integrated_operation)?
1056                        .integrated(integrated_operation)?;
1057                    let prefix_node = ForestNode {
1058                        spinney: self.source_spinney(state.integrated),
1059                        topo_order: integrated_operation.key.op_count(),
1060                    };
1061                    Local3DApproximation::new(localizer, graph, settings).run_integrated(
1062                        integrated,
1063                        &prefix_node,
1064                        &current,
1065                        &given,
1066                        &current,
1067                        &given,
1068                    )?
1069                };
1070
1071                for (offset, edge) in edges {
1072                    let step_order = integrated_operation.key.op_count() + offset;
1073                    let (current, given) = self.wood.current_given_pair(edge, step_order);
1074                    sector = Local3DApproximation::new(localizer, graph, settings)
1075                        .run_local(&sector, &current, &given, &current, &given)?;
1076                }
1077
1078                // Keep each active/frozen split after its root-path replay so any connected
1079                // descendants rescale only the loop variables still active in that sector.
1080                active_sectors.extend(
1081                    sector
1082                        .active_sectors()
1083                        .expect("a replayed union sector retains its active subgraph")
1084                        .iter()
1085                        .cloned(),
1086                );
1087            }
1088
1089            Local3DCts::from_active_sectors(active_sectors)
1090                .wrap_err_with(|| format!("{operation} has no proper integrated prefixes"))?
1091        } else {
1092            let (parent, edge) = self
1093                .graph
1094                .unique_parent(node)
1095                .ok_or_else(|| eyre!("{operation} has no unique local parent"))?;
1096            let parent_operation = &self.graph[parent];
1097            // An empty dependency frontier starts from the per-cut root integrand;
1098            // otherwise its typed local result remains the sequential accumulator.
1099            let parent_local = if parent_operation.key.is_empty() {
1100                Local3DCts::root(graph, localizer)?
1101            } else {
1102                self.compute_store
1103                    .require(parent_operation)?
1104                    .cut(parent_operation, cutset)?
1105                    .local_3d
1106                    .clone()
1107            };
1108            let parent_integrated = self
1109                .compute_store
1110                .require(parent_operation)?
1111                .integrated(parent_operation)
1112                .wrap_err_with(|| {
1113                    format!("while loading integrated parent {parent_operation} for {operation}")
1114                })?;
1115            let step_order = parent_operation.key.op_count();
1116            let (current, given) = self.wood.current_given_pair(edge, step_order);
1117            // `run` applies both subtraction signs; no external sign or raw
1118            // Foata-level product is introduced for an ordinary single-parent node.
1119            Local3DApproximation::new(localizer, graph, settings).run(
1120                &parent_local,
1121                parent_integrated,
1122                &current,
1123                &given,
1124                &current,
1125                &given,
1126            )?
1127        };
1128
1129        let integrated = self
1130            .compute_store
1131            .require(operation)?
1132            .integrated(operation)?;
1133        let forest_node = ForestNode {
1134            spinney: self.source_spinney(node),
1135            topo_order: operation.key.op_count(),
1136        };
1137        let final_integrands = FinalIntegrandBuilder::new(localizer, settings).build_3d(
1138            graph,
1139            &forest_node,
1140            &local_3d,
1141            integrated,
1142        )?;
1143
1144        Ok(CutComputation {
1145            local_3d,
1146            final_integrands,
1147        })
1148    }
1149
1150    pub fn integrate(
1151        &mut self,
1152        graph: &Graph,
1153        vakint: &Vakint,
1154        settings: &UVgenerationSettings,
1155    ) -> Result<()> {
1156        for (order, nidx) in self.graph.topo_sort_kahn()?.into_iter().enumerate() {
1157            debug!(order, nidx=%nidx, key=%self.graph[nidx], "Computing hedge-poset 4D term");
1158            let operation = self.graph[nidx].clone();
1159            let (local_4d, integrated) = self.compute_4d_for_node(nidx, graph, vakint, settings)?;
1160            let cover = operation
1161                .covers()
1162                .unwrap_or_else(|| self.graph.empty_subgraph())
1163                .string_label();
1164            let source = self.source_spinney(nidx).filter().string_label();
1165            debug_tags!(#generation, #uv, #integrated, #graph, #term, #inspect;
1166                stage = "hedge_poset_4d_node_done",
1167                order,
1168                node_index = %nidx,
1169                forest_term = %operation,
1170                cover = %cover,
1171                source = %source,
1172                is_union = self.graph.is_disjoint_union(nidx),
1173                log.local_4d = local_4d.atom(),
1174                log.integrated_pole = integrated.physical_pole_atom(),
1175                log.integrated_finite = integrated.physical_finite_counterterm_atom(),
1176                "Computed hedge-poset 4D node"
1177            );
1178            let computed = self.compute_store.entry(operation).or_default();
1179            computed.local_4d = Some(local_4d);
1180            computed.integrated = Some(integrated);
1181        }
1182
1183        Ok(())
1184    }
1185
1186    pub(crate) fn compute(
1187        &mut self,
1188        graph: &mut Graph,
1189        vakint: &Vakint,
1190        orientation: OrientationProjection<'_>,
1191        settings: &UVgenerationSettings,
1192    ) -> Result<()> {
1193        self.integrate(graph, vakint, settings)?;
1194
1195        for (compatible_subset, cutset) in self.cuts.clone() {
1196            let localizer = Localizer::new(&cutset, orientation);
1197            for (order, nidx) in self
1198                .compatible_topological_order(&compatible_subset)?
1199                .into_iter()
1200                .enumerate()
1201            {
1202                debug!(order, nidx=%nidx, key=%self.graph[nidx], "Computing hedge-poset per-cut term");
1203                let operation = self.graph[nidx].clone();
1204                let cut_computation =
1205                    self.local_3d_for_node(nidx, graph, &cutset, localizer, settings)?;
1206                self.compute_store
1207                    .entry(operation)
1208                    .or_default()
1209                    .cuts
1210                    .insert(cutset.clone(), cut_computation);
1211            }
1212        }
1213
1214        Ok(())
1215    }
1216
1217    pub(crate) fn orientation_parametric_exprs(
1218        &self,
1219        graph: &Graph,
1220        _settings: &UVgenerationSettings,
1221    ) -> Result<Vec<ParametricIntegrands>> {
1222        let split_momentum_replacements = graph
1223            .iter_edges_of(
1224                &graph
1225                    .full_filter()
1226                    .subtract(&graph.initial_state_cut)
1227                    .subtract(&graph.tree_edges),
1228            )
1229            .filter_map(|(pair, edge_index, _)| {
1230                (!matches!(pair, HedgePair::Unpaired { .. }))
1231                    .then(|| GS.split_mom_pattern_simple(edge_index))
1232            })
1233            .collect::<Vec<_>>();
1234        let mut expressions = Vec::with_capacity(self.cuts.len());
1235
1236        for (compatible_subset, cutset) in &self.cuts {
1237            let mut sum: Option<Integrands> = None;
1238            for nidx in self.compatible_topological_order(compatible_subset)? {
1239                let operation = &self.graph[nidx];
1240                let terms: Integrands = self
1241                    .compute_store
1242                    .require(operation)?
1243                    .cut(operation, cutset)?
1244                    .final_integrands
1245                    .iter()
1246                    .map(|(index, integrand)| (*index, integrand.clone().collect_color()))
1247                    .collect();
1248                sum = Some(match sum {
1249                    Some(sum) => sum.zip_add(&terms).wrap_err_with(|| {
1250                        format!("while aggregating hedge-poset term {operation} for cut {cutset:?}")
1251                    })?,
1252                    None => terms,
1253                });
1254            }
1255
1256            let integrands = sum
1257                .ok_or_else(|| eyre!("No terms in hedge-poset forest for cut {cutset:?}"))?
1258                .map(|integrand| {
1259                    integrand
1260                        .replace_multiple(&split_momentum_replacements)
1261                        .replace(function!(GS.den, W_.a_, W_.b_, W_.c_, W_.d_))
1262                        .with(W_.d_)
1263                });
1264            expressions.push(ParametricIntegrands {
1265                integrands,
1266                cuts: cutset.clone(),
1267            });
1268        }
1269
1270        Ok(expressions)
1271    }
1272
1273    pub(crate) fn export_node_expressions(
1274        &self,
1275        forest_index: usize,
1276        post_process: &mut impl FnMut(Atom) -> Atom,
1277    ) -> Result<Vec<UVForestNodeExpression>> {
1278        let (cut_compatible_forest_subset, cutset) = self
1279            .cuts
1280            .first()
1281            .ok_or_else(|| eyre!("No cuts in hedge-poset forest export"))?;
1282        let mut terms = Vec::new();
1283        for (node_index, nidx) in self
1284            .compatible_topological_order(cut_compatible_forest_subset)?
1285            .into_iter()
1286            .enumerate()
1287        {
1288            let operation = &self.graph[nidx];
1289            let computed = self.compute_store.require(operation)?;
1290            let final_integrands = &computed.cut(operation, cutset)?.final_integrands;
1291            let node_key = operation.to_string();
1292            for (term_index, (&residue_index, numerator)) in final_integrands.iter().enumerate() {
1293                terms.push(UVForestNodeExpression {
1294                    forest_index,
1295                    node_index,
1296                    node_key: node_key.clone(),
1297                    term_index,
1298                    residue_index,
1299                    numerator: post_process(numerator.clone()),
1300                });
1301            }
1302        }
1303
1304        Ok(terms)
1305    }
1306
1307    pub(crate) fn renormalization_part_of_ends(
1308        &self,
1309        graph: &Graph,
1310        settings: &UVgenerationSettings,
1311    ) -> Result<RenormalizationPart> {
1312        let mut sum = Atom::Zero;
1313        let marker = UvMarker::new(settings);
1314
1315        let wild = Atom::var(W_.x___);
1316
1317        let replacements =
1318            graph.integrand_replacement(&graph.full_filter(), &graph.loop_momentum_basis, &[wild]);
1319        for (node, mut crown, key) in self.graph.iter_nodes() {
1320            if crown.any(|r| self.graph.flow(r).is_source()) {
1321                continue;
1322            }
1323
1324            let forest_node = key.forest_node(graph, key.key.op_count());
1325            // A disconnected terminal can combine components with different schemes. Select
1326            // each component's projection before multiplying; the aggregate integrated value
1327            // only represents the homogeneous all-finite and all-pole projections.
1328            let components = if self.graph.is_disjoint_union(node) {
1329                self.disconnected_component_nodes(node)?
1330            } else {
1331                vec![node]
1332            };
1333            let physical = components.into_iter().try_fold(
1334                Atom::one(),
1335                |product, component| -> Result<Atom> {
1336                    let component_key = &self.graph[component];
1337                    let integrated = self
1338                        .compute_store
1339                        .require(component_key)?
1340                        .integrated(component_key)?;
1341                    let projection = match self.source_spinney(component).renormalization_scheme {
1342                        ApproximationType::MUV => integrated.physical_finite_counterterm_atom(),
1343                        ApproximationType::PolePart => integrated.physical_pole_atom(),
1344                        scheme => {
1345                            return Err(eyre!("No terminal counterterm projection for {scheme}"));
1346                        }
1347                    };
1348                    Ok(product * projection)
1349                },
1350            )?;
1351            let atom = marker.prefix(&graph.full_filter(), forest_node.subgraph(), &physical);
1352            debug!(
1353                key=%key,
1354               expr = % atom.expand_num().log_print(None),"Term before simplification"
1355            );
1356            let atom = (&atom
1357                * &graph.global_prefactor.projector
1358                * &graph.global_prefactor.num
1359                * &graph.overall_factor)
1360                .simplify_color()
1361                .expand_num()
1362                .to_dots();
1363
1364            debug!(
1365                key=%key,
1366               expr = % atom.log_print(None),"Term"
1367            );
1368            sum += atom;
1369        }
1370
1371        Ok(RenormalizationPart::new(
1372            sum.replace_multiple(&replacements)
1373                .replace(GS.m_uv_expansion)
1374                .with(GS.m_uv_vacuum),
1375            self.graph.n_nodes(),
1376        ))
1377    }
1378
1379    pub fn debug_walk(&self) {
1380        let mut cover_groups: BTreeMap<SuBitGraph, Vec<NodeIndex>> = BTreeMap::new();
1381
1382        self.graph
1383            .topo_sort_kahn()
1384            .unwrap()
1385            .iter()
1386            .for_each(|nidx| {
1387                let trace_key = &self.graph[*nidx];
1388                cover_groups
1389                    .entry(trace_key.covers().unwrap_or(self.graph.empty_subgraph()))
1390                    .and_modify(|e| e.push(*nidx))
1391                    .or_insert_with(|| vec![*nidx]);
1392
1393                println!("Node {}:{}", nidx, self.node_label(*nidx));
1394            });
1395
1396        println!("edge [constraint=true style=invis];");
1397        for (a, b) in cover_groups.values().tuple_windows() {
1398            println!("{}->{}", a.first().unwrap(), b.first().unwrap())
1399        }
1400        println!("edge [style=solid];");
1401
1402        for (s, g) in cover_groups.iter() {
1403            println!("subgraph group_{} {{rank=same; ", s.string_label());
1404            for n in g {
1405                println!("{};", n.0);
1406            }
1407            println!("}}");
1408        }
1409    }
1410}
1411
1412impl Display for Forests {
1413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1414        let labels: AHashMap<_, _> = self
1415            .graph
1416            .iter_nodes()
1417            .map(|(node, _, key)| (key.clone(), self.node_label(node)))
1418            .collect();
1419        self.graph.dot_impl_fmt(
1420            f,
1421            &self.graph.full_filter(),
1422            "start=2;\n",
1423            &|_| None,
1424            &|_| None,
1425            &|v| Some(format!("label=\"{}\"", labels[v])),
1426        )
1427    }
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432    use crate::{
1433        dot,
1434        graph::{Graph, parse::IntoGraph},
1435        initialisation::test_initialise,
1436        processes::DotExportSettings,
1437        uv::{UltravioletGraph, Wood as OldWood, settings::RenormalizationPrescriptionSettings},
1438    };
1439
1440    use super::*;
1441    use color_eyre::Result;
1442
1443    impl Forests {
1444        fn normalized_node_label(&self, node: NodeIndex) -> String {
1445            let label = self.node_label(node);
1446            let mut normalized = String::with_capacity(label.len());
1447            let mut chars = label.chars().peekable();
1448
1449            while let Some(ch) = chars.next() {
1450                normalized.push(ch);
1451                if ch != '(' {
1452                    continue;
1453                }
1454
1455                let mut digits = String::new();
1456                while chars.peek().is_some_and(|next| next.is_ascii_digit()) {
1457                    digits.push(chars.next().expect("peeked digit must exist"));
1458                }
1459
1460                if digits.is_empty() || !matches!(chars.peek(), Some(')')) {
1461                    normalized.push_str(&digits);
1462                } else {
1463                    normalized.push('_');
1464                }
1465            }
1466
1467            normalized
1468        }
1469
1470        fn normalized_node_labels_with_cover(&self, cover_label: &str) -> Vec<String> {
1471            let mut labels = self
1472                .graph
1473                .iter_nodes()
1474                .filter(|(_, _, operation)| {
1475                    operation
1476                        .covers()
1477                        .is_some_and(|cover| cover.string_label() == cover_label)
1478                })
1479                .map(|(node, _, _)| self.normalized_node_label(node))
1480                .collect::<Vec<_>>();
1481            labels.sort();
1482            labels
1483        }
1484    }
1485
1486    #[test]
1487    fn local_leaf_operations_follow_dependency_frontiers() -> Result<()> {
1488        test_initialise().unwrap();
1489        let dumbell: Graph = dot!(
1490            digraph G{
1491                edge [particle="scalar_1"];
1492                v1 -> v2;
1493                v2 -> v2;
1494                v1 -> v1;v1 -> v1;
1495            },"scalars"
1496        )?;
1497
1498        let forests = Wood::new(
1499            CutStructure::empty(&dumbell),
1500            &dumbell,
1501            &UVgenerationSettings::default(),
1502        )
1503        .unfold();
1504
1505        let frontier_label = |operation: &OperationNode| {
1506            operation
1507                .covers()
1508                .map_or_else(|| "∅".to_string(), |cover| cover.string_label())
1509        };
1510        let leaf_labels = |node| {
1511            forests
1512                .local_leaf_operations(node)
1513                .iter()
1514                .map(|leaf| {
1515                    (
1516                        leaf.op.order.string_label(),
1517                        frontier_label(&forests.graph[leaf.frontier]),
1518                    )
1519                })
1520                .collect::<Vec<_>>()
1521        };
1522
1523        let root_disconnected = forests
1524            .graph
1525            .iter_nodes()
1526            .find_map(|(node, _, _)| {
1527                forests
1528                    .normalized_node_label(node)
1529                    .starts_with("{36,F}:")
1530                    .then_some(node)
1531            })
1532            .expect("lopsided dumbbell should contain a root disconnected frontier");
1533        assert_eq!(
1534            leaf_labels(root_disconnected),
1535            vec![
1536                ("36".to_string(), "∅".to_string()),
1537                ("F".to_string(), "∅".to_string())
1538            ]
1539        );
1540        let structured_dot = forests.dot_serialize();
1541        let mut join_markers = Vec::new();
1542        for (node, _, _) in forests.graph.iter_nodes() {
1543            for leaf in forests.local_leaf_operations(node) {
1544                let frontier_depth = forests.graph[leaf.frontier].key.op_count();
1545                let (current, given) = forests
1546                    .wood
1547                    .current_given_pair(leaf.op.data, frontier_depth);
1548                if current.subgraph() == &leaf.op.order {
1549                    continue;
1550                }
1551                join_markers.push(
1552                    UvMarker::subgraph(current.subgraph(), given.subgraph())
1553                        .printer(SpensoPrintSettings::typst_options())
1554                        .to_string(),
1555                );
1556            }
1557        }
1558        assert!(
1559            !join_markers.is_empty(),
1560            "lopsided dumbbell must contain a disconnected join"
1561        );
1562        for marker in join_markers {
1563            assert!(
1564                structured_dot.contains(&marker),
1565                "structured DOT must use the runtime current/given marker {marker}"
1566            );
1567        }
1568
1569        let dependent_disconnected = forests
1570            .graph
1571            .iter_nodes()
1572            .find_map(|(node, _, _)| {
1573                forests
1574                    .normalized_node_label(node)
1575                    .starts_with("{C} · {36,F}:")
1576                    .then_some(node)
1577            })
1578            .expect("lopsided dumbbell should contain a C-dependent disconnected frontier");
1579        assert_eq!(
1580            leaf_labels(dependent_disconnected),
1581            vec![
1582                ("F".to_string(), "C".to_string()),
1583                ("36".to_string(), "∅".to_string())
1584            ]
1585        );
1586        let replay_states = forests.union_replay_states(dependent_disconnected)?;
1587        assert_eq!(replay_states.len(), 6);
1588        assert_eq!(
1589            replay_states
1590                .iter()
1591                .filter(|state| !state.local_edges.is_empty())
1592                .count(),
1593            5
1594        );
1595        Ok(())
1596    }
1597
1598    #[test]
1599    fn union_terms_replay_component_paths_from_typed_roots() -> Result<()> {
1600        test_initialise().unwrap();
1601        let mut graph: Graph = dot!(
1602            digraph G{
1603                edge [particle="scalar_1"];
1604                v1 -> v2;
1605                v2 -> v2;
1606                v1 -> v1;v1 -> v1;
1607            },"scalars"
1608        )?;
1609        let settings = UVgenerationSettings::default();
1610        let cut_structure = CutStructure::empty(&graph);
1611        let cutset = cut_structure
1612            .cuts
1613            .first()
1614            .expect("empty cut structure has one cut")
1615            .clone();
1616        let mut forests = Wood::new(cut_structure, &graph, &settings).unfold();
1617
1618        let root_disconnected = forests
1619            .graph
1620            .iter_nodes()
1621            .find_map(|(node, _, _)| {
1622                forests
1623                    .normalized_node_label(node)
1624                    .starts_with("{36,F}:")
1625                    .then_some(node)
1626            })
1627            .expect("lopsided dumbbell should contain a root disconnected frontier");
1628        let dependent_disconnected = forests
1629            .graph
1630            .iter_nodes()
1631            .find_map(|(node, _, _)| {
1632                forests
1633                    .normalized_node_label(node)
1634                    .starts_with("{C} · {36,F}:")
1635                    .then_some(node)
1636            })
1637            .expect("lopsided dumbbell should contain a C-dependent disconnected frontier");
1638        let non_root_frontier = forests
1639            .local_leaf_operations(dependent_disconnected)
1640            .first()
1641            .expect("dependent disconnected operation has a leaf")
1642            .frontier;
1643        assert_eq!(
1644            forests.graph[non_root_frontier]
1645                .covers()
1646                .expect("non-root frontier has a cover")
1647                .string_label(),
1648            "C"
1649        );
1650
1651        let operations = forests
1652            .graph
1653            .iter_nodes()
1654            .map(|(_, _, operation)| operation.clone())
1655            .collect::<Vec<_>>();
1656        // Zero integrated terms isolate component-path replay in the local accumulator.
1657        for operation in operations {
1658            forests
1659                .compute_store
1660                .entry(operation)
1661                .or_default()
1662                .integrated = Some(IntegratedCts::root());
1663        }
1664
1665        let orientation_pattern = crate::settings::global::OrientationPattern::default();
1666        let localizer = Localizer::new(
1667            &cutset,
1668            OrientationProjection::new(&[], &orientation_pattern),
1669        );
1670        let seed =
1671            forests.local_3d_for_node(forests.root, &mut graph, &cutset, localizer, &settings)?;
1672        let root_store_marker = symbolica::symbol!("root_store_marker");
1673        let frontier_store_marker = symbolica::symbol!("frontier_store_marker");
1674        // Mark cached local terms so the union result proves that replay starts from typed roots.
1675        let marked_computation = |marker| -> Result<CutComputation> {
1676            Ok(CutComputation {
1677                local_3d: seed.local_3d.map(|_| Ok(Atom::var(marker)))?,
1678                final_integrands: seed.final_integrands.clone(),
1679            })
1680        };
1681
1682        let root_operation = forests.graph[forests.root].clone();
1683        forests
1684            .compute_store
1685            .entry(root_operation)
1686            .or_default()
1687            .cuts
1688            .insert(cutset.clone(), marked_computation(root_store_marker)?);
1689        forests
1690            .compute_store
1691            .entry(forests.graph[non_root_frontier].clone())
1692            .or_default()
1693            .cuts
1694            .insert(cutset.clone(), marked_computation(frontier_store_marker)?);
1695
1696        let root_result = forests.local_3d_for_node(
1697            root_disconnected,
1698            &mut graph,
1699            &cutset,
1700            localizer,
1701            &settings,
1702        )?;
1703        assert_eq!(
1704            root_result
1705                .local_3d
1706                .active_sectors()
1707                .expect("a root union keeps its active sectors")
1708                .len(),
1709            3
1710        );
1711        assert!(
1712            root_result
1713                .local_3d
1714                .integrands()
1715                .iter()
1716                .all(|(_, term)| !term.contains_symbol(root_store_marker)),
1717            "an empty dependency frontier must start from the typed root"
1718        );
1719
1720        let frontier_result = forests.local_3d_for_node(
1721            dependent_disconnected,
1722            &mut graph,
1723            &cutset,
1724            localizer,
1725            &settings,
1726        )?;
1727        assert_eq!(
1728            frontier_result
1729                .local_3d
1730                .active_sectors()
1731                .expect("a dependent union keeps its active sectors")
1732                .len(),
1733            5
1734        );
1735        let replay_states = forests.union_replay_states(dependent_disconnected)?;
1736        let (state, (active_subgraph, _)) = replay_states
1737            .iter()
1738            .filter(|state| !state.local_edges.is_empty())
1739            .zip(
1740                frontier_result
1741                    .local_3d
1742                    .active_sectors()
1743                    .expect("a dependent union keeps its active sectors"),
1744            )
1745            .find(|(state, _)| {
1746                state.local_edges.len() == 2
1747                    && forests
1748                        .normalized_node_label(state.integrated)
1749                        .starts_with("{36}:")
1750            })
1751            .expect("the integrated 36 prefix has the two-step C-to-F suffix");
1752        let expected_active = state
1753            .local_edges
1754            .iter()
1755            .enumerate()
1756            .map(|(offset, edge)| {
1757                let step_order = forests.graph[state.integrated].key.op_count() + offset;
1758                let (current, given) = forests.wood.current_given_pair(*edge, step_order);
1759                current.reduced_subgraph(&given)
1760            })
1761            .reduce(|active, reduced| active.union(&reduced))
1762            .expect("the selected replay state has a local suffix");
1763        assert_eq!(active_subgraph, &expected_active);
1764        assert!(
1765            active_subgraph.empty_intersection(forests.source_spinney(state.integrated).filter())
1766        );
1767        assert!(
1768            frontier_result
1769                .local_3d
1770                .integrands()
1771                .iter()
1772                .all(|(_, term)| !term.contains_symbol(root_store_marker)
1773                    && !term.contains_symbol(frontier_store_marker)),
1774            "a union must replay every component path from its typed root"
1775        );
1776        Ok(())
1777    }
1778
1779    #[test]
1780    fn heterogeneous_terminal_projects_components_before_multiplying() -> Result<()> {
1781        test_initialise().unwrap();
1782        let graph: Graph = dot!(
1783            digraph G {
1784                num = "1";
1785                projector = "1";
1786                overall_factor = "1";
1787                edge [particle = "scalar_1"];
1788                node [num = "1"];
1789                v1 -> v2;
1790                v1 -> v1;
1791                v2 -> v3;
1792                v2 -> v3;
1793            },
1794            "scalars"
1795        )?;
1796        let settings = UVgenerationSettings {
1797            softct: false,
1798            renormalization_prescription: RenormalizationPrescriptionSettings {
1799                log_divergent: ApproximationType::MUV,
1800                massive_power_divergent: ApproximationType::PolePart,
1801                massless_power_divergent: ApproximationType::PolePart,
1802                ..Default::default()
1803            },
1804            ..Default::default()
1805        };
1806        let mut forests = Wood::new(CutStructure::empty(&graph), &graph, &settings).unfold();
1807        forests.integrate(&graph, crate::utils::vakint()?, &settings)?;
1808
1809        let terminals = forests
1810            .graph
1811            .iter_nodes()
1812            .filter_map(|(node, mut crown, _)| {
1813                (!crown.any(|hedge| forests.graph.flow(hedge).is_source())).then_some(node)
1814            })
1815            .collect::<Vec<_>>();
1816        assert_eq!(terminals.len(), 1);
1817        let terminal = terminals[0];
1818        assert!(forests.graph.is_disjoint_union(terminal));
1819
1820        let components = forests.disconnected_component_nodes(terminal)?;
1821        assert_eq!(components.len(), 2);
1822        let schemes = components
1823            .iter()
1824            .map(|component| forests.source_spinney(*component).renormalization_scheme)
1825            .collect::<Vec<_>>();
1826        assert!(schemes.contains(&ApproximationType::MUV));
1827        assert!(schemes.contains(&ApproximationType::PolePart));
1828
1829        let expected =
1830            components
1831                .iter()
1832                .try_fold(Atom::one(), |product, &component| -> Result<Atom> {
1833                    let component_key = &forests.graph[component];
1834                    let integrated = forests
1835                        .compute_store
1836                        .require(component_key)?
1837                        .integrated(component_key)?;
1838                    let projection = match forests.source_spinney(component).renormalization_scheme
1839                    {
1840                        ApproximationType::MUV => integrated.physical_finite_counterterm_atom(),
1841                        ApproximationType::PolePart => integrated.physical_pole_atom(),
1842                        scheme => panic!("unexpected component scheme {scheme}"),
1843                    };
1844                    Ok(product * projection)
1845                })?;
1846        let terminal_key = &forests.graph[terminal];
1847        let aggregate = forests
1848            .compute_store
1849            .require(terminal_key)?
1850            .integrated(terminal_key)?;
1851        assert_ne!(
1852            expected.expand(),
1853            aggregate.physical_finite_counterterm_atom().expand()
1854        );
1855        assert_ne!(expected.expand(), aggregate.physical_pole_atom().expand());
1856
1857        let wild = Atom::var(W_.x___);
1858        let replacements =
1859            graph.integrand_replacement(&graph.full_filter(), &graph.loop_momentum_basis, &[wild]);
1860        let expected = expected
1861            .simplify_color()
1862            .expand_num()
1863            .to_dots()
1864            .replace_multiple(&replacements)
1865            .replace(GS.m_uv_expansion)
1866            .with(GS.m_uv_vacuum);
1867        let actual = forests
1868            .renormalization_part_of_ends(&graph, &settings)?
1869            .expression;
1870        assert_eq!(actual.expand(), expected.expand());
1871
1872        Ok(())
1873    }
1874
1875    #[test]
1876    fn triple_tadpole() -> Result<()> {
1877        test_initialise().unwrap();
1878        let dumbell: Graph = dot!(
1879            digraph G{
1880                edge [particle="scalar_1"];
1881                v1 -> v2;
1882                v2 -> v3;
1883                v3 -> v3;
1884                v2 -> v2;
1885                v1 -> v1;
1886            },"scalars"
1887        )?;
1888
1889        let spinneys: Vec<_> = dumbell
1890            .spinneys(&dumbell.full_filter())
1891            .into_iter()
1892            .filter_map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis))
1893            .collect();
1894        let f = Wood::new(
1895            CutStructure::empty(&dumbell),
1896            &dumbell,
1897            &UVgenerationSettings::default(),
1898        );
1899
1900        println!("{}", f);
1901
1902        insta::assert_snapshot!(
1903            f.graph.n_nodes(),
1904            @"8",
1905            // format!("Wood does not have correct number of spinneys: \n{}",f)
1906        );
1907
1908        for (_, _, d) in f.graph.iter_nodes() {
1909            println!(
1910                "//Node {}: \n{}",
1911                d.subgraph.string_label(),
1912                dumbell.dot(&d.subgraph)
1913            );
1914        }
1915        let _ff = OldWood::from_spinneys(spinneys, &dumbell); //.unfold(&g, &g.loop_momentum_basis);
1916
1917        // println!("{}", ff.dot(&dumbell));
1918
1919        let f = f.unfold();
1920        println!("{}", f);
1921        insta::assert_snapshot!(
1922            f.graph.n_nodes(),
1923            @"8");
1924
1925        let three_component_union = f
1926            .graph
1927            .iter_nodes()
1928            .find_map(|(node, _, operation)| {
1929                (operation.key.op_count() == 3
1930                    && f.wood
1931                        .join_factors(f.graph.source_node(node))
1932                        .is_some_and(|factors| factors.len() == 3))
1933                .then_some(node)
1934            })
1935            .expect("triple tadpole should contain a three-component union");
1936        let replay_states = f.union_replay_states(three_component_union)?;
1937        assert_eq!(replay_states.len(), 8);
1938        assert_eq!(
1939            replay_states
1940                .iter()
1941                .filter(|state| !state.local_edges.is_empty())
1942                .count(),
1943            7
1944        );
1945
1946        Ok(())
1947    }
1948
1949    #[test]
1950    fn saclay() -> Result<()> {
1951        test_initialise().unwrap();
1952        let dt: Graph = dot!(digraph GL16{
1953
1954        num = "spenso::g(spenso::coad(8,gammalooprs::hedge(8)),spenso::coad(8,gammalooprs::hedge(11)))"
1955                ext	 [style=invis];
1956                ext	-> 0  [dir=none id=0 particle="g"];
1957                2	-> ext  [dir=none id=1 particle="g"];
1958                0	-> 1 -> 2->3->0  [ particle="d"];
1959                      1 ->3 [particle = "g"]
1960                    })?;
1961
1962        let f = Wood::new(
1963            CutStructure::empty(&dt),
1964            &dt,
1965            &UVgenerationSettings::default(),
1966        );
1967
1968        println!("{}", dt.dot_serialize(&DotExportSettings::default()));
1969
1970        insta::assert_snapshot!(
1971            f.graph.n_nodes(),
1972            @"5",
1973            // format!("Wood does not have correct number of spinneys: \n{}",f)
1974        );
1975
1976        let f = f.unfold();
1977        let structured_dot = f.dot_serialize();
1978        assert!(structured_dot.contains(r#"label="K[#expr S_44⊛0]""#));
1979        assert!(!structured_dot.contains("#T("));
1980        println!("{structured_dot}");
1981        insta::assert_snapshot!(
1982            f.graph.n_nodes(),
1983            @"8");
1984
1985        Ok(())
1986    }
1987
1988    #[test]
1989    fn dumbells() -> Result<()> {
1990        test_initialise().unwrap();
1991        let dumbell: Graph = dot!(
1992            digraph G{
1993                edge [particle="scalar_1"];
1994                v1 -> v2;
1995                v2 -> v2;
1996                v1 -> v1;
1997            },"scalars"
1998        )?;
1999
2000        let spinneys: Vec<_> = dumbell
2001            .spinneys(&dumbell.full_filter())
2002            .into_iter()
2003            .filter_map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis))
2004            .collect();
2005        let f = Wood::new(
2006            CutStructure::empty(&dumbell),
2007            &dumbell,
2008            &UVgenerationSettings::default(),
2009        );
2010
2011        println!("{}", f);
2012
2013        insta::assert_snapshot!(
2014            f.graph.n_nodes(),
2015            @"4",
2016            // format!("Wood does not have correct number of spinneys: \n{}",f)
2017        );
2018
2019        for (_, _, d) in f.graph.iter_nodes() {
2020            println!(
2021                "//Node {}: \n{}",
2022                d.subgraph.string_label(),
2023                dumbell.dot(&d.subgraph)
2024            );
2025        }
2026        let _ff = OldWood::from_spinneys(spinneys, &dumbell); //.unfold(&g, &g.loop_momentum_basis);
2027
2028        // println!("{}", ff.dot(&dumbell));
2029
2030        let f = f.unfold();
2031        println!("{}", f);
2032        insta::assert_snapshot!(
2033            f.graph.n_nodes(),
2034            @"4");
2035
2036        Ok(())
2037    }
2038
2039    #[test]
2040    fn bugblatter() -> Result<()> {
2041        test_initialise().unwrap();
2042
2043        match dot!(
2044            digraph G{
2045                A1 -> A2 [particle="t"];
2046                A2 -> A3 [particle="t"];
2047                A3 -> A1 [particle="t"];
2048                B1 -> B2 [particle="t"];
2049                B2 -> B3 [particle="t"];
2050                B3 -> B1 [particle="t"];
2051                A1 -> B1 [particle="a"];
2052                A2 -> B2 [particle="a"];
2053                A3 -> B3 [particle="a"];
2054            },"sm"
2055        ) {
2056            Ok(g) => {
2057                let g: Graph = g;
2058                let spinneys: Vec<_> = g
2059                    .spinneys(&g.full_filter())
2060                    .into_iter()
2061                    .filter_map(|a| Spinney::new(a, &g, &g.loop_momentum_basis))
2062                    .collect();
2063                let f = Wood::new(
2064                    CutStructure::empty(&g),
2065                    &g,
2066                    &UVgenerationSettings::default(),
2067                );
2068
2069                println!("{}", f);
2070
2071                assert_eq!(
2072                    20,
2073                    f.graph.n_nodes(),
2074                    "Wood does not have correct number of spinneys: \n{}",
2075                    f
2076                );
2077
2078                for (_, _, d) in f.graph.iter_nodes() {
2079                    println!(
2080                        "//Node {}: \n{}",
2081                        d.subgraph.string_label(),
2082                        g.dot_lmb_of(&d.subgraph, &d.lmb)
2083                    );
2084                }
2085                let _ff = OldWood::from_spinneys(spinneys, &g); //.unfold(&g, &g.loop_momentum_basis);
2086
2087                // println!("{}", ff.dot(&g));
2088
2089                let f = f.unfold();
2090                f.debug_walk();
2091                println!("{}", f);
2092                assert_eq!(
2093                    152,
2094                    f.graph.n_nodes(),
2095                    "Forest unfolds into the wrong number of terms :\n{}",
2096                    f
2097                );
2098
2099                // println!("{}", f)
2100            }
2101            Err(e) => {
2102                eprintln!("{}", e);
2103            }
2104        }
2105
2106        // let f = SpinneyWood::from_spinneys(
2107        //     g.spinneys(&g.full_filter()).into_iter().map(|a| a.filter),
2108        //     &g,
2109        // )
2110        // .unfold();
2111
2112        // println!("{}", f.graph.base_dot());
2113        Ok(())
2114    }
2115
2116    #[test]
2117    fn mercedes() -> Result<()> {
2118        test_initialise().unwrap();
2119
2120        let mercedes: Graph = dot!(
2121            digraph G{
2122                edge [particle="scalar_1"];
2123                v1 -> v2;
2124                v2 -> v3;
2125                v3 -> v1;
2126                v1 -> v4;
2127                v2 -> v4;
2128                v3 -> v4;
2129            },"scalars"
2130        )?;
2131
2132        let f = Wood::new(
2133            CutStructure::empty(&mercedes),
2134            &mercedes,
2135            &UVgenerationSettings::default(),
2136        );
2137        println!("{}", f);
2138        insta::assert_snapshot!(
2139        f.graph.n_nodes(),
2140        @"2",
2141        );
2142        let f = f.unfold();
2143        println!("{}", f);
2144        insta::assert_snapshot!(
2145        f.graph.n_nodes(),
2146        @"2",
2147         );
2148
2149        Ok(())
2150    }
2151
2152    #[test]
2153    fn sunrise() -> Result<()> {
2154        test_initialise().unwrap();
2155
2156        let sunrise: Graph = dot!( digraph sunrise{
2157            node [num = "1"]
2158            edge [particle=scalar_1]
2159            e        [style=invis]
2160            e -> A:0   [ id=3 ]
2161            B:1 -> e   [ id=4 ]
2162            A -> B    [ id=0 ]
2163            A -> B    [ id=1 ]
2164            A -> B    [ id=2 ]
2165        },"scalars")?;
2166        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
2167        let f = Wood::new(
2168            CutStructure::empty(&sunrise),
2169            &sunrise,
2170            &UVgenerationSettings::default(),
2171        );
2172        println!("{}", f);
2173        insta::assert_snapshot!(
2174        f.graph.n_nodes(),
2175        @"5",
2176        );
2177        let f = f.unfold();
2178        println!("{}", f);
2179        insta::assert_snapshot!(
2180        f.graph.n_nodes(),
2181        @"8",
2182         );
2183
2184        Ok(())
2185    }
2186    #[test]
2187    fn dotted_sunrise() -> Result<()> {
2188        test_initialise().unwrap();
2189
2190        let sunrise: Graph = dot!( digraph sunrise{
2191            edge [particle=scalar_1]
2192            e        [style=invis]
2193            e -> A:0   [ id=3]
2194            B:1 -> e   [ id=4]
2195
2196            A -> C    [ id=0]
2197            C -> e
2198            C -> B
2199            A -> B    [ id=1]
2200            A -> B    [ id=2]
2201        },"scalars")?;
2202        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
2203        let f = Wood::new(
2204            CutStructure::empty(&sunrise),
2205            &sunrise,
2206            &UVgenerationSettings::default(),
2207        );
2208        println!("{}", f);
2209        insta::assert_snapshot!(
2210        f.graph.n_nodes(),
2211        @"3",
2212        );
2213        let f = f.unfold();
2214        println!("{}", f);
2215        insta::assert_snapshot!(
2216        f.graph.n_nodes(),
2217        @"4",
2218         );
2219
2220        Ok(())
2221    }
2222
2223    #[test]
2224    fn dotted() -> Result<()> {
2225        test_initialise().unwrap();
2226
2227        let sunrise: Graph = dot!( digraph sunrise{
2228            edge [particle=scalar_1]
2229            e        [style=invis]
2230            e -> A:0   [ id=4]
2231            B:1 -> e   [ id=5]
2232            C:2 -> e   [ id=6]
2233
2234            A -> B    [ id=0]
2235            B -> C     [ id=1]
2236            C -> A   [ id=2]
2237            B -> C    [ id=3]
2238
2239        },"scalars")?;
2240        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
2241        let f = Wood::new(
2242            CutStructure::empty(&sunrise),
2243            &sunrise,
2244            &UVgenerationSettings::default(),
2245        );
2246        println!("{}", f);
2247        insta::assert_snapshot!(
2248        f.graph.n_nodes(),
2249        @"3",
2250        );
2251        let f = f.unfold();
2252        println!("{}", f);
2253        insta::assert_snapshot!(
2254        f.graph.n_nodes(),
2255        @"4",
2256         );
2257
2258        Ok(())
2259    }
2260
2261    #[test]
2262    fn spectacles() -> Result<()> {
2263        test_initialise().unwrap();
2264
2265        let mut spectacles: Graph = dot!(
2266            digraph G{
2267                edge [particle="scalar_1"];
2268                v1 -> v2;
2269                v1 -> v2;
2270
2271                v3 -> v4;
2272                v3 -> v4;
2273
2274                v2 -> v3;
2275                v1 -> v4;
2276            },"scalars"
2277        )?;
2278
2279        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
2280        let settings = UVgenerationSettings::default();
2281        let cut_structure = CutStructure::empty(&spectacles);
2282        let cutset = cut_structure
2283            .cuts
2284            .first()
2285            .expect("empty cut structure has one cut")
2286            .clone();
2287        let f = Wood::new(cut_structure, &spectacles, &settings);
2288        println!("{}", f);
2289        insta::assert_snapshot!(
2290        f.graph.n_nodes(),
2291        @"5",
2292        );
2293        let f = f.unfold();
2294        println!("{}", f);
2295        insta::assert_snapshot!(
2296        f.graph.n_nodes(),
2297        @"8",
2298         );
2299
2300        let (union, edge) = f
2301            .graph
2302            .iter_nodes()
2303            .find_map(|(child, _, _)| {
2304                let (parent, edge) = f.graph.unique_parent(child)?;
2305                (!f.graph.is_disjoint_union(child) && f.graph.is_disjoint_union(parent))
2306                    .then_some((parent, edge))
2307            })
2308            .expect("spectacles has a connected child above its disconnected union");
2309        let orientation_pattern = crate::settings::global::OrientationPattern::default();
2310        let localizer = Localizer::new(
2311            &cutset,
2312            OrientationProjection::new(&[], &orientation_pattern),
2313        );
2314        let union_active = f
2315            .union_replay_states(union)?
2316            .into_iter()
2317            .filter(|state| !state.local_edges.is_empty())
2318            .map(|state| {
2319                state
2320                    .local_edges
2321                    .iter()
2322                    .enumerate()
2323                    .map(|(offset, edge)| {
2324                        let step_order = f.graph[state.integrated].key.op_count() + offset;
2325                        let (current, given) = f.wood.current_given_pair(*edge, step_order);
2326                        current.reduced_subgraph(&given)
2327                    })
2328                    .reduce(|active, reduced| active.union(&reduced))
2329                    .expect("a proper union replay state has a local suffix")
2330            })
2331            .collect::<Vec<_>>();
2332        assert_eq!(union_active.len(), 3);
2333        let union_local = Local3DCts::from_active_sectors(
2334            union_active
2335                .iter()
2336                .cloned()
2337                .map(|active| (active, Integrands::root()))
2338                .collect(),
2339        )?;
2340
2341        let step_order = f.graph[union].key.op_count();
2342        let (current, given) = f.wood.current_given_pair(edge, step_order);
2343        let reduced = current.reduced_subgraph(&given);
2344        let expected_active = union_active
2345            .iter()
2346            .map(|active| active.union(&reduced))
2347            .chain(std::iter::once(reduced.clone()))
2348            .collect::<Vec<_>>();
2349        let child_local = Local3DApproximation::new(localizer, &mut spectacles, &settings).run(
2350            &union_local,
2351            &IntegratedCts::root(),
2352            &current,
2353            &given,
2354            &current,
2355            &given,
2356        )?;
2357        let child_active = child_local
2358            .active_sectors()
2359            .expect("a connected child keeps its parent's active sectors")
2360            .iter()
2361            .map(|(active, _)| active.clone())
2362            .collect::<Vec<_>>();
2363        assert_eq!(child_active.len(), 4);
2364        assert_eq!(child_active, expected_active);
2365
2366        Ok(())
2367    }
2368
2369    #[test]
2370    fn basketball() -> Result<()> {
2371        test_initialise().unwrap();
2372
2373        let basketball: Graph = dot!(
2374            digraph G{
2375                edge [particle="scalar_1"];
2376                v1 -> v2;
2377                v1 -> v2;
2378                v1 -> v2;
2379                v1 -> v2;
2380            },"scalars"
2381        )?;
2382
2383        let f = Wood::new(
2384            CutStructure::empty(&basketball),
2385            &basketball,
2386            &UVgenerationSettings::default(),
2387        );
2388        println!("{}", f);
2389        insta::assert_snapshot!(
2390        f.graph.n_nodes(),
2391        @"12",
2392              );
2393        let f = f.unfold();
2394        println!("{}", f.dot_serialize());
2395        insta::assert_snapshot!(
2396        f.graph.n_nodes(),
2397        @"46");
2398        Ok(())
2399    }
2400
2401    #[test]
2402    fn fourloop_b() -> Result<()> {
2403        test_initialise().unwrap();
2404
2405        let fourloop_b: Graph = dot!(
2406            digraph G{
2407                edge [particle="scalar_1"];
2408                v1 -> v2;
2409                v1 -> v2;
2410
2411                v3 -> v4;
2412                v3 -> v4;
2413
2414                v2 -> v3;
2415                v1 -> v3;
2416                v1 -> v4;
2417            },"scalars"
2418        )?;
2419
2420        // let spinneys = fourloop_b.spinneys(&fourloop_b.full_filter());
2421        let f = Wood::new(
2422            CutStructure::empty(&fourloop_b),
2423            &fourloop_b,
2424            &UVgenerationSettings::default(),
2425        );
2426        println!("{}", f);
2427        insta::assert_snapshot!(
2428        f.graph.n_nodes(),
2429        @"14",
2430        );
2431        let f = f.unfold();
2432        println!("{}", f);
2433        insta::assert_snapshot!(
2434        f.graph.n_nodes(),
2435        @"80",
2436         );
2437
2438        Ok(())
2439    }
2440
2441    #[test]
2442    fn four_loop_a() -> Result<()> {
2443        test_initialise().unwrap();
2444
2445        let four_loop_a: Graph = dot!(
2446            digraph G{
2447                edge [particle="scalar_1"];
2448                v1 -> v2;
2449                v1 -> v2;
2450                v2 -> v3;
2451                v3 -> v1;
2452                v1 -> v4;
2453                v2 -> v4;
2454                v3 -> v4;
2455            },"scalars"
2456        )?;
2457
2458        let f = Wood::new(
2459            CutStructure::empty(&four_loop_a),
2460            &four_loop_a,
2461            &UVgenerationSettings::default(),
2462        );
2463        println!("{}", f);
2464        insta::assert_snapshot!(
2465        f.graph.n_nodes(),
2466        @"12",
2467        );
2468        let f = f.unfold();
2469        println!("{}", f);
2470        insta::assert_snapshot!(
2471        f.graph.n_nodes(),
2472        @"60",
2473         );
2474
2475        Ok(())
2476    }
2477
2478    #[test]
2479    fn triple_double_tadpole() -> Result<()> {
2480        test_initialise().unwrap();
2481        let dumbell: Graph = dot!(
2482            digraph G{
2483                edge [particle="scalar_1"];
2484                v1 -> v2;
2485                v2 -> v3;
2486                v3 -> v3;v3 -> v3;
2487                v2 -> v2; v2 -> v2;
2488                v1 -> v1;v1 -> v1;
2489            },"scalars"
2490        )?;
2491
2492        let f = Wood::new(
2493            CutStructure::empty(&dumbell),
2494            &dumbell,
2495            &UVgenerationSettings::default(),
2496        );
2497
2498        insta::assert_snapshot!(
2499            f.graph.n_nodes(),
2500            @"64");
2501
2502        let f = f.unfold_uncached();
2503        insta::assert_snapshot!(
2504            f.graph.n_nodes(),
2505            @"307");
2506
2507        Ok(())
2508    }
2509
2510    mod failing {
2511        use super::*;
2512
2513        #[test]
2514        fn lobsided_double_dumbell() -> Result<()> {
2515            test_initialise().unwrap();
2516            let dumbell: Graph = dot!(
2517                digraph G{
2518                    edge [particle="scalar_1"];
2519                    v1 -> v2;
2520                    v2 -> v2; //v2 -> v2;
2521                    v1 -> v1;v1 -> v1;
2522                },"scalars"
2523            )?;
2524
2525            let spinneys: Vec<_> = dumbell
2526                .spinneys(&dumbell.full_filter())
2527                .into_iter()
2528                .filter_map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis))
2529                .collect();
2530            let f = Wood::new(
2531                CutStructure::empty(&dumbell),
2532                &dumbell,
2533                &UVgenerationSettings::default(),
2534            );
2535
2536            println!("{}", f);
2537
2538            insta::assert_snapshot!(
2539                f.graph.n_nodes(),
2540                @"8",
2541                // format!("Wood does not have correct number of spinneys: \n{}",f)
2542            );
2543
2544            for (_, _, d) in f.graph.iter_nodes() {
2545                println!(
2546                    "//Node {}: \n{}",
2547                    d.subgraph.string_label(),
2548                    dumbell.dot(&d.subgraph)
2549                );
2550            }
2551            let _ff = OldWood::from_spinneys(spinneys, &dumbell); //.unfold(&g, &g.loop_momentum_basis);
2552
2553            // println!("{}", ff.dot(&dumbell));
2554
2555            let f = f.unfold();
2556            f.debug_walk();
2557            println!("{}", f);
2558            insta::assert_snapshot!(
2559                f.normalized_node_labels_with_cover("3L").join("\n"),
2560                @r###"
2561{36,F}: T(S_36(_))*T(S_F(_))
2562{3} · {36,F}: T((-1*S_3+S_F(_))*T(S_3(_)))*T(S_36(_))
2563{C} · {36,F}: T((-1*S_C+S_F(_))*T(S_C(_)))*T(S_36(_))
2564"###
2565            );
2566            insta::assert_snapshot!(
2567                f.graph.n_nodes(),
2568                @"12");
2569
2570            let f = Wood::new(
2571                CutStructure::empty(&dumbell),
2572                &dumbell,
2573                &UVgenerationSettings::default(),
2574            )
2575            .unfold_uncached();
2576            assert!(f.compute_store.entries.is_empty());
2577            insta::assert_snapshot!(
2578                f.normalized_node_labels_with_cover("3L").join("\n"),
2579                @r###"
2580{36,F}: T(S_36(_))*T(S_F(_))
2581{3} · {36,F}: T((-1*S_3+S_F(_))*T(S_3(_)))*T(S_36(_))
2582{C} · {36,F}: T((-1*S_C+S_F(_))*T(S_C(_)))*T(S_36(_))
2583"###
2584            );
2585
2586            Ok(())
2587        }
2588
2589        #[test]
2590        fn double_double_dumbell() -> Result<()> {
2591            test_initialise().unwrap();
2592            let dumbell: Graph = dot!(
2593                digraph G{
2594                    edge [particle="scalar_1"];
2595                    v1 -> v2;
2596                    v2 -> v2;v2 -> v2; //v2 -> v2;
2597                    v1 -> v1;v1 -> v1;
2598                },"scalars"
2599            )?;
2600
2601            let _spinneys: Vec<_> = dumbell
2602                .spinneys(&dumbell.full_filter())
2603                .into_iter()
2604                .map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis))
2605                .collect();
2606            let f = Wood::new(
2607                CutStructure::empty(&dumbell),
2608                &dumbell,
2609                &UVgenerationSettings::default(),
2610            );
2611
2612            println!("{}", f);
2613
2614            insta::assert_snapshot!(
2615                f.graph.n_nodes(),
2616                @"16",
2617                // format!("Wood does not have correct number of spinneys: \n{}",f)
2618            );
2619
2620            let f = f.unfold();
2621
2622            insta::assert_snapshot!(
2623                f.graph.n_nodes(),
2624                @"36");
2625
2626            let f = Wood::new(
2627                CutStructure::empty(&dumbell),
2628                &dumbell,
2629                &UVgenerationSettings::default(),
2630            )
2631            .unfold_uncached();
2632
2633            let foata_labels = f
2634                .graph
2635                .iter_nodes()
2636                .map(|(_, _, key)| key.foata_level_labels())
2637                .collect::<Vec<_>>();
2638            assert!(
2639                foata_labels
2640                    .iter()
2641                    .any(|label| label == "3,36;FU,F" || label == "36,3;FU,F"),
2642                "expected 3;F and 36;FU branch histories to combine, got:\n{}",
2643                foata_labels.join("\n")
2644            );
2645
2646            Ok(())
2647        }
2648    }
2649}