Skip to main content

gammalooprs/uv/
hedge_poset.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt::Display,
4    sync::Arc,
5};
6
7#[cfg(test)]
8use crate::uv::Integrands;
9#[cfg(test)]
10use crate::{
11    cff::expression::OrientationID,
12    uv::approx::direct_3d::{DirectCoordinateFrame, DirectResidueBranches, DirectSector},
13};
14#[cfg(test)]
15use std::cmp::Reverse;
16
17use ahash::AHashMap;
18use eyre::{WrapErr, eyre};
19use gammaloop_tracing_filter::LogMessage;
20use idenso::{color::ColorSimplifier, shorthands::schoonschip::Schoonschip};
21use itertools::Itertools;
22use linnet::half_edge::{
23    HedgeGraph, NoData, NodeIndex,
24    algorithms::{
25        DirectionBasis,
26        trace_unfold::{HiddenData, Independence, TraceKey, TraceUnfold, UnfoldedTraceGraph},
27    },
28    involution::{EdgeIndex, Flow, HedgePair},
29    nodestore::{NodeStorageOps, NodeStorageVec},
30    subgraph::{Inclusion, InternalSubGraph, ModifySubSet, SuBitGraph, SubSetLike, SubSetOps},
31};
32use symbolica::{
33    atom::{Atom, AtomCore, FunctionBuilder},
34    function,
35};
36use tracing::debug;
37use vakint::Vakint;
38
39use crate::{
40    debug_tags,
41    graph::{
42        Graph, LMBext, LoopMomentumBasis,
43        cuts::CutSet,
44        parse::string_utils::{ToOrderedSimple, dot_attr_value},
45    },
46    settings::global::GenerationSettings,
47    utils::{GS, W_},
48    uv::{
49        ApproximationType, RenormalizationPart, Spinney, UVgenerationSettings, UltravioletGraph,
50        approx::{
51            CutStructure, ForestNodeLike, OrientationProjection, Rooted, UVCtx,
52            direct_3d::{Direct3dApproximation, Direct3dCts},
53            final_integrand::{FinalIntegrandBuilder, FinalIntegrands},
54            integrated::{Integrated, IntegratedCts},
55            local_3d::{Local3DCts, Localizer},
56            local_4d::{self, Full4dCts, Local4dCts},
57            projected_4d::{Local4dProjectionContext, Projected4dApproximation},
58        },
59        export::{
60            UVForestLocalProvenance, UVForestNodeExpression, UVForestNodeProvenance,
61            UVForestProvenance,
62        },
63        forest::ParametricIntegrands,
64        marker::UvMarker,
65    },
66};
67use color_eyre::Result;
68use spenso::shadowing::symbolica_utils::{LogPrint, SpensoPrintSettings};
69
70pub struct Wood {
71    pub graph: HedgeGraph<SuBitGraph, Spinney>,
72    pub root: NodeIndex,
73    pub vakint_settings: vakint::VakintSettings,
74    cuts: CutStructure,
75}
76
77impl Independence<HiddenData<SuBitGraph, EdgeIndex>> for Wood {
78    fn independent(
79        &self,
80        a: &HiddenData<SuBitGraph, EdgeIndex>,
81        b: &HiddenData<SuBitGraph, EdgeIndex>,
82    ) -> bool {
83        !a.order.intersects(&b.order)
84    }
85}
86
87impl TraceUnfold<SuBitGraph> for Wood {
88    type EdgeData = SuBitGraph;
89    type HedgeData = NoData;
90    type NodeData = Spinney;
91    type NodeStorage = NodeStorageVec<Spinney>;
92
93    fn graph(
94        &self,
95    ) -> &HedgeGraph<Self::EdgeData, Self::NodeData, Self::HedgeData, Self::NodeStorage> {
96        &self.graph
97    }
98
99    fn key(&self, e: EdgeIndex) -> SuBitGraph {
100        self.graph[e].clone()
101    }
102
103    /// Treats a wood node as a factorized join when its incoming sink edges are exactly the
104    /// disjoint connected components of the target spinney.
105    ///
106    /// The returned `SuBitGraph`s are the required branch factors for the generic trace unfold:
107    /// their union must be the target filter, they must be pairwise disjoint, and there must be
108    /// one factor per connected component.
109    fn join_factors(&self, target: NodeIndex) -> Option<BTreeSet<SuBitGraph>> {
110        if self.graph[target].n_components() < 2 {
111            return None;
112        }
113
114        let factors = self
115            .graph
116            .iter_crown(target)
117            .filter(|hedge| self.graph.flow(*hedge) == Flow::Sink)
118            .map(|hedge| self.graph[self.graph[&hedge]].clone())
119            .collect::<BTreeSet<_>>();
120        if factors.len() != self.graph[target].n_components() {
121            return None;
122        }
123        if factors.iter().any(|factor| {
124            !self
125                .graph
126                .iter_nodes()
127                .any(|(_, _, spinney)| spinney.n_components() == 1 && spinney.filter() == factor)
128        }) {
129            return None;
130        }
131
132        let mut cover: Option<SuBitGraph> = None;
133        for factor in &factors {
134            if let Some(acc) = &mut cover {
135                if acc.intersects(factor) {
136                    return None;
137                }
138                acc.union_with(factor);
139            } else {
140                cover = Some(factor.clone());
141            }
142        }
143
144        (cover.as_ref() == Some(self.graph[target].filter())).then_some(factors)
145    }
146}
147
148impl Wood {
149    pub fn current_given_pair<'a>(
150        &'a self,
151        edge_id: EdgeIndex,
152        order: usize,
153    ) -> (ForestNode<'a>, ForestNode<'a>) {
154        let HedgePair::Paired { source, sink } = self.graph[&edge_id].1 else {
155            panic!("edge in self is not paired");
156        };
157
158        // 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)
159        let given = ForestNode {
160            spinney: &self.graph[self.graph.node_id(source)],
161            topo_order: order,
162        };
163
164        // this is the current node, which should be the same for all union edges (since they all have the same sink)
165        let current_for_h = self.graph.node_id(sink);
166
167        // this is the current node, which we want to compute with
168        let current = ForestNode {
169            spinney: &self.graph[current_for_h],
170            topo_order: order,
171        };
172
173        (current, given)
174    }
175
176    pub(crate) fn new(
177        cuts: CutStructure,
178        graph: &Graph,
179        settings: &GenerationSettings,
180    ) -> Result<Self> {
181        let mut subgraph = graph.full_filter();
182        subgraph.subtract_with(&graph.initial_state_cut.left);
183        let mut spinneys = Vec::new();
184
185        for (cut_index, cut) in cuts.cuts.iter().enumerate() {
186            let cut_sub = subgraph.subtract(&cut.union);
187            spinneys.extend(
188                graph
189                    .classified_spinneys(&cut_sub, &settings.uv, &graph.loop_momentum_basis)
190                    .wrap_err_with(|| {
191                        format!(
192                            "graph '{}' cut {cut_index}: failed to classify UV counterterm components",
193                            graph.name
194                        )
195                    })?,
196            );
197        }
198        graph.remove_full_observable_spinney(&mut spinneys, &settings.medium);
199
200        Ok(Self::from_spinneys(spinneys, graph, cuts, &settings.uv))
201    }
202
203    pub(crate) fn from_spinneys<I: IntoIterator<Item = Spinney>>(
204        s: I,
205        graph: &Graph,
206        cuts: CutStructure,
207        settings: &UVgenerationSettings,
208    ) -> Self {
209        let mut max_loops = 0;
210        let mut spinneys = BTreeMap::new();
211        for spinney in s {
212            max_loops = max_loops.max(graph.n_loops(spinney.filter()));
213            spinneys.entry(spinney.filter().clone()).or_insert(spinney);
214        }
215        let empty = Spinney::empty(graph);
216        spinneys.entry(empty.filter().clone()).or_insert(empty);
217        let mut vakint_settings = settings.vakint.true_settings();
218        vakint_settings.project_onto_tensor_integrals =
219            settings.project_integrated_uv_cts_onto_tensor_integrals;
220        // Retain enough positive epsilon powers for finite terms formed when disconnected
221        // integrated counterterms are multiplied.
222        vakint_settings.number_of_terms_in_epsilon_expansion = max_loops as i64 + 1;
223
224        let mut unions = BTreeSet::new();
225        let g: HedgeGraph<_, _> = HedgeGraph::poset(spinneys.into_values());
226        let mut poset = g.map(
227            |_, _, v| v,
228            |_, n, pair, _, e| {
229                let HedgePair::Paired { source, sink } = pair else {
230                    return e.map(|_| graph.as_ref().empty_subgraph());
231                };
232                let nsource = n.node_id_ref(source);
233                let nsink = n.node_id_ref(sink);
234
235                let source_subgraph = &n.get_node_data(nsource).subgraph;
236                let sink_subgraph = &n.get_node_data(nsink).subgraph;
237                let reduced_subgraph = sink_subgraph.subtract(source_subgraph).filter;
238                let hairy_source = graph.as_ref().full_crown(source_subgraph);
239
240                if graph.as_ref().bridges_of(&reduced_subgraph).is_empty()
241                    && !hairy_source.intersects(&reduced_subgraph)
242                {
243                    // if the reduced graph is bridgless,and has no node overlap with the source, then the source subgraph is cycle independent of reduced subgraph
244                    // if the source is not empty then this is a disjoint union
245                    if !source_subgraph.is_empty() {
246                        unions.insert(nsink);
247                    }
248                    e.map(|_| reduced_subgraph)
249                } else {
250                    e.map(|_| sink_subgraph.filter.clone())
251                }
252            },
253            |_, d| d,
254        );
255
256        let mut to_remove: SuBitGraph = poset.empty_subgraph();
257        let classified_filters = poset
258            .iter_nodes()
259            .map(|(_, _, spinney)| spinney.filter().clone())
260            .collect::<BTreeSet<_>>();
261
262        // Not quite transitive closure. A disconnected target factorizes only when every
263        // connected component is both an independently classified UV spinney and supplied by
264        // an incoming edge. In that case, keep only those component edges so trace unfolding
265        // builds the canonical product. If either condition fails, the target is a genuinely
266        // collective UV region whose atomic Taylor term and nested forest terms are both
267        // required. Label every retained transition by that full Taylor scope: the reduced
268        // complement remains available from the edge's source/sink nodes, while using it as
269        // the trace order would incorrectly make a collective operation commute with its
270        // nested prefix and could coalesce distinct forests into a false factorized join.
271        for u in unions {
272            let collective_scope = poset[u].subgraph.filter.clone();
273            let comps: BTreeSet<_> = graph
274                .as_ref()
275                .connected_components(&collective_scope)
276                .into_iter()
277                .collect();
278            let incoming = poset
279                .iter_crown(u)
280                .filter(|c| poset.flow(*c) == Flow::Sink)
281                .collect::<Vec<_>>();
282            let supplied = incoming
283                .iter()
284                .map(|c| &poset[poset[c]])
285                .filter(|factor| comps.contains(*factor))
286                .cloned()
287                .collect::<BTreeSet<_>>();
288            let independently_classified = comps
289                .iter()
290                .all(|component| classified_filters.contains(component));
291            if independently_classified && supplied == comps {
292                for c in incoming {
293                    if comps.contains(&poset[poset[&c]]) {
294                        continue;
295                    }
296                    to_remove.add(c);
297                    to_remove.add(poset.inv(c));
298                }
299            } else {
300                for c in incoming {
301                    let edge = poset[&c];
302                    poset[edge] = collective_scope.clone();
303                }
304            }
305        }
306
307        poset.delete_hedges(&to_remove);
308        let root = poset
309            .iter_nodes()
310            .find(|(_, _, s)| s.subgraph.is_empty())
311            .map(|(n, _, _)| n);
312
313        Wood {
314            graph: poset,
315            root: root.expect("no empty spinney found"),
316            cuts,
317            vakint_settings,
318        }
319    }
320
321    fn unfold_with_cached_node_label_atoms(self, cache_node_label_atoms: bool) -> Forests {
322        let unfolded = self.trace_unfold::<NodeStorageVec<_>>(self.root);
323        let graph = unfolded.map(|_, _, key| OperationNode { key });
324
325        let mut cuts: Vec<(SuBitGraph, CutSet)> = Vec::new();
326        for c in &self.cuts.cuts {
327            let mut compatible: SuBitGraph = graph.empty_subgraph();
328            for (_, crown, s) in graph.iter_nodes() {
329                if s.is_compatible_with(c) {
330                    for h in crown {
331                        compatible.add(h);
332                    }
333                }
334            }
335            cuts.push((compatible, c.clone()));
336        }
337        let root = graph
338            .iter_nodes()
339            .find(|(_, _, operation)| operation.key.is_empty())
340            .map(|(node, _, _)| node)
341            .expect("no empty trace key found in unfolded hedge-poset forest");
342
343        let forests = Forests {
344            graph,
345            cuts,
346            root,
347            cached_node_label_atoms: cache_node_label_atoms.then(AHashMap::new),
348            compute_store: ComputeStore::default(),
349            wood: self,
350        };
351
352        if cache_node_label_atoms {
353            forests.with_cached_node_label_atoms()
354        } else {
355            forests
356        }
357    }
358
359    pub fn unfold(self) -> Forests {
360        self.unfold_with_cached_node_label_atoms(true)
361    }
362
363    pub fn unfold_uncached(self) -> Forests {
364        self.unfold_with_cached_node_label_atoms(false)
365    }
366}
367
368impl Display for Wood {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        self.graph.dot_impl_fmt(
371            f,
372            &self.graph.full_filter(),
373            "start=2;\n",
374            &|_| None,
375            &|a| Some(format!("label=\"{}\"", a.string_label())),
376            &|v| Some(format!("label=\"{}\"", v.subgraph.string_label())),
377        )
378    }
379}
380// pub type SpinneyGraph = HedgeGraph<SuBitGraph, Spinney>;
381
382// impl Key<&SuBitGraph> for SpinneyGraph {
383//     fn key(&self, e: linnet::half_edge::involution::EdgeIndex) -> K {
384//         &self[e]
385//     }
386// }
387
388#[derive(Default)]
389pub struct ComputeStore {
390    entries: AHashMap<OperationNode, ComputeNode>,
391}
392
393impl ComputeStore {
394    fn get(&self, key: &OperationNode) -> Option<&ComputeNode> {
395        self.entries.get(key)
396    }
397
398    fn require(&self, key: &OperationNode) -> Result<&ComputeNode> {
399        // Union operations, like individual Taylor steps, must be computed
400        // before their typed values are consumed by another forest node.
401        self.get(key)
402            .ok_or_else(|| eyre!("{key} not yet added to compute store"))
403    }
404
405    fn entry(
406        &mut self,
407        key: OperationNode,
408    ) -> std::collections::hash_map::Entry<'_, OperationNode, ComputeNode> {
409        self.entries.entry(key)
410    }
411}
412
413pub struct Forests {
414    pub graph: UnfoldedTraceGraph<Wood, OperationNode, NodeStorageVec<OperationNode>>,
415    pub root: NodeIndex,
416    /// Wood subgraph that has compatible
417    cuts: Vec<(SuBitGraph, CutSet)>,
418    cached_node_label_atoms: Option<AHashMap<NodeIndex, Atom>>,
419    pub compute_store: ComputeStore,
420    wood: Wood,
421}
422
423#[derive(Clone, Debug, Hash, PartialEq, Eq)]
424pub struct OperationNode {
425    pub key: TraceKey<SuBitGraph, EdgeIndex>,
426}
427
428pub struct ForestNode<'a> {
429    pub spinney: &'a Spinney,
430    pub topo_order: usize,
431}
432
433pub struct OwnedForestNode {
434    pub spinney: Spinney,
435    pub topo_order: usize,
436}
437
438#[cfg(test)]
439struct LocalLeafOperation {
440    op: HiddenData<SuBitGraph, EdgeIndex>,
441    frontier: NodeIndex,
442}
443
444struct UnionReplayState {
445    integrated: NodeIndex,
446    local_edges: Vec<EdgeIndex>,
447}
448
449#[cfg(test)]
450impl LocalLeafOperation {
451    fn new(op: &HiddenData<SuBitGraph, EdgeIndex>, frontier: NodeIndex) -> Self {
452        Self {
453            op: op.clone(),
454            frontier,
455        }
456    }
457}
458
459impl OperationNode {
460    pub fn is_compatible_with(&self, cut: &CutSet) -> bool {
461        self.covers().is_none_or(|c| !c.intersects(&cut.union))
462    }
463
464    pub fn current<'a>(&'a self, wood: &'a Wood, topo_order: usize) -> Option<Vec<ForestNode<'a>>> {
465        if self.key.is_empty() {
466            return None;
467        }
468
469        Some(
470            self.key
471                .iter_leaf_ops()
472                .map(|op| {
473                    let HedgePair::Paired { sink, .. } = wood.graph[&op.data].1 else {
474                        panic!("edge in trace key is not paired");
475                    };
476                    let spinney = &wood.graph[wood.graph.node_id(sink)];
477                    ForestNode {
478                        spinney,
479                        topo_order,
480                    }
481                })
482                .collect::<Vec<_>>(),
483        )
484    }
485}
486
487impl AsRef<TraceKey<SuBitGraph, EdgeIndex>> for OperationNode {
488    fn as_ref(&self) -> &TraceKey<SuBitGraph, EdgeIndex> {
489        &self.key
490    }
491}
492
493impl LogMessage for ForestNode<'_> {
494    fn log_display(&self) -> String {
495        format!(
496            "subgraph={}, topo_order={}, dod={}",
497            self.spinney.filter().string_label(),
498            self.topo_order,
499            self.spinney.dod
500        )
501    }
502}
503
504impl ForestNodeLike for ForestNode<'_> {
505    fn dod(&self) -> i32 {
506        self.spinney.dod
507    }
508    fn renormalization_scheme(&self) -> crate::uv::ApproximationType {
509        self.spinney.renormalization_scheme
510    }
511    fn lmb(&self) -> &LoopMomentumBasis {
512        &self.spinney.lmb
513    }
514    fn reduced_subgraph(&self, given: &Self) -> SuBitGraph {
515        self.spinney
516            .subgraph
517            .subtract(&given.spinney.subgraph)
518            .filter
519    }
520    fn subgraph(&self) -> &SuBitGraph {
521        self.spinney.filter()
522    }
523    fn topo_order(&self) -> usize {
524        self.topo_order
525    }
526}
527
528impl LogMessage for OwnedForestNode {
529    fn log_display(&self) -> String {
530        format!(
531            "subgraph={}, topo_order={}, dod={}",
532            self.spinney.filter().string_label(),
533            self.topo_order,
534            self.spinney.dod
535        )
536    }
537}
538
539impl ForestNodeLike for OwnedForestNode {
540    fn dod(&self) -> i32 {
541        self.spinney.dod
542    }
543
544    fn renormalization_scheme(&self) -> crate::uv::ApproximationType {
545        self.spinney.renormalization_scheme
546    }
547
548    fn lmb(&self) -> &LoopMomentumBasis {
549        &self.spinney.lmb
550    }
551
552    fn reduced_subgraph(&self, given: &Self) -> SuBitGraph {
553        self.spinney
554            .subgraph
555            .subtract(&given.spinney.subgraph)
556            .filter
557    }
558
559    fn subgraph(&self) -> &SuBitGraph {
560        self.spinney.filter()
561    }
562
563    fn topo_order(&self) -> usize {
564        self.topo_order
565    }
566}
567
568impl Display for OperationNode {
569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        if self.key.is_empty() {
571            write!(f, "∅")
572        } else {
573            self.key.write_foata_like(f, |op| op.string_label())
574        }
575    }
576}
577
578impl OperationNode {
579    fn foata_level_labels(&self) -> String {
580        self.key
581            .iter_levels_top_down()
582            .map(|level| {
583                level
584                    .iter_leaf_ops()
585                    .map(|op| op.order.string_label())
586                    .join(",")
587            })
588            .join(";")
589    }
590
591    pub fn covers(&self) -> Option<SuBitGraph> {
592        let mut acc: Option<SuBitGraph> = None;
593
594        for level in self.key.iter_levels_top_down() {
595            for op in level.iter_leaf_ops() {
596                if let Some(a) = &mut acc {
597                    a.union_with(&op.order);
598                } else {
599                    acc = Some(op.order.clone());
600                }
601            }
602        }
603
604        acc
605    }
606
607    fn forest_node(&self, graph: &Graph, topo_order: usize) -> Result<OwnedForestNode> {
608        let spinney = match self.covers() {
609            Some(cover) if !cover.is_empty() => {
610                let subgraph = InternalSubGraph::cleaned_filter_optimist(cover, graph.as_ref());
611                Spinney::new(subgraph, graph, &graph.loop_momentum_basis)
612                    .wrap_err_with(|| {
613                        format!(
614                            "graph '{}': failed to construct forest node for operation {self}",
615                            graph.name
616                        )
617                    })?
618                    .ok_or_else(|| {
619                        eyre!(
620                            "graph '{}': operation {self} cover has negative degree of divergence",
621                            graph.name
622                        )
623                    })?
624            }
625            _ => Spinney::empty(graph),
626        };
627
628        Ok(OwnedForestNode {
629            spinney,
630            topo_order,
631        })
632    }
633
634    pub fn to_atom(&self) -> Atom {
635        let mut acc = Atom::one();
636
637        let approx = FunctionBuilder::new(GS.uv_approx);
638        let mut levels = self.key.iter_levels_top_down();
639        let Some(first_level) = levels.next() else {
640            return acc;
641        };
642
643        let Some(first_op) = first_level.iter_leaf_ops().next() else {
644            return acc;
645        };
646
647        let mut last = SuBitGraph::empty(first_op.order.size());
648        for l in std::iter::once(first_level).chain(levels) {
649            let last_sym = if last.is_empty() {
650                Atom::Zero
651            } else {
652                last.symbol().to_atom()
653            };
654
655            let mut mul = Atom::one();
656
657            for op in l.iter_leaf_ops() {
658                let new = function!(op.order.symbol(), usize::from(op.data));
659                mul *= approx.clone().add_arg((new - &last_sym) * &acc).finish();
660                last.union_with(&op.order);
661            }
662
663            acc = mul
664        }
665
666        acc
667    }
668
669    // Four-dimensional and per-cut local terms are composed by `Forests` from typed
670    // dependency-frontier values. Empty frontiers start from the typed roots, and
671    // `Direct3dApproximation::run` applies the direct-3D subtraction signs.
672}
673
674#[derive(Default)]
675pub struct ComputeNode {
676    local_4d: Option<Local4dCts>,
677    integrated: Option<IntegratedCts>,
678    cuts: AHashMap<CutSet, CutComputation>,
679}
680
681pub struct CutComputation {
682    local_3d: Local3DCts,
683    final_integrands: FinalIntegrands,
684}
685
686impl ComputeNode {
687    fn local_4d(&self, operation: &OperationNode) -> Result<&Local4dCts> {
688        self.local_4d
689            .as_ref()
690            .ok_or_else(|| eyre!("{operation} has no computed local 4D counterterm"))
691    }
692
693    fn integrated(&self, operation: &OperationNode) -> Result<&IntegratedCts> {
694        self.integrated
695            .as_ref()
696            .ok_or_else(|| eyre!("{operation} has no computed integrated counterterm"))
697    }
698
699    fn cut(&self, operation: &OperationNode, cutset: &CutSet) -> Result<&CutComputation> {
700        self.cuts.get(cutset).ok_or_else(|| {
701            eyre!("{operation} has no computed local counterterms for cut {cutset:?}")
702        })
703    }
704}
705
706impl Forests {
707    fn source_spinney(&self, node: NodeIndex) -> &Spinney {
708        &self.wood.graph[self.graph.source_node(node)]
709    }
710
711    fn recursion_input_4d(&self, node: NodeIndex) -> Result<Full4dCts> {
712        // The typed 4D value is the sequential accumulator. It already encodes that an empty
713        // dependency frontier starts at the typed root, that other prefixes enter through their
714        // reduced branch, and that both local subtraction signs are applied without a raw
715        // Foata-level product. Disconnected values likewise retain their factorized local and
716        // integrated-prefix cross terms, so no separate active/frozen root-path replay is needed.
717        let operation = &self.graph[node];
718        let computed = self.compute_store.require(operation)?;
719        if self.graph.is_disjoint_union(node) {
720            return Ok(Full4dCts::from_factorized_local(
721                computed.local_4d(operation)?,
722            ));
723        }
724        Full4dCts::recursion_input(
725            computed.local_4d(operation)?,
726            computed.integrated(operation)?,
727            self.source_spinney(node).renormalization_scheme,
728            node == self.root,
729            &self.source_spinney(node).lmb,
730        )
731    }
732
733    fn cached_node_label_atom(&self, node: NodeIndex) -> Option<Atom> {
734        self.cached_node_label_atoms
735            .as_ref()
736            .and_then(|labels| labels.get(&node))
737            .cloned()
738    }
739
740    fn node_label_atom_factor(
741        &self,
742        frontier: NodeIndex,
743        op: &HiddenData<SuBitGraph, EdgeIndex>,
744    ) -> Atom {
745        let approx = FunctionBuilder::new(GS.uv_approx);
746        let frontier_atom = self.node_label_atom(frontier);
747
748        let current = function!(op.order.symbol(), usize::from(op.data));
749        let argument = if self.graph[frontier].covers().is_none() {
750            current
751        } else {
752            let previous = self.graph[frontier]
753                .covers()
754                .expect("non-empty frontier cover must exist")
755                .symbol();
756            (current - previous) * frontier_atom
757        };
758        approx.add_arg(argument).finish()
759    }
760
761    fn node_label_atom(&self, node: NodeIndex) -> Atom {
762        if self.graph[node].key.is_empty() {
763            return Atom::one();
764        }
765
766        if let Some(cached) = self.cached_node_label_atom(node) {
767            return cached;
768        }
769
770        self.graph
771            .leaf_op_dependency_frontiers(node, &self.wood)
772            .fold(Atom::one(), |acc, (op, frontier)| {
773                acc * self.node_label_atom_factor(frontier, op)
774            })
775    }
776
777    fn with_cached_node_label_atoms(mut self) -> Self {
778        self.cache_node_label_atoms();
779        self
780    }
781
782    fn cache_node_label_atoms(&mut self) {
783        self.cached_node_label_atoms = Some(AHashMap::new());
784        for nidx in self
785            .graph
786            .topo_sort_kahn(DirectionBasis::Underlying)
787            .unwrap()
788        {
789            let atom = self.node_label_atom(nidx);
790            self.cached_node_label_atoms
791                .as_mut()
792                .expect("node-label cache was initialized")
793                .insert(nidx, atom);
794        }
795    }
796
797    fn node_label(&self, node: NodeIndex) -> String {
798        let key = &self.graph[node];
799        if key.key.is_empty() {
800            return "∅".to_string();
801        }
802
803        let mut foata = String::new();
804        key.key
805            .write_foata_like(&mut foata, |op| op.string_label())
806            .expect("writing a trace key into a string must succeed");
807        let atom = self.node_label_atom(node);
808        format!("{foata}: {}", atom.to_ordered_simple())
809    }
810
811    fn dot_serialize_expr_atom() -> Atom {
812        Atom::var(GS.expr)
813    }
814
815    fn dot_serialize_node_atom_factor(
816        &self,
817        frontier: NodeIndex,
818        op: &HiddenData<SuBitGraph, EdgeIndex>,
819    ) -> Atom {
820        let frontier_depth = self.graph[frontier].key.op_count();
821        let (current, given) = self.wood.current_given_pair(op.data, frontier_depth);
822        // Structured forest-DOT approximation records use the same operation and subgraph
823        // markers as the computed UV expressions.
824        function!(
825            GS.uv_approx,
826            UvMarker::subgraph(current.subgraph(), given.subgraph())
827                * self.dot_serialize_node_atom(frontier)
828        )
829    }
830
831    fn dot_serialize_node_atom(&self, node: NodeIndex) -> Atom {
832        if self.graph[node].key.is_empty() {
833            return Self::dot_serialize_expr_atom();
834        }
835
836        self.graph
837            .leaf_op_dependency_frontiers(node, &self.wood)
838            .fold(Atom::one(), |acc, (op, frontier)| {
839                acc * self.dot_serialize_node_atom_factor(frontier, op)
840            })
841    }
842
843    fn dot_serialize_node_attrs(&self, node: NodeIndex) -> String {
844        let key = &self.graph[node];
845        let label = self
846            .dot_serialize_node_atom(node)
847            .printer(SpensoPrintSettings::typst_options())
848            .to_string();
849        let cover = key
850            .covers()
851            .unwrap_or_else(|| self.graph.empty_subgraph())
852            .string_label();
853
854        format!(
855            "label={} foata={} cover={}",
856            dot_attr_value(&label),
857            dot_attr_value(&key.foata_level_labels()),
858            dot_attr_value(&cover),
859        )
860    }
861
862    pub fn dot_serialize(&self) -> String {
863        let mut output = String::new();
864        self.dot_serialize_fmt(&mut output)
865            .expect("writing hedge-poset forest DOT into a string must succeed");
866        output
867    }
868
869    pub fn dot_serialize_fmt(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
870        let attrs: AHashMap<_, _> = self
871            .graph
872            .iter_nodes()
873            .map(|(node, _, key)| (key.clone(), self.dot_serialize_node_attrs(node)))
874            .collect();
875
876        self.graph.dot_impl_fmt(
877            writer,
878            &self.graph.full_filter(),
879            "start=2;\n",
880            &|_| None,
881            &|_| None,
882            &|v| Some(attrs[v].clone()),
883        )
884    }
885
886    fn compatible_topological_order(&self, subset: &SuBitGraph) -> Result<Vec<NodeIndex>> {
887        let mut order = self
888            .graph
889            .topo_sort_kahn_of(subset, DirectionBasis::Underlying)?;
890
891        if let Some(root_position) = order.iter().position(|node| *node == self.root) {
892            if root_position != 0 {
893                let root = order.remove(root_position);
894                order.insert(0, root);
895            }
896        } else {
897            order.insert(0, self.root);
898        }
899
900        Ok(order)
901    }
902
903    #[cfg(test)]
904    fn local_leaf_operations(&self, node: NodeIndex) -> Vec<LocalLeafOperation> {
905        let mut leaves = self
906            .graph
907            .leaf_op_dependency_frontiers(node, &self.wood)
908            .map(|(op, frontier)| LocalLeafOperation::new(op, frontier))
909            .collect::<Vec<_>>();
910
911        leaves.sort_by_key(|leaf| {
912            (
913                Reverse(self.graph[leaf.frontier].key.op_count()),
914                leaf.op.order.clone(),
915                usize::from(leaf.op.data),
916            )
917        });
918        leaves
919    }
920
921    fn disconnected_component_nodes(&self, node: NodeIndex) -> Result<Vec<NodeIndex>> {
922        let operation = &self.graph[node];
923        let factors = self
924            .wood
925            .join_factors(self.graph.source_node(node))
926            .ok_or_else(|| eyre!("{operation} has no disconnected component factors"))?;
927        let target_ops = operation
928            .key
929            .iter_levels_top_down()
930            .flat_map(|level| level.iter_leaf_ops())
931            .cloned()
932            .collect::<Vec<_>>();
933
934        for op in &target_ops {
935            let owners = factors
936                .iter()
937                .filter(|factor| factor.includes(&op.order))
938                .count();
939            if owners != 1 {
940                return Err(eyre!(
941                    "operation {} in {operation} belongs to {owners} disconnected factors",
942                    op.order.string_label()
943                ));
944            }
945        }
946
947        factors
948            .into_iter()
949            .map(|factor| {
950                let key = target_ops
951                    .iter()
952                    .filter(|op| factor.includes(&op.order))
953                    .fold(TraceKey::empty(), |key, op| {
954                        key.push(&self.wood, op.clone())
955                    });
956                self.graph
957                    .iter_nodes()
958                    .find_map(|(component, _, candidate)| {
959                        (candidate.key == key && self.source_spinney(component).filter() == &factor)
960                            .then_some(component)
961                    })
962                    .ok_or_else(|| {
963                        eyre!(
964                            "component {} of {operation} is not in the unfolded forest",
965                            factor.string_label()
966                        )
967                    })
968            })
969            .collect()
970    }
971
972    fn operation_node_index(&self, operation: &OperationNode) -> Result<NodeIndex> {
973        let cover = operation.covers();
974        self.graph
975            .iter_nodes()
976            .find_map(|(node, _, candidate)| {
977                let source_matches = cover.as_ref().map_or_else(
978                    || self.source_spinney(node).filter().is_empty(),
979                    |cover| self.source_spinney(node).filter() == cover,
980                );
981                (candidate == operation && source_matches).then_some(node)
982            })
983            .ok_or_else(|| eyre!("{operation} is not in the unfolded forest"))
984    }
985
986    fn union_replay_states(&self, node: NodeIndex) -> Result<Vec<UnionReplayState>> {
987        let operation = &self.graph[node];
988        if operation.key.is_empty() {
989            return Ok(vec![UnionReplayState {
990                integrated: node,
991                local_edges: Vec::new(),
992            }]);
993        }
994
995        if !self.graph.is_disjoint_union(node) {
996            let (parent, edge) = self
997                .graph
998                .unique_parent(node)
999                .ok_or_else(|| eyre!("{operation} has no unique replay parent"))?;
1000            let mut states = self.union_replay_states(parent)?;
1001            for state in &mut states {
1002                state.local_edges.push(edge);
1003            }
1004            states.push(UnionReplayState {
1005                integrated: node,
1006                local_edges: Vec::new(),
1007            });
1008            return Ok(states);
1009        }
1010
1011        let component_states = self
1012            .disconnected_component_nodes(node)?
1013            .into_iter()
1014            .map(|component| self.union_replay_states(component))
1015            .collect::<Result<Vec<_>>>()?;
1016        let mut states = Vec::new();
1017        for components in component_states
1018            .iter()
1019            .map(|states| states.iter())
1020            .multi_cartesian_product()
1021        {
1022            let key = TraceKey::try_foata_join(
1023                components
1024                    .iter()
1025                    .map(|component| &self.graph[component.integrated].key),
1026                &self.wood,
1027            )
1028            .ok_or_else(|| eyre!("cannot join integrated prefixes for {operation}"))?;
1029            let mut local_edges = Vec::new();
1030            for component in components {
1031                local_edges.extend_from_slice(&component.local_edges);
1032            }
1033            let integrated = self.operation_node_index(&OperationNode { key })?;
1034            states.push(UnionReplayState {
1035                integrated,
1036                local_edges,
1037            });
1038        }
1039
1040        Ok(states)
1041    }
1042
1043    fn compute_4d_for_node(
1044        &self,
1045        node: NodeIndex,
1046        graph: &Graph,
1047        vakint: &Vakint,
1048        settings: &UVgenerationSettings,
1049    ) -> Result<(Local4dCts, IntegratedCts)> {
1050        let operation = &self.graph[node];
1051        if operation.key.is_empty() {
1052            return Ok((Local4dCts::root(), IntegratedCts::root()));
1053        }
1054
1055        if self.graph.is_disjoint_union(node) {
1056            let components = self.disconnected_component_nodes(node)?;
1057            let mut full_components = Vec::with_capacity(components.len());
1058            let mut integrated_components = Vec::with_capacity(components.len());
1059            for component in components {
1060                let component_operation = &self.graph[component];
1061                full_components.push(self.recursion_input_4d(component).wrap_err_with(|| {
1062                    format!("while loading 4D component {component_operation} for {operation}")
1063                })?);
1064                integrated_components.push(
1065                    self.compute_store
1066                        .require(component_operation)?
1067                        .integrated(component_operation)?,
1068                );
1069            }
1070
1071            let depth = graph.n_loops(
1072                &operation
1073                    .covers()
1074                    .expect("a non-root operation has a cover"),
1075            ) + 1;
1076            return Ok((
1077                Local4dCts::from_full_product(full_components),
1078                IntegratedCts::factorized_product(integrated_components, depth)?,
1079            ));
1080        }
1081
1082        let (parent, edge) = self
1083            .graph
1084            .unique_parent(node)
1085            .ok_or_else(|| eyre!("{operation} has no unique 4D parent"))?;
1086        let full = self
1087            .recursion_input_4d(parent)
1088            .wrap_err_with(|| format!("while loading the 4D parent for {operation}"))?;
1089        let step_order = self.graph[parent].key.op_count();
1090        let (current, given) = self.wood.current_given_pair(edge, step_order);
1091        let ctx = UVCtx::new(graph, settings);
1092        let integrated_approximation = Integrated::new(vakint, &self.wood.vakint_settings);
1093        let local = local_4d::uv_limit(&full, &ctx, &current, &given, &current, &given)?;
1094        let integrated = if settings.generate_integrated {
1095            integrated_approximation.run(&local, &ctx, &current, &given, &current, &given)?
1096        } else {
1097            IntegratedCts::root()
1098        };
1099
1100        Ok((local, integrated))
1101    }
1102
1103    fn local_3d_for_node(
1104        &self,
1105        node: NodeIndex,
1106        graph: &mut Graph,
1107        cutset: &CutSet,
1108        localizer: Localizer<'_>,
1109        settings: &UVgenerationSettings,
1110        projection_context: &mut Local4dProjectionContext,
1111    ) -> Result<CutComputation> {
1112        let operation = &self.graph[node];
1113        let forest_node = ForestNode {
1114            spinney: self.source_spinney(node),
1115            topo_order: operation.key.op_count(),
1116        };
1117        let local_3d = if operation.key.is_empty() {
1118            Local3DCts::Direct(Direct3dCts::root(graph, localizer)?)
1119        } else if settings.local_uv_cts_from_expanded_4d_integrands {
1120            // This is the only route that may produce `Projected4d`: the 4D
1121            // Taylor coefficient is kept factorized and final assembly later
1122            // attaches its untouched outer CFF. It never enters any of the
1123            // `Direct3dApproximation` replay operations below.
1124            let local_4d = self.compute_store.require(operation)?.local_4d(operation)?;
1125            Local3DCts::Projected4d(
1126                Projected4dApproximation::new(localizer, graph, settings)
1127                    .project_local_4d(local_4d, projection_context)?,
1128            )
1129        } else if self.graph.is_disjoint_union(node) {
1130            // Both direct variants replay the Taylor operators on the complete
1131            // post-energy-integration CFF. `explicit_orientation_sum_only`
1132            // changes selector materialization only, not this construction.
1133            let mut active_sectors = Vec::new();
1134            for state in self
1135                .union_replay_states(node)?
1136                .into_iter()
1137                .filter(|state| !state.local_edges.is_empty())
1138            {
1139                let integrated_operation = &self.graph[state.integrated];
1140                let mut edges = state.local_edges.into_iter().enumerate();
1141                let (offset, first_edge) = edges
1142                    .next()
1143                    .expect("a proper integrated prefix has a local suffix");
1144                let step_order = integrated_operation.key.op_count() + offset;
1145                let (current, given) = self.wood.current_given_pair(first_edge, step_order);
1146
1147                // An empty integrated prefix starts from the per-cut root integrand. Every
1148                // other prefix enters through the reduced branch of its first local operation.
1149                let mut sector = if integrated_operation.key.is_empty() {
1150                    let root = Direct3dCts::root(graph, localizer)?;
1151                    Direct3dApproximation::new(localizer, graph, settings)
1152                        .run_local(&root, &current, &given, &current, &given)?
1153                } else {
1154                    // Retain each connected coefficient's owner before the
1155                    // product enters a local suffix. Equal physical masses in
1156                    // a disjoint sibling must not acquire the parent's grading.
1157                    // Keep disconnected vacuum masses owned as well; the
1158                    // complete union still determines the localization kernel.
1159                    let components = if self.graph.is_disjoint_union(state.integrated) {
1160                        self.disconnected_component_nodes(state.integrated)?
1161                    } else {
1162                        vec![state.integrated]
1163                    };
1164                    let finite_components = components
1165                        .into_iter()
1166                        .map(|component| {
1167                            let operation = &self.graph[component];
1168                            let integrated = self
1169                                .compute_store
1170                                .require(operation)?
1171                                .integrated(operation)?;
1172                            Ok((
1173                                integrated.physical_finite_counterterm_atom(),
1174                                ForestNode {
1175                                    spinney: self.source_spinney(component),
1176                                    topo_order: operation.key.op_count(),
1177                                },
1178                            ))
1179                        })
1180                        .collect::<Result<Vec<_>>>()?;
1181                    let finite_counterterms = finite_components
1182                        .iter()
1183                        .map(|(coefficient, owner)| (coefficient, owner))
1184                        .collect::<Vec<_>>();
1185                    // A disabled (or algebraically vanishing) integrated prefix
1186                    // still owns its typed component-path frame. Replay that
1187                    // zero coefficient through the local suffix; it remains
1188                    // algebraically zero without deleting the union sector.
1189                    let prefix_node = ForestNode {
1190                        spinney: self.source_spinney(state.integrated),
1191                        topo_order: integrated_operation.key.op_count(),
1192                    };
1193                    Direct3dApproximation::new(localizer, graph, settings).run_integrated(
1194                        &finite_counterterms,
1195                        &prefix_node,
1196                        &current,
1197                        &given,
1198                        &current,
1199                        &given,
1200                    )?
1201                };
1202
1203                for (offset, edge) in edges {
1204                    let step_order = integrated_operation.key.op_count() + offset;
1205                    let (current, given) = self.wood.current_given_pair(edge, step_order);
1206                    sector = Direct3dApproximation::new(localizer, graph, settings)
1207                        .run_local(&sector, &current, &given, &current, &given)?;
1208                }
1209
1210                // Keep each active/frozen split after its root-path replay so any connected
1211                // descendants rescale only the loop variables still active in that sector.
1212                active_sectors.extend(sector.sectors()?.iter().cloned());
1213            }
1214
1215            Local3DCts::Direct(
1216                Direct3dCts::from_sectors(active_sectors)
1217                    .wrap_err_with(|| format!("{operation} has no proper integrated prefixes"))?,
1218            )
1219        } else {
1220            let (parent, edge) = self
1221                .graph
1222                .unique_parent(node)
1223                .ok_or_else(|| eyre!("{operation} has no unique local parent"))?;
1224            let parent_operation = &self.graph[parent];
1225            // An empty dependency frontier starts from the per-cut root integrand;
1226            // otherwise its typed local result remains the sequential accumulator.
1227            let root;
1228            let parent_local = if parent_operation.key.is_empty() {
1229                root = Direct3dCts::root(graph, localizer)?;
1230                &root
1231            } else {
1232                self.compute_store
1233                    .require(parent_operation)?
1234                    .cut(parent_operation, cutset)?
1235                    .local_3d
1236                    .direct()?
1237            };
1238            let parent_integrated = self
1239                .compute_store
1240                .require(parent_operation)?
1241                .integrated(parent_operation)
1242                .wrap_err_with(|| {
1243                    format!("while loading integrated parent {parent_operation} for {operation}")
1244                })?;
1245            let step_order = parent_operation.key.op_count();
1246            let (current, given) = self.wood.current_given_pair(edge, step_order);
1247            // `run` applies the next Taylor operator to the complete CFF and
1248            // applies both subtraction signs; no projected 4D coefficient,
1249            // external sign, or raw Foata-level product enters this route.
1250            Local3DCts::Direct(Direct3dApproximation::new(localizer, graph, settings).run(
1251                parent_local,
1252                parent_integrated,
1253                &current,
1254                &given,
1255                &current,
1256                &given,
1257            )?)
1258        };
1259
1260        let integrated = self
1261            .compute_store
1262            .require(operation)?
1263            .integrated(operation)?;
1264        let final_builder = FinalIntegrandBuilder::new(localizer, settings);
1265        let final_integrands = match &local_3d {
1266            Local3DCts::Direct(direct) => {
1267                final_builder.build_direct(graph, &forest_node, direct, integrated)?
1268            }
1269            Local3DCts::Projected4d(projected) => {
1270                final_builder.build_projected(graph, &forest_node, projected, integrated)?
1271            }
1272        };
1273
1274        Ok(CutComputation {
1275            local_3d,
1276            final_integrands,
1277        })
1278    }
1279
1280    pub fn integrate(
1281        &mut self,
1282        graph: &Graph,
1283        vakint: &Vakint,
1284        settings: &UVgenerationSettings,
1285    ) -> Result<()> {
1286        for (order, nidx) in self
1287            .graph
1288            .topo_sort_kahn(DirectionBasis::Underlying)?
1289            .into_iter()
1290            .enumerate()
1291        {
1292            debug!(order, nidx=%nidx, key=%self.graph[nidx], "Computing hedge-poset 4D term");
1293            let operation = self.graph[nidx].clone();
1294            let (local_4d, integrated) = self.compute_4d_for_node(nidx, graph, vakint, settings)?;
1295            let cover = operation
1296                .covers()
1297                .unwrap_or_else(|| self.graph.empty_subgraph())
1298                .string_label();
1299            let source = self.source_spinney(nidx).filter().string_label();
1300            debug_tags!(#generation, #uv, #integrated, #graph, #term, #inspect;
1301                stage = "hedge_poset_4d_node_done",
1302                order,
1303                node_index = %nidx,
1304                forest_term = %operation,
1305                cover = %cover,
1306                source = %source,
1307                is_union = self.graph.is_disjoint_union(nidx),
1308                log.local_4d = local_4d.atom(),
1309                log.integrated_pole = integrated.physical_pole_atom(),
1310                log.integrated_finite = integrated.physical_finite_counterterm_atom(),
1311                "Computed hedge-poset 4D node"
1312            );
1313            let computed = self.compute_store.entry(operation).or_default();
1314            computed.local_4d = Some(local_4d);
1315            computed.integrated = Some(integrated);
1316        }
1317
1318        Ok(())
1319    }
1320
1321    pub(crate) fn compute(
1322        &mut self,
1323        graph: &mut Graph,
1324        vakint: &Vakint,
1325        orientation: OrientationProjection<'_>,
1326        settings: &UVgenerationSettings,
1327    ) -> Result<()> {
1328        if settings.generate_integrated || settings.local_uv_cts_from_expanded_4d_integrands {
1329            // The projected route needs local 4D coefficients even when their
1330            // integrated addbacks are disabled.
1331            self.integrate(graph, vakint, settings)?;
1332        } else {
1333            // Direct local 3D generation only consumes the integrated branch,
1334            // which is identically zero when integration is disabled.  Do not
1335            // construct and simplify unrelated 4D local atoms merely to obtain
1336            // that zero.  Seed every node because disconnected-union replay can
1337            // read an integrated prefix before that node's cut result is built.
1338            let zero = IntegratedCts::root();
1339            for (_, _, operation) in self.graph.iter_nodes() {
1340                self.compute_store
1341                    .entry(operation.clone())
1342                    .or_default()
1343                    .integrated = Some(zero.clone());
1344            }
1345        }
1346        let mut projection_context = Local4dProjectionContext::default();
1347
1348        for (compatible_subset, cutset) in self.cuts.clone() {
1349            let localizer = Localizer::new(&cutset, orientation);
1350            for (order, nidx) in self
1351                .compatible_topological_order(&compatible_subset)?
1352                .into_iter()
1353                .enumerate()
1354            {
1355                debug!(order, nidx=%nidx, key=%self.graph[nidx], "Computing hedge-poset per-cut term");
1356                let operation = self.graph[nidx].clone();
1357                // Direct local-3D nodes Taylor-expand the complete post-energy-integration CFF.
1358                // Expanded-4D nodes instead project their typed local coefficients and attach
1359                // the outer CFF only during final assembly.
1360                let cut_computation = self.local_3d_for_node(
1361                    nidx,
1362                    graph,
1363                    &cutset,
1364                    localizer,
1365                    settings,
1366                    &mut projection_context,
1367                )?;
1368                self.compute_store
1369                    .entry(operation)
1370                    .or_default()
1371                    .cuts
1372                    .insert(cutset.clone(), cut_computation);
1373            }
1374        }
1375
1376        Ok(())
1377    }
1378
1379    pub(crate) fn orientation_parametric_exprs(
1380        &self,
1381        graph: &Graph,
1382        _settings: &UVgenerationSettings,
1383    ) -> Result<Vec<ParametricIntegrands>> {
1384        let split_momentum_replacements = graph
1385            .iter_edges_of(
1386                &graph
1387                    .full_filter()
1388                    .subtract(&graph.initial_state_cut)
1389                    .subtract(&graph.tree_edges),
1390            )
1391            .filter_map(|(pair, edge_index, _)| {
1392                (!matches!(pair, HedgePair::Unpaired { .. }))
1393                    .then(|| GS.split_mom_pattern_simple(edge_index))
1394            })
1395            .collect::<Vec<_>>();
1396        let mut expressions = Vec::with_capacity(self.cuts.len());
1397
1398        for (compatible_subset, cutset) in &self.cuts {
1399            let mut sum: Option<FinalIntegrands> = None;
1400            for nidx in self.compatible_topological_order(compatible_subset)? {
1401                let operation = &self.graph[nidx];
1402                let terms = self
1403                    .compute_store
1404                    .require(operation)?
1405                    .cut(operation, cutset)?
1406                    .final_integrands
1407                    .map_expressions(|integrand| Ok(integrand.clone().collect_color()))?;
1408                sum = Some(match sum {
1409                    Some(sum) => sum.zip_add(terms).wrap_err_with(|| {
1410                        format!("while aggregating hedge-poset term {operation} for cut {cutset:?}")
1411                    })?,
1412                    None => terms,
1413                });
1414            }
1415            // A per-node final integrand retains inspect/export provenance but
1416            // is not independently evaluator-ready.  In particular, temporal
1417            // components in localized integrated counterterms can cancel only
1418            // in the completed signed forest.  Preserve that exact cancellation
1419            // before the final orientation-local momentum split.
1420            let integrands = sum
1421                .ok_or_else(|| eyre!("No terms in hedge-poset forest for cut {cutset:?}"))?
1422                .map_expressions(|integrand| {
1423                    Ok(integrand
1424                        .replace_multiple(&split_momentum_replacements)
1425                        .replace(function!(GS.den, W_.a_, W_.b_, W_.c_, W_.d_))
1426                        .with(W_.d_))
1427                })?;
1428            expressions.push(ParametricIntegrands::from_final(integrands, cutset.clone()));
1429        }
1430
1431        Ok(expressions)
1432    }
1433
1434    pub(crate) fn export_node_expressions(
1435        &self,
1436        forest_index: usize,
1437        post_process: &mut impl FnMut(Atom) -> Atom,
1438    ) -> Result<Vec<UVForestNodeExpression>> {
1439        let (cut_compatible_forest_subset, cutset) = self
1440            .cuts
1441            .first()
1442            .ok_or_else(|| eyre!("No cuts in hedge-poset forest export"))?;
1443        let mut terms = Vec::new();
1444        for (node_index, nidx) in self
1445            .compatible_topological_order(cut_compatible_forest_subset)?
1446            .into_iter()
1447            .enumerate()
1448        {
1449            let operation = &self.graph[nidx];
1450            let computed = self.compute_store.require(operation)?;
1451            let cut_computation = computed.cut(operation, cutset)?;
1452            let final_integrands = cut_computation
1453                .final_integrands
1454                .clone()
1455                .into_integrands()
1456                .resolved()?
1457                .map(|numerator| post_process(numerator.clone()));
1458            let node_key = operation.to_string();
1459            let provenance = self.node_export_3d_provenance(nidx, &cut_computation.local_3d);
1460            for (term_index, (&residue_index, numerator)) in final_integrands.iter().enumerate() {
1461                terms.push(UVForestNodeExpression {
1462                    forest_index,
1463                    node_index,
1464                    node_key: node_key.clone(),
1465                    term_index,
1466                    residue_index,
1467                    numerator: numerator.clone(),
1468                    forest_provenance: Some(provenance.clone()),
1469                });
1470            }
1471        }
1472
1473        Ok(terms)
1474    }
1475
1476    pub(crate) fn export_4d_node_expressions(
1477        &self,
1478        forest_index: usize,
1479    ) -> Result<Vec<UVForestNodeExpression>> {
1480        let (cut_compatible_forest_subset, _) = self
1481            .cuts
1482            .first()
1483            .ok_or_else(|| eyre!("No cuts in hedge-poset forest export"))?;
1484        self.compatible_topological_order(cut_compatible_forest_subset)?
1485            .into_iter()
1486            .enumerate()
1487            .map(|(node_index, nidx)| {
1488                let operation = &self.graph[nidx];
1489                let local_4d = self.compute_store.require(operation)?.local_4d(operation)?;
1490                Ok(UVForestNodeExpression {
1491                    forest_index,
1492                    node_index,
1493                    node_key: operation.to_string(),
1494                    term_index: 0,
1495                    residue_index: crate::cff::CutCFFIndex::new_all_none(),
1496                    numerator: local_4d.atom().clone(),
1497                    forest_provenance: Some(self.node_export_4d_provenance(nidx, local_4d)),
1498                })
1499            })
1500            .collect()
1501    }
1502
1503    fn node_parent_keys(&self, node: NodeIndex) -> Vec<String> {
1504        let mut parent_keys = self
1505            .graph
1506            .parents(node)
1507            .map(|(parent, _)| self.graph[parent].to_string())
1508            .collect::<Vec<_>>();
1509        parent_keys.sort();
1510        parent_keys.dedup();
1511        parent_keys
1512    }
1513
1514    fn node_export_4d_provenance(
1515        &self,
1516        node: NodeIndex,
1517        local_4d: &Local4dCts,
1518    ) -> Arc<UVForestProvenance> {
1519        Arc::new(UVForestProvenance {
1520            node: UVForestNodeProvenance {
1521                parent_keys: self.node_parent_keys(node),
1522                local: UVForestLocalProvenance::FourD {
1523                    components: local_4d.provenance_components().to_vec(),
1524                    branches: local_4d.provenance_branches().to_vec(),
1525                },
1526            },
1527        })
1528    }
1529
1530    fn node_export_3d_provenance(
1531        &self,
1532        node: NodeIndex,
1533        local_3d: &Local3DCts,
1534    ) -> Arc<UVForestProvenance> {
1535        Arc::new(UVForestProvenance {
1536            node: UVForestNodeProvenance {
1537                parent_keys: self.node_parent_keys(node),
1538                local: UVForestLocalProvenance::ThreeD {
1539                    projection_paths: local_3d.projection_paths(),
1540                },
1541            },
1542        })
1543    }
1544
1545    pub(crate) fn renormalization_part_of_ends(
1546        &self,
1547        graph: &Graph,
1548        settings: &UVgenerationSettings,
1549    ) -> Result<RenormalizationPart> {
1550        let mut sum = Atom::Zero;
1551        let marker = UvMarker::new(settings);
1552
1553        let wild = Atom::var(W_.x___);
1554
1555        let replacements =
1556            graph.integrand_replacement(&graph.full_filter(), &graph.loop_momentum_basis, &[wild]);
1557        for (node, mut crown, key) in self.graph.iter_nodes() {
1558            if crown.any(|r| self.graph.flow(r).is_source()) {
1559                continue;
1560            }
1561
1562            let forest_node = key.forest_node(graph, key.key.op_count())?;
1563            // A disconnected terminal can combine components with different schemes. Select
1564            // each component's projection before multiplying; the aggregate integrated value
1565            // only represents the homogeneous all-finite and all-pole projections.
1566            let components = if self.graph.is_disjoint_union(node) {
1567                self.disconnected_component_nodes(node)?
1568            } else {
1569                vec![node]
1570            };
1571            let physical = components.into_iter().try_fold(
1572                Atom::one(),
1573                |product, component| -> Result<Atom> {
1574                    let component_key = &self.graph[component];
1575                    let integrated = self
1576                        .compute_store
1577                        .require(component_key)?
1578                        .integrated(component_key)?;
1579                    let projection = match self.source_spinney(component).renormalization_scheme {
1580                        ApproximationType::MUV | ApproximationType::IR => {
1581                            integrated.physical_finite_counterterm_atom()
1582                        }
1583                        ApproximationType::PolePart => integrated.physical_pole_atom(),
1584                        scheme => {
1585                            return Err(eyre!("No terminal counterterm projection for {scheme}"));
1586                        }
1587                    };
1588                    Ok(product * projection)
1589                },
1590            )?;
1591            let atom = marker.prefix(&graph.full_filter(), forest_node.subgraph(), &physical);
1592            debug!(
1593                key=%key,
1594               expr = % atom.expand_num().log_print(None),"Term before simplification"
1595            );
1596            let atom = (&atom
1597                * &graph.global_prefactor.projector
1598                * &graph.global_prefactor.num
1599                * &graph.overall_factor)
1600                .simplify_color()
1601                .expand_num()
1602                .to_dots();
1603
1604            debug!(
1605                key=%key,
1606               expr = % atom.log_print(None),"Term"
1607            );
1608            sum += atom;
1609        }
1610
1611        Ok(RenormalizationPart::new(
1612            sum.replace_multiple(&replacements)
1613                .replace(GS.m_uv_expansion)
1614                .with(GS.m_uv_vacuum),
1615            self.graph.n_nodes(),
1616        ))
1617    }
1618
1619    pub fn debug_walk(&self) {
1620        let mut cover_groups: BTreeMap<SuBitGraph, Vec<NodeIndex>> = BTreeMap::new();
1621
1622        self.graph
1623            .topo_sort_kahn(DirectionBasis::Underlying)
1624            .unwrap()
1625            .iter()
1626            .for_each(|nidx| {
1627                let trace_key = &self.graph[*nidx];
1628                cover_groups
1629                    .entry(trace_key.covers().unwrap_or(self.graph.empty_subgraph()))
1630                    .and_modify(|e| e.push(*nidx))
1631                    .or_insert_with(|| vec![*nidx]);
1632
1633                println!("Node {}:{}", nidx, self.node_label(*nidx));
1634            });
1635
1636        println!("edge [constraint=true style=invis];");
1637        for (a, b) in cover_groups.values().tuple_windows() {
1638            println!("{}->{}", a.first().unwrap(), b.first().unwrap())
1639        }
1640        println!("edge [style=solid];");
1641
1642        for (s, g) in cover_groups.iter() {
1643            println!("subgraph group_{} {{rank=same; ", s.string_label());
1644            for n in g {
1645                println!("{};", n.0);
1646            }
1647            println!("}}");
1648        }
1649    }
1650}
1651
1652impl Display for Forests {
1653    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1654        let labels: AHashMap<_, _> = self
1655            .graph
1656            .iter_nodes()
1657            .map(|(node, _, key)| (key.clone(), self.node_label(node)))
1658            .collect();
1659        self.graph.dot_impl_fmt(
1660            f,
1661            &self.graph.full_filter(),
1662            "start=2;\n",
1663            &|_| None,
1664            &|_| None,
1665            &|v| Some(format!("label=\"{}\"", labels[v])),
1666        )
1667    }
1668}
1669
1670#[cfg(test)]
1671mod tests {
1672    use crate::{
1673        dot,
1674        graph::{FeynmanGraph, Graph, parse::IntoGraph},
1675        initialisation::test_initialise,
1676        processes::DotExportSettings,
1677        uv::{
1678            UltravioletGraph, Wood as OldWood, approx::local_4d::Local4dBranch,
1679            settings::RenormalizationPrescriptionSettings,
1680        },
1681    };
1682
1683    use super::*;
1684    use color_eyre::Result;
1685
1686    impl Forests {
1687        fn normalized_node_label(&self, node: NodeIndex) -> String {
1688            let label = self.node_label(node);
1689            let mut normalized = String::with_capacity(label.len());
1690            let mut chars = label.chars().peekable();
1691
1692            while let Some(ch) = chars.next() {
1693                normalized.push(ch);
1694                if ch != '(' {
1695                    continue;
1696                }
1697
1698                let mut digits = String::new();
1699                while chars.peek().is_some_and(|next| next.is_ascii_digit()) {
1700                    digits.push(chars.next().expect("peeked digit must exist"));
1701                }
1702
1703                if digits.is_empty() || !matches!(chars.peek(), Some(')')) {
1704                    normalized.push_str(&digits);
1705                } else {
1706                    normalized.push('_');
1707                }
1708            }
1709
1710            normalized
1711        }
1712
1713        fn normalized_node_labels_with_cover(&self, cover_label: &str) -> Vec<String> {
1714            let mut labels = self
1715                .graph
1716                .iter_nodes()
1717                .filter(|(_, _, operation)| {
1718                    operation
1719                        .covers()
1720                        .is_some_and(|cover| cover.string_label() == cover_label)
1721                })
1722                .map(|(node, _, _)| self.normalized_node_label(node))
1723                .collect::<Vec<_>>();
1724            labels.sort();
1725            labels
1726        }
1727    }
1728
1729    #[test]
1730    fn three_d_compute_skips_unused_four_d_atoms_when_integration_is_disabled() -> Result<()> {
1731        test_initialise().unwrap();
1732        for project_local_4d in [false, true] {
1733            let mut graph: Graph = dot!(
1734                digraph scalar_self_energy {
1735                    edge [particle="scalar_1"];
1736                    node [num=1];
1737                    ext [style=invis];
1738                    ext -> a;
1739                    b -> ext;
1740                    a -> b;
1741                    a -> b;
1742                },
1743                "scalars"
1744            )?;
1745            let settings = GenerationSettings {
1746                uv: UVgenerationSettings {
1747                    generate_integrated: false,
1748                    local_uv_cts_from_expanded_4d_integrands: project_local_4d,
1749                    ..Default::default()
1750                },
1751                ..Default::default()
1752            };
1753            let orientation_pattern = crate::settings::global::OrientationPattern::default();
1754            let options = graph.denominator_only_cff_3d_expression_options();
1755            let production = graph.generate_3d_expression_for_integrand(
1756                &[],
1757                &graph.get_esurface_canonization(&graph.loop_momentum_basis),
1758                &options,
1759                Some(&Atom::one()),
1760            )?;
1761            let mut forests = Wood::new(CutStructure::empty(&graph), &graph, &settings)?.unfold();
1762
1763            forests.compute(
1764                &mut graph,
1765                crate::utils::vakint()?,
1766                OrientationProjection::exact_expression(
1767                    &production,
1768                    &options,
1769                    &orientation_pattern,
1770                    true,
1771                ),
1772                &settings.uv,
1773            )?;
1774
1775            assert_eq!(forests.compute_store.entries.len(), forests.graph.n_nodes());
1776            for (_, _, operation) in forests.graph.iter_nodes() {
1777                let computed = forests.compute_store.require(operation)?;
1778                assert_eq!(
1779                    computed.local_4d.is_some(),
1780                    project_local_4d,
1781                    "4D coefficients must be constructed exactly when the projected route needs them for {operation}"
1782                );
1783                let integrated = computed.integrated(operation)?;
1784                assert!(integrated.physical_pole_atom().is_zero());
1785                assert!(integrated.physical_finite_counterterm_atom().is_zero());
1786                assert!(
1787                    !computed.cuts.is_empty(),
1788                    "both routes must compute a cut for {operation}"
1789                );
1790                assert!(
1791                    computed
1792                        .cuts
1793                        .values()
1794                        .all(|cut| cut.final_integrands.iter().next().is_some()),
1795                    "every computed cut must retain a 3D final integrand for {operation}"
1796                );
1797            }
1798        }
1799
1800        Ok(())
1801    }
1802
1803    #[test]
1804    fn vacuum_subtraction_prunes_only_full_observable_spinneys() -> Result<()> {
1805        use crate::settings::global::MediumMode;
1806
1807        test_initialise()?;
1808        let graphs: Vec<Graph> = dot!(
1809            digraph sunrise {
1810                edge [particle="scalar_1"];
1811                v1 -> v2; v1 -> v2; v1 -> v2;
1812            }
1813            digraph dumbbell {
1814                edge [particle="scalar_1"];
1815                v1 -> v2;
1816                v2 -> v2;
1817                v1 -> v1; v1 -> v1;
1818            }
1819            digraph sunrise_with_bridge {
1820                edge [particle="scalar_1"];
1821                ext [style=invis];
1822                v1 -> v2; v1 -> v2; v1 -> v2;
1823                v2 -> v3;
1824                ext -> v3; v3 -> ext;
1825            }, "scalars"
1826        )?;
1827        for graph in graphs {
1828            // The counterterm covers the loop part, leaving any bridge untouched.
1829            let full = InternalSubGraph::cleaned_filter_pessimist(
1830                graph.full_filter().subtract(&graph.tree_edges),
1831                graph.as_ref(),
1832            );
1833            for subtract_uv in [false, true] {
1834                let mut settings = GenerationSettings::default();
1835                settings.uv.subtract_uv = subtract_uv;
1836                let ordinary = Wood::new(CutStructure::empty(&graph), &graph, &settings)?;
1837                let all = ordinary
1838                    .graph
1839                    .iter_nodes()
1840                    .map(|(_, _, spinney)| spinney.filter().clone())
1841                    .collect::<BTreeSet<_>>();
1842                let proper = all
1843                    .iter()
1844                    .filter(|filter| **filter != full.filter)
1845                    .cloned()
1846                    .collect::<BTreeSet<_>>();
1847                assert!(proper.iter().any(|filter| filter.is_empty()));
1848                if subtract_uv {
1849                    assert!(all.contains(&full.filter), "{}", graph.name);
1850                    assert!(proper.len() > 1, "{}", graph.name);
1851                }
1852                for mode in [
1853                    MediumMode::Vacuum,
1854                    MediumMode::ThermodynamicEquilibrium,
1855                    MediumMode::ZeroTemperatureEquilibrium,
1856                ] {
1857                    settings.medium.mode = mode;
1858                    for vacuum_subtraction in [false, true] {
1859                        if mode == MediumMode::Vacuum && vacuum_subtraction {
1860                            continue; // Generation validation rejects this combination.
1861                        }
1862                        settings.medium.vacuum_subtraction = vacuum_subtraction;
1863                        let expected = if vacuum_subtraction { &proper } else { &all };
1864                        let wood = Wood::new(CutStructure::empty(&graph), &graph, &settings)?;
1865                        assert_eq!(
1866                            &wood
1867                                .graph
1868                                .iter_nodes()
1869                                .map(|(_, _, spinney)| spinney.filter().clone())
1870                                .collect::<BTreeSet<_>>(),
1871                            expected,
1872                            "{}: {mode:?}, subtract_uv={subtract_uv}, vacuum_subtraction={vacuum_subtraction}",
1873                            graph.name
1874                        );
1875                        // The shared filter also supplies the legacy executor.
1876                        let mut spinneys = graph.classified_spinneys(
1877                            &graph.full_filter(),
1878                            &settings.uv,
1879                            &graph.loop_momentum_basis,
1880                        )?;
1881                        graph.remove_full_observable_spinney(&mut spinneys, &settings.medium);
1882                        assert_eq!(
1883                            &spinneys
1884                                .iter()
1885                                .map(|spinney| spinney.filter().clone())
1886                                .collect::<BTreeSet<_>>(),
1887                            expected,
1888                            "legacy {}: {mode:?}, subtract_uv={subtract_uv}, vacuum_subtraction={vacuum_subtraction}",
1889                            graph.name
1890                        );
1891                    }
1892                }
1893            }
1894        }
1895        Ok(())
1896    }
1897
1898    #[test]
1899    fn vacuum_subtraction_commutes_with_local_and_integrated_forests() -> Result<()> {
1900        use crate::{
1901            integrands::process::param_builder::ParamBuilderGraph,
1902            numerator::{
1903                soft_energy_certificate::{EnergyZeroOutcome, SoftEnergyAlgebra},
1904                symbolica_ext::NumeratorAtomExt,
1905            },
1906            settings::global::MediumMode,
1907        };
1908        use symbolica::atom::AtomView;
1909        use three_dimensional_reps::thermal::OCCUPATION_FORM;
1910        test_initialise()?;
1911        let graphs: Vec<Graph> = dot!(
1912            digraph nested {
1913                edge [particle="scalar_1"]; node [num=1];
1914                v1 -> v2; v1 -> v2; v1 -> v2;
1915                v1 -> v1;
1916            }
1917            digraph disjoint {
1918                edge [particle="scalar_1"]; node [num=1];
1919                v1 -> v2; v1 -> v2;
1920                v3 -> v4; v3 -> v4;
1921                v2 -> v3; v1 -> v4;
1922            }, "scalars"
1923        )?;
1924        for graph in graphs {
1925            for medium in [
1926                MediumMode::ThermodynamicEquilibrium,
1927                MediumMode::ZeroTemperatureEquilibrium,
1928            ] {
1929                for generate_integrated in [false, true] {
1930                    let mut settings = GenerationSettings::default();
1931                    settings.medium.mode = medium;
1932                    settings.medium.vacuum_subtraction = true;
1933                    settings.uv.softct = false;
1934                    settings.uv.generate_integrated = generate_integrated;
1935                    let mut graph = graph.clone();
1936                    let options = graph.production_cff_3d_expression_options(&settings)?;
1937                    let contract =
1938                        graph.paired_edges(&graph.tree_edges.subtract(&graph.initial_state_cut));
1939                    let production = graph
1940                        .generate_3d_expression_for_integrand(&contract, &None, &options, None)?;
1941                    let mut results = Vec::new();
1942                    for early_subtraction in [false, true] {
1943                        let projection = OrientationProjection::exact_expression(
1944                            &production,
1945                            &options,
1946                            &settings.orientation_pattern,
1947                            true,
1948                        )
1949                        .with_vacuum_subtraction(early_subtraction);
1950                        // Both routes use precisely the same proper forests. Only the
1951                        // boundary where the full vacuum projection acts is different.
1952                        let mut forests =
1953                            Wood::new(CutStructure::empty(&graph), &graph, &settings)?.unfold();
1954                        forests.compute(
1955                            &mut graph,
1956                            crate::utils::vakint()?,
1957                            projection,
1958                            &settings.uv,
1959                        )?;
1960                        results.push(forests.orientation_parametric_exprs(&graph, &settings.uv)?);
1961                    }
1962                    assert_eq!(results[0].len(), results[1].len());
1963                    for (late, early) in results[0].iter().zip(&results[1]) {
1964                        let late = late.integrands.resolved()?;
1965                        let early = early.integrands.resolved()?;
1966                        assert_eq!(late.iter().count(), early.iter().count());
1967                        for ((late_key, raw), (early_key, actual)) in late.iter().zip(early.iter())
1968                        {
1969                            assert_eq!(late_key, early_key);
1970                            let vacuum = graph.make_thermal_distributions_explicit(
1971                                raw,
1972                                MediumMode::Vacuum,
1973                                graph.iter_edges().map(|(_, edge, _)| edge),
1974                            )?;
1975                            assert!(
1976                                raw.is_zero() || raw.contains_symbol(GS.thermal_weight_wrapper)
1977                            );
1978                            assert!(
1979                                actual.is_zero()
1980                                    || actual.contains_symbol(GS.thermal_weight_wrapper)
1981                            );
1982                            // Resolve only this scalar comparison. Final energy factors can
1983                            // group as E^-5 or E^-1/P^2, with E^2 = P, after the two routes.
1984                            let difference = (actual - (raw - vacuum))
1985                                .replace(function!(GS.thermal_weight_wrapper, W_.a_))
1986                                .with(W_.a_)
1987                                .replace_multiple(&*OCCUPATION_FORM)
1988                                .expand();
1989                            // Certify each occupation coefficient separately so unrelated
1990                            // energy denominators do not multiply the proof's term budget.
1991                            let mut occupations = BTreeSet::new();
1992                            difference.visitor(&mut |view| {
1993                                if let AtomView::Fun(call) = view
1994                                    && call.get_symbol() == GS.thermal_distribution
1995                                {
1996                                    occupations.insert(view.to_owned());
1997                                    return false;
1998                                }
1999                                true
2000                            });
2001                            let coefficients = difference
2002                                .coefficient_list_exact(&occupations.into_iter().collect_vec())
2003                                .expect("scalar difference must be polynomial in occupations");
2004                            for (occupation, coefficient) in coefficients {
2005                                let certificate =
2006                                    SoftEnergyAlgebra::default().scalar_zero(&coefficient)?;
2007                                assert!(
2008                                    matches!(certificate, EnergyZeroOutcome::Zero(_)),
2009                                    "{}: {medium:?}, integrated={generate_integrated}, residue={late_key:?}, occupation={occupation}: {certificate:?}: {coefficient}",
2010                                    graph.name
2011                                );
2012                            }
2013                        }
2014                    }
2015                }
2016            }
2017        }
2018        Ok(())
2019    }
2020
2021    #[test]
2022    fn local_leaf_operations_follow_dependency_frontiers() -> Result<()> {
2023        test_initialise().unwrap();
2024        let dumbell: Graph = dot!(
2025            digraph G{
2026                edge [particle="scalar_1"];
2027                v1 -> v2;
2028                v2 -> v2;
2029                v1 -> v1;v1 -> v1;
2030            },"scalars"
2031        )?;
2032
2033        let forests = Wood::new(
2034            CutStructure::empty(&dumbell),
2035            &dumbell,
2036            &GenerationSettings::default(),
2037        )?
2038        .unfold();
2039
2040        let frontier_label = |operation: &OperationNode| {
2041            operation
2042                .covers()
2043                .map_or_else(|| "∅".to_string(), |cover| cover.string_label())
2044        };
2045        let leaf_labels = |node| {
2046            forests
2047                .local_leaf_operations(node)
2048                .iter()
2049                .map(|leaf| {
2050                    (
2051                        leaf.op.order.string_label(),
2052                        frontier_label(&forests.graph[leaf.frontier]),
2053                    )
2054                })
2055                .collect::<Vec<_>>()
2056        };
2057
2058        let root_disconnected = forests
2059            .graph
2060            .iter_nodes()
2061            .find_map(|(node, _, _)| {
2062                forests
2063                    .normalized_node_label(node)
2064                    .starts_with("{36,F}:")
2065                    .then_some(node)
2066            })
2067            .expect("lopsided dumbbell should contain a root disconnected frontier");
2068        assert_eq!(
2069            leaf_labels(root_disconnected),
2070            vec![
2071                ("36".to_string(), "∅".to_string()),
2072                ("F".to_string(), "∅".to_string())
2073            ]
2074        );
2075        let structured_dot = forests.dot_serialize();
2076        let mut join_markers = Vec::new();
2077        for (node, _, _) in forests.graph.iter_nodes() {
2078            for leaf in forests.local_leaf_operations(node) {
2079                let frontier_depth = forests.graph[leaf.frontier].key.op_count();
2080                let (current, given) = forests
2081                    .wood
2082                    .current_given_pair(leaf.op.data, frontier_depth);
2083                if current.subgraph() == &leaf.op.order {
2084                    continue;
2085                }
2086                join_markers.push(
2087                    UvMarker::subgraph(current.subgraph(), given.subgraph())
2088                        .printer(SpensoPrintSettings::typst_options())
2089                        .to_string(),
2090                );
2091            }
2092        }
2093        assert!(
2094            !join_markers.is_empty(),
2095            "lopsided dumbbell must contain a disconnected join"
2096        );
2097        for marker in join_markers {
2098            assert!(
2099                structured_dot.contains(&marker),
2100                "structured DOT must use the runtime current/given marker {marker}"
2101            );
2102        }
2103
2104        let dependent_disconnected = forests
2105            .graph
2106            .iter_nodes()
2107            .find_map(|(node, _, _)| {
2108                forests
2109                    .normalized_node_label(node)
2110                    .starts_with("{C} · {36,F}:")
2111                    .then_some(node)
2112            })
2113            .expect("lopsided dumbbell should contain a C-dependent disconnected frontier");
2114        assert_eq!(
2115            leaf_labels(dependent_disconnected),
2116            vec![
2117                ("F".to_string(), "C".to_string()),
2118                ("36".to_string(), "∅".to_string())
2119            ]
2120        );
2121        let replay_states = forests.union_replay_states(dependent_disconnected)?;
2122        assert_eq!(replay_states.len(), 6);
2123        assert_eq!(
2124            replay_states
2125                .iter()
2126                .filter(|state| !state.local_edges.is_empty())
2127                .count(),
2128            5
2129        );
2130        Ok(())
2131    }
2132
2133    #[test]
2134    fn union_terms_project_factorized_typed_4d_values() -> Result<()> {
2135        test_initialise().unwrap();
2136        let graph: Graph = dot!(
2137            digraph G{
2138                edge [particle="scalar_1"];
2139                v1 -> v2;
2140                v2 -> v2;
2141                v1 -> v1;v1 -> v1;
2142            },"scalars"
2143        )?;
2144        // Disable integrated terms so each union equality isolates factorized
2145        // local 4D composition.
2146        let settings = GenerationSettings {
2147            uv: UVgenerationSettings {
2148                generate_integrated: false,
2149                local_uv_cts_from_expanded_4d_integrands: true,
2150                ..Default::default()
2151            },
2152            ..Default::default()
2153        };
2154        let cut_structure = CutStructure::empty(&graph);
2155        let mut forests = Wood::new(cut_structure, &graph, &settings)?.unfold();
2156        forests.integrate(&graph, crate::utils::vakint()?, &settings.uv)?;
2157
2158        let unions = forests
2159            .graph
2160            .iter_nodes()
2161            .filter_map(|(node, _, operation)| {
2162                (forests.graph.is_disjoint_union(node) && !operation.key.is_empty()).then_some(node)
2163            })
2164            .collect::<Vec<_>>();
2165        assert!(!unions.is_empty());
2166
2167        for union in unions {
2168            let components = forests.disconnected_component_nodes(union)?;
2169            let expected = Local4dCts::from_full_product(
2170                components
2171                    .into_iter()
2172                    .map(|component| forests.recursion_input_4d(component))
2173                    .collect::<Result<Vec<_>>>()?,
2174            );
2175            let operation = &forests.graph[union];
2176            let local = forests
2177                .compute_store
2178                .require(operation)?
2179                .local_4d(operation)?;
2180
2181            // Compare the complete typed union expression; no per-cut parent cache
2182            // or component-path replay participates.
2183            assert!((local.atom().collect_factors() - expected.atom().collect_factors()).is_zero());
2184        }
2185        Ok(())
2186    }
2187
2188    #[test]
2189    fn union_terms_replay_component_paths_from_typed_roots() -> Result<()> {
2190        use crate::graph::feynman_graph::FeynmanGraph;
2191
2192        test_initialise().unwrap();
2193        let mut graph: Graph = dot!(
2194            digraph G{
2195                edge [particle="scalar_1"];
2196                v1 -> v2;
2197                v2 -> v2;
2198                v1 -> v1;v1 -> v1;
2199            },"scalars"
2200        )?;
2201        let settings = GenerationSettings::default();
2202        let cut_structure = CutStructure::empty(&graph);
2203        let cutset = cut_structure
2204            .cuts
2205            .first()
2206            .expect("empty cut structure has one cut")
2207            .clone();
2208        let mut forests = Wood::new(cut_structure, &graph, &settings)?.unfold();
2209
2210        let root_disconnected = forests
2211            .graph
2212            .iter_nodes()
2213            .find_map(|(node, _, _)| {
2214                forests
2215                    .normalized_node_label(node)
2216                    .starts_with("{36,F}:")
2217                    .then_some(node)
2218            })
2219            .expect("lopsided dumbbell should contain a root disconnected frontier");
2220        let dependent_disconnected = forests
2221            .graph
2222            .iter_nodes()
2223            .find_map(|(node, _, _)| {
2224                forests
2225                    .normalized_node_label(node)
2226                    .starts_with("{C} · {36,F}:")
2227                    .then_some(node)
2228            })
2229            .expect("lopsided dumbbell should contain a C-dependent disconnected frontier");
2230        let non_root_frontier = forests
2231            .local_leaf_operations(dependent_disconnected)
2232            .first()
2233            .expect("dependent disconnected operation has a leaf")
2234            .frontier;
2235        assert_eq!(
2236            forests.graph[non_root_frontier]
2237                .covers()
2238                .expect("non-root frontier has a cover")
2239                .string_label(),
2240            "C"
2241        );
2242
2243        let operations = forests
2244            .graph
2245            .iter_nodes()
2246            .map(|(_, _, operation)| operation.clone())
2247            .collect::<Vec<_>>();
2248        // Zero integrated terms isolate component-path replay in the local accumulator.
2249        for operation in operations {
2250            forests
2251                .compute_store
2252                .entry(operation)
2253                .or_default()
2254                .integrated = Some(IntegratedCts::root());
2255        }
2256
2257        let orientation_pattern = crate::settings::global::OrientationPattern::default();
2258        let options = graph.denominator_only_cff_3d_expression_options();
2259        let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
2260        let contract_edges = graph
2261            .iter_edges_of(&graph.tree_edges)
2262            .map(|(_, edge, _)| edge)
2263            .collect_vec();
2264        let production = graph.generate_3d_expression_for_integrand(
2265            &contract_edges,
2266            &canonization,
2267            &options,
2268            Some(&Atom::one()),
2269        )?;
2270        let localizer = Localizer::new(
2271            &cutset,
2272            OrientationProjection::exact_expression(
2273                &production,
2274                &options,
2275                &orientation_pattern,
2276                false,
2277            ),
2278        );
2279        let seed = forests.local_3d_for_node(
2280            forests.root,
2281            &mut graph,
2282            &cutset,
2283            localizer,
2284            &settings.uv,
2285            &mut Local4dProjectionContext::default(),
2286        )?;
2287        let root_store_marker = symbolica::symbol!("root_store_marker");
2288        let frontier_store_marker = symbolica::symbol!("frontier_store_marker");
2289        // Mark cached local terms so the union result proves that replay starts from typed roots.
2290        let marked_computation = |marker| -> Result<CutComputation> {
2291            Ok(CutComputation {
2292                local_3d: seed.local_3d.map(|_| Ok(Atom::var(marker)))?,
2293                final_integrands: seed.final_integrands.clone(),
2294            })
2295        };
2296
2297        let root_operation = forests.graph[forests.root].clone();
2298        forests
2299            .compute_store
2300            .entry(root_operation)
2301            .or_default()
2302            .cuts
2303            .insert(cutset.clone(), marked_computation(root_store_marker)?);
2304        forests
2305            .compute_store
2306            .entry(forests.graph[non_root_frontier].clone())
2307            .or_default()
2308            .cuts
2309            .insert(cutset.clone(), marked_computation(frontier_store_marker)?);
2310
2311        let root_result = forests.local_3d_for_node(
2312            root_disconnected,
2313            &mut graph,
2314            &cutset,
2315            localizer,
2316            &settings.uv,
2317            &mut Local4dProjectionContext::default(),
2318        )?;
2319        assert!(
2320            root_result
2321                .local_3d
2322                .direct()?
2323                .branches()?
2324                .iter_keys()
2325                .all(|(_, integrands)| integrands
2326                    .iter()
2327                    .all(|(_, term)| !term.contains_symbol(root_store_marker))),
2328            "an empty dependency frontier must start from the typed root"
2329        );
2330
2331        let frontier_result = forests.local_3d_for_node(
2332            dependent_disconnected,
2333            &mut graph,
2334            &cutset,
2335            localizer,
2336            &settings.uv,
2337            &mut Local4dProjectionContext::default(),
2338        )?;
2339        let replay_states = forests.union_replay_states(dependent_disconnected)?;
2340        let state = replay_states
2341            .iter()
2342            .find(|state| {
2343                state.local_edges.len() == 2
2344                    && forests
2345                        .normalized_node_label(state.integrated)
2346                        .starts_with("{36}:")
2347            })
2348            .expect("the integrated 36 prefix has the two-step C-to-F suffix");
2349        let expected_active = state
2350            .local_edges
2351            .iter()
2352            .enumerate()
2353            .map(|(offset, edge)| {
2354                let step_order = forests.graph[state.integrated].key.op_count() + offset;
2355                let (current, given) = forests.wood.current_given_pair(*edge, step_order);
2356                current.reduced_subgraph(&given)
2357            })
2358            .reduce(|active, reduced| active.union(&reduced))
2359            .expect("the selected replay state has a local suffix");
2360        assert!(
2361            expected_active.empty_intersection(forests.source_spinney(state.integrated).filter())
2362        );
2363        let sector = frontier_result
2364            .local_3d
2365            .direct()?
2366            .sectors()?
2367            .iter()
2368            .find(|sector| sector.active_subgraph == expected_active)
2369            .expect("the replay frontier contains the expected active sector");
2370        assert!(
2371            !sector.projection_paths.is_empty()
2372                && sector.projection_paths.iter().all(|path| {
2373                    path.steps.len() == state.local_edges.len()
2374                        && path
2375                            .steps
2376                            .windows(2)
2377                            .all(|pair| pair[0].topo_order <= pair[1].topo_order)
2378                }),
2379            "each disconnected sector must retain its own ordered local replay path"
2380        );
2381        assert!(
2382            frontier_result
2383                .local_3d
2384                .direct()?
2385                .branches()?
2386                .iter_keys()
2387                .all(|(_, integrands)| integrands.iter().all(|(_, term)| {
2388                    !term.contains_symbol(root_store_marker)
2389                        && !term.contains_symbol(frontier_store_marker)
2390                })),
2391            "a union must replay every component path from its typed root"
2392        );
2393        Ok(())
2394    }
2395
2396    #[test]
2397    fn disconnected_soft_union_is_the_product_of_completed_component_atoms() -> Result<()> {
2398        test_initialise().unwrap();
2399        let mut graph: Graph =
2400            include_str!("../../../../tests/resources/graphs/local_ir_dod2_scalar_spectacles.dot")
2401                .into_graph(&crate::utils::load_generic_model("scalars"))?;
2402        let settings = GenerationSettings {
2403            uv: UVgenerationSettings {
2404                generate_integrated: false,
2405                renormalization_prescription: RenormalizationPrescriptionSettings {
2406                    log_divergent: ApproximationType::IR,
2407                    massive_power_divergent: ApproximationType::IR,
2408                    massless_power_divergent: ApproximationType::IR,
2409                    ..Default::default()
2410                },
2411                ..Default::default()
2412            },
2413            ..Default::default()
2414        };
2415        let mut forests = Wood::new(CutStructure::empty(&graph), &graph, &settings)?.unfold();
2416        forests.integrate(&graph, crate::utils::vakint()?, &settings.uv)?;
2417
2418        let union = forests
2419            .graph
2420            .iter_nodes()
2421            .find_map(|(node, _, _)| forests.graph.is_disjoint_union(node).then_some(node))
2422            .expect("the spectacles fixture must contain a disconnected UV union");
2423        assert_eq!(
2424            forests.source_spinney(union).renormalization_scheme,
2425            ApproximationType::MUV,
2426            "a disconnected union carries only the neutral aggregate scheme"
2427        );
2428        let components = forests.disconnected_component_nodes(union)?;
2429        assert_eq!(components.len(), 2);
2430        assert!(components.iter().all(|component| {
2431            forests.source_spinney(*component).renormalization_scheme == ApproximationType::IR
2432        }));
2433
2434        let expected =
2435            components
2436                .iter()
2437                .try_fold(Atom::one(), |product, component| -> Result<Atom> {
2438                    let completed = forests.recursion_input_4d(*component)?;
2439                    assert!(
2440                        !completed.atom().is_zero(),
2441                        "each disconnected soft component must be completed before the join"
2442                    );
2443                    Ok(product * completed.atom())
2444                })?;
2445        let operation = &forests.graph[union];
2446        let actual = forests
2447            .compute_store
2448            .require(operation)?
2449            .local_4d(operation)?;
2450        assert_eq!(actual.atom(), &expected);
2451        let provenance = forests.node_export_4d_provenance(union, actual);
2452        assert_eq!(provenance.node.parent_keys.len(), 2);
2453        let UVForestLocalProvenance::FourD {
2454            components: provenance_components,
2455            branches,
2456        } = &provenance.node.local
2457        else {
2458            panic!("a computed 4D union must carry 4D provenance")
2459        };
2460        assert_eq!(provenance_components.len(), 2);
2461        assert!(provenance_components.iter().all(|component| {
2462            component.scheme == ApproximationType::IR
2463                && !component.route_loop_edges.is_empty()
2464                && !component.route_signatures.is_empty()
2465        }));
2466        assert_eq!(branches.len(), 1);
2467        assert_eq!(branches[0].branch, Local4dBranch::Combined);
2468        assert!(branches[0].byte_size > 0);
2469
2470        // Exercise the same factorization directly in one parent-CFF
2471        // orientation. With integrated generation disabled, only the replay
2472        // sector containing both local component operations may survive at
2473        // the disconnected-union node.
2474        let cutset = CutSet::empty(graph.n_hedges());
2475        let orientation_pattern = crate::settings::global::OrientationPattern::default();
2476        let options = graph.production_cff_3d_expression_options(&Default::default())?;
2477        let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
2478        // Match the production root: its structural bridge remains a 4D
2479        // denominator outside the CFF and its loop-energy contour.
2480        let contract_edges = graph
2481            .iter_edges_of(
2482                &graph
2483                    .tree_edges
2484                    .subtract(&graph.initial_state_cut)
2485                    .subtract(&graph.external_filter::<SuBitGraph>()),
2486            )
2487            .map(|(_, edge, _)| edge)
2488            .collect::<Vec<_>>();
2489        let mut production = graph.generate_3d_expression_for_integrand(
2490            &contract_edges,
2491            &canonization,
2492            &options,
2493            None,
2494        )?;
2495        assert!(
2496            !production.expression.orientations.is_empty(),
2497            "the spectacles parent CFF has at least one orientation"
2498        );
2499        production.expression.orientations.truncate(1);
2500        let localizer = Localizer::new(
2501            &cutset,
2502            OrientationProjection::exact_expression(
2503                &production,
2504                &options,
2505                &orientation_pattern,
2506                false,
2507            ),
2508        );
2509        let union_3d = forests.local_3d_for_node(
2510            union,
2511            &mut graph,
2512            &cutset,
2513            localizer,
2514            &settings.uv,
2515            &mut Local4dProjectionContext::default(),
2516        )?;
2517        let sectors = union_3d
2518            .local_3d
2519            .direct()?
2520            .sectors()
2521            .expect("a disconnected local 3D union retains its replay sectors");
2522        assert_eq!(sectors.len(), 3);
2523        let combined_sectors = sectors
2524            .iter()
2525            .map(DirectSector::combine)
2526            .collect::<Result<Vec<_>>>()?;
2527        let nonzero_sectors = combined_sectors
2528            .iter()
2529            .filter(|branches| {
2530                branches
2531                    .iter_keys()
2532                    .any(|(_, integrands)| integrands.iter().any(|(_, atom)| !atom.is_zero()))
2533            })
2534            .collect::<Vec<_>>();
2535        assert_eq!(
2536            nonzero_sectors.len(),
2537            1,
2538            "zero integrated prefixes must leave only the two-local-component cross sector"
2539        );
2540        let cross = nonzero_sectors[0];
2541
2542        let assert_same_branches = |left: &DirectResidueBranches,
2543                                    right: &DirectResidueBranches,
2544                                    label: &str|
2545         -> Result<()> {
2546            let left = left.iter_keys().collect::<Vec<_>>();
2547            let right = right.iter_keys().collect::<Vec<_>>();
2548            assert_eq!(
2549                left.len(),
2550                right.len(),
2551                "{label} differs in residue-map count"
2552            );
2553            for ((left_key, left), (right_key, right)) in left.into_iter().zip(right) {
2554                assert_eq!(
2555                    left_key, right_key,
2556                    "{label} changed the complete residue map"
2557                );
2558                // Independently replayed forests have different numerator-family
2559                // scopes. Compare their values using the retained definitions.
2560                let left = left.resolved()?;
2561                let right = right.resolved()?;
2562                left.checked_zip(&right, |residue, left, right| {
2563                    // Compare the exact factorized forest values before selectors
2564                    // are materialized or any graph numerator is distributed.
2565                    let difference = (left - right).together().cancel();
2566                    assert!(
2567                        difference.is_zero(),
2568                        "{label} differs in residue {residue:?}: {difference}"
2569                    );
2570                    Ok(Atom::Zero)
2571                })?;
2572            }
2573            Ok(())
2574        };
2575        assert_same_branches(
2576            &union_3d.local_3d.direct()?.branches()?,
2577            cross,
2578            "the disconnected aggregate and its cross sector",
2579        )?;
2580
2581        let component_edges = components
2582            .iter()
2583            .map(|component| {
2584                let (parent, edge) = forests
2585                    .graph
2586                    .unique_parent(*component)
2587                    .expect("a primitive disconnected component has one root edge");
2588                assert!(
2589                    forests.graph[parent].key.is_empty(),
2590                    "the spectacles components must both be primitive"
2591                );
2592                edge
2593            })
2594            .collect::<Vec<_>>();
2595        let mut replay = |edges: &[EdgeIndex]| -> Result<Direct3dCts> {
2596            let mut local = Direct3dCts::root(&graph, localizer)?;
2597            for (order, edge) in edges.iter().enumerate() {
2598                let (current, given) = forests.wood.current_given_pair(*edge, order);
2599                local = Direct3dApproximation::new(localizer, &mut graph, &settings.uv)
2600                    .run_local(&local, &current, &given, &current, &given)?;
2601            }
2602            assert!(local.branches()?.iter_keys().all(|(_, integrands)| {
2603                integrands
2604                    .iter()
2605                    .all(|(_, atom)| !atom.contains_symbol(GS.rescale))
2606            }));
2607            Ok(local)
2608        };
2609        let forward = replay(&component_edges)?;
2610        let reverse = replay(&component_edges.iter().rev().copied().collect::<Vec<_>>())?;
2611        assert_same_branches(
2612            &forward.branches()?,
2613            cross,
2614            "forward component-local replay and the produced cross sector",
2615        )?;
2616        assert_same_branches(
2617            &reverse.branches()?,
2618            cross,
2619            "reverse component-local replay and the produced cross sector",
2620        )?;
2621        Ok(())
2622    }
2623
2624    #[test]
2625    fn heterogeneous_terminal_projects_components_before_multiplying() -> Result<()> {
2626        test_initialise().unwrap();
2627        let graph: Graph = dot!(
2628            digraph G {
2629                num = "1";
2630                projector = "1";
2631                overall_factor = "1";
2632                edge [particle = "scalar_1"];
2633                node [num = "1"];
2634                v1 -> v2;
2635                v1 -> v1;
2636                v2 -> v3;
2637                v2 -> v3;
2638            },
2639            "scalars"
2640        )?;
2641        let settings = GenerationSettings {
2642            uv: UVgenerationSettings {
2643                softct: false,
2644                renormalization_prescription: RenormalizationPrescriptionSettings {
2645                    log_divergent: ApproximationType::MUV,
2646                    massive_power_divergent: ApproximationType::PolePart,
2647                    massless_power_divergent: ApproximationType::PolePart,
2648                    ..Default::default()
2649                },
2650                ..Default::default()
2651            },
2652            ..Default::default()
2653        };
2654        let mut forests = Wood::new(CutStructure::empty(&graph), &graph, &settings)?.unfold();
2655        forests.integrate(&graph, crate::utils::vakint()?, &settings.uv)?;
2656
2657        let terminals = forests
2658            .graph
2659            .iter_nodes()
2660            .filter_map(|(node, mut crown, _)| {
2661                (!crown.any(|hedge| forests.graph.flow(hedge).is_source())).then_some(node)
2662            })
2663            .collect::<Vec<_>>();
2664        assert_eq!(terminals.len(), 1);
2665        let terminal = terminals[0];
2666        assert!(forests.graph.is_disjoint_union(terminal));
2667
2668        let components = forests.disconnected_component_nodes(terminal)?;
2669        assert_eq!(components.len(), 2);
2670        let schemes = components
2671            .iter()
2672            .map(|component| forests.source_spinney(*component).renormalization_scheme)
2673            .collect::<Vec<_>>();
2674        assert!(schemes.contains(&ApproximationType::MUV));
2675        assert!(schemes.contains(&ApproximationType::PolePart));
2676
2677        let expected =
2678            components
2679                .iter()
2680                .try_fold(Atom::one(), |product, &component| -> Result<Atom> {
2681                    let component_key = &forests.graph[component];
2682                    let integrated = forests
2683                        .compute_store
2684                        .require(component_key)?
2685                        .integrated(component_key)?;
2686                    let projection = match forests.source_spinney(component).renormalization_scheme
2687                    {
2688                        ApproximationType::MUV => integrated.physical_finite_counterterm_atom(),
2689                        ApproximationType::PolePart => integrated.physical_pole_atom(),
2690                        scheme => panic!("unexpected component scheme {scheme}"),
2691                    };
2692                    Ok(product * projection)
2693                })?;
2694        let terminal_key = &forests.graph[terminal];
2695        let aggregate = forests
2696            .compute_store
2697            .require(terminal_key)?
2698            .integrated(terminal_key)?;
2699        assert_ne!(
2700            expected.expand(),
2701            aggregate.physical_finite_counterterm_atom().expand()
2702        );
2703        assert_ne!(expected.expand(), aggregate.physical_pole_atom().expand());
2704
2705        let wild = Atom::var(W_.x___);
2706        let replacements =
2707            graph.integrand_replacement(&graph.full_filter(), &graph.loop_momentum_basis, &[wild]);
2708        let expected = expected
2709            .simplify_color()
2710            .expand_num()
2711            .to_dots()
2712            .replace_multiple(&replacements)
2713            .replace(GS.m_uv_expansion)
2714            .with(GS.m_uv_vacuum);
2715        let actual = forests
2716            .renormalization_part_of_ends(&graph, &settings.uv)?
2717            .expression;
2718        assert_eq!(actual.expand(), expected.expand());
2719
2720        Ok(())
2721    }
2722
2723    #[test]
2724    fn triple_tadpole() -> Result<()> {
2725        test_initialise().unwrap();
2726        let dumbell: Graph = dot!(
2727            digraph G{
2728                edge [particle="scalar_1"];
2729                v1 -> v2;
2730                v2 -> v3;
2731                v3 -> v3;
2732                v2 -> v2;
2733                v1 -> v1;
2734            },"scalars"
2735        )?;
2736
2737        let spinneys: Vec<_> = dumbell
2738            .spinneys(&dumbell.full_filter())
2739            .into_iter()
2740            .filter_map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis).transpose())
2741            .collect::<Result<_>>()?;
2742        let f = Wood::new(
2743            CutStructure::empty(&dumbell),
2744            &dumbell,
2745            &GenerationSettings::default(),
2746        )?;
2747
2748        println!("{}", f);
2749
2750        insta::assert_snapshot!(
2751            f.graph.n_nodes(),
2752            @"8",
2753            // format!("Wood does not have correct number of spinneys: \n{}",f)
2754        );
2755
2756        let three_component_target = f
2757            .graph
2758            .iter_nodes()
2759            .find_map(|(node, _, spinney)| (spinney.n_components() == 3).then_some(node))
2760            .expect("triple tadpole should contain its fully factorized target");
2761        let target_filter = f.graph[three_component_target].filter().clone();
2762        let components = dumbell
2763            .as_ref()
2764            .connected_components(&target_filter)
2765            .into_iter()
2766            .collect::<BTreeSet<_>>();
2767        assert_eq!(components.len(), 3);
2768        let incoming = f
2769            .graph
2770            .iter_crown(three_component_target)
2771            .filter(|hedge| f.graph.flow(*hedge) == Flow::Sink)
2772            .map(|hedge| f.graph[f.graph[&hedge]].clone())
2773            .collect::<BTreeSet<_>>();
2774        assert_eq!(incoming, components);
2775        assert!(
2776            !incoming.contains(&target_filter),
2777            "a fully supplied product must not retain a duplicate atomic full-target transition"
2778        );
2779
2780        for (_, _, d) in f.graph.iter_nodes() {
2781            println!(
2782                "//Node {}: \n{}",
2783                d.subgraph.string_label(),
2784                dumbell.dot(&d.subgraph)
2785            );
2786        }
2787        let _ff = OldWood::from_spinneys(spinneys, &dumbell); //.unfold(&g, &g.loop_momentum_basis);
2788
2789        // println!("{}", ff.dot(&dumbell));
2790
2791        let f = f.unfold();
2792        println!("{}", f);
2793        insta::assert_snapshot!(
2794            f.graph.n_nodes(),
2795            @"8");
2796
2797        let three_component_union = f
2798            .graph
2799            .iter_nodes()
2800            .find_map(|(node, _, operation)| {
2801                (operation.key.op_count() == 3
2802                    && f.wood
2803                        .join_factors(f.graph.source_node(node))
2804                        .is_some_and(|factors| factors.len() == 3))
2805                .then_some(node)
2806            })
2807            .expect("triple tadpole should contain a three-component union");
2808        assert_eq!(
2809            f.disconnected_component_nodes(three_component_union)?.len(),
2810            3
2811        );
2812        let replay_states = f.union_replay_states(three_component_union)?;
2813        assert_eq!(replay_states.len(), 8);
2814        assert_eq!(
2815            replay_states
2816                .iter()
2817                .filter(|state| !state.local_edges.is_empty())
2818                .count(),
2819            7
2820        );
2821
2822        Ok(())
2823    }
2824
2825    #[test]
2826    fn saclay() -> Result<()> {
2827        test_initialise().unwrap();
2828        let dt: Graph = dot!(digraph GL16{
2829
2830        num = "spenso::g(spenso::coad(8,gammalooprs::hedge(8)),spenso::coad(8,gammalooprs::hedge(11)))"
2831                ext	 [style=invis];
2832                ext	-> 0  [dir=none id=0 particle="g"];
2833                2	-> ext  [dir=none id=1 particle="g"];
2834                0	-> 1 -> 2->3->0  [ particle="d"];
2835                      1 ->3 [particle = "g"]
2836                    })?;
2837
2838        let f = Wood::new(
2839            CutStructure::empty(&dt),
2840            &dt,
2841            &GenerationSettings::default(),
2842        )?;
2843
2844        println!("{}", dt.dot_serialize(&DotExportSettings::default()));
2845
2846        insta::assert_snapshot!(
2847            f.graph.n_nodes(),
2848            @"5",
2849            // format!("Wood does not have correct number of spinneys: \n{}",f)
2850        );
2851
2852        let f = f.unfold();
2853        let structured_dot = f.dot_serialize();
2854        assert!(structured_dot.contains(r#"label="K[#expr S_44⊛0]""#));
2855        assert!(!structured_dot.contains("#T("));
2856        println!("{structured_dot}");
2857        insta::assert_snapshot!(
2858            f.graph.n_nodes(),
2859            @"8");
2860
2861        Ok(())
2862    }
2863
2864    #[test]
2865    fn dumbells() -> Result<()> {
2866        test_initialise().unwrap();
2867        let dumbell: Graph = dot!(
2868            digraph G{
2869                edge [particle="scalar_1"];
2870                v1 -> v2;
2871                v2 -> v2;
2872                v1 -> v1;
2873            },"scalars"
2874        )?;
2875
2876        let spinneys: Vec<_> = dumbell
2877            .spinneys(&dumbell.full_filter())
2878            .into_iter()
2879            .filter_map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis).transpose())
2880            .collect::<Result<_>>()?;
2881        let f = Wood::new(
2882            CutStructure::empty(&dumbell),
2883            &dumbell,
2884            &GenerationSettings::default(),
2885        )?;
2886
2887        println!("{}", f);
2888
2889        insta::assert_snapshot!(
2890            f.graph.n_nodes(),
2891            @"4",
2892            // format!("Wood does not have correct number of spinneys: \n{}",f)
2893        );
2894
2895        for (_, _, d) in f.graph.iter_nodes() {
2896            println!(
2897                "//Node {}: \n{}",
2898                d.subgraph.string_label(),
2899                dumbell.dot(&d.subgraph)
2900            );
2901        }
2902        let _ff = OldWood::from_spinneys(spinneys, &dumbell); //.unfold(&g, &g.loop_momentum_basis);
2903
2904        // println!("{}", ff.dot(&dumbell));
2905
2906        let f = f.unfold();
2907        println!("{}", f);
2908        insta::assert_snapshot!(
2909            f.graph.n_nodes(),
2910            @"4");
2911
2912        Ok(())
2913    }
2914
2915    #[test]
2916    fn bugblatter() -> Result<()> {
2917        test_initialise().unwrap();
2918
2919        match dot!(
2920            digraph G{
2921                A1 -> A2 [particle="t"];
2922                A2 -> A3 [particle="t"];
2923                A3 -> A1 [particle="t"];
2924                B1 -> B2 [particle="t"];
2925                B2 -> B3 [particle="t"];
2926                B3 -> B1 [particle="t"];
2927                A1 -> B1 [particle="a"];
2928                A2 -> B2 [particle="a"];
2929                A3 -> B3 [particle="a"];
2930            },"sm"
2931        ) {
2932            Ok(g) => {
2933                let g: Graph = g;
2934                let spinneys: Vec<_> = g
2935                    .spinneys(&g.full_filter())
2936                    .into_iter()
2937                    .filter_map(|a| Spinney::new(a, &g, &g.loop_momentum_basis).transpose())
2938                    .collect::<Result<_>>()?;
2939                let f = Wood::new(CutStructure::empty(&g), &g, &GenerationSettings::default())?;
2940
2941                println!("{}", f);
2942
2943                assert_eq!(
2944                    20,
2945                    f.graph.n_nodes(),
2946                    "Wood does not have correct number of spinneys: \n{}",
2947                    f
2948                );
2949
2950                for (_, _, d) in f.graph.iter_nodes() {
2951                    println!(
2952                        "//Node {}: \n{}",
2953                        d.subgraph.string_label(),
2954                        g.dot_lmb_of(&d.subgraph, &d.lmb)
2955                    );
2956                }
2957                let _ff = OldWood::from_spinneys(spinneys, &g); //.unfold(&g, &g.loop_momentum_basis);
2958
2959                // println!("{}", ff.dot(&g));
2960
2961                let f = f.unfold();
2962                f.debug_walk();
2963                println!("{}", f);
2964                assert_eq!(
2965                    152,
2966                    f.graph.n_nodes(),
2967                    "Forest unfolds into the wrong number of terms :\n{}",
2968                    f
2969                );
2970
2971                // println!("{}", f)
2972            }
2973            Err(e) => {
2974                eprintln!("{}", e);
2975            }
2976        }
2977
2978        // let f = SpinneyWood::from_spinneys(
2979        //     g.spinneys(&g.full_filter()).into_iter().map(|a| a.filter),
2980        //     &g,
2981        // )
2982        // .unfold();
2983
2984        // println!("{}", f.graph.base_dot());
2985        Ok(())
2986    }
2987
2988    #[test]
2989    fn mercedes() -> Result<()> {
2990        test_initialise().unwrap();
2991
2992        let mercedes: Graph = dot!(
2993            digraph G{
2994                edge [particle="scalar_1"];
2995                v1 -> v2;
2996                v2 -> v3;
2997                v3 -> v1;
2998                v1 -> v4;
2999                v2 -> v4;
3000                v3 -> v4;
3001            },"scalars"
3002        )?;
3003
3004        let f = Wood::new(
3005            CutStructure::empty(&mercedes),
3006            &mercedes,
3007            &GenerationSettings::default(),
3008        )?;
3009        println!("{}", f);
3010        insta::assert_snapshot!(
3011        f.graph.n_nodes(),
3012        @"2",
3013        );
3014        let f = f.unfold();
3015        println!("{}", f);
3016        insta::assert_snapshot!(
3017        f.graph.n_nodes(),
3018        @"2",
3019         );
3020
3021        Ok(())
3022    }
3023
3024    #[test]
3025    fn sunrise() -> Result<()> {
3026        test_initialise().unwrap();
3027
3028        let sunrise: Graph = dot!( digraph sunrise{
3029            node [num = "1"]
3030            edge [particle=scalar_1]
3031            e        [style=invis]
3032            e -> A:0   [ id=3 ]
3033            B:1 -> e   [ id=4 ]
3034            A -> B    [ id=0 ]
3035            A -> B    [ id=1 ]
3036            A -> B    [ id=2 ]
3037        },"scalars")?;
3038        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
3039        let f = Wood::new(
3040            CutStructure::empty(&sunrise),
3041            &sunrise,
3042            &GenerationSettings::default(),
3043        )?;
3044        println!("{}", f);
3045        insta::assert_snapshot!(
3046        f.graph.n_nodes(),
3047        @"5",
3048        );
3049        let f = f.unfold();
3050        println!("{}", f);
3051        insta::assert_snapshot!(
3052        f.graph.n_nodes(),
3053        @"8",
3054         );
3055
3056        Ok(())
3057    }
3058    #[test]
3059    fn dotted_sunrise() -> Result<()> {
3060        test_initialise().unwrap();
3061
3062        let sunrise: Graph = dot!( digraph sunrise{
3063            edge [particle=scalar_1]
3064            e        [style=invis]
3065            e -> A:0   [ id=3]
3066            B:1 -> e   [ id=4]
3067
3068            A -> C    [ id=0]
3069            C -> e
3070            C -> B
3071            A -> B    [ id=1]
3072            A -> B    [ id=2]
3073        },"scalars")?;
3074        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
3075        let f = Wood::new(
3076            CutStructure::empty(&sunrise),
3077            &sunrise,
3078            &GenerationSettings::default(),
3079        )?;
3080        println!("{}", f);
3081        insta::assert_snapshot!(
3082        f.graph.n_nodes(),
3083        @"3",
3084        );
3085        let f = f.unfold();
3086        println!("{}", f);
3087        insta::assert_snapshot!(
3088        f.graph.n_nodes(),
3089        @"4",
3090         );
3091
3092        Ok(())
3093    }
3094
3095    #[test]
3096    fn dotted() -> Result<()> {
3097        test_initialise().unwrap();
3098
3099        let sunrise: Graph = dot!( digraph sunrise{
3100            edge [particle=scalar_1]
3101            e        [style=invis]
3102            e -> A:0   [ id=4]
3103            B:1 -> e   [ id=5]
3104            C:2 -> e   [ id=6]
3105
3106            A -> B    [ id=0]
3107            B -> C     [ id=1]
3108            C -> A   [ id=2]
3109            B -> C    [ id=3]
3110
3111        },"scalars")?;
3112        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
3113        let f = Wood::new(
3114            CutStructure::empty(&sunrise),
3115            &sunrise,
3116            &GenerationSettings::default(),
3117        )?;
3118        println!("{}", f);
3119        insta::assert_snapshot!(
3120        f.graph.n_nodes(),
3121        @"3",
3122        );
3123        let f = f.unfold();
3124        println!("{}", f);
3125        insta::assert_snapshot!(
3126        f.graph.n_nodes(),
3127        @"4",
3128         );
3129
3130        Ok(())
3131    }
3132
3133    #[test]
3134    fn spectacles() -> Result<()> {
3135        use crate::graph::feynman_graph::FeynmanGraph;
3136
3137        test_initialise().unwrap();
3138
3139        let mut spectacles: Graph = dot!(
3140            digraph G{
3141                edge [particle="scalar_1"];
3142                v1 -> v2;
3143                v1 -> v2;
3144
3145                v3 -> v4;
3146                v3 -> v4;
3147
3148                v2 -> v3;
3149                v1 -> v4;
3150            },"scalars"
3151        )?;
3152
3153        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
3154        let settings = GenerationSettings::default();
3155        let cut_structure = CutStructure::empty(&spectacles);
3156        let cutset = cut_structure
3157            .cuts
3158            .first()
3159            .expect("empty cut structure has one cut")
3160            .clone();
3161        let f = Wood::new(cut_structure, &spectacles, &settings)?;
3162        println!("{}", f);
3163        let mut f = f.unfold();
3164        println!("{}", f);
3165
3166        let (union, edge) = f
3167            .graph
3168            .iter_nodes()
3169            .find_map(|(child, _, _)| {
3170                let (parent, edge) = f.graph.unique_parent(child)?;
3171                (!f.graph.is_disjoint_union(child) && f.graph.is_disjoint_union(parent))
3172                    .then_some((parent, edge))
3173            })
3174            .expect("spectacles has a connected child above its disconnected union");
3175        let operations = f
3176            .graph
3177            .iter_nodes()
3178            .map(|(_, _, operation)| operation.clone())
3179            .collect::<Vec<_>>();
3180        // Zero integrated terms isolate component-path replay in the local accumulator.
3181        for operation in operations {
3182            f.compute_store.entry(operation).or_default().integrated = Some(IntegratedCts::root());
3183        }
3184        let orientation_pattern = crate::settings::global::OrientationPattern::default();
3185        // The replay fixture keeps its synthetic unit branch, while the
3186        // existing exact source supplies a valid energy map for that key.
3187        let options = spectacles.denominator_only_cff_3d_expression_options();
3188        let canonization = spectacles.get_esurface_canonization(&spectacles.loop_momentum_basis);
3189        let contract_edges = spectacles.paired_edges(
3190            &spectacles
3191                .tree_edges
3192                .subtract(&spectacles.initial_state_cut),
3193        );
3194        let production = spectacles.generate_3d_expression_for_integrand(
3195            &contract_edges,
3196            &canonization,
3197            &options,
3198            None,
3199        )?;
3200        assert!(!production.expression.orientations.is_empty());
3201        let localizer = Localizer::new(
3202            &cutset,
3203            OrientationProjection::exact_expression(
3204                &production,
3205                &options,
3206                &orientation_pattern,
3207                false,
3208            ),
3209        );
3210        let replay_states = f
3211            .union_replay_states(union)?
3212            .into_iter()
3213            .filter(|state| !state.local_edges.is_empty())
3214            .collect::<Vec<_>>();
3215        let union_active = replay_states
3216            .iter()
3217            .map(|state| {
3218                state
3219                    .local_edges
3220                    .iter()
3221                    .enumerate()
3222                    .map(|(offset, edge)| {
3223                        let step_order = f.graph[state.integrated].key.op_count() + offset;
3224                        let (current, given) = f.wood.current_given_pair(*edge, step_order);
3225                        current.reduced_subgraph(&given)
3226                    })
3227                    .reduce(|active, reduced| active.union(&reduced))
3228                    .expect("a proper union replay state has a local suffix")
3229            })
3230            .collect::<Vec<_>>();
3231        let computed = f.local_3d_for_node(
3232            union,
3233            &mut spectacles,
3234            &cutset,
3235            localizer,
3236            &settings.uv,
3237            &mut Local4dProjectionContext::default(),
3238        )?;
3239        let sectors = computed.local_3d.direct()?.sectors()?;
3240        assert_eq!(
3241            sectors.len(),
3242            replay_states.len(),
3243            "every proper integrated prefix must produce one disconnected replay sector"
3244        );
3245        assert_eq!(
3246            sectors
3247                .iter()
3248                .map(|sector| sector.active_subgraph.clone())
3249                .collect::<Vec<_>>(),
3250            union_active,
3251            "the completed union must retain the active component support of every replay path"
3252        );
3253        assert!(
3254            computed.final_integrands.iter().next().is_some(),
3255            "the successfully replayed disconnected union must reach final-integrand construction"
3256        );
3257        let union_local = Direct3dCts::from_sectors(
3258            union_active
3259                .iter()
3260                .cloned()
3261                .map(|active_subgraph| {
3262                    let coordinate_frames = spectacles
3263                        .as_ref()
3264                        .connected_components(&active_subgraph)
3265                        .into_iter()
3266                        .filter(|component| spectacles.n_loops(component) != 0)
3267                        .map(|component| DirectCoordinateFrame {
3268                            lmb: spectacles.lmb_of(&component),
3269                            active_subgraph: component,
3270                        })
3271                        .collect();
3272                    Ok(DirectSector {
3273                        active_subgraph,
3274                        coordinate_frames,
3275                        active: DirectResidueBranches::production(
3276                            OrientationID(0),
3277                            Integrands::root(),
3278                        )?,
3279                        frozen_integrands: Integrands::root(),
3280                        projection_paths: Vec::new(),
3281                    })
3282                })
3283                .collect::<Result<_>>()?,
3284        )?;
3285
3286        let step_order = f.graph[union].key.op_count();
3287        let (current, given) = f.wood.current_given_pair(edge, step_order);
3288        let reduced = current.reduced_subgraph(&given);
3289        let numerator = spectacles
3290            .numerator(&reduced, given.subgraph())
3291            .get_single_atom()
3292            .expect("the scalar reduced numerator is available");
3293        assert!(!numerator.contains_symbol(GS.emr_mom));
3294        // A Taylor projection leaves these momentum-independent source values
3295        // unchanged and supplies the subtraction sign. Compare the complete
3296        // result so retained and quotient coordinates can use any compatible
3297        // basis without fixing the number or storage of coordinate frames.
3298        let expected = union_local
3299            .branches()?
3300            .materialize(false)?
3301            .resolved()?
3302            .map(|atom| -(atom * &numerator));
3303        let given_internal =
3304            InternalSubGraph::try_new(given.subgraph().clone(), spectacles.as_ref())
3305                .expect("the connected child has an internal given prefix");
3306        let quotient_rank = spectacles
3307            .shrunken_sub_lmb(
3308                current.subgraph(),
3309                &given_internal,
3310                spectacles.dummy_stripped_external_flows_of(current.subgraph()),
3311                None,
3312            )?
3313            .loop_edges
3314            .len();
3315        let expected_frames = union_local
3316            .sectors()?
3317            .iter()
3318            .map(|sector| {
3319                let retained_region = sector.active_subgraph.intersection(given.subgraph());
3320                let mut retained = sector
3321                    .coordinate_frames
3322                    .iter()
3323                    .filter(|frame| retained_region.includes(&frame.active_subgraph))
3324                    .flat_map(|frame| frame.lmb.loop_edges.iter().copied())
3325                    .collect::<Vec<_>>();
3326                retained.sort();
3327                retained.dedup();
3328                let expected_rank = retained.len() + quotient_rank;
3329                (retained, expected_rank)
3330            })
3331            .collect::<Vec<_>>();
3332        let external: SuBitGraph = spectacles.external_filter();
3333        let full_internal = spectacles.full_filter().subtract(&external);
3334        let canonical_route = if current.subgraph() == &full_internal
3335            && spectacles.n_loops(&full_internal) == spectacles.loop_momentum_basis.loop_edges.len()
3336        {
3337            spectacles.loop_momentum_basis.clone()
3338        } else {
3339            spectacles.try_compatible_sub_lmb(
3340                current.subgraph(),
3341                spectacles.dummy_less_full_crown(current.subgraph()),
3342                &spectacles.loop_momentum_basis,
3343            )?
3344        };
3345        let expected_active = union_active
3346            .iter()
3347            .map(|active| active.union(&reduced))
3348            .collect::<Vec<_>>();
3349        let child_local = Direct3dApproximation::new(localizer, &mut spectacles, &settings.uv)
3350            .run(
3351                &union_local,
3352                &IntegratedCts::root(),
3353                &current,
3354                &given,
3355                &current,
3356                &given,
3357            )?;
3358        let actual = child_local.branches()?.materialize(false)?.resolved()?;
3359        assert_eq!(
3360            actual.map(|atom| atom.collect_factors()),
3361            expected.map(|atom| atom.collect_factors()),
3362            "the connected child must retain every complete source value across its enclosing Taylor operation",
3363        );
3364        let child_sectors = child_local.sectors()?;
3365        let child_active = child_sectors
3366            .iter()
3367            .map(|sector| sector.active_subgraph.clone())
3368            .collect::<Vec<_>>();
3369        assert_eq!(child_active.len(), 3);
3370        assert_eq!(child_active, expected_active);
3371        for (sector, (retained, expected_rank)) in child_sectors.iter().zip(expected_frames.iter())
3372        {
3373            assert_eq!(
3374                sector.coordinate_frames.len(),
3375                1,
3376                "one connected enclosing Taylor operation must merge every contained child frame"
3377            );
3378            let child_carriers = sector
3379                .coordinate_frames
3380                .iter()
3381                .flat_map(|frame| frame.lmb.loop_edges.iter())
3382                .copied()
3383                .collect::<BTreeSet<_>>();
3384            assert!(
3385                retained.iter().all(|edge| child_carriers.contains(edge)),
3386                "the connected child must retain every carrier from the contained union frames"
3387            );
3388            assert!(
3389                child_carriers.iter().all(|edge| {
3390                    spectacles.loop_momentum_basis.edge_signatures[*edge]
3391                        .external
3392                        .iter()
3393                        .all(|sign| sign.is_zero())
3394                }),
3395                "the connected child route must be homogeneous in graph-external momenta"
3396            );
3397            assert_eq!(
3398                sector
3399                    .coordinate_frames
3400                    .iter()
3401                    .map(|frame| frame.lmb.loop_edges.len())
3402                    .sum::<usize>(),
3403                *expected_rank,
3404                "the connected child rank is the retained prefix rank plus its quotient rank"
3405            );
3406            let canonical_carriers = canonical_route
3407                .loop_edges
3408                .iter()
3409                .copied()
3410                .collect::<BTreeSet<_>>();
3411            let canonical_is_admissible = retained
3412                .iter()
3413                .all(|edge| canonical_carriers.contains(edge))
3414                && canonical_carriers.len() == *expected_rank
3415                && canonical_carriers.iter().all(|edge| {
3416                    spectacles.loop_momentum_basis.edge_signatures[*edge]
3417                        .external
3418                        .iter()
3419                        .all(|sign| sign.is_zero())
3420                });
3421            if canonical_is_admissible {
3422                assert_eq!(
3423                    child_carriers, canonical_carriers,
3424                    "an admissible graph-canonical route must win before equivalent fallbacks"
3425                );
3426            }
3427        }
3428
3429        Ok(())
3430    }
3431
3432    #[test]
3433    fn collective_two_component_region_requires_independently_divergent_factors() -> Result<()> {
3434        test_initialise().unwrap();
3435
3436        // The two-line component has DOD two, while the scalar triangle has DOD
3437        // minus two. Their disconnected union has logarithmic simultaneous
3438        // power counting, but the triangle is not a UV region on its own.
3439        // Componentwise classification therefore retains only the bubble.
3440        let graph: Graph = dot!(
3441            digraph G {
3442                node [num = "1"];
3443                edge [particle = "scalar_1"];
3444                a0 -> a1 [id = 0, dod = "-1", num = "gammalooprs::Q(0,spenso::mink(4,0))"];
3445                a0 -> a1 [id = 1, dod = "-1", num = "gammalooprs::Q(1,spenso::mink(4,0))"];
3446                a1 -> b0 [id = 2];
3447                b0 -> b1 [id = 3];
3448                b1 -> b2 [id = 4];
3449                b2 -> b0 [id = 5];
3450            },
3451            "scalars"
3452        )?;
3453
3454        let settings = GenerationSettings {
3455            uv: UVgenerationSettings {
3456                generate_integrated: false,
3457                ..Default::default()
3458            },
3459            ..Default::default()
3460        };
3461        let wood = Wood::new(CutStructure::empty(&graph), &graph, &settings)?;
3462        let collective = graph
3463            .spinneys(&graph.full_filter())
3464            .into_iter()
3465            .find(|spinney| graph.as_ref().connected_components(spinney).len() == 2)
3466            .expect("the raw cycle inventory contains the collective region");
3467        let collective_filter = collective.filter;
3468        let components = graph
3469            .as_ref()
3470            .connected_components(&collective_filter)
3471            .into_iter()
3472            .collect::<BTreeSet<_>>();
3473        assert_eq!(components.len(), 2);
3474        assert_eq!(graph.compute_dod(&collective_filter), 0);
3475        assert_eq!(
3476            components
3477                .iter()
3478                .map(|component| graph.compute_dod(component))
3479                .sorted()
3480                .collect::<Vec<_>>(),
3481            vec![-2, 2]
3482        );
3483
3484        let root = OwnedForestNode {
3485            spinney: Spinney::empty(&graph),
3486            topo_order: 0,
3487        };
3488        let divergent_filter = components
3489            .iter()
3490            .find(|component| graph.compute_dod(*component) >= 0)
3491            .expect("the bubble is the independently divergent component")
3492            .clone();
3493        let divergent = OwnedForestNode {
3494            spinney: Spinney::new(
3495                InternalSubGraph::cleaned_filter_optimist(divergent_filter, graph.as_ref()),
3496                &graph,
3497                &graph.loop_momentum_basis,
3498            )?
3499            .expect("the divergent component has a compatible source chart"),
3500            topo_order: 0,
3501        };
3502        let collective_source = OwnedForestNode {
3503            spinney: Spinney::new(
3504                InternalSubGraph::cleaned_filter_optimist(
3505                    collective_filter.clone(),
3506                    graph.as_ref(),
3507                ),
3508                &graph,
3509                &graph.loop_momentum_basis,
3510            )?
3511            .expect("the collective region has a compatible source chart"),
3512            topo_order: 1,
3513        };
3514        let ctx = UVCtx::new(&graph, &settings.uv);
3515        let input = Full4dCts::from_factorized_local(&Local4dCts::root());
3516        let prefix = local_4d::uv_limit(&input, &ctx, &divergent, &root, &divergent, &root)?;
3517        // The two physical forests containing U are {U} and {A,U}:
3518        // (-T_U) I + (-T_U)(-T_A) I. Their full Taylor scope is U;
3519        // its convergent complement does not supply a separate counterterm.
3520        let atomic = local_4d::uv_limit(
3521            &input,
3522            &ctx,
3523            &collective_source,
3524            &root,
3525            &collective_source,
3526            &root,
3527        )?;
3528        let nested = local_4d::uv_limit(
3529            &Full4dCts::from_factorized_local(&prefix),
3530            &ctx,
3531            &collective_source,
3532            &divergent,
3533            &collective_source,
3534            &divergent,
3535        )?;
3536        let expected = atomic.atom() + nested.atom();
3537        let mut forests = wood.unfold();
3538        forests.integrate(&graph, crate::utils::vakint()?, &settings.uv)?;
3539        let actual = forests
3540            .graph
3541            .iter_nodes()
3542            .filter(|(_, _, operation)| operation.covers().as_ref() == Some(&collective_filter))
3543            .try_fold(Atom::Zero, |sum, (_, _, operation)| -> Result<_> {
3544                Ok(sum
3545                    + forests
3546                        .compute_store
3547                        .require(operation)?
3548                        .local_4d(operation)?
3549                        .atom())
3550            })?;
3551        // These vacuum components have no nonzero crown shifts. Put both
3552        // complete coefficients into the same formal K coordinates, retaining
3553        // factorization and every denominator multiplicity before comparison.
3554        let replacements = graph.integrand_replacement(
3555            &graph.full_filter(),
3556            &graph.loop_momentum_basis,
3557            &[Atom::var(W_.x___)],
3558        );
3559        let [actual, expected] = [actual, expected].map(|expression| {
3560            GS.erase_uv_momentum_provenance(&expression)
3561                .replace(GS.den(W_.a_, W_.mom_, W_.mass_, W_.prop_))
3562                .with(W_.prop_)
3563                .replace_multiple(&replacements)
3564                .normalize_dots()
3565                .collect_factors()
3566        });
3567        assert!(
3568            !atomic.atom().is_zero(),
3569            "the atomic Taylor term must be nonzero"
3570        );
3571        assert!((actual - expected).collect_factors().is_zero());
3572
3573        assert!(
3574            forests
3575                .wood
3576                .graph
3577                .iter_nodes()
3578                .all(|(_, _, spinney)| { spinney.filter() != &collective_filter }),
3579            "a convergent connected factor cannot enter a disconnected UV spinney"
3580        );
3581        let convergent = components
3582            .iter()
3583            .find(|component| graph.compute_dod(*component) < 0)
3584            .expect("the collective region has one convergent component");
3585        let divergent = components
3586            .iter()
3587            .find(|component| graph.compute_dod(*component) >= 0)
3588            .expect("the collective region has one independently divergent component");
3589        assert!(
3590            forests
3591                .wood
3592                .graph
3593                .iter_nodes()
3594                .all(|(_, _, spinney)| spinney.filter() != convergent)
3595        );
3596        assert!(
3597            forests
3598                .wood
3599                .graph
3600                .iter_nodes()
3601                .any(|(_, _, spinney)| spinney.filter() == divergent)
3602        );
3603
3604        assert!(
3605            forests.graph.iter_nodes().all(|(_, _, operation)| {
3606                operation.covers().as_ref() != Some(&collective_filter)
3607            }),
3608            "neither atomic nor nested collective counterterms may bypass factor selection"
3609        );
3610        let mut cut = CutSet::empty(graph.n_hedges());
3611        cut.union = convergent.clone();
3612        assert!(
3613            forests.graph.iter_nodes().any(|(_, _, operation)| {
3614                operation.covers().as_ref() == Some(divergent) && operation.is_compatible_with(&cut)
3615            }),
3616            "a disjoint independently divergent prefix remains cut-compatible"
3617        );
3618        cut.union = divergent.clone();
3619        assert!(
3620            forests
3621                .graph
3622                .iter_nodes()
3623                .filter(|(_, _, operation)| { operation.covers().as_ref() == Some(divergent) })
3624                .all(|(_, _, operation)| !operation.is_compatible_with(&cut)),
3625            "cutting the retained divergent factor must exclude its counterterms"
3626        );
3627
3628        Ok(())
3629    }
3630
3631    #[test]
3632    fn collective_three_component_region_keeps_only_its_divergent_join() -> Result<()> {
3633        test_initialise().unwrap();
3634
3635        // The two bubbles have DOD two and the scalar triangle has DOD minus two.
3636        // Every component nevertheless has a divergent complement because the
3637        // other two components have non-negative combined DOD. The triangle itself
3638        // supplies no independent counterterm: this aggregate edge-label cover
3639        // cannot replace the collective Taylor operation by a factorized product.
3640        // Only the two independently divergent bubbles form a factorized join;
3641        // their complete Taylor cancellation permits omitting the collective
3642        // transition rather than classifying the triangle as a UV factor.
3643        let graph: Graph = dot!(
3644            digraph G {
3645                node [num = "1"];
3646                edge [particle = "scalar_1"];
3647                a0 -> a1 [id = 0, dod = "-1", num = "gammalooprs::Q(0,spenso::mink(4,0))"];
3648                a0 -> a1 [id = 1, dod = "-1", num = "gammalooprs::Q(1,spenso::mink(4,0))"];
3649                a1 -> b0 [id = 2];
3650                b0 -> b1 [id = 3, dod = "-1", num = "gammalooprs::Q(3,spenso::mink(4,1))"];
3651                b0 -> b1 [id = 4, dod = "-1", num = "gammalooprs::Q(4,spenso::mink(4,1))"];
3652                b1 -> c0 [id = 5];
3653                c0 -> c1 [id = 6];
3654                c1 -> c2 [id = 7];
3655                c2 -> c0 [id = 8];
3656            },
3657            "scalars"
3658        )?;
3659
3660        let settings = GenerationSettings {
3661            uv: UVgenerationSettings {
3662                generate_integrated: false,
3663                ..Default::default()
3664            },
3665            ..Default::default()
3666        };
3667        let wood = Wood::new(CutStructure::empty(&graph), &graph, &settings)?;
3668        let collective = graph
3669            .spinneys(&graph.full_filter())
3670            .into_iter()
3671            .find(|spinney| graph.as_ref().connected_components(spinney).len() == 3)
3672            .expect("the raw cycle inventory contains the collective region");
3673        let collective_filter = collective.filter;
3674        let components = graph
3675            .as_ref()
3676            .connected_components(&collective_filter)
3677            .into_iter()
3678            .collect::<BTreeSet<_>>();
3679        assert_eq!(components.len(), 3);
3680        assert_eq!(graph.compute_dod(&collective_filter), 2);
3681        assert_eq!(
3682            components
3683                .iter()
3684                .map(|component| graph.compute_dod(component))
3685                .sorted()
3686                .collect::<Vec<_>>(),
3687            vec![-2, 2, 2]
3688        );
3689        assert!(
3690            wood.graph.iter_nodes().all(|(_, _, spinney)| {
3691                graph
3692                    .as_ref()
3693                    .connected_components(spinney.filter())
3694                    .iter()
3695                    .all(|component| graph.compute_dod(component) >= 0)
3696            }),
3697            "a convergent connected factor cannot enter a disconnected UV spinney"
3698        );
3699        assert!(
3700            components.iter().all(|component| {
3701                graph.compute_dod(&collective_filter.subtract(component)) >= 0
3702            })
3703        );
3704        let retained_components = components
3705            .iter()
3706            .filter(|component| {
3707                wood.graph
3708                    .iter_nodes()
3709                    .any(|(_, _, spinney)| spinney.filter() == *component)
3710            })
3711            .cloned()
3712            .collect::<BTreeSet<_>>();
3713        assert_eq!(
3714            retained_components.len(),
3715            2,
3716            "complete complement coverage must not stand in for independent component counterterms"
3717        );
3718        assert!(
3719            retained_components
3720                .iter()
3721                .all(|component| graph.compute_dod(component) >= 0)
3722        );
3723        let retained_union = retained_components
3724            .iter()
3725            .cloned()
3726            .reduce(|left, right| left.union(&right))
3727            .unwrap();
3728        let join = wood
3729            .graph
3730            .iter_nodes()
3731            .find_map(|(node, _, spinney)| (spinney.filter() == &retained_union).then_some(node))
3732            .expect("the two independently divergent bubbles retain their union");
3733        assert_eq!(wood.join_factors(join), Some(retained_components));
3734
3735        let root = OwnedForestNode {
3736            spinney: Spinney::empty(&graph),
3737            topo_order: 0,
3738        };
3739        let root_local = Local4dCts::root();
3740        let input = Full4dCts::from_factorized_local(&root_local);
3741        let ctx = UVCtx::new(&graph, &settings.uv);
3742        let convergent = components
3743            .iter()
3744            .find(|component| graph.compute_dod(*component) < 0)
3745            .expect("the triangle is the convergent component");
3746        // Use one common physical chart only because these vacuum components
3747        // have no nonzero crown shifts. This is not a general replacement for
3748        // the source reconstruction certificates used by projected CFF.
3749        let replacements = graph.integrand_replacement(
3750            &graph.full_filter(),
3751            &graph.loop_momentum_basis,
3752            &[Atom::var(W_.x___)],
3753        );
3754        let mut independent = Vec::<(OwnedForestNode, Local4dCts)>::new();
3755        for component in components
3756            .iter()
3757            .filter(|component| graph.compute_dod(*component) >= 0)
3758        {
3759            let source = OwnedForestNode {
3760                spinney: Spinney::new(
3761                    InternalSubGraph::cleaned_filter_optimist(component.clone(), graph.as_ref()),
3762                    &graph,
3763                    &graph.loop_momentum_basis,
3764                )?
3765                .expect("each divergent bubble has a compatible source chart"),
3766                topo_order: 0,
3767            };
3768            let local = local_4d::uv_limit(&input, &ctx, &source, &root, &source, &root)?;
3769            let mixed = OwnedForestNode {
3770                spinney: Spinney::new(
3771                    InternalSubGraph::cleaned_filter_optimist(
3772                        component.union(convergent),
3773                        graph.as_ref(),
3774                    ),
3775                    &graph,
3776                    &graph.loop_momentum_basis,
3777                )?
3778                .expect("the bubble-triangle collective region is logarithmic"),
3779                topo_order: 1,
3780            };
3781            let atomic = local_4d::uv_limit(&input, &ctx, &mixed, &root, &mixed, &root)?;
3782            let nested = local_4d::uv_limit(
3783                &Full4dCts::from_factorized_local(&local),
3784                &ctx,
3785                &mixed,
3786                &source,
3787                &mixed,
3788                &source,
3789            )?;
3790            assert!(
3791                !atomic.atom().is_zero(),
3792                "the mixed atomic Taylor term must be nonzero"
3793            );
3794            // T_AC(1-T_A)I and T_BC(1-T_B)I vanish: the degree-two
3795            // bubble subtraction leaves no divergent scaling with the triangle.
3796            // Their complete contributions therefore cancel even if a forest
3797            // representation includes or removes zero-quotient outer operations.
3798            let mixed_remainder = GS
3799                .erase_uv_momentum_provenance(&(atomic.atom() + nested.atom()))
3800                .replace(GS.den(W_.a_, W_.mom_, W_.mass_, W_.prop_))
3801                .with(W_.prop_)
3802                .replace_multiple(&replacements)
3803                .normalize_dots()
3804                .collect_factors();
3805            assert!(
3806                mixed_remainder.is_zero(),
3807                "mixed collective Taylor terms must cancel"
3808            );
3809            independent.push((source, local));
3810        }
3811        let joint_scope = independent.iter().fold(
3812            graph.empty_subgraph::<SuBitGraph>(),
3813            |scope, (source, _)| scope.union(source.subgraph()),
3814        );
3815        let joint = OwnedForestNode {
3816            spinney: Spinney::new(
3817                InternalSubGraph::cleaned_filter_optimist(joint_scope, graph.as_ref()),
3818                &graph,
3819                &graph.loop_momentum_basis,
3820            )?
3821            .expect("the disjoint divergent bubbles have a compatible joint source chart"),
3822            topo_order: 1,
3823        };
3824        let product = Local4dCts::from_full_product(
3825            independent
3826                .iter()
3827                .map(|(_, local)| Full4dCts::from_factorized_local(local)),
3828        );
3829        let collective_source = OwnedForestNode {
3830            spinney: Spinney::new(
3831                InternalSubGraph::cleaned_filter_optimist(
3832                    collective_filter.clone(),
3833                    graph.as_ref(),
3834                ),
3835                &graph,
3836                &graph.loop_momentum_basis,
3837            )?
3838            .expect("the collective region has a compatible source chart"),
3839            topo_order: 2,
3840        };
3841        // The surviving complete value is -T_U(1-T_A)(1-T_B)I.
3842        // AB supplies a product of independent counterterms, while the
3843        // convergent triangle supplies none; no incoming-edge or join catalogue
3844        // determines these four physical prefix values.
3845        let mut expected = Atom::Zero;
3846        for (given, local) in std::iter::once((&root, &root_local))
3847            .chain(independent.iter().map(|(source, local)| (source, local)))
3848            .chain(std::iter::once((&joint, &product)))
3849        {
3850            let term = local_4d::uv_limit(
3851                &Full4dCts::from_factorized_local(local),
3852                &ctx,
3853                &collective_source,
3854                given,
3855                &collective_source,
3856                given,
3857            )?;
3858            if given.subgraph().is_empty() {
3859                assert!(
3860                    !term.atom().is_zero(),
3861                    "the full atomic Taylor term must be nonzero"
3862                );
3863            }
3864            expected += term.atom();
3865        }
3866        let mut forests = wood.unfold();
3867        forests.integrate(&graph, crate::utils::vakint()?, &settings.uv)?;
3868        let actual = forests
3869            .graph
3870            .iter_nodes()
3871            .filter(|(_, _, operation)| operation.covers().as_ref() == Some(&collective_filter))
3872            .try_fold(Atom::Zero, |sum, (_, _, operation)| -> Result<_> {
3873                Ok(sum
3874                    + forests
3875                        .compute_store
3876                        .require(operation)?
3877                        .local_4d(operation)?
3878                        .atom())
3879            })?;
3880        let [actual, expected] = [actual, expected].map(|expression| {
3881            GS.erase_uv_momentum_provenance(&expression)
3882                .replace(GS.den(W_.a_, W_.mom_, W_.mass_, W_.prop_))
3883                .with(W_.prop_)
3884                .replace_multiple(&replacements)
3885                .normalize_dots()
3886        });
3887        assert!(
3888            (actual - expected).collect_factors().cancel().is_zero(),
3889            "the complete collective Taylor value must equal -T_U(1-T_A)(1-T_B)I"
3890        );
3891
3892        assert!(
3893            forests.graph.iter_nodes().all(|(_, _, operation)| {
3894                operation.covers().is_none_or(|scope| {
3895                    graph
3896                        .as_ref()
3897                        .connected_components(&scope)
3898                        .iter()
3899                        .all(|component| graph.compute_dod(component) >= 0)
3900                })
3901            }),
3902            "no atomic or nested collective operation may include the convergent triangle"
3903        );
3904        assert!(
3905            forests.graph.iter_nodes().any(|(node, _, operation)| {
3906                operation.covers().as_ref() == Some(&retained_union)
3907                    && forests.graph.is_disjoint_union(node)
3908            }),
3909            "the complete independently divergent cover remains a factorized join"
3910        );
3911
3912        Ok(())
3913    }
3914
3915    #[test]
3916    fn spectacles_typed_4d_local_construction_matches_uv_limit() -> Result<()> {
3917        test_initialise().unwrap();
3918
3919        let spectacles: Graph = dot!(
3920            digraph G{
3921                edge [particle="scalar_1"];
3922                v1 -> v2;
3923                v1 -> v2;
3924
3925                v3 -> v4;
3926                v3 -> v4;
3927
3928                v2 -> v3;
3929                v1 -> v4;
3930            },"scalars"
3931        )?;
3932
3933        // let spinneys = spectacles.spinneys(&spectacles.full_filter());
3934        let settings = GenerationSettings {
3935            uv: UVgenerationSettings {
3936                generate_integrated: false,
3937                local_uv_cts_from_expanded_4d_integrands: true,
3938                ..Default::default()
3939            },
3940            ..Default::default()
3941        };
3942        let cut_structure = CutStructure::empty(&spectacles);
3943        let f = Wood::new(cut_structure, &spectacles, &settings)?;
3944        println!("{}", f);
3945        let mut f = f.unfold();
3946        println!("{}", f);
3947
3948        let (union, child, edge) = f
3949            .graph
3950            .iter_nodes()
3951            .find_map(|(child, _, _)| {
3952                let (parent, edge) = f.graph.unique_parent(child)?;
3953                (!f.graph.is_disjoint_union(child) && f.graph.is_disjoint_union(parent))
3954                    .then_some((parent, child, edge))
3955            })
3956            .expect("spectacles has a connected child above its disconnected union");
3957        f.integrate(&spectacles, crate::utils::vakint()?, &settings.uv)?;
3958
3959        let step_order = f.graph[union].key.op_count();
3960        let (current, given) = f.wood.current_given_pair(edge, step_order);
3961        let expected = local_4d::uv_limit(
3962            &f.recursion_input_4d(union)?,
3963            &UVCtx::new(&spectacles, &settings.uv),
3964            &current,
3965            &given,
3966            &current,
3967            &given,
3968        )?;
3969        let operation = &f.graph[child];
3970        let local = f.compute_store.require(operation)?.local_4d(operation)?;
3971        assert!((local.atom().collect_factors() - expected.atom().collect_factors()).is_zero());
3972
3973        Ok(())
3974    }
3975
3976    #[test]
3977    fn basketball() -> Result<()> {
3978        test_initialise().unwrap();
3979
3980        let basketball: Graph = dot!(
3981            digraph G{
3982                edge [particle="scalar_1"];
3983                v1 -> v2;
3984                v1 -> v2;
3985                v1 -> v2;
3986                v1 -> v2;
3987            },"scalars"
3988        )?;
3989
3990        let f = Wood::new(
3991            CutStructure::empty(&basketball),
3992            &basketball,
3993            &GenerationSettings::default(),
3994        )?;
3995        println!("{}", f);
3996        insta::assert_snapshot!(
3997        f.graph.n_nodes(),
3998        @"12",
3999              );
4000        let f = f.unfold();
4001        println!("{}", f.dot_serialize());
4002        insta::assert_snapshot!(
4003        f.graph.n_nodes(),
4004        @"46");
4005        Ok(())
4006    }
4007
4008    #[test]
4009    fn fourloop_b() -> Result<()> {
4010        test_initialise().unwrap();
4011
4012        let fourloop_b: Graph = dot!(
4013            digraph G{
4014                edge [particle="scalar_1"];
4015                v1 -> v2;
4016                v1 -> v2;
4017
4018                v3 -> v4;
4019                v3 -> v4;
4020
4021                v2 -> v3;
4022                v1 -> v3;
4023                v1 -> v4;
4024            },"scalars"
4025        )?;
4026
4027        // let spinneys = fourloop_b.spinneys(&fourloop_b.full_filter());
4028        let f = Wood::new(
4029            CutStructure::empty(&fourloop_b),
4030            &fourloop_b,
4031            &GenerationSettings::default(),
4032        )?;
4033        println!("{}", f);
4034        insta::assert_snapshot!(
4035        f.graph.n_nodes(),
4036        @"14",
4037        );
4038        let f = f.unfold();
4039        println!("{}", f);
4040        insta::assert_snapshot!(
4041        f.graph.n_nodes(),
4042        @"80",
4043         );
4044
4045        Ok(())
4046    }
4047
4048    #[test]
4049    fn four_loop_a() -> Result<()> {
4050        test_initialise().unwrap();
4051
4052        let four_loop_a: Graph = dot!(
4053            digraph G{
4054                edge [particle="scalar_1"];
4055                v1 -> v2;
4056                v1 -> v2;
4057                v2 -> v3;
4058                v3 -> v1;
4059                v1 -> v4;
4060                v2 -> v4;
4061                v3 -> v4;
4062            },"scalars"
4063        )?;
4064
4065        let f = Wood::new(
4066            CutStructure::empty(&four_loop_a),
4067            &four_loop_a,
4068            &GenerationSettings::default(),
4069        )?;
4070        println!("{}", f);
4071        insta::assert_snapshot!(
4072        f.graph.n_nodes(),
4073        @"12",
4074        );
4075        let f = f.unfold();
4076        println!("{}", f);
4077        insta::assert_snapshot!(
4078        f.graph.n_nodes(),
4079        @"60",
4080         );
4081
4082        Ok(())
4083    }
4084
4085    #[test]
4086    fn triple_double_tadpole() -> Result<()> {
4087        test_initialise().unwrap();
4088        let dumbell: Graph = dot!(
4089            digraph G{
4090                edge [particle="scalar_1"];
4091                v1 -> v2;
4092                v2 -> v3;
4093                v3 -> v3;v3 -> v3;
4094                v2 -> v2; v2 -> v2;
4095                v1 -> v1;v1 -> v1;
4096            },"scalars"
4097        )?;
4098
4099        let f = Wood::new(
4100            CutStructure::empty(&dumbell),
4101            &dumbell,
4102            &GenerationSettings::default(),
4103        )?;
4104
4105        insta::assert_snapshot!(
4106            f.graph.n_nodes(),
4107            @"64");
4108
4109        let f = f.unfold_uncached();
4110        insta::assert_snapshot!(
4111            f.graph.n_nodes(),
4112            @"307");
4113
4114        Ok(())
4115    }
4116
4117    mod failing {
4118        use super::*;
4119
4120        #[test]
4121        fn lobsided_double_dumbell() -> Result<()> {
4122            test_initialise().unwrap();
4123            let dumbell: Graph = dot!(
4124                digraph G{
4125                    edge [particle="scalar_1"];
4126                    v1 -> v2;
4127                    v2 -> v2; //v2 -> v2;
4128                    v1 -> v1;v1 -> v1;
4129                },"scalars"
4130            )?;
4131
4132            let spinneys: Vec<_> = dumbell
4133                .spinneys(&dumbell.full_filter())
4134                .into_iter()
4135                .filter_map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis).transpose())
4136                .collect::<Result<_>>()?;
4137            let f = Wood::new(
4138                CutStructure::empty(&dumbell),
4139                &dumbell,
4140                &GenerationSettings::default(),
4141            )?;
4142
4143            println!("{}", f);
4144
4145            insta::assert_snapshot!(
4146                f.graph.n_nodes(),
4147                @"8",
4148                // format!("Wood does not have correct number of spinneys: \n{}",f)
4149            );
4150
4151            for (_, _, d) in f.graph.iter_nodes() {
4152                println!(
4153                    "//Node {}: \n{}",
4154                    d.subgraph.string_label(),
4155                    dumbell.dot(&d.subgraph)
4156                );
4157            }
4158            let _ff = OldWood::from_spinneys(spinneys, &dumbell); //.unfold(&g, &g.loop_momentum_basis);
4159
4160            // println!("{}", ff.dot(&dumbell));
4161
4162            let f = f.unfold();
4163            f.debug_walk();
4164            println!("{}", f);
4165            insta::assert_snapshot!(
4166                f.normalized_node_labels_with_cover("3L").join("\n"),
4167                @r###"
4168{36,F}: Approx(S_36(_))*Approx(S_F(_))
4169{3} · {36,F}: Approx((-1*S_3+S_F(_))*Approx(S_3(_)))*Approx(S_36(_))
4170{C} · {36,F}: Approx((-1*S_C+S_F(_))*Approx(S_C(_)))*Approx(S_36(_))
4171"###
4172            );
4173            insta::assert_snapshot!(
4174                f.graph.n_nodes(),
4175                @"12");
4176
4177            let f = Wood::new(
4178                CutStructure::empty(&dumbell),
4179                &dumbell,
4180                &GenerationSettings::default(),
4181            )?
4182            .unfold_uncached();
4183            assert!(f.compute_store.entries.is_empty());
4184            insta::assert_snapshot!(
4185                f.normalized_node_labels_with_cover("3L").join("\n"),
4186                @r###"
4187{36,F}: Approx(S_36(_))*Approx(S_F(_))
4188{3} · {36,F}: Approx((-1*S_3+S_F(_))*Approx(S_3(_)))*Approx(S_36(_))
4189{C} · {36,F}: Approx((-1*S_C+S_F(_))*Approx(S_C(_)))*Approx(S_36(_))
4190"###
4191            );
4192
4193            Ok(())
4194        }
4195
4196        #[test]
4197        fn double_double_dumbell() -> Result<()> {
4198            test_initialise().unwrap();
4199            let dumbell: Graph = dot!(
4200                digraph G{
4201                    edge [particle="scalar_1"];
4202                    v1 -> v2;
4203                    v2 -> v2;v2 -> v2; //v2 -> v2;
4204                    v1 -> v1;v1 -> v1;
4205                },"scalars"
4206            )?;
4207
4208            let _spinneys: Vec<_> = dumbell
4209                .spinneys(&dumbell.full_filter())
4210                .into_iter()
4211                .filter_map(|a| Spinney::new(a, &dumbell, &dumbell.loop_momentum_basis).transpose())
4212                .collect::<Result<_>>()?;
4213            let f = Wood::new(
4214                CutStructure::empty(&dumbell),
4215                &dumbell,
4216                &GenerationSettings::default(),
4217            )?;
4218
4219            println!("{}", f);
4220
4221            insta::assert_snapshot!(
4222                f.graph.n_nodes(),
4223                @"16",
4224                // format!("Wood does not have correct number of spinneys: \n{}",f)
4225            );
4226
4227            let f = f.unfold();
4228
4229            insta::assert_snapshot!(
4230                f.graph.n_nodes(),
4231                @"36");
4232
4233            let f = Wood::new(
4234                CutStructure::empty(&dumbell),
4235                &dumbell,
4236                &GenerationSettings::default(),
4237            )?
4238            .unfold_uncached();
4239
4240            let foata_labels = f
4241                .graph
4242                .iter_nodes()
4243                .map(|(_, _, key)| key.foata_level_labels())
4244                .collect::<Vec<_>>();
4245            assert!(
4246                foata_labels
4247                    .iter()
4248                    .any(|label| label == "3,36;FU,F" || label == "36,3;FU,F"),
4249                "expected 3;F and 36;FU branch histories to combine, got:\n{}",
4250                foata_labels.join("\n")
4251            );
4252
4253            Ok(())
4254        }
4255    }
4256}
4257
4258#[cfg(test)]
4259#[path = "overlap_poles.rs"]
4260pub(crate) mod overlap_poles;