Skip to main content

gammalooprs/graph/
mod.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    ops::Index,
4};
5
6use ahash::AHashSet;
7use bincode_trait_derive::{Decode, Encode};
8use gammaloop_tracing_filter::LogMessage;
9use itertools::Itertools;
10use linnet::{
11    half_edge::{
12        HedgeGraph,
13        involution::{EdgeData, EdgeIndex, Hedge, HedgePair},
14        subgraph::{
15            HedgeNode, Inclusion, ModifySubSet, OrientedCut, SuBitGraph, SubGraphLike, SubSetLike,
16            SubSetOps, subset::SubSet,
17        },
18    },
19    parser::DotGraph,
20};
21use tracing::debug;
22
23use rand::{Rng, SeedableRng, rngs::SmallRng};
24// use petgraph::Direction::Outgoing;
25use spenso::shadowing::symbolica_utils::LogPrint;
26use symbolica::atom::{Atom, AtomCore};
27use tracing::warn;
28use typed_index_collections::TiVec;
29
30use crate::{
31    cff::generation::SurfaceCache,
32    define_index,
33    feyngen::diagram_generator::evaluate_overall_factor,
34    integrands::process::{ChannelIndex, LmbMultiChannelingSetup, ParamBuilder},
35    momentum::{Dep, ExternalMomenta, PolDef, sample::ExternalIndex},
36    numerator::GlobalPrefactor,
37    processes::DotExportSettings,
38    settings::runtime::kinematic::{Externals, improvement::PhaseSpaceImprovementSettings},
39    utils::{F, Length, ose_atom_from_index},
40    uv::uv_graph::UVE,
41};
42
43pub(crate) mod attribute_warnings;
44pub mod autogen;
45pub mod cuts;
46pub mod global;
47
48#[derive(Clone, Copy, bincode_trait_derive::Encode, bincode_trait_derive::Decode, Default)]
49pub struct VertexOrder(pub u8);
50
51define_index! {pub struct GroupId;}
52
53#[derive(Clone, bincode_trait_derive::Encode, bincode_trait_derive::Decode)]
54#[trait_decode(trait = crate::GammaLoopContext)]
55pub struct Graph {
56    pub overall_factor: Atom,
57    pub name: String,
58    pub group_id: Option<GroupId>,
59    pub is_group_master: bool,
60    pub tree_edges: SuBitGraph,
61    pub underlying: HedgeGraph<Edge, Vertex, HedgeData>,
62    pub loop_momentum_basis: LoopMomentumBasis,
63    pub param_builder: ParamBuilder,
64    pub global_prefactor: GlobalPrefactor,
65    pub surface_cache: SurfaceCache,
66    /// The cross section initial state cut
67    /// Only relevant for cross sections, but stored here for the parsing
68    pub initial_state_cut: OrientedCut,
69    pub polarizations: Vec<(PolDef, Atom)>,
70}
71
72impl LogMessage for Graph {
73    fn log_display(&self) -> String {
74        self.name.to_string()
75    }
76}
77
78// impl Deref for Graph {
79//     type Target = HedgeGraph<Edge, Vertex>;
80// }
81
82pub mod feynman_graph;
83pub use feynman_graph::FeynmanGraph;
84pub mod ext;
85
86#[derive(Clone, Copy)]
87pub(crate) enum LmbChannelFallback {
88    CurrentGraphBasis,
89    FirstBasis,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub(crate) enum ThresholdPinchStatus {
94    Always,
95    CanBecome,
96    NotProven,
97}
98
99impl Graph {
100    pub(crate) fn with_global_numerator_only(&self, name: String, numerator: Atom) -> Self {
101        let mut graph = self.clone();
102        graph.name = name;
103        graph.overall_factor = Atom::one();
104        graph.global_prefactor.num = numerator;
105        graph.global_prefactor.projector = Atom::one();
106        graph.polarizations.clear();
107        graph.underlying = self.underlying.map_data_ref(
108            |_, _, vertex| {
109                let mut vertex = vertex.clone();
110                vertex.num = autogen::Autogen::explicit(Atom::one());
111                vertex
112            },
113            |_, _, _, edge| {
114                edge.map(|edge| {
115                    let mut edge = edge.clone();
116                    edge.num = autogen::Autogen::explicit(Atom::one());
117                    edge
118                })
119            },
120            |_, hedge_data| hedge_data.clone(),
121        );
122        graph
123    }
124
125    pub fn debug_dot(&self) -> String {
126        DotGraph::from(self).debug_dot()
127    }
128
129    pub fn debug_dot_with_settings(&self, settings: &DotExportSettings) -> String {
130        self.to_dot_graph_with_settings(settings).debug_dot()
131    }
132
133    pub fn pretty_dot(&self) -> String {
134        self.dot_impl(
135            &self.full_filter(),
136            "",
137            &|_a| None,
138            &|e| Some(format!("label=\"{}\"", e.num.log_print(None))),
139            &|v| Some(format!("label=\"{}\"", v.num.log_print(None))),
140        )
141    }
142
143    pub(crate) fn global_atom(&self) -> Atom {
144        &self.global_prefactor.num
145            * &self.global_prefactor.projector
146            * evaluate_overall_factor(self.overall_factor.as_view())
147    }
148
149    pub(crate) fn external_momentum_edge_order(&self) -> Vec<EdgeIndex> {
150        if self.initial_state_cut.nedges(&self.underlying) == 0 {
151            let external_filter: SuBitGraph = self.external_filter();
152            external_filter
153                .included_iter()
154                .sorted()
155                .map(|hedge| self.underlying[&hedge])
156                .collect_vec()
157        } else {
158            self.initial_state_cut
159                .iter_left_hedges()
160                .sorted()
161                .map(|hedge| self.underlying[&hedge])
162                .collect_vec()
163        }
164    }
165
166    pub(crate) fn canonicalize_lmb_external_order(&self, lmb: &mut LoopMomentumBasis) {
167        lmb.canonicalize_external_order(&self.external_momentum_edge_order());
168    }
169
170    pub(crate) fn dummy_stripped_external_flows_of<S: SubGraphLike>(&self, subgraph: &S) -> S::Base
171    where
172        S::Base: ModifySubSet<HedgePair> + ModifySubSet<Hedge>,
173    {
174        let mut externals = self.underlying.full_crown(subgraph);
175        for (pair, _, edge) in self.underlying.iter_edges() {
176            if edge.data.is_dummy {
177                externals.sub(pair);
178            }
179        }
180        externals
181    }
182
183    pub(crate) fn random_externals(&self, seed: u64) -> Externals {
184        let mut rng = SmallRng::seed_from_u64(seed);
185        let mom_range = -10.0..10.0;
186
187        let mut momenta = vec![ExternalMomenta::Dependent(Dep::Dep)];
188        let mut helicities = vec![];
189        let ext: SuBitGraph = self.external_filter();
190
191        for (_, _, d) in self.iter_edges_of(&ext) {
192            let hel = d.data.random_helicity(seed);
193            helicities.push(hel);
194            if helicities.len() == 2 {
195                continue;
196            }
197            let mom = ExternalMomenta::Independent([
198                F(rng.random_range(mom_range.clone())),
199                F(rng.random_range(mom_range.clone())),
200                F(rng.random_range(mom_range.clone())),
201                F(rng.random_range(mom_range.clone())),
202            ]);
203
204            momenta.push(mom);
205        }
206        Externals::Constant {
207            momenta,
208            helicities,
209            improvement_settings: PhaseSpaceImprovementSettings::default(),
210            f_64_cache: None,
211            f_128_cache: None,
212        }
213    }
214
215    // pub(crate) fn new(
216    //     name: SmartString<LazyCompact>,
217    //     multiplicity: Atom,
218    //     underlying: HedgeGraph<Edge, Vertex, NumHedgeData>,
219    // ) -> Result<Self> {
220    //     Ok(Self {
221    //         name: name.to_string(),
222    //         overall_factor: multiplicity,
223    //         loop_momentum_basis: underlying.lmb(&underlying.full_filter()),
224    //         group_id: None,
225    //         is_group_master: false,
226    //         underlying,
227    //         global_prefactor: GlobalPrefactor::default(),
228    //         polarizations: vec![],
229    //     })
230    // }
231
232    pub(crate) fn build_multi_channeling_channels(
233        &self,
234        lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
235        override_lmb_heuristics: bool,
236    ) -> LmbMultiChannelingSetup {
237        let channels = self.select_amplitude_lmb_channel_indices(
238            lmbs,
239            override_lmb_heuristics,
240            LmbChannelFallback::CurrentGraphBasis,
241        );
242
243        debug!(
244            "number of lmbs: {}, number of channels: {}",
245            lmbs.len(),
246            channels.len()
247        );
248
249        LmbMultiChannelingSetup {
250            channels,
251            graph: self.clone(),
252            all_bases: lmbs.clone(),
253        }
254    }
255
256    pub(crate) fn select_amplitude_lmb_channel_indices(
257        &self,
258        lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
259        override_lmb_heuristics: bool,
260        fallback: LmbChannelFallback,
261    ) -> TiVec<ChannelIndex, LmbIndex> {
262        if override_lmb_heuristics {
263            return lmbs
264                .iter_enumerated()
265                .map(|(lmb_index, _)| lmb_index)
266                .collect();
267        }
268
269        let Some((_, first_lmb)) = lmbs.iter_enumerated().next() else {
270            return TiVec::new();
271        };
272        let num_loops = first_lmb.loop_edges.len();
273        if num_loops == 0 {
274            return self.fallback_lmb_channel_indices(lmbs, fallback);
275        }
276
277        let mut universe = BTreeMap::<Vec<EdgeIndex>, usize>::new();
278        let mut candidate_covers = Vec::<(LmbIndex, BTreeSet<usize>)>::new();
279        for (lmb_index, lmb) in lmbs.iter_enumerated() {
280            let mut cover = BTreeSet::<usize>::new();
281            let massless_loop_edges = lmb
282                .loop_edges
283                .iter()
284                .copied()
285                .filter(|edge_id| self.underlying[*edge_id].particle.is_massless())
286                .collect_vec();
287
288            for mut combination in massless_loop_edges.into_iter().combinations(num_loops) {
289                combination.sort();
290                let next_universe_id = universe.len();
291                let universe_id = *universe.entry(combination).or_insert(next_universe_id);
292                cover.insert(universe_id);
293            }
294
295            if !cover.is_empty() {
296                candidate_covers.push((lmb_index, cover));
297            }
298        }
299
300        if universe.is_empty() {
301            return self.fallback_lmb_channel_indices(lmbs, fallback);
302        }
303
304        let channels = Self::minimum_lmb_set_cover(universe.len(), &candidate_covers);
305        if channels.is_empty() {
306            self.fallback_lmb_channel_indices(lmbs, fallback)
307        } else {
308            channels.into_iter().sorted().collect()
309        }
310    }
311
312    fn fallback_lmb_channel_indices(
313        &self,
314        lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
315        fallback: LmbChannelFallback,
316    ) -> TiVec<ChannelIndex, LmbIndex> {
317        let fallback_index = match fallback {
318            LmbChannelFallback::CurrentGraphBasis => lmbs
319                .iter_enumerated()
320                .find(|(_, lmb)| lmb.loop_edges == self.loop_momentum_basis.loop_edges)
321                .map(|(lmb_index, _)| lmb_index)
322                .or_else(|| {
323                    warn!(
324                        "lmb not in the list of all lmb, falling back to the first generated basis"
325                    );
326                    lmbs.iter_enumerated()
327                        .next()
328                        .map(|(lmb_index, _)| lmb_index)
329                }),
330            LmbChannelFallback::FirstBasis => lmbs
331                .iter_enumerated()
332                .next()
333                .map(|(lmb_index, _)| lmb_index),
334        };
335
336        fallback_index.into_iter().collect()
337    }
338
339    fn minimum_lmb_set_cover(
340        universe_len: usize,
341        candidate_covers: &[(LmbIndex, BTreeSet<usize>)],
342    ) -> Vec<LmbIndex> {
343        let mut candidates_by_element = vec![Vec::<usize>::new(); universe_len];
344        for (candidate_pos, (_, cover)) in candidate_covers.iter().enumerate() {
345            for element in cover {
346                candidates_by_element[*element].push(candidate_pos);
347            }
348        }
349
350        let remaining = (0..universe_len).collect::<BTreeSet<_>>();
351        let mut chosen = Vec::<usize>::new();
352        let mut best = None;
353        Self::search_minimum_lmb_set_cover(
354            remaining,
355            &mut chosen,
356            &mut best,
357            candidate_covers,
358            &candidates_by_element,
359        );
360        best.unwrap_or_default()
361    }
362
363    fn search_minimum_lmb_set_cover(
364        remaining: BTreeSet<usize>,
365        chosen: &mut Vec<usize>,
366        best: &mut Option<Vec<LmbIndex>>,
367        candidate_covers: &[(LmbIndex, BTreeSet<usize>)],
368        candidates_by_element: &[Vec<usize>],
369    ) {
370        if remaining.is_empty() {
371            let mut candidate = chosen
372                .iter()
373                .map(|candidate_pos| candidate_covers[*candidate_pos].0)
374                .collect_vec();
375            candidate.sort();
376            let better = best.as_ref().is_none_or(|current_best| {
377                candidate.len() < current_best.len()
378                    || (candidate.len() == current_best.len() && candidate < *current_best)
379            });
380            if better {
381                *best = Some(candidate);
382            }
383            return;
384        }
385
386        if let Some(current_best) = best.as_ref()
387            && chosen.len() >= current_best.len()
388        {
389            return;
390        }
391
392        let Some(element) = remaining
393            .iter()
394            .copied()
395            .min_by_key(|element| candidates_by_element[*element].len())
396        else {
397            return;
398        };
399        if candidates_by_element[element].is_empty() {
400            return;
401        }
402
403        for candidate_pos in &candidates_by_element[element] {
404            if chosen.contains(candidate_pos) {
405                continue;
406            }
407            let cover = &candidate_covers[*candidate_pos].1;
408            if cover.is_disjoint(&remaining) {
409                continue;
410            }
411
412            let mut next_remaining = remaining.clone();
413            for covered_element in cover {
414                next_remaining.remove(covered_element);
415            }
416            chosen.push(*candidate_pos);
417            Self::search_minimum_lmb_set_cover(
418                next_remaining,
419                chosen,
420                best,
421                candidate_covers,
422                candidates_by_element,
423            );
424            chosen.pop();
425        }
426    }
427
428    pub(crate) fn get_edge_subgraph(&self, edge: EdgeIndex) -> SuBitGraph {
429        let mut subgraph: SuBitGraph = self.underlying.empty_subgraph();
430
431        match self[&edge].1 {
432            HedgePair::Paired { source, sink } => {
433                subgraph.add(source);
434                subgraph.add(sink);
435            }
436            HedgePair::Unpaired { hedge, .. } => {
437                subgraph.add(hedge);
438            }
439            HedgePair::Split { .. } => unreachable!(),
440        }
441
442        subgraph
443    }
444
445    pub(crate) fn iter_loop_edges(
446        &self,
447    ) -> impl Iterator<Item = (HedgePair, EdgeIndex, EdgeData<&Edge>)> {
448        self.underlying.iter_edges().filter(|(_, edge_index, _)| {
449            self.loop_momentum_basis.edge_signatures[*edge_index]
450                .internal
451                .iter()
452                .any(|sign| sign.is_sign())
453        })
454    }
455
456    pub(crate) fn iter_non_loop_edges(
457        &self,
458    ) -> impl Iterator<Item = (HedgePair, EdgeIndex, EdgeData<&Edge>)> {
459        self.underlying.iter_edges().filter(|(_, edge_index, _)| {
460            self.loop_momentum_basis.edge_signatures[*edge_index]
461                .internal
462                .iter()
463                .all(|sign| sign.is_zero())
464        })
465    }
466
467    pub(crate) fn get_source_and_target(&self) -> (HedgeNode, HedgeNode) {
468        let mut source_nodes = AHashSet::new();
469        let mut target_nodes = AHashSet::new();
470
471        for (hedge_pair, _, _) in self.underlying.iter_edges_of(&self.initial_state_cut) {
472            match hedge_pair {
473                HedgePair::Split { source, sink, .. } => {
474                    let source_node = self.underlying.node_id(sink);
475                    let sink_node = self.underlying.node_id(source);
476
477                    source_nodes.insert(source_node);
478                    target_nodes.insert(sink_node);
479                }
480                _ => {
481                    unreachable!();
482                }
483            }
484        }
485
486        // They don't need to be the same!
487        // assert_eq!(
488        //     source_nodes.len(),
489        //     target_nodes.len(),
490        //     "The number of source and target nodes should be the same{}",
491        //     self.debug_dot()
492        // );
493
494        let source_node_vec = source_nodes.into_iter().collect_vec();
495        let target_node_vec = target_nodes.into_iter().collect_vec();
496
497        //println!("source nodes: {:?}", source_node_vec);
498        //println!("target nodes: {:?}", target_node_vec);
499        //panic!("stop");
500
501        let source_node = self
502            .underlying
503            .combine_to_single_hedgenode(&source_node_vec);
504
505        let target_node = self
506            .underlying
507            .combine_to_single_hedgenode(&target_node_vec);
508
509        (source_node, target_node)
510    }
511
512    pub(crate) fn edge_name_to_index(&self, name: &str) -> Option<EdgeIndex> {
513        for (_, edge_index, edge_data) in self.underlying.iter_edges() {
514            if edge_data.data.name.value == name {
515                return Some(edge_index);
516            }
517        }
518
519        None
520    }
521
522    pub(crate) fn get_initial_state_tree(&self) -> (SuBitGraph, Vec<EdgeIndex>) {
523        let mut tree_like_edges = Vec::new();
524        let full_graph = self.underlying.full_filter();
525        let full_is_cut = self
526            .initial_state_cut
527            .left
528            .union(&self.initial_state_cut.right);
529
530        let full_graph_without_initial_state_cut = full_graph.subtract(&full_is_cut);
531
532        let mut result: SubSet<Hedge> = self.underlying.empty_subgraph();
533
534        for (pair, edge_id, _) in self
535            .underlying
536            .iter_edges_of(&full_graph_without_initial_state_cut)
537        {
538            if let HedgePair::Paired { source, sink } = pair {
539                let loop_signature = &self.loop_momentum_basis.edge_signatures[edge_id];
540                let is_tree_like = loop_signature.internal.iter().all(|sign| sign.is_zero());
541                if is_tree_like {
542                    tree_like_edges.push(edge_id);
543                    let source_node = self.underlying.node_id(source);
544                    let sink_node = self.underlying.node_id(sink);
545
546                    let source_connects_initial_state =
547                        self.underlying.iter_crown(source_node).any(|hedge| {
548                            let mut single_hedge_subgraph: SubSet<Hedge> =
549                                self.underlying.empty_subgraph();
550
551                            single_hedge_subgraph.add(hedge);
552
553                            single_hedge_subgraph.intersects(&full_is_cut)
554                        });
555
556                    if source_connects_initial_state {
557                        for hedge in self.underlying.iter_crown(source_node) {
558                            result.add(hedge);
559                        }
560                    } else {
561                        for hedge in self.underlying.iter_crown(sink_node) {
562                            result.add(hedge);
563                        }
564                    }
565                }
566            }
567        }
568
569        (result, tree_like_edges)
570    }
571
572    pub(crate) fn get_raised_edge_groups(&self) -> Vec<Vec<EdgeIndex>> {
573        let mut result = Vec::<Vec<EdgeIndex>>::new();
574
575        for (_, edge_index, _) in self.iter_loop_edges() {
576            let group_position = result.iter().position(|group| {
577                group.iter().all(|e| {
578                    self.loop_momentum_basis.edges_are_raised(*e, edge_index)
579                        && self[edge_index].mass == self[*e].mass
580                })
581            });
582
583            if let Some(pos) = group_position {
584                result[pos].push(edge_index);
585            } else {
586                result.push(vec![edge_index]);
587            }
588        }
589
590        result.iter_mut().for_each(|group| group.sort());
591
592        result
593    }
594
595    pub fn get_edges_in_initial_state_cut(&self) -> Vec<EdgeIndex> {
596        self.initial_state_cut
597            .iter_left_hedges()
598            .map(|hedge| self.underlying[&hedge])
599            .collect_vec()
600    }
601    pub(crate) fn classify_threshold_pinch(
602        &self,
603        cut_boundary_edges: &[EdgeIndex],
604        threshold_boundary_edges: &[EdgeIndex],
605    ) -> ThresholdPinchStatus {
606        // The caller has already intersected both cut boundaries with the connected sandwich.
607        // These edge sets therefore carry the same graph-relative information that the former
608        // `is_always_pinch` implementation derived from the two cuts and sandwich internally.
609        // Keep every mass representation symbolic here. A non-zero difference is not a
610        // model-independent conclusion; resolved values handle that case during generation.
611        let boundary_mass_sum = |edges: &[EdgeIndex]| {
612            edges
613                .iter()
614                .map(|edge_id| self[*edge_id].mass_atom())
615                .fold(Atom::new(), |sum, mass| sum + mass)
616        };
617
618        let mass_sums_are_identical = (boundary_mass_sum(cut_boundary_edges)
619            - boundary_mass_sum(threshold_boundary_edges))
620        .expand()
621        .is_zero();
622
623        if !mass_sums_are_identical {
624            return ThresholdPinchStatus::NotProven;
625        }
626
627        if cut_boundary_edges.len() > 1 && threshold_boundary_edges.len() > 1 {
628            ThresholdPinchStatus::CanBecome
629        } else {
630            ThresholdPinchStatus::Always
631        }
632    }
633}
634
635pub mod edge;
636pub mod parse;
637pub use autogen::Autogen;
638pub use edge::Edge;
639pub mod hedge_data;
640pub use hedge_data::HedgeData;
641pub mod vertex;
642pub use vertex::Vertex;
643
644pub mod lmb;
645pub use lmb::{LMBext, LmbError, LmbIndex, LoopMomentumBasis};
646
647#[derive(Debug, Clone, Encode, Decode, PartialEq, Eq)]
648pub struct GraphGroup {
649    master: usize,
650    remaining: Vec<usize>,
651}
652
653impl GraphGroup {
654    pub(crate) fn master(&self) -> usize {
655        self.master
656    }
657
658    pub(crate) fn iter_enumerated(&self) -> impl Iterator<Item = (GraphGroupPosition, usize)> + '_ {
659        self.into_iter()
660            .enumerate()
661            .map(|(i, graph_id)| (GraphGroupPosition(i), graph_id))
662    }
663
664    pub(crate) fn find_position(&self, graph_id: usize) -> Option<GraphGroupPosition> {
665        self.into_iter()
666            .position(|id| id == graph_id)
667            .map(GraphGroupPosition)
668    }
669}
670
671impl<'a> IntoIterator for &'a GraphGroup {
672    type Item = usize;
673    type IntoIter = std::iter::Chain<
674        std::array::IntoIter<usize, 1>,
675        std::iter::Copied<std::slice::Iter<'a, usize>>,
676    >;
677
678    fn into_iter(self) -> Self::IntoIter {
679        [self.master]
680            .into_iter()
681            .chain(self.remaining.iter().copied())
682    }
683}
684
685impl Length for GraphGroup {
686    fn len(&self) -> usize {
687        1 + self.remaining.len()
688    }
689}
690
691impl Index<GraphGroupPosition> for GraphGroup {
692    type Output = usize;
693
694    fn index(&self, index: GraphGroupPosition) -> &Self::Output {
695        if index.0 == 0 {
696            &self.master
697        } else {
698            &self.remaining[index.0 - 1]
699        }
700    }
701}
702
703define_index! {pub struct GraphGroupPosition;}
704
705#[derive(
706    Debug, Copy, Clone, PartialEq, Eq, bincode_trait_derive::Encode, bincode_trait_derive::Decode,
707)]
708#[trait_decode(trait = symbolica::state::HasStateMap)]
709pub struct ExternalConnection {
710    pub incoming_index: ExternalIndex,
711    pub outgoing_index: ExternalIndex,
712}
713
714pub fn get_cff_inverse_energy_product_impl<E, V, H, S: SubSetLike>(
715    graph: &HedgeGraph<E, V, H>,
716    subgraph: &S,
717    contract_edges: &[EdgeIndex],
718) -> Atom {
719    Atom::num(1)
720        / graph
721            .iter_edges_of(subgraph)
722            .filter_map(|(pair, edge_index, _)| match pair {
723                HedgePair::Paired { .. } => {
724                    if contract_edges.contains(&edge_index) {
725                        None
726                    } else {
727                        Some(-Atom::num(2) * ose_atom_from_index(edge_index))
728                    }
729                }
730                _ => None,
731            })
732            .reduce(|acc, x| acc * x)
733            .unwrap_or_else(|| Atom::num(1))
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use crate::{
740        dot, graph::parse::from_dot::IntoGraph, initialisation::test_initialise,
741        momentum::signature::LoopExtSignature,
742    };
743    use std::sync::OnceLock;
744
745    fn test_lmb(graph: &Graph, loop_edges: &[usize]) -> LoopMomentumBasis {
746        LoopMomentumBasis {
747            tree: graph.underlying.empty_subgraph(),
748            loop_edges: loop_edges.iter().copied().map(EdgeIndex::from).collect(),
749            ext_edges: Vec::new().into(),
750            edge_signatures: graph.underlying.new_edgevec(|_, _, _| {
751                LoopExtSignature::from((Vec::<isize>::new(), Vec::<isize>::new()))
752            }),
753        }
754    }
755
756    fn selector_test_graph() -> Graph {
757        static GRAPH: OnceLock<Graph> = OnceLock::new();
758        GRAPH
759            .get_or_init(|| {
760                test_initialise().unwrap();
761                dot!(
762                    digraph lmb_selector {
763                        edge [num=1 mass=0]
764                        node [num=1]
765                        A -> B [id=0]
766                        A -> B [id=1]
767                        A -> B [id=2]
768                        A -> B [id=3]
769                        A -> B [id=4 mass=1]
770                    }
771                )
772                .unwrap()
773            })
774            .clone()
775    }
776
777    #[test]
778    fn exact_lmb_set_cover_prefers_smallest_deterministic_solution() {
779        let candidates = [
780            (LmbIndex::from(0), BTreeSet::from([0])),
781            (LmbIndex::from(1), BTreeSet::from([1])),
782            (LmbIndex::from(2), BTreeSet::from([0, 1])),
783            (LmbIndex::from(3), BTreeSet::from([0, 1])),
784        ];
785
786        assert_eq!(
787            Graph::minimum_lmb_set_cover(2, &candidates),
788            vec![LmbIndex::from(2)]
789        );
790    }
791
792    #[test]
793    fn amplitude_lmb_selector_covers_massless_combinations_and_skips_duplicates() {
794        let mut graph = selector_test_graph();
795        graph.loop_momentum_basis = test_lmb(&graph, &[0, 1]);
796        let lmbs: TiVec<LmbIndex, LoopMomentumBasis> = vec![
797            test_lmb(&graph, &[0, 1]),
798            test_lmb(&graph, &[2, 3]),
799            test_lmb(&graph, &[0, 2]),
800            test_lmb(&graph, &[0, 1]),
801            test_lmb(&graph, &[0, 4]),
802        ]
803        .into();
804
805        let selected = graph.select_amplitude_lmb_channel_indices(
806            &lmbs,
807            false,
808            LmbChannelFallback::CurrentGraphBasis,
809        );
810
811        assert_eq!(
812            selected.into_iter().collect_vec(),
813            vec![LmbIndex::from(0), LmbIndex::from(1), LmbIndex::from(2)]
814        );
815    }
816
817    #[test]
818    fn amplitude_lmb_selector_override_keeps_all_bases() {
819        let graph = selector_test_graph();
820        let lmbs: TiVec<LmbIndex, LoopMomentumBasis> = vec![
821            test_lmb(&graph, &[0, 1]),
822            test_lmb(&graph, &[2, 3]),
823            test_lmb(&graph, &[0, 4]),
824        ]
825        .into();
826
827        let selected = graph.select_amplitude_lmb_channel_indices(
828            &lmbs,
829            true,
830            LmbChannelFallback::CurrentGraphBasis,
831        );
832
833        assert_eq!(
834            selected.into_iter().collect_vec(),
835            vec![LmbIndex::from(0), LmbIndex::from(1), LmbIndex::from(2)]
836        );
837    }
838
839    #[test]
840    fn amplitude_lmb_selector_falls_back_when_no_massless_combination_exists() {
841        let mut graph = selector_test_graph();
842        graph.loop_momentum_basis = test_lmb(&graph, &[0, 4]);
843        let lmbs: TiVec<LmbIndex, LoopMomentumBasis> =
844            vec![test_lmb(&graph, &[1, 4]), test_lmb(&graph, &[0, 4])].into();
845
846        let selected_current = graph.select_amplitude_lmb_channel_indices(
847            &lmbs,
848            false,
849            LmbChannelFallback::CurrentGraphBasis,
850        );
851        let selected_first = graph.select_amplitude_lmb_channel_indices(
852            &lmbs,
853            false,
854            LmbChannelFallback::FirstBasis,
855        );
856
857        assert_eq!(
858            selected_current.into_iter().collect_vec(),
859            vec![LmbIndex::from(1)]
860        );
861        assert_eq!(
862            selected_first.into_iter().collect_vec(),
863            vec![LmbIndex::from(0)]
864        );
865    }
866
867    #[test]
868    fn threshold_pinch_classification_distinguishes_fixed_and_multiparticle_boundaries() {
869        let graph = selector_test_graph();
870
871        assert_eq!(
872            graph.classify_threshold_pinch(&[EdgeIndex::from(0)], &[EdgeIndex::from(1)],),
873            ThresholdPinchStatus::Always,
874        );
875        assert_eq!(
876            graph.classify_threshold_pinch(
877                &[EdgeIndex::from(0), EdgeIndex::from(1)],
878                &[EdgeIndex::from(2), EdgeIndex::from(3)],
879            ),
880            ThresholdPinchStatus::CanBecome,
881        );
882        assert_eq!(
883            graph.classify_threshold_pinch(&[EdgeIndex::from(0)], &[EdgeIndex::from(4)],),
884            ThresholdPinchStatus::NotProven,
885        );
886    }
887}