Skip to main content

gammalooprs/processes/
cross_section.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt::{Display, Formatter},
4    fs::{self, File},
5    io::Write,
6    ops::{Index, IndexMut},
7    path::{Path, PathBuf},
8};
9
10use ahash::HashMap;
11// use bincode::{Decode, Encode};
12use bincode_trait_derive::{Decode, Encode};
13use color_eyre::Result;
14use itertools::Itertools;
15use rayon::{
16    ThreadPool,
17    iter::{IntoParallelRefMutIterator, ParallelIterator},
18};
19use spenso::algebra::algebraic_traits::IsZero;
20use tracing::info;
21use vakint::Vakint;
22
23use crate::{
24    DependentMomentaConstructor, GammaLoopContext, GammaLoopContextContainer,
25    cff::{
26        CutCFFIndex,
27        esurface::{RaisedEsurfaceData, RaisedEsurfaceGroup, RaisedEsurfaceId},
28        expression::{CFFExpression, OrientationID},
29    },
30    debug_tags, define_index,
31    graph::{
32        GraphGroup, GroupId, LMBext, LmbChannelFallback, LmbIndex, LoopMomentumBasis,
33        ThresholdPinchStatus,
34        cuts::{CutSet, ResidueSelector},
35        edge::EdgeMass,
36        parse::complete_group_parsing,
37    },
38    integrands::process::{
39        GenericEvaluator, LmbMultiChannelingSetup, ParamBuilder,
40        cross_section::CrossSectionIntegrandData, graph_to_group_id_for_group_structure,
41    },
42    model::ArcParticle,
43    momentum::{
44        Helicity,
45        sample::{ExternalIndex, SubspaceData},
46    },
47    processes::{
48        DotExportSettings, EvaluatorSettings, GraphCutSelectionSubject, GraphGenerationStats,
49        GraphGroupSelectionMode, GraphGroupSelectionPlan, GraphGroupSelectionSpec,
50        GraphSelectionSignatureInventory, GraphSelectionSubject, NamedGraphGenerationReport,
51        RaisedCutSignatureInventory,
52    },
53    settings::{
54        GlobalSettings, RuntimeSettings, global::GenerationSettings, runtime::LockedRuntimeSettings,
55    },
56    utils::{
57        DEFAULT_ESURFACE_EXISTENCE_THRESHOLD, F, GS, W_,
58        hyperdual_utils::{shape_from_cut_cff_index, simple_n_deriv_shape},
59    },
60    uv::{
61        approx::{CutStructure, OrientationProjection},
62        forest::ParametricIntegrands,
63    },
64};
65use eyre::{Context, eyre};
66use linnet::half_edge::{
67    involution::{EdgeIndex, EdgeVec, Orientation},
68    subgraph::{
69        HedgeNode, Inclusion, InternalSubGraph, ModifySubSet, OrientedCut, SuBitGraph,
70        SubGraphLike, SubSetLike, SubSetOps,
71    },
72};
73use serde::{Deserialize, Serialize};
74use symbolica::{domains::dual::HyperDual, prelude::*};
75use tracing::{debug, warn};
76use typed_index_collections::{TiVec, ti_vec};
77
78use super::generation_progress::{self, GenerationProcessKind, GenerationProgressPhase};
79
80use crate::{
81    cff::esurface::{Esurface, EsurfaceID},
82    graph::{ExternalConnection, FeynmanGraph, Graph},
83    integrands::process::{
84        ProcessIntegrand,
85        cross_section::{CrossSectionGraphTerm, CrossSectionIntegrand},
86    },
87    model::Model,
88};
89
90use crate::processes::ProcessDefinition;
91
92#[derive(Clone, Debug, Encode, Decode)]
93pub struct IteratedCtCollection<T> {
94    data: Vec<T>,
95    num_right_thresholds: usize,
96}
97
98impl<T> IteratedCtCollection<T> {
99    pub(crate) fn new(
100        data: Vec<T>,
101        num_left_thresholds: usize,
102        num_right_thresholds: usize,
103    ) -> Self {
104        let expected_len = num_left_thresholds
105            .checked_mul(num_right_thresholds)
106            .expect("iterated threshold-counterterm dimensions overflow usize");
107        assert_eq!(
108            data.len(),
109            expected_len,
110            "iterated threshold-counterterm data must contain one entry per left/right pair"
111        );
112        Self {
113            data,
114            num_right_thresholds,
115        }
116    }
117
118    pub fn map_ref<U, F>(&self, f: F) -> IteratedCtCollection<U>
119    where
120        F: Fn(&T) -> U,
121    {
122        let data = self.data.iter().map(f).collect();
123        IteratedCtCollection {
124            data,
125            num_right_thresholds: self.num_right_thresholds,
126        }
127    }
128
129    pub(crate) fn iter(&self) -> impl Iterator<Item = &T> {
130        self.data.iter()
131    }
132
133    pub(crate) fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
134        self.data.iter_mut()
135    }
136
137    pub(crate) fn num_right_thresholds(&self) -> usize {
138        self.num_right_thresholds
139    }
140}
141
142impl<T> Index<(LeftThresholdId, RightThresholdId)> for IteratedCtCollection<T> {
143    type Output = T;
144
145    fn index(&self, index: (LeftThresholdId, RightThresholdId)) -> &Self::Output {
146        let (left_id, right_id) = index;
147        &self.data[left_id.0 * self.num_right_thresholds + right_id.0]
148    }
149}
150
151impl<T> IndexMut<(LeftThresholdId, RightThresholdId)> for IteratedCtCollection<T> {
152    fn index_mut(&mut self, index: (LeftThresholdId, RightThresholdId)) -> &mut Self::Output {
153        let (left_id, right_id) = index;
154        &mut self.data[left_id.0 * self.num_right_thresholds + right_id.0]
155    }
156}
157
158#[derive(Clone, Encode, Decode)]
159#[trait_decode(trait = GammaLoopContext)]
160pub struct LUCounterTermData {
161    pub left_thresholds: TiVec<LeftThresholdId, RaisedEsurfaceGroup>,
162    pub right_thresholds: TiVec<RightThresholdId, RaisedEsurfaceGroup>,
163    pub left_atoms: TiVec<LeftThresholdId, ParametricIntegrands>,
164    pub right_atoms: TiVec<RightThresholdId, ParametricIntegrands>,
165    pub iterated: IteratedCtCollection<ParametricIntegrands>,
166}
167
168fn max_dual_size_for_cut_cff_indices<'a>(
169    cut_cff_indices: impl Iterator<Item = &'a CutCFFIndex>,
170) -> usize {
171    cut_cff_indices
172        .map(|cut_cff_index| {
173            shape_from_cut_cff_index(cut_cff_index)
174                .map(|shape| HyperDual::<F<f64>>::new(shape).values.len())
175                .unwrap_or(1)
176        })
177        .max()
178        .unwrap_or(1)
179}
180
181define_index! {pub struct RightThresholdId;}
182define_index! {pub struct LeftThresholdId;}
183define_index! {pub struct CutGroupId;}
184
185/// Eligibility of one threshold E-surface relative to one side of one physical cut.
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187pub enum ThresholdCountertermStatus {
188    /// The E-surface does not depend on any active loop coordinate of the cut-side subspace.
189    NoRadialDependence,
190    /// The zero can only occur at a pinched boundary and has no strictly existing region.
191    AlwaysPinched,
192    /// A strictly existing region is possible, but its boundary includes a pinched limit.
193    CanBecomePinched,
194    /// Current resolved masses and kinematics rigorously exclude a zero over the full cut phase space.
195    ProvenNonExisting,
196    /// A zero is possible, or the available metadata is insufficient to exclude one safely.
197    PotentiallyExisting,
198}
199
200impl ThresholdCountertermStatus {
201    pub fn is_eligible_for_generation(self, check_current_model: bool) -> bool {
202        match self {
203            Self::NoRadialDependence | Self::AlwaysPinched => false,
204            Self::ProvenNonExisting => !check_current_model,
205            Self::CanBecomePinched | Self::PotentiallyExisting => true,
206        }
207    }
208}
209
210/// Persisted topology and invariant-bound metadata for one generated cut-relative CT association.
211#[derive(Clone, Debug, Encode, Decode)]
212#[trait_decode(trait = GammaLoopContext)]
213pub struct ThresholdCountertermAssociation {
214    pub esurface_id: EsurfaceID,
215    pub cut_boundary_edges: Vec<EdgeIndex>,
216    pub threshold_boundary_edges: Vec<EdgeIndex>,
217    pub invariant_bound_is_applicable: bool,
218}
219
220#[derive(Clone, Debug, Default, Encode, Decode)]
221#[trait_decode(trait = GammaLoopContext)]
222pub struct CutThresholdCountertermAssociations {
223    pub left: Vec<ThresholdCountertermAssociation>,
224    pub right: Vec<ThresholdCountertermAssociation>,
225}
226
227impl ThresholdCountertermAssociation {
228    fn mass_sum(edges: &[EdgeIndex], masses: &EdgeVec<Option<F<f64>>>) -> Option<F<f64>> {
229        edges.iter().try_fold(F(0.0), |sum, edge_id| {
230            masses[*edge_id].as_ref().map(|mass| sum + mass.abs())
231        })
232    }
233
234    fn incoming_invariant_mass(
235        graph: &Graph,
236        runtime_settings: &RuntimeSettings,
237    ) -> Option<F<f64>> {
238        let external_momenta = runtime_settings
239            .kinematics
240            .externals
241            .get_dependent_externals(DependentMomentaConstructor::CrossSection)
242            .ok()?;
243        if external_momenta.is_empty()
244            || external_momenta.len() != graph.get_edges_in_initial_state_cut().len()
245        {
246            return None;
247        }
248
249        let total = external_momenta
250            .into_iter()
251            .reduce(|sum, momentum| sum + momentum)?;
252        let invariant_squared = total.square();
253        if invariant_squared.is_nan()
254            || invariant_squared.is_infinite()
255            || invariant_squared <= F(0.0)
256        {
257            None
258        } else {
259            Some(invariant_squared.sqrt())
260        }
261    }
262
263    fn classify_from_invariant_bounds(
264        &self,
265        cut_boundary_mass: &F<f64>,
266        threshold_boundary_mass: &F<f64>,
267        maximum_cut_invariant: Option<F<f64>>,
268        tolerance: &F<f64>,
269    ) -> ThresholdCountertermStatus {
270        if !self.invariant_bound_is_applicable
271            || cut_boundary_mass.is_nan()
272            || cut_boundary_mass.is_infinite()
273            || threshold_boundary_mass.is_nan()
274            || threshold_boundary_mass.is_infinite()
275            || tolerance.is_nan()
276            || tolerance.is_infinite()
277            || maximum_cut_invariant
278                .as_ref()
279                .is_some_and(|bound| bound.is_nan() || bound.is_infinite())
280        {
281            return ThresholdCountertermStatus::PotentiallyExisting;
282        }
283
284        let minimum_margin = cut_boundary_mass * cut_boundary_mass
285            - threshold_boundary_mass * threshold_boundary_mass;
286        let masses_match = minimum_margin.abs() <= *tolerance;
287        let threshold_minimum_is_reachable = minimum_margin <= *tolerance;
288
289        if masses_match
290            && (self.cut_boundary_edges.len() <= 1 || self.threshold_boundary_edges.len() <= 1)
291        {
292            return ThresholdCountertermStatus::AlwaysPinched;
293        }
294
295        if self.cut_boundary_edges.len() == 1 && self.threshold_boundary_edges.len() == 1 {
296            return ThresholdCountertermStatus::ProvenNonExisting;
297        }
298
299        if self.cut_boundary_edges.len() > 1 && self.threshold_boundary_edges.len() == 1 {
300            let lower_margin = threshold_boundary_mass * threshold_boundary_mass
301                - cut_boundary_mass * cut_boundary_mass;
302            if lower_margin <= *tolerance {
303                return ThresholdCountertermStatus::ProvenNonExisting;
304            }
305        }
306
307        let Some(maximum_cut_invariant) = maximum_cut_invariant else {
308            return if masses_match
309                && self.cut_boundary_edges.len() > 1
310                && self.threshold_boundary_edges.len() > 1
311            {
312                ThresholdCountertermStatus::CanBecomePinched
313            } else {
314                ThresholdCountertermStatus::PotentiallyExisting
315            };
316        };
317
318        let maximum_margin = maximum_cut_invariant * maximum_cut_invariant
319            - threshold_boundary_mass * threshold_boundary_mass;
320        if maximum_margin.abs() <= *tolerance {
321            ThresholdCountertermStatus::AlwaysPinched
322        } else if maximum_margin < -*tolerance {
323            ThresholdCountertermStatus::ProvenNonExisting
324        } else if threshold_minimum_is_reachable
325            && self.cut_boundary_edges.len() > 1
326            && self.threshold_boundary_edges.len() > 1
327        {
328            ThresholdCountertermStatus::CanBecomePinched
329        } else {
330            ThresholdCountertermStatus::PotentiallyExisting
331        }
332    }
333
334    pub fn classify_for_model(
335        &self,
336        graph: &Graph,
337        cut: &CrossSectionCut,
338        model: &Model,
339        param_builder: &ParamBuilder,
340        runtime_settings: &RuntimeSettings,
341        normalized_margin_tolerance: f64,
342    ) -> ThresholdCountertermStatus {
343        if !self.invariant_bound_is_applicable {
344            return ThresholdCountertermStatus::PotentiallyExisting;
345        }
346
347        match graph
348            .classify_threshold_pinch(&self.cut_boundary_edges, &self.threshold_boundary_edges)
349        {
350            ThresholdPinchStatus::Always => {
351                return ThresholdCountertermStatus::AlwaysPinched;
352            }
353            ThresholdPinchStatus::CanBecome | ThresholdPinchStatus::NotProven => {}
354        }
355
356        // Unlike `get_real_mass_vector`, retain unresolved or complex masses as unknown:
357        // replacing either with zero could turn an inconclusive bound into a false rejection.
358        let masses = graph.new_edgevec(|edge, _, _| {
359            if matches!(edge.mass, EdgeMass::Zero) {
360                Some(F(0.0))
361            } else {
362                edge.mass_value::<f64>(model, param_builder)
363                    .and_then(|mass| IsZero::is_zero(&mass.im).then_some(mass.re))
364            }
365        });
366        let Some(cut_boundary_mass) = Self::mass_sum(&self.cut_boundary_edges, &masses) else {
367            return ThresholdCountertermStatus::PotentiallyExisting;
368        };
369        let Some(threshold_boundary_mass) = Self::mass_sum(&self.threshold_boundary_edges, &masses)
370        else {
371            return ThresholdCountertermStatus::PotentiallyExisting;
372        };
373        let e_cm = F(runtime_settings.kinematics.e_cm.abs());
374        // The classified margins are differences of invariant masses squared, so the
375        // dimensionless threshold is normalized by E_cm squared.
376        let normalized_margin_tolerance = if normalized_margin_tolerance.is_finite() {
377            normalized_margin_tolerance.abs()
378        } else {
379            DEFAULT_ESURFACE_EXISTENCE_THRESHOLD
380        };
381        let tolerance = F(normalized_margin_tolerance) * e_cm * e_cm;
382
383        let maximum_cut_invariant = if self.cut_boundary_edges.len() == 1 {
384            Some(cut_boundary_mass)
385        } else {
386            let incoming_invariant = Self::incoming_invariant_mass(graph, runtime_settings);
387            let complement_mass = graph
388                .iter_edges_of(&cut.cut)
389                .map(|(_, edge_id, _)| edge_id)
390                .filter(|edge_id| !self.cut_boundary_edges.contains(edge_id))
391                .try_fold(F(0.0), |sum, edge_id| {
392                    masses[edge_id].as_ref().map(|mass| sum + mass.abs())
393                });
394            incoming_invariant
395                .zip(complement_mass)
396                .and_then(|(invariant, complement_mass)| {
397                    let maximum = invariant - complement_mass;
398                    (maximum > F(0.0)).then_some(maximum)
399                })
400        };
401        self.classify_from_invariant_bounds(
402            &cut_boundary_mass,
403            &threshold_boundary_mass,
404            maximum_cut_invariant,
405            &tolerance,
406        )
407    }
408}
409
410#[derive(Clone)]
411struct TopologicalThresholdCandidate {
412    left: SuBitGraph,
413    cut: OrientedCut,
414    right: SuBitGraph,
415    esurface_id: EsurfaceID,
416}
417
418use derive_more::{From, Into};
419#[derive(Clone, Encode, Decode)]
420#[trait_decode(trait = GammaLoopContext)]
421pub struct CrossSection {
422    pub name: String,
423    pub integrand: Option<ProcessIntegrand>,
424    pub supergraphs: Vec<CrossSectionGraph>,
425    pub external_particles: Vec<ArcParticle>,
426    pub external_connections: Vec<ExternalConnection>,
427    pub n_incmoming: usize,
428    pub graph_group_structure: TiVec<GroupId, GraphGroup>,
429}
430
431impl CrossSection {
432    pub fn plan_graph_group_selection(
433        &self,
434        spec: &GraphGroupSelectionSpec,
435    ) -> Result<GraphGroupSelectionPlan> {
436        if spec.mode() == GraphGroupSelectionMode::CrossSectionAmplitudeGraphs {
437            return Err(eyre!(
438                "`select --amplitude-graphs` for cross sections requires process and generation settings context."
439            ));
440        }
441        if spec.has_raised_cut_rules() {
442            return Err(eyre!(
443                "Raised-cut signature selection for cross sections requires process and generation settings context."
444            ));
445        }
446        spec.plan(&self.graph_group_structure, |graph_id| {
447            self.supergraphs.get(graph_id).map(|graph| &graph.graph)
448        })
449    }
450
451    pub fn plan_graph_group_selection_with_context(
452        &self,
453        spec: &GraphGroupSelectionSpec,
454        model: &Model,
455        process_definition: &ProcessDefinition,
456        generation_settings: &GenerationSettings,
457    ) -> Result<GraphGroupSelectionPlan> {
458        match spec.mode() {
459            GraphGroupSelectionMode::MasterGraphs => {
460                spec.plan_with_analysis_contexts(
461                    &self.graph_group_structure,
462                    |graph_id| self.supergraphs.get(graph_id).map(|graph| &graph.graph),
463                    |_master_graph_id, master_graph| {
464                        Ok(vec![GraphSelectionSubject::whole_graph(master_graph)])
465                    },
466                    |master_graph_id, master_graph| {
467                        self.cut_selection_subjects(
468                            master_graph_id,
469                            master_graph,
470                            model,
471                            process_definition,
472                            generation_settings,
473                        )
474                    },
475                    "Graph-group selection structural filters have no graph analysis subjects.",
476                    "Raised-cut signature selection found no process-valid Cutkosky cuts to analyse.",
477                )
478            }
479            GraphGroupSelectionMode::CrossSectionAmplitudeGraphs => {
480                spec.plan_with_analysis_contexts(
481                    &self.graph_group_structure,
482                    |graph_id| self.supergraphs.get(graph_id).map(|graph| &graph.graph),
483                    |master_graph_id, master_graph| {
484                        let cross_section_graph =
485                            self.supergraphs.get(master_graph_id).ok_or_else(|| {
486                                eyre!(
487                                    "Graph group refers to missing master supergraph id {}.",
488                                    master_graph_id
489                                )
490                            })?;
491                        let cuts = cross_section_graph.process_valid_cuts(
492                            model,
493                            process_definition,
494                            generation_settings,
495                        )?;
496                        Ok(cuts
497                            .into_iter()
498                            .flat_map(|cut| cut.amplitude_side_subjects(master_graph))
499                            .collect())
500                    },
501                    |master_graph_id, master_graph| {
502                        self.cut_selection_subjects(
503                            master_graph_id,
504                            master_graph,
505                            model,
506                            process_definition,
507                            generation_settings,
508                        )
509                    },
510                    "`select --amplitude-graphs` found no process-valid Cutkosky cuts to analyse for structural filters.",
511                    "Raised-cut signature selection found no process-valid Cutkosky cuts to analyse.",
512                )
513            }
514        }
515    }
516
517    fn cut_selection_subjects<'a>(
518        &'a self,
519        master_graph_id: usize,
520        master_graph: &'a Graph,
521        model: &Model,
522        process_definition: &ProcessDefinition,
523        generation_settings: &GenerationSettings,
524    ) -> Result<Vec<GraphCutSelectionSubject<'a>>> {
525        let cross_section_graph = self.supergraphs.get(master_graph_id).ok_or_else(|| {
526            eyre!(
527                "Graph group refers to missing master supergraph id {}.",
528                master_graph_id
529            )
530        })?;
531        let cuts = cross_section_graph.process_valid_cuts(
532            model,
533            process_definition,
534            generation_settings,
535        )?;
536        Ok(cuts
537            .into_iter()
538            .map(|cut| GraphCutSelectionSubject::new(master_graph, cut.cut.as_subgraph()))
539            .collect())
540    }
541
542    pub fn amplitude_graph_signature_inventory(
543        &self,
544        model: &Model,
545        process_definition: &ProcessDefinition,
546        generation_settings: &GenerationSettings,
547    ) -> Result<GraphSelectionSignatureInventory> {
548        let subjects = self
549            .graph_group_structure
550            .iter()
551            .flat_map(|group| group.into_iter().next())
552            .map(|master_graph_id| {
553                let cross_section_graph =
554                    self.supergraphs.get(master_graph_id).ok_or_else(|| {
555                        eyre!(
556                            "Graph group refers to missing master supergraph id {}.",
557                            master_graph_id
558                        )
559                    })?;
560                let cuts = cross_section_graph.process_valid_cuts(
561                    model,
562                    process_definition,
563                    generation_settings,
564                )?;
565                Ok(cuts
566                    .into_iter()
567                    .flat_map(|cut| cut.amplitude_side_subjects(&cross_section_graph.graph))
568                    .collect::<Vec<_>>())
569            })
570            .collect::<Result<Vec<_>>>()?
571            .into_iter()
572            .flatten()
573            .collect::<Vec<_>>();
574
575        Ok(GraphSelectionSignatureInventory::from_analysis_subjects(
576            subjects,
577        ))
578    }
579
580    pub fn raised_cut_signature_inventory(
581        &self,
582        model: &Model,
583        process_definition: &ProcessDefinition,
584        generation_settings: &GenerationSettings,
585    ) -> Result<RaisedCutSignatureInventory> {
586        let subjects = self
587            .graph_group_structure
588            .iter()
589            .flat_map(|group| group.into_iter().next())
590            .map(|master_graph_id| {
591                let cross_section_graph =
592                    self.supergraphs.get(master_graph_id).ok_or_else(|| {
593                        eyre!(
594                            "Graph group refers to missing master supergraph id {}.",
595                            master_graph_id
596                        )
597                    })?;
598                let cuts = cross_section_graph.process_valid_cuts(
599                    model,
600                    process_definition,
601                    generation_settings,
602                )?;
603                Ok(cuts
604                    .into_iter()
605                    .map(|cut| {
606                        GraphCutSelectionSubject::new(
607                            &cross_section_graph.graph,
608                            cut.cut.as_subgraph(),
609                        )
610                    })
611                    .collect::<Vec<_>>())
612            })
613            .collect::<Result<Vec<_>>>()?
614            .into_iter()
615            .flatten()
616            .collect::<Vec<_>>();
617
618        Ok(RaisedCutSignatureInventory::from_cut_subjects(subjects))
619    }
620
621    pub fn validate_graph_group_selection_plan(
622        &self,
623        plan: &GraphGroupSelectionPlan,
624    ) -> Result<()> {
625        for &old_group_id in plan.retained_group_ids() {
626            plan.new_group_id_for_old(old_group_id).ok_or_else(|| {
627                eyre!(
628                    "Selection plan is missing compact group id for old group {}.",
629                    old_group_id.0
630                )
631            })?;
632            if old_group_id.0 >= self.graph_group_structure.len() {
633                return Err(eyre!(
634                    "Selection plan refers to missing graph group {}.",
635                    old_group_id.0
636                ));
637            }
638            let group = &self.graph_group_structure[old_group_id];
639            for old_graph_id in group {
640                if old_graph_id >= self.supergraphs.len() {
641                    return Err(eyre!(
642                        "Graph group {} refers to missing graph id {}.",
643                        old_group_id.0,
644                        old_graph_id
645                    ));
646                }
647            }
648            let master = group.master();
649            if master >= self.supergraphs.len() {
650                return Err(eyre!(
651                    "Graph group {} refers to missing master graph id {}.",
652                    old_group_id.0,
653                    master
654                ));
655            }
656        }
657
658        Ok(())
659    }
660
661    pub fn apply_graph_group_selection(&mut self, plan: &GraphGroupSelectionPlan) -> Result<()> {
662        self.validate_graph_group_selection_plan(plan)?;
663
664        let mut old_graph_to_new_group = vec![None; self.supergraphs.len()];
665        let mut old_graph_is_master = vec![false; self.supergraphs.len()];
666        for &old_group_id in plan.retained_group_ids() {
667            let new_group_id = plan.new_group_id_for_old(old_group_id).ok_or_else(|| {
668                eyre!(
669                    "Selection plan is missing compact group id for old group {}.",
670                    old_group_id.0
671                )
672            })?;
673            let group = &self.graph_group_structure[old_group_id];
674            for old_graph_id in group {
675                old_graph_to_new_group[old_graph_id] = Some(new_group_id);
676            }
677            old_graph_is_master[group.master()] = true;
678        }
679
680        let mut new_supergraphs = self
681            .supergraphs
682            .iter()
683            .cloned()
684            .enumerate()
685            .filter_map(|(old_graph_id, mut graph)| {
686                let new_group_id = old_graph_to_new_group[old_graph_id]?;
687                graph.graph.group_id = Some(new_group_id);
688                graph.graph.is_group_master = old_graph_is_master[old_graph_id];
689                if let Some(multi_channeling_setup) = &mut graph.derived_data.multi_channeling_setup
690                {
691                    multi_channeling_setup.graph.group_id = Some(new_group_id);
692                    multi_channeling_setup.graph.is_group_master =
693                        old_graph_is_master[old_graph_id];
694                }
695                Some(graph)
696            })
697            .collect::<Vec<_>>();
698
699        let mut parsed_graphs = new_supergraphs
700            .iter()
701            .map(|graph| graph.graph.clone())
702            .collect::<Vec<_>>();
703        let new_graph_group_structure = complete_group_parsing(&mut parsed_graphs)?;
704        for (graph, parsed_graph) in new_supergraphs.iter_mut().zip(parsed_graphs) {
705            graph.graph.group_id = parsed_graph.group_id;
706            graph.graph.is_group_master = parsed_graph.is_group_master;
707            if let Some(multi_channeling_setup) = &mut graph.derived_data.multi_channeling_setup {
708                multi_channeling_setup.graph.group_id = graph.graph.group_id;
709                multi_channeling_setup.graph.is_group_master = graph.graph.is_group_master;
710            }
711        }
712
713        self.supergraphs = new_supergraphs;
714        self.graph_group_structure = new_graph_group_structure;
715        self.integrand = None;
716        Ok(())
717    }
718
719    fn storage_path(&self, base: &Path) -> PathBuf {
720        base.join(&self.name)
721    }
722
723    pub fn export_standalone(
724        &self,
725        path: impl AsRef<Path>,
726        settings: &crate::processes::StandaloneExportSettings,
727    ) -> Result<()> {
728        if let Some(integrand) = &self.integrand {
729            integrand.export_standalone(path, settings)?;
730        } else {
731            return Err(eyre!(
732                "Cannot export standalone cross section {} without integrand",
733                self.name
734            ));
735        }
736
737        Ok(())
738    }
739
740    #[allow(dead_code)]
741    pub(crate) fn write_dot<W: std::io::Write>(
742        &self,
743        writer: &mut W,
744        settings: &DotExportSettings,
745    ) -> Result<(), std::io::Error> {
746        for graph in &self.supergraphs {
747            graph.write_dot(writer, settings)?;
748            writeln!(writer)?;
749        }
750        Ok(())
751    }
752
753    pub fn write_dot_fmt<W: std::fmt::Write>(
754        &self,
755        writer: &mut W,
756        settings: &DotExportSettings,
757    ) -> Result<(), std::fmt::Error> {
758        for graph in &self.supergraphs {
759            graph.write_dot_fmt(writer, settings)?;
760            writeln!(writer)?;
761        }
762        Ok(())
763    }
764
765    pub(crate) fn new(name: String) -> Self {
766        Self {
767            name,
768            integrand: None,
769            supergraphs: vec![],
770            external_connections: vec![],
771            external_particles: vec![],
772            n_incmoming: 0,
773            graph_group_structure: TiVec::new(),
774        }
775    }
776
777    pub fn from_graph_list(name: String, mut graphs: Vec<Graph>, _model: &Model) -> Result<Self> {
778        let mut cross_section = CrossSection::new(name);
779        cross_section.graph_group_structure = complete_group_parsing(&mut graphs)?;
780        // println!("group structure: {:?}", cross_section.graph_group_structure);
781
782        for cross_section_graph in graphs {
783            // cross_section_graph.param_builder =  ParamBuilder::new(&cross_section_graph, model);
784            cross_section.add_supergraph(cross_section_graph)?;
785        }
786        Ok(cross_section)
787    }
788
789    pub(crate) fn warm_up(&mut self, model: &Model) -> Result<()> {
790        if let Some(integrand) = &mut self.integrand {
791            integrand.warm_up(model)
792        } else {
793            Err(eyre!(
794                "Cannot warm up amplitude {} without integrand",
795                self.name
796            ))
797        }
798    }
799
800    fn add_supergraph(&mut self, supergraph: Graph) -> Result<()> {
801        if self.external_particles.is_empty() {
802            let external_particles = supergraph.get_external_partcles();
803            if !external_particles.len().is_multiple_of(2) {
804                return Err(eyre!(
805                    "expected even number of externals for forward scattering graph"
806                ));
807            }
808            self.external_particles = external_particles;
809            self.n_incmoming = self.external_particles.len() / 2;
810        } else if self.external_particles != supergraph.get_external_partcles() {
811            return Err(eyre!(
812                "attempt to add supergraph with differnt external particles"
813            ));
814        }
815
816        let cross_section_graph = CrossSectionGraph::new(supergraph);
817        self.supergraphs.push(cross_section_graph);
818
819        // TODO: validate that the graph is compatible
820        Ok(())
821    }
822
823    pub fn preprocess(
824        &mut self,
825        model: &Model,
826        process_definition: &ProcessDefinition,
827        global_settings: &GenerationSettings,
828        runtime_default: LockedRuntimeSettings,
829        generation_pool: &ThreadPool,
830    ) -> Result<Vec<NamedGraphGenerationReport>> {
831        let integrand_name = self.name.clone();
832        generation_progress::begin_phase(
833            GenerationProgressPhase::GraphPreprocessing,
834            GenerationProcessKind::CrossSection,
835            &process_definition.folder_name,
836            &integrand_name,
837            self.supergraphs.len(),
838            None,
839        );
840        generation_pool.install(|| {
841            self.supergraphs
842                .par_iter_mut()
843                .map(|supergraph| {
844                    if crate::is_interrupted() {
845                        return Err(eyre!("Generation interrupted by user"));
846                    }
847                    let graph_name = supergraph.graph.name.clone();
848                    generation_progress::graph_started(
849                        GenerationProcessKind::CrossSection,
850                        &integrand_name,
851                        &graph_name,
852                        None,
853                    );
854                    let stats = supergraph.preprocess(
855                        model,
856                        process_definition,
857                        global_settings,
858                        runtime_default,
859                    )?;
860                    if crate::is_interrupted() {
861                        return Err(eyre!("Generation interrupted by user"));
862                    }
863                    generation_progress::graph_finished(
864                        GenerationProcessKind::CrossSection,
865                        &integrand_name,
866                        &graph_name,
867                        &stats,
868                        None,
869                    );
870                    Ok(NamedGraphGenerationReport {
871                        integrand_name: integrand_name.clone(),
872                        graph_name,
873                        stats,
874                    })
875                })
876                .collect::<Result<Vec<_>>>()
877        })
878    }
879
880    pub fn build_integrand(
881        &mut self,
882        model: &Model,
883        process_name: &str,
884        global_settings: &GlobalSettings,
885        runtime_default: LockedRuntimeSettings,
886        generation_pool: &ThreadPool,
887    ) -> Result<Vec<NamedGraphGenerationReport>> {
888        let started = std::time::Instant::now();
889        crate::debug_tags!(#generation, #profile, #graph, #summary;
890            stage = "cross_section_build_integrand_start",
891            integrand = %self.name,
892            graph_count = self.supergraphs.len(),
893            "Generation timing milestone"
894        );
895        if crate::is_interrupted() {
896            return Err(eyre!("Generation interrupted by user"));
897        }
898        let integrand_name = self.name.clone();
899        let total_cuts = self
900            .supergraphs
901            .iter()
902            .map(|sg| {
903                sg.derived_data
904                    .cut_group_data
905                    .cut_groups
906                    .iter()
907                    .map(|cut_group| cut_group.cuts.len())
908                    .sum::<usize>()
909            })
910            .sum();
911        generation_progress::begin_phase(
912            GenerationProgressPhase::GraphGeneration,
913            GenerationProcessKind::CrossSection,
914            process_name,
915            &integrand_name,
916            self.supergraphs.len(),
917            Some(total_cuts),
918        );
919        let mut graph_reports = Vec::new();
920        let terms = generation_pool.install(|| {
921            self.supergraphs
922                .par_iter_mut()
923                .map(|sg| {
924                    if crate::is_interrupted() {
925                        return Err(eyre!("Generation interrupted by user"));
926                    }
927                    let graph_started = std::time::Instant::now();
928                    crate::debug_tags!(#generation, #profile, #graph, #summary;
929                        stage = "generate_term_for_graph_start",
930                        integrand = %integrand_name,
931                        graph = %sg.graph.name,
932                        "Generation timing milestone"
933                    );
934                    generation_progress::graph_started(
935                        GenerationProcessKind::CrossSection,
936                        &integrand_name,
937                        &sg.graph.name,
938                        Some(sg.cuts.len()),
939                    );
940                    let _progress_context_guard = generation_progress::enter_progress_context(
941                        format!("{} / {} cuts", sg.graph.name, sg.cuts.len()),
942                    );
943                    let (term, mut stats) = sg.generate_term_for_graph(model, global_settings)?;
944                    if crate::is_interrupted() {
945                        return Err(eyre!("Generation interrupted by user"));
946                    }
947                    stats.total_time += graph_started.elapsed();
948                    crate::debug_tags!(#generation, #profile, #graph, #summary;
949                        stage = "generate_term_for_graph_done",
950                        integrand = %integrand_name,
951                        graph = %sg.graph.name,
952                        elapsed_ms = graph_started.elapsed().as_secs_f64() * 1000.0,
953                        "Generation timing milestone"
954                    );
955                    generation_progress::graph_finished(
956                        GenerationProcessKind::CrossSection,
957                        &integrand_name,
958                        &sg.graph.name,
959                        &stats,
960                        None,
961                    );
962                    Ok((
963                        term,
964                        NamedGraphGenerationReport {
965                            integrand_name: integrand_name.clone(),
966                            graph_name: sg.graph.name.clone(),
967                            stats,
968                        },
969                    ))
970                })
971                .collect::<Result<Vec<_>>>()
972        })?;
973        if crate::is_interrupted() {
974            return Err(eyre!("Generation interrupted by user"));
975        }
976        for (_, report) in &terms {
977            graph_reports.push(report.clone());
978        }
979        let mut terms = terms.into_iter().map(|(term, _)| term).collect::<Vec<_>>();
980
981        for group in self.graph_group_structure.iter() {
982            let master = group.master();
983            let mc_of_master = self.supergraphs[master]
984                .derived_data
985                .multi_channeling_setup
986                .as_ref()
987                .unwrap();
988
989            for graph_id in group.into_iter() {
990                terms[graph_id].multi_channeling_setup = mc_of_master.clone();
991            }
992        }
993
994        let backend_started = std::time::Instant::now();
995        let graph_count = terms.len();
996        crate::debug_tags!(#generation, #profile, #compile, #graph, #summary;
997            stage = "prepare_runtime_backends_start",
998            integrand = %self.name,
999            graph_count,
1000            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1001            "Generation timing milestone"
1002        );
1003        generation_progress::backend_started(
1004            GenerationProcessKind::CrossSection,
1005            &self.name,
1006            graph_count,
1007        );
1008        let mut cross_section_integrand = CrossSectionIntegrand {
1009            settings: runtime_default.into(),
1010            data: CrossSectionIntegrandData {
1011                compilation: global_settings
1012                    .generation
1013                    .compile
1014                    .frozen_mode(&global_settings.generation.evaluator),
1015                loop_cache_id: 0,
1016                external_cache_id: 0,
1017                base_external_cache_id: 0,
1018                rotations: None,
1019                name: self.name.clone(),
1020                external_connections: self.external_connections.clone(),
1021                n_incoming: self.n_incmoming,
1022                // polarizations,
1023                graph_terms: terms,
1024                graph_group_structure: self.graph_group_structure.clone(),
1025                graph_to_group_id: graph_to_group_id_for_group_structure(
1026                    &self.graph_group_structure,
1027                ),
1028            },
1029            event_processing_runtime: Default::default(),
1030            active_f64_backend: Default::default(),
1031        };
1032        let compile_times = cross_section_integrand
1033            .prepare_runtime_backends_after_generation_with_compile_times()?;
1034        crate::debug_tags!(#generation, #profile, #compile, #graph, #summary;
1035            stage = "prepare_runtime_backends_done",
1036            integrand = %self.name,
1037            graph_count,
1038            elapsed_ms = backend_started.elapsed().as_secs_f64() * 1000.0,
1039            total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1040            "Generation timing milestone"
1041        );
1042        generation_progress::backend_finished(
1043            GenerationProcessKind::CrossSection,
1044            &self.name,
1045            backend_started.elapsed(),
1046        );
1047        for (report, compile_time) in graph_reports.iter_mut().zip(compile_times) {
1048            report.stats.evaluator_compile_time += compile_time;
1049            report.stats.total_time += compile_time;
1050        }
1051
1052        self.integrand = Some(ProcessIntegrand::CrossSection(cross_section_integrand));
1053        crate::debug_tags!(#generation, #profile, #graph, #summary;
1054            stage = "cross_section_build_integrand_done",
1055            integrand = %self.name,
1056            graph_count = self.supergraphs.len(),
1057            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1058            "Generation timing milestone"
1059        );
1060        Ok(graph_reports)
1061    }
1062
1063    pub fn compile(
1064        &mut self,
1065        path: impl AsRef<Path>,
1066        override_existing: bool,
1067        thread_pool: &ThreadPool,
1068    ) -> Result<Vec<NamedGraphGenerationReport>> {
1069        info!("Compiling cross section {}", self.name);
1070        let p = self.storage_path(path.as_ref());
1071
1072        let result = fs::create_dir_all(&p).with_context(|| {
1073            format!(
1074                "Trying to create directory to compile cross section {}",
1075                p.display()
1076            )
1077        });
1078
1079        if override_existing {
1080            result?;
1081        }
1082
1083        if let Some(integrand) = &mut self.integrand {
1084            let compile_times = integrand.compile(&p, override_existing, thread_pool)?;
1085            return Ok(compile_times
1086                .into_iter()
1087                .map(|(graph_name, duration)| NamedGraphGenerationReport {
1088                    integrand_name: self.name.clone(),
1089                    graph_name,
1090                    stats: GraphGenerationStats {
1091                        total_time: duration,
1092                        evaluator_compile_time: duration,
1093                        ..GraphGenerationStats::default()
1094                    },
1095                })
1096                .collect());
1097        }
1098        Ok(Vec::new())
1099    }
1100
1101    pub fn save(&mut self, path: impl AsRef<Path>, override_existing: bool) -> Result<()> {
1102        let p = self.storage_path(path.as_ref());
1103        let r = fs::create_dir_all(&p).with_context(|| {
1104            format!(
1105                "Trying to create directory to save cross section {}",
1106                p.display()
1107            )
1108        });
1109
1110        if override_existing {
1111            r?;
1112        }
1113
1114        let integrand = self.integrand.take();
1115        if let Some(integrand) = &integrand {
1116            integrand.save(&p, override_existing)?;
1117        }
1118
1119        let binary = bincode::encode_to_vec(&(*self), bincode::config::standard())?;
1120        if override_existing {
1121            fs::write(p.join("cs.bin"), &binary)?;
1122        } else {
1123            let mut file = File::create_new(p.join("cs.bin"))?;
1124            file.write_all(&binary)?;
1125        }
1126
1127        self.integrand = integrand;
1128        Ok(())
1129    }
1130
1131    pub(crate) fn load(path: impl AsRef<Path>, context: GammaLoopContextContainer) -> Result<Self> {
1132        let binary = fs::read(path.as_ref().join("cs.bin"))?;
1133        let (mut cs, _): (Self, _) =
1134            bincode::decode_from_slice_with_context(&binary, bincode::config::standard(), context)?;
1135
1136        if path.as_ref().join("integrand").exists() {
1137            let integrand = CrossSectionIntegrand::load(path.as_ref().join("integrand"), context)?;
1138            cs.integrand = Some(ProcessIntegrand::CrossSection(integrand));
1139        }
1140
1141        Ok(cs)
1142    }
1143}
1144
1145#[derive(Clone, bincode::Encode, bincode::Decode)]
1146pub struct CrossSectionCut {
1147    pub cut: OrientedCut,
1148    pub left: SuBitGraph,
1149    pub right: SuBitGraph,
1150}
1151
1152impl CrossSectionCut {
1153    fn amplitude_side_subjects<'a>(&self, graph: &'a Graph) -> [GraphSelectionSubject<'a>; 2] {
1154        let cut_edges = self.cut.as_subgraph();
1155        [
1156            GraphSelectionSubject::cut_side_amplitude_subgraph(
1157                graph,
1158                self.left.subtract(&cut_edges),
1159            ),
1160            GraphSelectionSubject::cut_side_amplitude_subgraph(
1161                graph,
1162                self.right.subtract(&cut_edges),
1163            ),
1164        ]
1165    }
1166
1167    pub(crate) fn is_s_channel(&self, cross_section_graph: &CrossSectionGraph) -> Result<bool> {
1168        let nodes_of_left_cut: Vec<_> = cross_section_graph
1169            .graph
1170            .underlying
1171            .iter_nodes_of(&self.left)
1172            .map(|(nid, _, _)| nid)
1173            .collect();
1174
1175        let left_node = cross_section_graph
1176            .graph
1177            .underlying
1178            .combine_to_single_hedgenode(&nodes_of_left_cut);
1179        let res = left_node.includes(&cross_section_graph.source_nodes)
1180            && cross_section_graph.target_nodes.weakly_disjoint(&left_node);
1181
1182        if !res {
1183            warn!("s channel check wrong");
1184        }
1185
1186        Ok(true)
1187    }
1188
1189    pub(crate) fn is_valid_for_process(
1190        &self,
1191        cross_section_graph: &CrossSectionGraph,
1192        process: &ProcessDefinition,
1193        model: &Model,
1194    ) -> Result<bool> {
1195        if self.is_s_channel(cross_section_graph)? {
1196            let cut_content_builder = self
1197                .cut
1198                .iter_edges(&cross_section_graph.graph.underlying)
1199                .filter_map(|(orientation, edge_data)| {
1200                    Some(if orientation == Orientation::Reversed {
1201                        edge_data.data.particle()?.get_anti_particle(model)
1202                    } else {
1203                        edge_data.data.particle()?.clone()
1204                    })
1205                })
1206                .collect_vec();
1207
1208            let any_pdg_list_passes = process
1209                .final_pdgs_lists
1210                .iter()
1211                .map(|x| {
1212                    x.iter()
1213                        .map(|pdg| model.get_particle_from_pdg(*pdg as isize))
1214                })
1215                .any(|particle_content| {
1216                    let mut cut_content = cut_content_builder.clone();
1217                    debug!(
1218                        "cut content: {:?}",
1219                        cut_content.iter().map(|p| p.name.clone()).collect_vec()
1220                    );
1221
1222                    for particle in particle_content {
1223                        if let Some(index) = cut_content.iter().position(|p| p == &particle) {
1224                            cut_content.remove(index);
1225                        } else {
1226                            debug!("wrong particles");
1227                            return false;
1228                        }
1229                    }
1230
1231                    let (n_unresolved, unresolved_cut_content) =
1232                        process.unresolved_cut_content(model);
1233
1234                    if cut_content.len() > n_unresolved {
1235                        debug!(" too many unresolved particles");
1236                        return false;
1237                    }
1238
1239                    if !cut_content
1240                        .iter()
1241                        .all(|particle| unresolved_cut_content.contains(particle))
1242                    {
1243                        debug!("wrong unresolved particles");
1244                        return false;
1245                    }
1246
1247                    true
1248                });
1249
1250            if !any_pdg_list_passes {
1251                debug!("wrong pdg list");
1252                return Ok(false);
1253            }
1254
1255            let amplitude_couplings = process.amplitude_filters.get_coupling_orders();
1256            let amplitude_loop_count = process.amplitude_filters.get_loop_count_range();
1257
1258            if let Some((min_loop, max_loop)) = amplitude_loop_count {
1259                let loop_range = min_loop..=max_loop;
1260                let left_internal_subgraph = InternalSubGraph::cleaned_filter_pessimist(
1261                    self.left.clone(),
1262                    &cross_section_graph.graph.underlying,
1263                );
1264
1265                let right_internal_subgraph = InternalSubGraph::cleaned_filter_pessimist(
1266                    self.right.clone(),
1267                    &cross_section_graph.graph.underlying,
1268                );
1269
1270                let left_loop = cross_section_graph
1271                    .graph
1272                    .underlying
1273                    .cyclotomatic_number(&left_internal_subgraph);
1274
1275                let right_loop = cross_section_graph
1276                    .graph
1277                    .underlying
1278                    .cyclotomatic_number(&right_internal_subgraph);
1279
1280                let total_loops = left_loop + right_loop;
1281
1282                if !loop_range.contains(&total_loops) {
1283                    debug!("incorrect loop count");
1284                    return Ok(false);
1285                }
1286            }
1287
1288            if amplitude_couplings.is_some() {
1289                todo!("waiting for update")
1290            }
1291
1292            Ok(true)
1293        } else {
1294            debug!("cut is not s channel");
1295            Ok(false)
1296        }
1297    }
1298}
1299
1300#[derive(
1301    Debug, Clone, Serialize, Decode, Deserialize, From, Into, Hash, PartialEq, Copy, Eq, Encode,
1302)]
1303pub struct CutId(pub usize);
1304
1305impl Display for CutId {
1306    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1307        write!(f, "{}", self.0)
1308    }
1309}
1310
1311#[derive(Clone, Encode, Decode)]
1312#[trait_decode(trait = GammaLoopContext)]
1313pub struct CrossSectionGraph {
1314    pub graph: Graph,
1315    pub source_nodes: HedgeNode,
1316    pub target_nodes: HedgeNode,
1317    pub cuts: TiVec<CutId, CrossSectionCut>,
1318    pub cut_esurface: TiVec<CutId, Esurface>,
1319    pub cut_esurface_id_map: TiVec<CutId, EsurfaceID>,
1320    pub derived_data: CrossSectionDerivedData,
1321}
1322
1323impl CrossSectionGraph {
1324    pub(crate) fn new(graph: Graph) -> Self {
1325        let (source_node, target_node) = graph.get_source_and_target();
1326
1327        Self {
1328            graph,
1329            source_nodes: source_node,
1330            target_nodes: target_node,
1331            cuts: TiVec::new(),
1332            cut_esurface: TiVec::new(),
1333            cut_esurface_id_map: TiVec::new(),
1334            derived_data: CrossSectionDerivedData::new_empty(),
1335        }
1336    }
1337
1338    pub(crate) fn apply_spin_sum(
1339        &mut self,
1340        model: &Model,
1341        generation_settings: &GenerationSettings,
1342        locked_runtime_settings: &LockedRuntimeSettings,
1343    ) -> Result<()> {
1344        for (extid, hel) in locked_runtime_settings.helicities().iter().enumerate() {
1345            //println!("{extid},{hel:?}");
1346            let eid = self.graph.loop_momentum_basis.ext_edges[ExternalIndex(extid)];
1347
1348            let Some(p) = self.graph.underlying[eid].particle() else {
1349                continue;
1350            };
1351
1352            match hel {
1353                Helicity::Summed => {
1354                    let Some(p) = p.polarization_sum(
1355                        eid,
1356                        false,
1357                        generation_settings.vector_polarization_sum_gauge,
1358                    )?
1359                    else {
1360                        continue;
1361                    };
1362                    self.graph.global_prefactor.projector =
1363                        self.graph.global_prefactor.projector.replace_multiple(&[p]);
1364                }
1365                Helicity::SummedAveraged => {
1366                    let Some(p) = p.polarization_sum(
1367                        eid,
1368                        true,
1369                        generation_settings.vector_polarization_sum_gauge,
1370                    )?
1371                    else {
1372                        continue;
1373                    };
1374                    self.graph.global_prefactor.projector =
1375                        self.graph.global_prefactor.projector.replace_multiple(&[p]);
1376                }
1377                _ => {}
1378            }
1379        }
1380
1381        self.graph.polarizations = self.graph.global_prefactor.polarizations();
1382        self.graph.param_builder = ParamBuilder::new(
1383            &self.graph,
1384            model,
1385            &self.graph.loop_momentum_basis,
1386            &self.graph.param_builder.pairs.additional_params.params,
1387        );
1388        Ok(())
1389    }
1390
1391    pub(crate) fn preprocess(
1392        &mut self,
1393        model: &Model,
1394        process_definition: &ProcessDefinition,
1395        settings: &GenerationSettings,
1396        runtime_default: LockedRuntimeSettings,
1397    ) -> Result<GraphGenerationStats> {
1398        let preprocess_started = std::time::Instant::now();
1399        let mut stats = GraphGenerationStats::default();
1400        self.apply_spin_sum(model, settings, &runtime_default)?;
1401        debug_tags!(#generation; "generating cuts");
1402        self.generate_cuts(model, process_definition, settings)?;
1403        debug_tags!(#generation; "generating esurfaces corresponding to cuts");
1404        self.generate_esurface_cuts();
1405        debug_tags!(#generation; "generating cff");
1406        stats.merge_in_place(&self.generate_cff(settings)?);
1407        debug_tags!(#generation; "building lmbs");
1408        self.build_lmbs()?;
1409        debug_tags!(#generation; "building multi channeling channels");
1410
1411        if self.graph.is_group_master {
1412            self.build_multi_channeling_channels(settings.override_lmb_heuristics)?;
1413        }
1414
1415        let vk = crate::utils::vakint()?;
1416        debug_tags!(#generation; "building parametric integrand");
1417        self.build_parametric_integrand(settings, vk)?;
1418        //self.build_parametric_integrand_cut_groups(settings)?;
1419
1420        let threshold_candidates = self.topological_threshold_candidates()?;
1421        self.derived_data.threshold_candidate_esurface_ids = threshold_candidates
1422            .iter()
1423            .map(|candidate| candidate.esurface_id)
1424            .sorted()
1425            .dedup()
1426            .collect();
1427        self.derived_data.cut_threshold_associations =
1428            ti_vec![CutThresholdCountertermAssociations::default(); self.cuts.len()];
1429
1430        if settings.threshold_subtraction.enable_thresholds {
1431            debug_tags!(#generation, #subtraction; "building threshold counterterm");
1432            self.build_subspace_data()?;
1433            let runtime_settings: RuntimeSettings = runtime_default.into();
1434            self.build_threshold_counterterm(
1435                model,
1436                settings,
1437                &runtime_settings,
1438                &threshold_candidates,
1439                vk,
1440            )?;
1441        }
1442
1443        stats.total_time += preprocess_started.elapsed();
1444        Ok(stats)
1445    }
1446
1447    #[allow(dead_code)]
1448    pub(crate) fn write_dot<W: std::io::Write>(
1449        &self,
1450        writer: &mut W,
1451        settings: &DotExportSettings,
1452    ) -> Result<(), std::io::Error> {
1453        self.graph.dot_serialize_io(writer, settings)
1454    }
1455
1456    pub(crate) fn write_dot_fmt<W: std::fmt::Write>(
1457        &self,
1458        writer: &mut W,
1459        settings: &DotExportSettings,
1460    ) -> Result<(), std::fmt::Error> {
1461        self.graph.dot_serialize_fmt(writer, settings)
1462    }
1463
1464    fn generate_cff(&mut self, settings: &GenerationSettings) -> Result<GraphGenerationStats> {
1465        let canonize_esurface = self
1466            .graph
1467            .get_esurface_canonization(&self.graph.loop_momentum_basis);
1468
1469        let contract_edges = self
1470            .graph
1471            .iter_edges_of(
1472                &self
1473                    .graph
1474                    .tree_edges
1475                    .subtract(&self.graph.initial_state_cut),
1476            )
1477            .map(|x| x.1)
1478            .collect_vec();
1479
1480        let global_cff = self.graph.generate_cff(
1481            &contract_edges,
1482            &canonize_esurface,
1483            &settings.orientation_pattern,
1484        )?;
1485
1486        let cut_esurface_map = self
1487            .cut_esurface
1488            .iter()
1489            .map(|esurface| {
1490                if let Some(pos) = self
1491                    .graph
1492                    .surface_cache
1493                    .esurface_cache
1494                    .iter()
1495                    .position(|e_sf| e_sf == esurface)
1496                    .map(Into::<EsurfaceID>::into)
1497                {
1498                    pos
1499                } else {
1500                    let pos = self.graph.surface_cache.esurface_cache.len();
1501                    self.graph
1502                        .surface_cache
1503                        .esurface_cache
1504                        .push(esurface.clone());
1505                    EsurfaceID(pos)
1506                }
1507            })
1508            .collect();
1509
1510        self.cut_esurface_id_map = cut_esurface_map;
1511
1512        let esurface_raised_data = self
1513            .graph
1514            .determine_raised_esurfaces_from_expression(&global_cff);
1515
1516        let (cut_group_data, cut_group_stats) = CutGroupData::new_from_esurface(
1517            &esurface_raised_data,
1518            &self.cut_esurface_id_map,
1519            &settings.evaluator,
1520        );
1521
1522        self.derived_data.global_cff_expression = Some(global_cff);
1523        self.derived_data.cut_group_data = cut_group_data;
1524
1525        Ok(cut_group_stats)
1526    }
1527
1528    pub(crate) fn process_valid_cuts(
1529        &self,
1530        model: &Model,
1531        process_definition: &ProcessDefinition,
1532        settings: &GenerationSettings,
1533    ) -> Result<TiVec<CutId, CrossSectionCut>> {
1534        if !self.cuts.is_empty() {
1535            return Ok(self.cuts.clone());
1536        }
1537        self.compute_process_valid_cuts(model, process_definition, settings)
1538            .map(|(_, cuts)| cuts)
1539    }
1540
1541    fn compute_process_valid_cuts(
1542        &self,
1543        model: &Model,
1544        process_definition: &ProcessDefinition,
1545        settings: &GenerationSettings,
1546    ) -> Result<(usize, TiVec<CutId, CrossSectionCut>)> {
1547        let all_st_cuts = self.graph.all_st_cuts_for_cs(
1548            self.source_nodes.clone(),
1549            self.target_nodes.clone(),
1550            &self.graph.get_initial_state_tree().0,
1551        );
1552        let num_st_cuts = all_st_cuts.len();
1553
1554        let mut cuts: TiVec<CutId, CrossSectionCut> = all_st_cuts
1555            .into_iter()
1556            .map(|(left, cut, right)| CrossSectionCut { cut, left, right })
1557            .filter(|cut| cut.cut.nedges(&self.graph) > 1)
1558            .filter_map(
1559                |cut| match cut.is_valid_for_process(self, process_definition, model) {
1560                    Ok(true) => Some(Ok(cut)),
1561                    Ok(false) => None,
1562                    Err(e) => Some(Err(e)),
1563                },
1564            )
1565            .collect::<Result<_>>()?;
1566
1567        cuts.sort_by(|a, b| a.cut.cmp(&b.cut));
1568
1569        if !settings.force_cuts.is_empty() {
1570            let force_cuts_sorted = settings
1571                .force_cuts
1572                .clone()
1573                .into_iter()
1574                .map(|cut_edges| cut_edges.into_iter().sorted().collect_vec())
1575                .collect_vec();
1576
1577            cuts.retain(|cut| {
1578                let edges_in_cut = self
1579                    .graph
1580                    .iter_edges_of(&cut.cut)
1581                    .map(|(_, _, e)| e.data.name.value.clone())
1582                    .sorted()
1583                    .collect_vec();
1584
1585                force_cuts_sorted.contains(&edges_in_cut)
1586            });
1587        }
1588
1589        Ok((num_st_cuts, cuts))
1590    }
1591
1592    fn generate_cuts(
1593        &mut self,
1594        model: &Model,
1595        process_definition: &ProcessDefinition,
1596        settings: &GenerationSettings,
1597    ) -> Result<()> {
1598        debug_tags!(#generation, #profile, #graph;
1599            stage = "cross_section_generate_cuts_start",
1600            graph = %self.graph.name,
1601            "Cut discovery timing milestone"
1602        );
1603        let started = std::time::Instant::now();
1604        let (num_st_cuts, cuts) =
1605            self.compute_process_valid_cuts(model, process_definition, settings)?;
1606        self.cuts = cuts;
1607        debug_tags!(#generation, #profile, #graph;
1608            stage = "cross_section_generate_cuts_done",
1609            graph = %self.graph.name,
1610            st_cut_count = num_st_cuts,
1611            valid_cut_count = self.cuts.len(),
1612            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1613            "Cut discovery timing milestone"
1614        );
1615        generation_progress::cuts_discovered("", &self.graph.name, num_st_cuts, self.cuts.len());
1616
1617        Ok(())
1618    }
1619
1620    fn generate_esurface_cuts(&mut self) {
1621        debug!("generating esurfaces for cuts");
1622
1623        let esurfaces: TiVec<CutId, Esurface> = self
1624            .cuts
1625            .iter()
1626            .map(|cut| {
1627                Esurface::new_from_cut_left(
1628                    &self.graph.underlying,
1629                    cut,
1630                    Some(&self.graph.initial_state_cut),
1631                )
1632            })
1633            .collect();
1634
1635        debug!("generated esurfaces {:?}", esurfaces);
1636
1637        self.cut_esurface = esurfaces;
1638    }
1639
1640    pub(crate) fn build_parametric_integrand(
1641        &mut self,
1642        settings: &GenerationSettings,
1643        vakint: &Vakint,
1644    ) -> Result<()> {
1645        self.derived_data.cut_paramatric_integrand = self.build_integrand(settings, vakint)?;
1646        Ok(())
1647    }
1648
1649    fn build_integrand(
1650        &mut self,
1651        settings: &GenerationSettings,
1652        vakint: &Vakint,
1653    ) -> Result<TiVec<CutGroupId, ParametricIntegrands>> {
1654        let started = std::time::Instant::now();
1655        crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1656            stage = "supergraph_build_integrand_start",
1657            graph = %self.graph.name,
1658            subtract_uv = settings.uv.subtract_uv,
1659            generate_integrated = settings.uv.generate_integrated,
1660            "Generation timing milestone"
1661        );
1662        let max_order = self
1663            .derived_data
1664            .cut_group_data
1665            .cut_groups
1666            .iter()
1667            .map(|cut_group| cut_group.related_esurface_group.max_occurence)
1668            .max()
1669            .unwrap();
1670
1671        self.graph.param_builder.initialize_duals(max_order);
1672        crate::debug_tags!(#generation, #profile, #graph, #summary;
1673            stage = "supergraph_initialize_duals_done",
1674            graph = %self.graph.name,
1675            max_order,
1676            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1677            "Generation timing milestone"
1678        );
1679
1680        let cuts = self
1681            .derived_data
1682            .cut_group_data
1683            .cut_groups
1684            .iter()
1685            .map(|cuts| CutSet {
1686                residue_selector: ResidueSelector {
1687                    lu_cut: Some(cuts.related_esurface_group.clone()),
1688                    left_th_cut: None,
1689                    right_th_cut: None,
1690                },
1691                union: cuts
1692                    .cuts
1693                    .iter()
1694                    .map(|cut_id| self.cuts[*cut_id].cut.as_subgraph())
1695                    .reduce(|cut_1, cut_2| cut_1.union(&cut_2))
1696                    .unwrap_or_else(|| self.graph.empty_subgraph()),
1697                canonicalize_external_shifts: false,
1698            })
1699            .collect();
1700
1701        let cut_structure = CutStructure { cuts };
1702        crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1703            stage = "supergraph_cutsets_done",
1704            graph = %self.graph.name,
1705            cut_count = cut_structure.cuts.len(),
1706            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1707            "Generation timing milestone"
1708        );
1709
1710        let valid_orientations: Vec<_> = self
1711            .derived_data
1712            .global_cff_expression
1713            .as_ref()
1714            .expect("global_cff_expression should have been created")
1715            .orientations
1716            .iter()
1717            .map(|orientation| orientation.data.orientation.clone())
1718            .collect();
1719
1720        let lu_prefactor = self.lu_prefactor_helper();
1721
1722        let orchestration_started = std::time::Instant::now();
1723        let parametric_integrands = settings.uv.orchestrator.parametric_integrands(
1724            &mut self.graph,
1725            cut_structure,
1726            vakint,
1727            OrientationProjection::new(&valid_orientations, &settings.orientation_pattern),
1728            &settings.uv,
1729        )?;
1730        crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1731            stage = "supergraph_parametric_orchestration_done",
1732            graph = %self.graph.name,
1733            parametric_integrand_count = parametric_integrands.len(),
1734            elapsed_ms = orchestration_started.elapsed().as_secs_f64() * 1000.0,
1735            total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1736            "Generation timing milestone"
1737        );
1738
1739        let finalize_started = std::time::Instant::now();
1740        let result = parametric_integrands
1741            .into_iter()
1742            .map(|integrand| integrand.map(|a| a * &lu_prefactor))
1743            .collect();
1744        crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1745            stage = "supergraph_build_integrand_done",
1746            graph = %self.graph.name,
1747            elapsed_ms = finalize_started.elapsed().as_secs_f64() * 1000.0,
1748            total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1749            "Generation timing milestone"
1750        );
1751        Ok(result)
1752    }
1753
1754    fn lu_prefactor_helper(&self) -> Atom {
1755        let loop_number = self.graph.cyclotomatic_number(&self.graph.full_filter())
1756            - self.graph.initial_state_cut.nedges(&self.graph);
1757
1758        let loop_3 = loop_number as i64 * 3;
1759        let energy_conservation_delta_factor = Atom::num(2) * Atom::var(GS.pi);
1760
1761        crate::debug_tags!(#generation, #normalization, #lu, #graph, #summary;
1762            stage = "cross_section_lu_prefactor",
1763            graph = %self.graph.name,
1764            loop_number,
1765            loop_3,
1766            "Cross-section LU prefactor normalization"
1767        );
1768
1769        let tstar = Atom::var(GS.rescale_star);
1770        let tsrat_pow = tstar.pow(loop_3);
1771        let hfunction = Atom::var(GS.hfunction_lu_cut);
1772        tsrat_pow * hfunction * energy_conservation_delta_factor
1773    }
1774
1775    fn single_th_prefactor_helper_atom(
1776        order: u8,
1777        subspace_loop_count: usize,
1778        is_on_right: bool,
1779        include_integrated: bool,
1780    ) -> Atom {
1781        let loop_3 = subspace_loop_count as i64 * 3;
1782
1783        let i = Atom::i();
1784
1785        let radius = if is_on_right {
1786            Atom::var(GS.radius_right)
1787        } else {
1788            Atom::var(GS.radius_left)
1789        };
1790
1791        let radius_star = if is_on_right {
1792            Atom::var(GS.radius_star_right)
1793        } else {
1794            Atom::var(GS.radius_star_left)
1795        };
1796        let uv_damp_plus = if is_on_right {
1797            Atom::var(GS.uv_damp_plus_right)
1798        } else {
1799            Atom::var(GS.uv_damp_plus_left)
1800        };
1801        let uv_damp_minus = if is_on_right {
1802            Atom::var(GS.uv_damp_minus_right)
1803        } else {
1804            Atom::var(GS.uv_damp_minus_left)
1805        };
1806        let hfunction = if is_on_right {
1807            Atom::var(GS.hfunction_right_th)
1808        } else {
1809            Atom::var(GS.hfunction_left_th)
1810        };
1811
1812        let laurent_coeff_indices = (1..=order).map(|i| -(i as i8));
1813
1814        let mut laurent_coeffs = laurent_coeff_indices.map(|laurent_coeff_index| {
1815            build_derivative_structure_atom(order, laurent_coeff_index)
1816                .replace(GS.rescale_star)
1817                .with(radius_star.clone())
1818        });
1819
1820        let delta_r_plus = &radius - &radius_star;
1821        let delta_r_minus = -&radius - &radius_star;
1822
1823        let jacobian_ratio = (Atom::one() / &radius).pow(loop_3 - 1);
1824
1825        let local_prefactor =
1826            &jacobian_ratio * (uv_damp_plus / &delta_r_plus + uv_damp_minus / &delta_r_minus);
1827
1828        let integrated_prefactor = if include_integrated {
1829            if is_on_right {
1830                -i * Atom::var(GS.pi) * &jacobian_ratio * hfunction
1831            } else {
1832                i * Atom::var(GS.pi) * &jacobian_ratio * hfunction
1833            }
1834        } else {
1835            Atom::zero()
1836        };
1837
1838        let mut result = (local_prefactor + integrated_prefactor) * laurent_coeffs.next().unwrap();
1839
1840        for pow in 2..=order {
1841            result += laurent_coeffs.next().unwrap()
1842                * &jacobian_ratio
1843                * (Atom::one() / delta_r_plus.pow(pow as i64)
1844                    + Atom::one() / delta_r_minus.pow(pow as i64));
1845        }
1846
1847        debug!(
1848            "Threshold counterterm helper atom for order {} and loop number {}: {}",
1849            order, subspace_loop_count, result
1850        );
1851
1852        result
1853    }
1854
1855    fn iterated_th_prefactor_helper_atom(
1856        left_order: u8,
1857        right_order: u8,
1858        left_subspace_loop_count: usize,
1859        right_subspace_loop_count: usize,
1860        include_integrated: bool,
1861    ) -> Atom {
1862        let left_prefactor = Self::single_th_prefactor_helper_atom(
1863            left_order,
1864            left_subspace_loop_count,
1865            false,
1866            include_integrated,
1867        )
1868        .replace(GS.eta)
1869        .with(Atom::var(GS.eta_left));
1870        let right_prefactor = Self::single_th_prefactor_helper_atom(
1871            right_order,
1872            right_subspace_loop_count,
1873            true,
1874            include_integrated,
1875        )
1876        .replace(GS.eta)
1877        .with(Atom::var(GS.eta_right));
1878
1879        let mut product = (left_prefactor * right_prefactor).expand();
1880
1881        // The two single-side helpers start with the same generic `f` and `η` families.
1882        // Iterated subtraction evaluates one bivariate `f` and distinct left/right inverse-map
1883        // derivatives. Expansion exposes every product of univariate `f` derivatives so the
1884        // replacements below can fuse them into the corresponding bivariate derivative.
1885        product = product.replace_multiple(Self::fuse_left_right_replacement());
1886        product
1887    }
1888
1889    fn single_th_prefactor_helper_params(
1890        order: u8,
1891        _subspace_loop_count: usize,
1892        is_on_right: bool,
1893    ) -> Vec<Atom> {
1894        let radius_star = if is_on_right {
1895            Atom::var(GS.radius_star_right)
1896        } else {
1897            Atom::var(GS.radius_star_left)
1898        };
1899
1900        let uv_damp_plus = if is_on_right {
1901            Atom::var(GS.uv_damp_plus_right)
1902        } else {
1903            Atom::var(GS.uv_damp_plus_left)
1904        };
1905        let uv_damp_minus = if is_on_right {
1906            Atom::var(GS.uv_damp_minus_right)
1907        } else {
1908            Atom::var(GS.uv_damp_minus_left)
1909        };
1910        let hfunction = if is_on_right {
1911            Atom::var(GS.hfunction_right_th)
1912        } else {
1913            Atom::var(GS.hfunction_left_th)
1914        };
1915
1916        let radius = if is_on_right {
1917            Atom::var(GS.radius_right)
1918        } else {
1919            Atom::var(GS.radius_left)
1920        };
1921
1922        let mut params = params_for_derivative_order(order)
1923            .into_iter()
1924            .map(|param| param.replace(GS.rescale_star).with(radius_star.clone()))
1925            .collect_vec();
1926
1927        params.push(radius);
1928        params.push(radius_star);
1929        params.push(uv_damp_plus);
1930        params.push(uv_damp_minus);
1931        params.push(hfunction);
1932        params
1933    }
1934
1935    fn iterated_th_prefactor_helper_params(left_order: u8, right_order: u8) -> Vec<Atom> {
1936        let mut iterated_params = params_for_iterated_threshold_ct(left_order, right_order);
1937        let left_radius_star = Atom::var(GS.radius_star_left);
1938        let right_radius_star = Atom::var(GS.radius_star_right);
1939        let left_radius = Atom::var(GS.radius_left);
1940        let right_radius = Atom::var(GS.radius_right);
1941        let left_uv_damp_plus = Atom::var(GS.uv_damp_plus_left);
1942        let left_uv_damp_minus = Atom::var(GS.uv_damp_minus_left);
1943        let right_uv_damp_plus = Atom::var(GS.uv_damp_plus_right);
1944        let right_uv_damp_minus = Atom::var(GS.uv_damp_minus_right);
1945        let left_hfunction = Atom::var(GS.hfunction_left_th);
1946        let right_hfunction = Atom::var(GS.hfunction_right_th);
1947
1948        iterated_params.push(left_radius);
1949        iterated_params.push(left_radius_star);
1950        iterated_params.push(left_uv_damp_plus);
1951        iterated_params.push(left_uv_damp_minus);
1952        iterated_params.push(left_hfunction);
1953        iterated_params.push(right_radius);
1954        iterated_params.push(right_radius_star);
1955        iterated_params.push(right_uv_damp_plus);
1956        iterated_params.push(right_uv_damp_minus);
1957        iterated_params.push(right_hfunction);
1958        iterated_params
1959    }
1960
1961    #[allow(clippy::too_many_arguments)]
1962    pub(crate) fn single_th_helper(
1963        &self,
1964        order: u8,
1965        subspace_loop_count: usize,
1966        is_on_right: bool,
1967        include_integrated: bool,
1968        dual_shape: Option<Vec<Vec<usize>>>,
1969        optimization_settings: OptimizationSettings,
1970        evaluator_settings: &EvaluatorSettings,
1971    ) -> Result<GenericEvaluator> {
1972        let atom = Self::single_th_prefactor_helper_atom(
1973            order,
1974            subspace_loop_count,
1975            is_on_right,
1976            include_integrated,
1977        );
1978        let params =
1979            Self::single_th_prefactor_helper_params(order, subspace_loop_count, is_on_right);
1980
1981        let mut fn_map = FunctionMap::new();
1982        fn_map
1983            .add_aliases([(
1984                GS.pi.into(),
1985                Atom::num(Rational::try_from(std::f64::consts::PI).unwrap()),
1986            )])
1987            .unwrap();
1988
1989        let evaluator = GenericEvaluator::new_from_raw_params(
1990            [atom],
1991            &params,
1992            &fn_map,
1993            vec![],
1994            optimization_settings,
1995            dual_shape,
1996            evaluator_settings,
1997        )?
1998        .into_eager_only();
1999
2000        Ok(evaluator)
2001    }
2002
2003    #[allow(clippy::too_many_arguments)]
2004    pub(crate) fn iterated_th_helper(
2005        &self,
2006        left_order: u8,
2007        right_order: u8,
2008        left_subspace_loop_count: usize,
2009        right_subspace_loop_count: usize,
2010        include_integrated: bool,
2011        dual_shape: Option<Vec<Vec<usize>>>,
2012        optimization_settings: OptimizationSettings,
2013        evaluator_settings: &EvaluatorSettings,
2014    ) -> Result<GenericEvaluator> {
2015        let atom = Self::iterated_th_prefactor_helper_atom(
2016            left_order,
2017            right_order,
2018            left_subspace_loop_count,
2019            right_subspace_loop_count,
2020            include_integrated,
2021        );
2022
2023        let params = Self::iterated_th_prefactor_helper_params(left_order, right_order);
2024
2025        let mut fn_map = FunctionMap::new();
2026        fn_map
2027            .add_aliases([(
2028                GS.pi.into(),
2029                Atom::num(Rational::try_from(std::f64::consts::PI).unwrap()),
2030            )])
2031            .unwrap();
2032
2033        let evaluator = GenericEvaluator::new_from_raw_params(
2034            [atom],
2035            &params,
2036            &fn_map,
2037            vec![],
2038            optimization_settings,
2039            dual_shape,
2040            evaluator_settings,
2041        )?
2042        .into_eager_only();
2043
2044        Ok(evaluator)
2045    }
2046
2047    fn fuse_left_right_replacement() -> Vec<Replacement> {
2048        let f = symbol!("f");
2049
2050        vec![
2051            Replacement::new(
2052                (function!(f, GS.radius_star_left) * function!(f, GS.radius_star_right))
2053                    .to_pattern(),
2054                function!(f, GS.radius_star_left, GS.radius_star_right),
2055            ),
2056            Replacement::new(
2057                (function!(f, GS.radius_star_left)
2058                    * function!(Symbol::DERIVATIVE, W_.x_, f, GS.radius_star_right))
2059                .to_pattern(),
2060                function!(
2061                    Symbol::DERIVATIVE,
2062                    0,
2063                    W_.x_,
2064                    f,
2065                    GS.radius_star_left,
2066                    GS.radius_star_right
2067                ),
2068            ),
2069            Replacement::new(
2070                (function!(Symbol::DERIVATIVE, W_.x_, f, GS.radius_star_left)
2071                    * function!(f, GS.radius_star_right))
2072                .to_pattern(),
2073                function!(
2074                    Symbol::DERIVATIVE,
2075                    W_.x_,
2076                    0,
2077                    f,
2078                    GS.radius_star_left,
2079                    GS.radius_star_right
2080                ),
2081            ),
2082            Replacement::new(
2083                (function!(Symbol::DERIVATIVE, W_.x_, f, GS.radius_star_left)
2084                    * function!(Symbol::DERIVATIVE, W_.y_, f, GS.radius_star_right))
2085                .to_pattern(),
2086                function!(
2087                    Symbol::DERIVATIVE,
2088                    W_.x_,
2089                    W_.y_,
2090                    f,
2091                    GS.radius_star_left,
2092                    GS.radius_star_right
2093                ),
2094            ),
2095        ]
2096    }
2097    //fn th_prefactor_helper(
2098    //    &self,
2099    //    subspace_loop_count: usize,
2100    //    is_on_right: bool,
2101    //    include_integrated: bool,
2102    //) -> Atom {
2103    //    let radius = if is_on_right {
2104    //        Atom::var(GS.radius_right)
2105    //    } else {
2106    //        Atom::var(GS.radius_left)
2107    //    };
2108
2109    //    let radius_star = if is_on_right {
2110    //        Atom::var(GS.radius_star_right)
2111    //    } else {
2112    //        Atom::var(GS.radius_star_left)
2113    //    };
2114
2115    //    let grad_eta = if is_on_right {
2116    //        Atom::var(GS.deta_right_th)
2117    //    } else {
2118    //        Atom::var(GS.deta_left_th)
2119    //    };
2120
2121    //    let uv_damp_plus = if is_on_right {
2122    //        Atom::var(GS.uv_damp_plus_right)
2123    //    } else {
2124    //        Atom::var(GS.uv_damp_plus_left)
2125    //    };
2126
2127    //    let uv_damp_minus = if is_on_right {
2128    //        Atom::var(GS.uv_damp_minus_right)
2129    //    } else {
2130    //        Atom::var(GS.uv_damp_minus_left)
2131    //    };
2132
2133    //    let h_function = if is_on_right {
2134    //        Atom::var(GS.hfunction_right_th)
2135    //    } else {
2136    //        Atom::var(GS.hfunction_left_th)
2137    //    };
2138
2139    //    let i = if is_on_right { -Atom::i() } else { Atom::i() };
2140    //    let pi = Atom::var(GS.pi);
2141
2142    //    let jacobian_ratio = (&radius_star / &radius).pow(subspace_loop_count as i64 * 3 - 1);
2143
2144    //    let local_prefactor = (&uv_damp_plus / (&radius - &radius_star)
2145    //        + &uv_damp_minus / (-&radius - &radius_star))
2146    //        / &grad_eta
2147    //        * &jacobian_ratio;
2148
2149    //    let integrated_prefactor = if include_integrated {
2150    //        &h_function * &i * &pi * &jacobian_ratio / &grad_eta
2151    //    } else {
2152    //        Atom::new()
2153    //    };
2154
2155    //    debug!(
2156    //        "th prefactor local: {}, integrated: {}",
2157    //        local_prefactor, integrated_prefactor
2158    //    );
2159
2160    //    local_prefactor + integrated_prefactor
2161    //}
2162
2163    fn build_lmbs(&mut self) -> Result<()> {
2164        let mut lmbs: TiVec<LmbIndex, LoopMomentumBasis> = vec![].into();
2165
2166        let externals: SuBitGraph = self.graph.empty_subgraph();
2167        let full_filter = self.graph.full_filter();
2168        let cut_graph = full_filter.subtract(&self.graph.initial_state_cut.right);
2169
2170        for s in self.graph.all_spanning_forests_of(&cut_graph) {
2171            let mut lmb = self.graph.lmb_impl(&full_filter, &s, externals.clone())?;
2172            let mut exts = vec![];
2173
2174            for i in lmb.loop_edges.iter() {
2175                let (_, p) = &self.graph[i];
2176
2177                if self.graph.initial_state_cut.intersects(p) {
2178                    exts.push(*i);
2179                }
2180            }
2181
2182            exts.sort();
2183
2184            for e in exts {
2185                let mut loopid = None;
2186                for (l, s) in lmb.edge_signatures[e].internal.iter_enumerated() {
2187                    if s.is_non_zero() {
2188                        if loopid.is_none() {
2189                            loopid = Some(l);
2190                        } else {
2191                            panic!("external edge has multiple loop momenta")
2192                        }
2193                    }
2194                }
2195                lmb.put_loop_to_ext(loopid.unwrap());
2196            }
2197            let external_momentum_edge_order = self.graph.external_momentum_edge_order();
2198            lmb.canonicalize_external_order(&external_momentum_edge_order);
2199            //lmbs.push(self.graph.lmb_impl(&full_filter, &s, externals.clone()));
2200            lmbs.push(lmb);
2201        }
2202
2203        let sorted_graph_loop_edges = self
2204            .graph
2205            .loop_momentum_basis
2206            .loop_edges
2207            .iter()
2208            .copied()
2209            .sorted()
2210            .collect_vec();
2211
2212        let matching_lmb_index = lmbs.iter_enumerated().find_map(|(lmb_index, lmb)| {
2213            let sorted_loop_edges = lmb.loop_edges.iter().copied().sorted().collect_vec();
2214            (sorted_loop_edges == sorted_graph_loop_edges).then_some(lmb_index)
2215        });
2216
2217        if let Some(matching_lmb_index) = matching_lmb_index {
2218            {
2219                let matching_lmb = &mut lmbs[matching_lmb_index];
2220                for (target_pos, target_edge) in
2221                    self.graph.loop_momentum_basis.loop_edges.iter_enumerated()
2222                {
2223                    let current_pos = matching_lmb
2224                        .loop_edges
2225                        .iter()
2226                        .find_position(|edge| *edge == target_edge)
2227                        .map(|(pos, _)| pos)
2228                        .unwrap();
2229
2230                    if current_pos != target_pos.0 {
2231                        matching_lmb.swap_loops(current_pos.into(), target_pos);
2232                    }
2233                }
2234            }
2235
2236            let matching_lmb_pos: usize = matching_lmb_index.into();
2237            if matching_lmb_pos != 0 {
2238                lmbs.raw[..=matching_lmb_pos].rotate_right(1);
2239            }
2240        } else {
2241            warn!(
2242                "Could not match current graph LMB against generated LMBs for graph {}",
2243                self.graph.name
2244            );
2245        }
2246
2247        let sorted_graph_loop_edges = self
2248            .graph
2249            .loop_momentum_basis
2250            .loop_edges
2251            .iter()
2252            .copied()
2253            .sorted()
2254            .collect_vec();
2255
2256        let matching_lmb_index = lmbs.iter_enumerated().find_map(|(lmb_index, lmb)| {
2257            let sorted_loop_edges = lmb.loop_edges.iter().copied().sorted().collect_vec();
2258            (sorted_loop_edges == sorted_graph_loop_edges).then_some(lmb_index)
2259        });
2260
2261        if let Some(matching_lmb_index) = matching_lmb_index {
2262            {
2263                let matching_lmb = &mut lmbs[matching_lmb_index];
2264                for (target_pos, target_edge) in
2265                    self.graph.loop_momentum_basis.loop_edges.iter_enumerated()
2266                {
2267                    let current_pos = matching_lmb
2268                        .loop_edges
2269                        .iter()
2270                        .find_position(|edge| *edge == target_edge)
2271                        .map(|(pos, _)| pos)
2272                        .unwrap();
2273
2274                    if current_pos != target_pos.0 {
2275                        matching_lmb.swap_loops(current_pos.into(), target_pos);
2276                    }
2277                }
2278            }
2279
2280            let matching_lmb_pos: usize = matching_lmb_index.into();
2281            if matching_lmb_pos != 0 {
2282                lmbs.raw[..=matching_lmb_pos].rotate_right(1);
2283            }
2284        } else {
2285            warn!(
2286                "Could not match current graph LMB against generated LMBs for graph {}",
2287                self.graph.name
2288            );
2289        }
2290
2291        self.derived_data.lmbs = Some(lmbs);
2292        Ok(())
2293    }
2294
2295    fn build_multi_channeling_channels(&mut self, override_lmb_heuristics: bool) -> Result<()> {
2296        let lmbs = self.derived_data.lmbs.as_ref().unwrap();
2297        let channels = if override_lmb_heuristics {
2298            self.graph
2299                .build_multi_channeling_channels(lmbs, override_lmb_heuristics)
2300        } else {
2301            self.build_cross_section_multi_channeling_channels(lmbs)?
2302        };
2303
2304        self.derived_data.multi_channeling_setup = Some(channels);
2305        Ok(())
2306    }
2307
2308    fn build_cross_section_multi_channeling_channels(
2309        &self,
2310        lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
2311    ) -> Result<LmbMultiChannelingSetup> {
2312        let channels = self.select_cross_section_lmb_channel_indices(lmbs)?;
2313        debug!(
2314            "number of lmbs: {}, number of cross-section channels: {}",
2315            lmbs.len(),
2316            channels.len()
2317        );
2318        Ok(LmbMultiChannelingSetup {
2319            channels,
2320            graph: self.graph.clone(),
2321            all_bases: lmbs.clone(),
2322        })
2323    }
2324
2325    fn select_cross_section_lmb_channel_indices(
2326        &self,
2327        lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
2328    ) -> Result<TiVec<crate::integrands::process::ChannelIndex, LmbIndex>> {
2329        if self.cuts.is_empty() {
2330            return Ok(self.graph.select_amplitude_lmb_channel_indices(
2331                lmbs,
2332                false,
2333                LmbChannelFallback::CurrentGraphBasis,
2334            ));
2335        }
2336
2337        let mut lmb_index_by_loop_edges = BTreeMap::<Vec<EdgeIndex>, LmbIndex>::new();
2338        for (lmb_index, lmb) in lmbs.iter_enumerated() {
2339            lmb_index_by_loop_edges
2340                .entry(lmb.loop_edges.iter().copied().sorted().collect())
2341                .or_insert(lmb_index);
2342        }
2343
2344        let mut selected_loop_edge_sets = BTreeSet::<Vec<EdgeIndex>>::new();
2345        for cut in &self.cuts {
2346            let cut_edges = cut.cut.as_subgraph();
2347            let cut_edge_ids = self
2348                .graph
2349                .underlying
2350                .iter_edges_of(&cut_edges)
2351                .map(|(_, edge_id, _)| edge_id)
2352                .sorted()
2353                .collect_vec();
2354            let Some(excluded_cut_edge) = self.excluded_cut_edge_for_lmb_channel(&cut_edge_ids)
2355            else {
2356                continue;
2357            };
2358
2359            let fixed_cut_edges = cut_edge_ids
2360                .iter()
2361                .copied()
2362                .filter(|edge_id| *edge_id != excluded_cut_edge)
2363                .collect::<BTreeSet<_>>();
2364            let left_basis_edge_sets =
2365                self.selected_cut_side_lmb_edge_sets(cut.left.subtract(&cut_edges))?;
2366            let right_basis_edge_sets =
2367                self.selected_cut_side_lmb_edge_sets(cut.right.subtract(&cut_edges))?;
2368
2369            for left_basis_edges in &left_basis_edge_sets {
2370                for right_basis_edges in &right_basis_edge_sets {
2371                    let loop_edges = fixed_cut_edges
2372                        .iter()
2373                        .copied()
2374                        .chain(left_basis_edges.iter().copied())
2375                        .chain(right_basis_edges.iter().copied())
2376                        .collect::<BTreeSet<_>>()
2377                        .into_iter()
2378                        .collect_vec();
2379                    selected_loop_edge_sets.insert(loop_edges);
2380                }
2381            }
2382        }
2383
2384        let mut channels = Vec::<LmbIndex>::new();
2385        for loop_edges in selected_loop_edge_sets {
2386            let lmb_index = lmb_index_by_loop_edges.get(&loop_edges).ok_or_else(|| {
2387                eyre!(
2388                    "Could not find a generated cross-section LMB with loop edges [{}] for graph '{}'.",
2389                    loop_edges.iter().map(|edge| edge.to_string()).join(", "),
2390                    self.graph.name
2391                )
2392            })?;
2393            channels.push(*lmb_index);
2394        }
2395
2396        if channels.is_empty() {
2397            Ok(self.graph.select_amplitude_lmb_channel_indices(
2398                lmbs,
2399                false,
2400                LmbChannelFallback::CurrentGraphBasis,
2401            ))
2402        } else {
2403            Ok(channels.into_iter().sorted().dedup().collect())
2404        }
2405    }
2406
2407    fn excluded_cut_edge_for_lmb_channel(&self, cut_edge_ids: &[EdgeIndex]) -> Option<EdgeIndex> {
2408        Self::excluded_cut_edge_for_lmb_channel_in(&self.graph, cut_edge_ids)
2409    }
2410
2411    fn excluded_cut_edge_for_lmb_channel_in(
2412        graph: &Graph,
2413        cut_edge_ids: &[EdgeIndex],
2414    ) -> Option<EdgeIndex> {
2415        cut_edge_ids
2416            .iter()
2417            .copied()
2418            .filter(|edge_id| graph[*edge_id].particle.is_massive())
2419            .min()
2420            .or_else(|| {
2421                cut_edge_ids
2422                    .iter()
2423                    .copied()
2424                    .filter(|edge_id| graph[*edge_id].particle.is_fermion())
2425                    .min()
2426            })
2427            .or_else(|| cut_edge_ids.iter().copied().min())
2428    }
2429
2430    fn selected_cut_side_lmb_edge_sets(
2431        &self,
2432        mut side_subgraph: SuBitGraph,
2433    ) -> Result<Vec<Vec<EdgeIndex>>> {
2434        for (pair, _, edge) in self.graph.underlying.iter_edges() {
2435            if edge.data.is_dummy {
2436                side_subgraph.sub(pair);
2437            }
2438        }
2439
2440        if self.graph.underlying.cyclotomatic_number(&side_subgraph) == 0 {
2441            return Ok(vec![Vec::new()]);
2442        }
2443
2444        let side_lmbs = self.graph.generate_loop_momentum_bases_of(&side_subgraph);
2445        if side_lmbs.is_empty() {
2446            return Err(eyre!(
2447                "Could not generate cut-side LMBs for a non-tree side of graph '{}'.",
2448                self.graph.name
2449            ));
2450        }
2451
2452        let selected_side_lmbs = self.graph.select_amplitude_lmb_channel_indices(
2453            &side_lmbs,
2454            false,
2455            LmbChannelFallback::FirstBasis,
2456        );
2457        let mut edge_sets = BTreeSet::<Vec<EdgeIndex>>::new();
2458        for lmb_index in selected_side_lmbs {
2459            edge_sets.insert(
2460                side_lmbs[lmb_index]
2461                    .loop_edges
2462                    .iter()
2463                    .copied()
2464                    .sorted()
2465                    .collect(),
2466            );
2467        }
2468
2469        if edge_sets.is_empty() {
2470            Ok(vec![Vec::new()])
2471        } else {
2472            Ok(edge_sets.into_iter().collect())
2473        }
2474    }
2475
2476    fn threshold_counterterm_association(
2477        &self,
2478        threshold_candidate: &TopologicalThresholdCandidate,
2479        sandwich: &SuBitGraph,
2480        cut: &OrientedCut,
2481    ) -> ThresholdCountertermAssociation {
2482        let boundary_edges = |boundary: &OrientedCut| {
2483            let boundary_filter = boundary.left.union(&boundary.right).intersection(sandwich);
2484            let edges = self
2485                .graph
2486                .iter_edges_of(&boundary_filter)
2487                .map(|(_, edge_id, _)| edge_id)
2488                .sorted()
2489                .collect();
2490            let has_left_orientation =
2491                boundary_filter.intersection(&boundary.left).n_included() > 0;
2492            let has_right_orientation =
2493                boundary_filter.intersection(&boundary.right).n_included() > 0;
2494            (edges, has_left_orientation != has_right_orientation)
2495        };
2496
2497        let (cut_boundary_edges, cut_bound_is_applicable) = boundary_edges(cut);
2498        let (threshold_boundary_edges, threshold_bound_is_applicable) =
2499            boundary_edges(&threshold_candidate.cut);
2500
2501        ThresholdCountertermAssociation {
2502            esurface_id: threshold_candidate.esurface_id,
2503            cut_boundary_edges,
2504            threshold_boundary_edges,
2505            invariant_bound_is_applicable: cut_bound_is_applicable && threshold_bound_is_applicable,
2506        }
2507    }
2508
2509    #[allow(clippy::too_many_arguments)]
2510    fn classify_threshold_counterterm_association(
2511        &self,
2512        association: &ThresholdCountertermAssociation,
2513        cut_id: CutId,
2514        cut: &CrossSectionCut,
2515        subspace: &SubspaceData,
2516        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
2517        model: &Model,
2518        runtime_settings: &RuntimeSettings,
2519        settings: &GenerationSettings,
2520    ) -> ThresholdCountertermStatus {
2521        let esurface = &self.graph.surface_cache.esurface_cache[association.esurface_id];
2522        if !esurface.has_radial_dependence_in_subspace(subspace, all_lmbs, &self.graph) {
2523            debug!(
2524                "Skipping graph '{}' cut {} threshold E-surface {} after cut-relative classification {:?}",
2525                self.graph.name,
2526                cut_id.0,
2527                association.esurface_id.0,
2528                ThresholdCountertermStatus::NoRadialDependence,
2529            );
2530            return ThresholdCountertermStatus::NoRadialDependence;
2531        }
2532
2533        let structural_status = if association.invariant_bound_is_applicable {
2534            self.graph.classify_threshold_pinch(
2535                &association.cut_boundary_edges,
2536                &association.threshold_boundary_edges,
2537            )
2538        } else {
2539            ThresholdPinchStatus::NotProven
2540        };
2541        if structural_status == ThresholdPinchStatus::Always {
2542            debug!(
2543                "Skipping graph '{}' cut {} threshold E-surface {} after cut-relative classification {:?}",
2544                self.graph.name,
2545                cut_id.0,
2546                association.esurface_id.0,
2547                ThresholdCountertermStatus::AlwaysPinched,
2548            );
2549            return ThresholdCountertermStatus::AlwaysPinched;
2550        }
2551
2552        let status = if settings.threshold_subtraction.check_esurface_at_generation {
2553            association.classify_for_model(
2554                &self.graph,
2555                cut,
2556                model,
2557                &self.graph.param_builder,
2558                runtime_settings,
2559                settings.threshold_subtraction.esurface_existence_threshold,
2560            )
2561        } else {
2562            match structural_status {
2563                ThresholdPinchStatus::CanBecome => ThresholdCountertermStatus::CanBecomePinched,
2564                ThresholdPinchStatus::NotProven => ThresholdCountertermStatus::PotentiallyExisting,
2565                ThresholdPinchStatus::Always => unreachable!(
2566                    "identically pinched threshold associations return before classification"
2567                ),
2568            }
2569        };
2570        if !status
2571            .is_eligible_for_generation(settings.threshold_subtraction.check_esurface_at_generation)
2572        {
2573            debug!(
2574                "Skipping graph '{}' cut {} threshold E-surface {} after cut-relative classification {:?}",
2575                self.graph.name, cut_id.0, association.esurface_id.0, status,
2576            );
2577        }
2578        status
2579    }
2580
2581    fn topological_threshold_candidates(&self) -> Result<Vec<TopologicalThresholdCandidate>> {
2582        let mut candidates = self.graph.all_st_cuts_for_cs(
2583            self.source_nodes.clone(),
2584            self.target_nodes.clone(),
2585            &self.graph.get_initial_state_tree().0,
2586        );
2587        candidates.retain(|(_left, cut, _right)| cut.nedges(&self.graph) > 1);
2588        candidates.sort_by(|a, b| a.1.cmp(&b.1));
2589
2590        candidates
2591            .into_iter()
2592            .map(|(left, cut, right)| {
2593                let threshold_esurface = Esurface::new_from_cut_left(
2594                    &self.graph.underlying,
2595                    &CrossSectionCut {
2596                        cut: cut.clone(),
2597                        left: left.clone(),
2598                        right: right.clone(),
2599                    },
2600                    Some(&self.graph.initial_state_cut),
2601                );
2602                let esurface_id = self
2603                    .graph
2604                    .surface_cache
2605                    .esurface_cache
2606                    .position(|esurface| esurface == &threshold_esurface)
2607                    .ok_or_else(|| {
2608                        eyre!(
2609                            "Topology-discovered threshold surface {:?} is missing from graph '{}' CFF surface cache",
2610                            threshold_esurface.energies,
2611                            self.graph.name,
2612                        )
2613                    })?;
2614                Ok(TopologicalThresholdCandidate {
2615                    left,
2616                    cut,
2617                    right,
2618                    esurface_id,
2619                })
2620            })
2621            .collect()
2622    }
2623
2624    fn build_threshold_counterterm(
2625        &mut self,
2626        model: &Model,
2627        settings: &GenerationSettings,
2628        runtime_settings: &RuntimeSettings,
2629        all_possible_thresholds: &[TopologicalThresholdCandidate],
2630        vakint: &Vakint,
2631    ) -> Result<()> {
2632        // Thresholds are topology-discovered independently of whether a CT association survives
2633        // the structural and optional current-model checks below.
2634        let mut cut_threshold_associations: TiVec<CutId, CutThresholdCountertermAssociations> =
2635            ti_vec![CutThresholdCountertermAssociations::default(); self.cuts.len()];
2636
2637        let mut cut_group_by_cut = vec![None; self.cuts.len()];
2638        for (cut_group_id, cut_group) in self
2639            .derived_data
2640            .cut_group_data
2641            .cut_groups
2642            .iter_enumerated()
2643        {
2644            for cut_id in &cut_group.cuts {
2645                cut_group_by_cut[cut_id.0] = Some(cut_group_id);
2646            }
2647        }
2648        let all_lmbs = self
2649            .derived_data
2650            .lmbs
2651            .as_ref()
2652            .expect("threshold generation requires loop-momentum bases");
2653
2654        // Keep topology-discovered E-surfaces in the graph inventory, but do not generate a
2655        // threshold CT association for any E-surface that is also one of this graph's physical
2656        // Cutkosky cuts when the corresponding generation setting is enabled. This comparison
2657        // must cover every physical cut, not only the cut currently being processed below.
2658        let subtraction_threshold_candidates = all_possible_thresholds
2659            .iter()
2660            .filter(|threshold_candidate| {
2661                !settings.threshold_subtraction.skip_thresholds_that_are_cuts
2662                    || !self
2663                        .cuts
2664                        .iter()
2665                        .any(|physical_cut| physical_cut.cut == threshold_candidate.cut)
2666            })
2667            .collect_vec();
2668
2669        for (cut_id, cut) in self.cuts.iter_enumerated() {
2670            let cut_group_id = cut_group_by_cut[cut_id.0].ok_or_else(|| {
2671                eyre!(
2672                    "Physical cut {} in graph '{}' is missing from the cut-group partition",
2673                    cut_id.0,
2674                    self.graph.name,
2675                )
2676            })?;
2677            let (left_subspace, right_subspace) = &self.derived_data.subspace_data[cut_group_id];
2678
2679            for threshold_candidate in &subtraction_threshold_candidates {
2680                // if the subgraph on the left of the threshold cut is a subgraph of the left amplitude, then the threshold is on the left of the cut
2681                if cut.left.includes(&threshold_candidate.left) {
2682                    let sandwich = cut.left.intersection(&threshold_candidate.right);
2683
2684                    // The shared projected-signature check below determines whether the threshold
2685                    // cuts an active loop. Connectivity remains a separate structural requirement.
2686                    if self.graph.underlying.is_connected(&sandwich) {
2687                        let association = self.threshold_counterterm_association(
2688                            threshold_candidate,
2689                            &sandwich,
2690                            &cut.cut,
2691                        );
2692                        let status = self.classify_threshold_counterterm_association(
2693                            &association,
2694                            cut_id,
2695                            cut,
2696                            left_subspace,
2697                            all_lmbs,
2698                            model,
2699                            runtime_settings,
2700                            settings,
2701                        );
2702                        if status.is_eligible_for_generation(
2703                            settings.threshold_subtraction.check_esurface_at_generation,
2704                        ) {
2705                            cut_threshold_associations[cut_id].left.push(association);
2706                        }
2707                    }
2708                } else if cut.right.includes(&threshold_candidate.right) {
2709                    let sandwich = cut.right.intersection(&threshold_candidate.left);
2710                    if self.graph.underlying.is_connected(&sandwich) {
2711                        // threshold_esurface.subspace_graph =
2712                        //     InternalSubGraph::cleaned_filter_pessimist(
2713                        //         cut.right.clone(),
2714                        //         &self.graph,
2715                        //     );
2716                        let association = self.threshold_counterterm_association(
2717                            threshold_candidate,
2718                            &sandwich,
2719                            &cut.cut,
2720                        );
2721                        let status = self.classify_threshold_counterterm_association(
2722                            &association,
2723                            cut_id,
2724                            cut,
2725                            right_subspace,
2726                            all_lmbs,
2727                            model,
2728                            runtime_settings,
2729                            settings,
2730                        );
2731                        if status.is_eligible_for_generation(
2732                            settings.threshold_subtraction.check_esurface_at_generation,
2733                        ) {
2734                            cut_threshold_associations[cut_id].right.push(association);
2735                        }
2736                    }
2737                }
2738            }
2739        }
2740
2741        let left_cut_threshold_data: TiVec<CutId, Vec<EsurfaceID>> = cut_threshold_associations
2742            .iter()
2743            .map(|associations| {
2744                associations
2745                    .left
2746                    .iter()
2747                    .map(|association| association.esurface_id)
2748                    .collect()
2749            })
2750            .collect();
2751        let right_cut_threshold_data: TiVec<CutId, Vec<EsurfaceID>> = cut_threshold_associations
2752            .iter()
2753            .map(|associations| {
2754                associations
2755                    .right
2756                    .iter()
2757                    .map(|association| association.esurface_id)
2758                    .collect()
2759            })
2760            .collect();
2761        self.derived_data.cut_threshold_associations = cut_threshold_associations;
2762
2763        let threshold_raised_data = self.graph.determine_raised_esurfaces_from_expression(
2764            self.derived_data
2765                .global_cff_expression
2766                .as_ref()
2767                .expect("global_cff_expression should have been created"),
2768        );
2769        let mut raised_threshold_ids: TiVec<EsurfaceID, Option<RaisedEsurfaceId>> =
2770            ti_vec![None; self.graph.surface_cache.esurface_cache.len()];
2771
2772        for (raised_threshold_id, raised_group) in
2773            threshold_raised_data.raised_groups.iter_enumerated()
2774        {
2775            for &esurface_id in &raised_group.esurface_ids {
2776                raised_threshold_ids[esurface_id] = Some(raised_threshold_id);
2777            }
2778        }
2779
2780        let raised_threshold_ids: TiVec<EsurfaceID, RaisedEsurfaceId> = raised_threshold_ids
2781            .into_iter()
2782            .map(|raised_threshold_id| {
2783                raised_threshold_id
2784                    .expect("every esurface should belong to exactly one raised threshold group")
2785            })
2786            .collect();
2787
2788        let collect_raised_threshold_groups = |threshold_ids: Vec<EsurfaceID>| {
2789            let mut groups = Vec::new();
2790            for esurface_id in threshold_ids.into_iter().sorted().dedup() {
2791                let raised_threshold_id = raised_threshold_ids[esurface_id];
2792                let raised_group = threshold_raised_data.raised_groups[raised_threshold_id].clone();
2793                if !groups.contains(&raised_group) {
2794                    groups.push(raised_group);
2795                }
2796            }
2797            groups
2798        };
2799
2800        let mut left_cut_group_threshold_data: TiVec<
2801            CutGroupId,
2802            TiVec<LeftThresholdId, RaisedEsurfaceGroup>,
2803        > = TiVec::new();
2804
2805        let mut right_cut_group_threshold_data: TiVec<
2806            CutGroupId,
2807            TiVec<RightThresholdId, RaisedEsurfaceGroup>,
2808        > = TiVec::new();
2809
2810        for cut_group in self.derived_data.cut_group_data.cut_groups.iter() {
2811            let left_thresholds = collect_raised_threshold_groups(
2812                cut_group
2813                    .cuts
2814                    .iter()
2815                    .flat_map(|cut_id| left_cut_threshold_data[*cut_id].iter().copied())
2816                    .collect(),
2817            );
2818
2819            let mut right_thresholds = collect_raised_threshold_groups(
2820                cut_group
2821                    .cuts
2822                    .iter()
2823                    .flat_map(|cut_id| right_cut_threshold_data[*cut_id].iter().copied())
2824                    .collect(),
2825            );
2826
2827            right_thresholds.retain(|raised_group| !left_thresholds.contains(raised_group));
2828
2829            left_cut_group_threshold_data.push(left_thresholds.into());
2830            right_cut_group_threshold_data.push(right_thresholds.into());
2831        }
2832
2833        let mut cut_structure = vec![];
2834
2835        for (cut_group_id, cut_group) in self
2836            .derived_data
2837            .cut_group_data
2838            .cut_groups
2839            .iter_enumerated()
2840        {
2841            let left_thresholds = &left_cut_group_threshold_data[cut_group_id];
2842            let right_thresholds = &right_cut_group_threshold_data[cut_group_id];
2843
2844            let cutkosky_cut_union = cut_group
2845                .cuts
2846                .iter()
2847                .map(|cut_id| self.cuts[*cut_id].cut.as_subgraph())
2848                .reduce(|a, b| a.union(&b))
2849                .unwrap_or(self.graph.empty_subgraph());
2850
2851            let add_threshold_group_to_union =
2852                |base: SuBitGraph, raised_group: &RaisedEsurfaceGroup| {
2853                    let representative_esurface =
2854                        &self.graph.surface_cache.esurface_cache[raised_group.esurface_ids[0]];
2855
2856                    representative_esurface
2857                        .energies
2858                        .iter()
2859                        .map(|edge_id| self.graph.get_edge_subgraph(*edge_id))
2860                        .fold(base, |acc, subgraph| acc.union(&subgraph))
2861                };
2862
2863            for raised_esurface_group in left_thresholds {
2864                let esurface_cut_union =
2865                    add_threshold_group_to_union(cutkosky_cut_union.clone(), raised_esurface_group);
2866
2867                cut_structure.push(CutSet {
2868                    residue_selector: ResidueSelector {
2869                        lu_cut: Some(cut_group.related_esurface_group.clone()),
2870                        left_th_cut: Some(raised_esurface_group.clone()),
2871                        right_th_cut: None,
2872                    },
2873                    union: esurface_cut_union,
2874                    canonicalize_external_shifts: false,
2875                });
2876            }
2877
2878            for raised_esurface_group in right_thresholds {
2879                let esurface_cut_union =
2880                    add_threshold_group_to_union(cutkosky_cut_union.clone(), raised_esurface_group);
2881
2882                cut_structure.push(CutSet {
2883                    residue_selector: ResidueSelector {
2884                        lu_cut: Some(cut_group.related_esurface_group.clone()),
2885                        left_th_cut: None,
2886                        right_th_cut: Some(raised_esurface_group.clone()),
2887                    },
2888                    union: esurface_cut_union,
2889                    canonicalize_external_shifts: false,
2890                });
2891            }
2892
2893            for (left_raised_esurface_group, right_raised_esurface_group) in left_thresholds
2894                .iter()
2895                .cartesian_product(right_thresholds.iter())
2896            {
2897                let esurface_cut_union = add_threshold_group_to_union(
2898                    add_threshold_group_to_union(
2899                        cutkosky_cut_union.clone(),
2900                        left_raised_esurface_group,
2901                    ),
2902                    right_raised_esurface_group,
2903                );
2904
2905                cut_structure.push(CutSet {
2906                    residue_selector: ResidueSelector {
2907                        lu_cut: Some(cut_group.related_esurface_group.clone()),
2908                        left_th_cut: Some(left_raised_esurface_group.clone()),
2909                        right_th_cut: Some(right_raised_esurface_group.clone()),
2910                    },
2911                    union: esurface_cut_union,
2912                    canonicalize_external_shifts: false,
2913                });
2914            }
2915        }
2916
2917        let cut_structure = CutStructure {
2918            cuts: cut_structure,
2919        };
2920
2921        let valid_orientations: Vec<_> = self
2922            .derived_data
2923            .global_cff_expression
2924            .as_ref()
2925            .expect("global_cff_expression should have been created")
2926            .orientations
2927            .iter()
2928            .map(|orientation| orientation.data.orientation.clone())
2929            .collect();
2930
2931        let mut threshold_counterterms = settings
2932            .uv
2933            .orchestrator
2934            .parametric_integrands(
2935                &mut self.graph,
2936                cut_structure,
2937                vakint,
2938                OrientationProjection::new(&valid_orientations, &settings.orientation_pattern),
2939                &settings.uv,
2940            )?
2941            .into_iter();
2942
2943        let lu_prefactor = self.lu_prefactor_helper();
2944
2945        let mut result = TiVec::<CutGroupId, LUCounterTermData>::new();
2946        for (cut_group_id, _cut_group) in self
2947            .derived_data
2948            .cut_group_data
2949            .cut_groups
2950            .iter_enumerated()
2951        {
2952            let (left_subspace, right_subspace) = &self.derived_data.subspace_data[cut_group_id];
2953
2954            let left_rstar_pow =
2955                Atom::var(GS.radius_star_left).pow(left_subspace.loopcount() as i32 * 3 - 1);
2956
2957            let right_rstar_pow =
2958                Atom::var(GS.radius_star_right).pow(right_subspace.loopcount() as i32 * 3 - 1);
2959
2960            let mut left_atoms = TiVec::<LeftThresholdId, _>::new();
2961            let mut right_atoms = TiVec::<RightThresholdId, _>::new();
2962            let mut iterated_atoms = vec![];
2963
2964            for _ in 0..left_cut_group_threshold_data[cut_group_id].len() {
2965                left_atoms.push(
2966                    threshold_counterterms
2967                        .next()
2968                        .unwrap()
2969                        .map(|x| x * &lu_prefactor * &left_rstar_pow),
2970                );
2971            }
2972
2973            for _ in 0..right_cut_group_threshold_data[cut_group_id].len() {
2974                right_atoms.push(
2975                    threshold_counterterms
2976                        .next()
2977                        .unwrap()
2978                        .map(|x| x * &lu_prefactor * &right_rstar_pow),
2979                );
2980            }
2981
2982            for _ in 0..(left_cut_group_threshold_data[cut_group_id].len()
2983                * right_cut_group_threshold_data[cut_group_id].len())
2984            {
2985                iterated_atoms.push(
2986                    threshold_counterterms
2987                        .next()
2988                        .unwrap()
2989                        .map(|x| x * &lu_prefactor * &left_rstar_pow * &right_rstar_pow),
2990                );
2991            }
2992
2993            let iterated_collection =
2994                IteratedCtCollection::new(iterated_atoms, left_atoms.len(), right_atoms.len());
2995
2996            let counterterm_data = LUCounterTermData {
2997                left_thresholds: left_cut_group_threshold_data[cut_group_id].clone(),
2998                right_thresholds: right_cut_group_threshold_data[cut_group_id].clone(),
2999                left_atoms,
3000                right_atoms,
3001                iterated: iterated_collection,
3002            };
3003            result.push(counterterm_data);
3004        }
3005
3006        let max_dual_size =
3007            max_dual_size_for_cut_cff_indices(result.iter().flat_map(|counterterm_data| {
3008                counterterm_data
3009                    .left_atoms
3010                    .iter()
3011                    .chain(counterterm_data.right_atoms.iter())
3012                    .chain(counterterm_data.iterated.iter())
3013                    .flat_map(|integrands| integrands.integrands.iter().map(|(index, _)| index))
3014            }));
3015        self.graph.param_builder.initialize_duals(max_dual_size);
3016
3017        self.derived_data.threshold_counterterms = result;
3018        Ok(())
3019    }
3020
3021    fn build_subspace_data(&mut self) -> Result<()> {
3022        let all_lmbs = self.derived_data.lmbs.as_ref().unwrap();
3023
3024        let subspace_data = self
3025            .derived_data
3026            .cut_group_data
3027            .cut_groups
3028            .iter_enumerated()
3029            .map(|(cut_group_id, cut_group)| {
3030                let representative_cut_id = cut_group.cuts.first().copied().ok_or_else(|| {
3031                    eyre!(
3032                        "Graph '{}' has an empty cut group {} while building threshold-counterterm subspaces",
3033                        self.graph.name,
3034                        cut_group_id.0,
3035                    )
3036                })?;
3037                let valid_subspace_lmbs = all_lmbs
3038                    .iter_enumerated()
3039                    .filter_map(|(index, lmb)| {
3040                        let mut edges_in_cut = self
3041                            .graph
3042                            .underlying
3043                            .iter_edges_of(&self.cuts[representative_cut_id].cut)
3044                            .map(|(_, e, _)| e)
3045                            .collect_vec();
3046
3047                        edges_in_cut.retain(|e| !lmb.loop_edges.contains(e));
3048                        if edges_in_cut.len() == 1 {
3049                            Some(index)
3050                        } else {
3051                            None
3052                        }
3053                    })
3054                    .collect_vec();
3055
3056                let left_subgraphs = cut_group
3057                    .cuts
3058                    .iter()
3059                    .map(|cut_id| self.cuts[*cut_id].left.clone())
3060                    .sorted_by(|subgraph_a, subgraph_b| {
3061                        let n_edges_a = subgraph_a.nedges(&self.graph);
3062                        let n_edges_b = subgraph_b.nedges(&self.graph);
3063                        n_edges_a.cmp(&n_edges_b)
3064                    })
3065                    .collect_vec();
3066
3067                let right_subgraphs = cut_group
3068                    .cuts
3069                    .iter()
3070                    .map(|cut_id| self.cuts[*cut_id].right.clone())
3071                    .sorted_by(|subgraph_a, subgraph_b| {
3072                        let n_edges_a = subgraph_a.nedges(&self.graph);
3073                        let n_edges_b = subgraph_b.nedges(&self.graph);
3074                        n_edges_a.cmp(&n_edges_b)
3075                    })
3076                    .collect_vec();
3077
3078                let smallest_left_subgraph = left_subgraphs.first().cloned().ok_or_else(|| {
3079                    eyre!(
3080                        "Graph '{}' cut group {} has no left subgraph",
3081                        self.graph.name,
3082                        cut_group_id.0,
3083                    )
3084                })?;
3085                let smallest_right_subgraph = right_subgraphs.first().cloned().ok_or_else(|| {
3086                    eyre!(
3087                        "Graph '{}' cut group {} has no right subgraph",
3088                        self.graph.name,
3089                        cut_group_id.0,
3090                    )
3091                })?;
3092
3093                let mut possible_subspaces = Vec::new();
3094                let mut rejected_lmbs = Vec::new();
3095                for lmb_index in valid_subspace_lmbs {
3096                    let left = SubspaceData::new_with_user_selected_lmb(
3097                        smallest_left_subgraph.clone(),
3098                        lmb_index,
3099                        &self.graph,
3100                        all_lmbs,
3101                    );
3102                    let right = SubspaceData::new_with_user_selected_lmb(
3103                        smallest_right_subgraph.clone(),
3104                        lmb_index,
3105                        &self.graph,
3106                        all_lmbs,
3107                    );
3108
3109                    match (left, right) {
3110                        (Ok(left), Ok(right)) if left.is_mergable_with(&right) => {
3111                            possible_subspaces.push((left, right));
3112                        }
3113                        (Ok(left), Ok(right)) => rejected_lmbs.push(format!(
3114                            "LMB {} produced non-disjoint subspaces: left={:?}, right={:?}",
3115                            usize::from(lmb_index),
3116                            left.iter_lmb_indices().collect_vec(),
3117                            right.iter_lmb_indices().collect_vec(),
3118                        )),
3119                        (left, right) => rejected_lmbs.push(format!(
3120                            "LMB {}: left={}, right={}",
3121                            usize::from(lmb_index),
3122                            left.err()
3123                                .map(|error| format!("{error:#}"))
3124                                .unwrap_or_else(|| "compatible".to_string()),
3125                            right
3126                                .err()
3127                                .map(|error| format!("{error:#}"))
3128                                .unwrap_or_else(|| "compatible".to_string()),
3129                        )),
3130                    }
3131                }
3132
3133                possible_subspaces.sort_by_key(|(left, right)| {
3134                    (
3135                        left.iter_basis_edges(all_lmbs).collect_vec(),
3136                        right.iter_basis_edges(all_lmbs).collect_vec(),
3137                    )
3138                });
3139
3140                possible_subspaces.first().cloned().ok_or_else(|| {
3141                    eyre!(
3142                        "No topology-compatible parent LMB found for graph '{}' cut group {}. Rejections:\n{}",
3143                        self.graph.name,
3144                        cut_group_id.0,
3145                        rejected_lmbs.join("\n"),
3146                    )
3147                })
3148            })
3149            .collect::<Result<_>>()?;
3150
3151        let _: () = self.derived_data.subspace_data = subspace_data;
3152        Ok(())
3153    }
3154
3155    fn generate_term_for_graph(
3156        &self,
3157        _model: &Model,
3158        settings: &GlobalSettings,
3159    ) -> Result<(CrossSectionGraphTerm, GraphGenerationStats)> {
3160        CrossSectionGraphTerm::from_cross_section_graph(self, settings)
3161    }
3162}
3163
3164#[derive(Clone, Encode, Decode)]
3165#[trait_decode(trait = GammaLoopContext)]
3166pub struct CrossSectionDerivedData {
3167    pub orientations: Option<TiVec<OrientationID, EdgeVec<Orientation>>>,
3168    pub cut_paramatric_integrand: TiVec<CutGroupId, ParametricIntegrands>,
3169    pub global_cff_expression: Option<CFFExpression<OrientationID>>,
3170    pub lmbs: Option<TiVec<LmbIndex, LoopMomentumBasis>>,
3171    pub multi_channeling_setup: Option<LmbMultiChannelingSetup>,
3172    pub threshold_counterterms: TiVec<CutGroupId, LUCounterTermData>,
3173    /// Graph-level inventory of every topology-discovered threshold E-surface. This remains
3174    /// independent of whether a counterterm is generated for any particular physical cut.
3175    pub threshold_candidate_esurface_ids: Vec<EsurfaceID>,
3176    /// Exact generated left/right threshold associations for each physical cut. Runtime
3177    /// evaluators aggregate these into cut groups, but display and model reclassification
3178    /// must retain the physical-cut-relative eligibility information.
3179    pub cut_threshold_associations: TiVec<CutId, CutThresholdCountertermAssociations>,
3180    pub subspace_data: TiVec<CutGroupId, (SubspaceData, SubspaceData)>,
3181    pub cut_group_data: CutGroupData,
3182}
3183
3184/// Partition and residue-derivative metadata for all Cutkosky cuts, including order-one cuts.
3185#[derive(Clone, Encode, Decode, Debug)]
3186#[trait_decode(trait = GammaLoopContext)]
3187pub struct CutGroupData {
3188    pub cut_groups: TiVec<CutGroupId, CutGroup>,
3189    pub dual_shapes: Vec<Vec<Vec<usize>>>,
3190    pub pass_two_evaluators: Vec<GenericEvaluator>,
3191}
3192
3193/// Physical cuts that share one normalized E-surface residue, whether ordinary or raised.
3194#[derive(Clone, Encode, Decode, Debug)]
3195#[trait_decode(trait = GammaLoopContext)]
3196pub struct CutGroup {
3197    pub cuts: Vec<CutId>,
3198    pub related_esurface_group: RaisedEsurfaceGroup,
3199}
3200
3201impl Default for CutGroupData {
3202    fn default() -> Self {
3203        Self::new()
3204    }
3205}
3206
3207impl CutGroupData {
3208    pub fn new() -> Self {
3209        CutGroupData {
3210            cut_groups: TiVec::new(),
3211            dual_shapes: vec![],
3212            pass_two_evaluators: vec![],
3213        }
3214    }
3215
3216    pub fn new_from_esurface(
3217        raised_esurface_data: &RaisedEsurfaceData,
3218        cut_esurface_map: &TiVec<CutId, EsurfaceID>,
3219        evaluator_settings: &EvaluatorSettings,
3220    ) -> (Self, GraphGenerationStats) {
3221        let mut stats = GraphGenerationStats::default();
3222        let reversed_map = cut_esurface_map
3223            .iter_enumerated()
3224            .map(|(cut_id, &esurface_id)| (esurface_id, cut_id))
3225            .collect::<HashMap<EsurfaceID, CutId>>();
3226
3227        let mut groups = TiVec::new();
3228
3229        for (_raised_esurface_id, raised_esurface_group) in
3230            raised_esurface_data.raised_groups.iter_enumerated()
3231        {
3232            if cut_esurface_map.contains(&raised_esurface_group.esurface_ids[0]) {
3233                let cuts = raised_esurface_group
3234                    .esurface_ids
3235                    .iter()
3236                    .map(|esurface_id| reversed_map[esurface_id])
3237                    .collect::<Vec<_>>();
3238
3239                let cut_group = CutGroup {
3240                    cuts,
3241                    related_esurface_group: raised_esurface_group.clone(),
3242                };
3243
3244                groups.push(cut_group);
3245            } else {
3246                continue;
3247            }
3248        }
3249
3250        let global_max_occurence = groups
3251            .iter()
3252            .map(|group| group.related_esurface_group.max_occurence)
3253            .max()
3254            .unwrap_or_else(|| {
3255                println!("corrupted groups");
3256                panic!();
3257            });
3258
3259        let dual_shapes = (1..global_max_occurence)
3260            .map(simple_n_deriv_shape)
3261            .collect();
3262
3263        let pass_two_evaluators = (1..=global_max_occurence)
3264            .map(|i| {
3265                let evaluator_started = std::time::Instant::now();
3266                let evaluator = build_derivative_structure(i as u8, -1, evaluator_settings);
3267                stats.evaluator_symbolica_time += evaluator_started.elapsed();
3268                stats.evaluator_count += 1;
3269                evaluator
3270            })
3271            .collect();
3272
3273        (
3274            Self {
3275                cut_groups: groups,
3276                dual_shapes,
3277                pass_two_evaluators,
3278            },
3279            stats,
3280        )
3281    }
3282}
3283
3284impl CrossSectionDerivedData {
3285    fn new_empty() -> Self {
3286        Self {
3287            orientations: None,
3288            global_cff_expression: None,
3289            cut_paramatric_integrand: TiVec::new(),
3290            lmbs: None,
3291            multi_channeling_setup: None,
3292            threshold_counterterms: TiVec::new(),
3293            threshold_candidate_esurface_ids: Vec::new(),
3294            cut_threshold_associations: TiVec::new(),
3295            subspace_data: TiVec::new(),
3296            cut_group_data: CutGroupData::new(),
3297        }
3298    }
3299}
3300
3301pub(crate) fn build_derivative_structure_atom(
3302    singularity_order: u8,
3303    laurent_coefficient: i8,
3304) -> Atom {
3305    assert!(
3306        laurent_coefficient <= -1,
3307        "only laurent coefficients up to -1 are supported"
3308    );
3309
3310    assert!(
3311        singularity_order >= 1,
3312        "eta order must be at least 1, got {singularity_order}"
3313    );
3314    assert!(
3315        singularity_order >= -laurent_coefficient as u8,
3316        "eta order must be at least the negative of the laurent coefficient, got {singularity_order} for laurent coefficient {laurent_coefficient}"
3317    );
3318
3319    let order = singularity_order as i32;
3320    let laurent_coefficient = laurent_coefficient as i32;
3321    let f = symbol!("f");
3322
3323    let expansion = function!(GS.eta, GS.rescale)
3324        .series(GS.rescale, Atom::var(GS.rescale_star), (order, 1))
3325        .unwrap()
3326        .to_atom()
3327        .replace(function!(GS.eta, GS.rescale_star))
3328        .level_range((0, Some(0)))
3329        .with(0);
3330
3331    let mut expression_to_derive = function!(f, GS.rescale)
3332        * expansion.pow(-order)
3333        * (GS.rescale - GS.rescale_star).pow(order);
3334
3335    for _ in 1..=(order + laurent_coefficient) {
3336        expression_to_derive = expression_to_derive.derivative(GS.rescale);
3337    }
3338
3339    expression_to_derive = expression_to_derive
3340        .replace(GS.rescale - GS.rescale_star)
3341        .with(parse!("delta_t"));
3342
3343    let polynomial_in_delta_t = expression_to_derive
3344        .series(symbol!("delta_t"), Atom::num(0), (0, 1))
3345        .unwrap();
3346
3347    let factorial_prefactor = (2..=(order + laurent_coefficient)).product::<i32>();
3348    debug!("factorial prefactor: {}", factorial_prefactor);
3349    let mut expression_to_derive = polynomial_in_delta_t.to_atom() / Atom::num(factorial_prefactor);
3350
3351    expression_to_derive = expression_to_derive
3352        .replace(GS.rescale)
3353        .with(GS.rescale_star);
3354
3355    expression_to_derive
3356}
3357
3358pub(crate) fn build_derivative_structure(
3359    singularity_order: u8,
3360    laurent_coefficient: i8,
3361    evaluator_settings: &EvaluatorSettings,
3362) -> GenericEvaluator {
3363    let expression_to_derive =
3364        build_derivative_structure_atom(singularity_order, laurent_coefficient);
3365
3366    let params = params_for_derivative_order(singularity_order);
3367
3368    GenericEvaluator::new_from_raw_params(
3369        [expression_to_derive],
3370        &params,
3371        &FunctionMap::default(),
3372        vec![],
3373        evaluator_settings.optimization_settings(),
3374        None,
3375        evaluator_settings,
3376    )
3377    .unwrap()
3378    .into_eager_only()
3379}
3380
3381fn ordered_f_derivative_params(
3382    base: Atom,
3383    derivative_vars: &[Symbol],
3384    derivative_shape: Option<&Vec<Vec<usize>>>,
3385) -> Vec<Atom> {
3386    derivative_shape
3387        .cloned()
3388        .unwrap_or_else(|| vec![vec![0; derivative_vars.len()]])
3389        .into_iter()
3390        .map(|orders| {
3391            debug_assert_eq!(orders.len(), derivative_vars.len());
3392
3393            let mut param = base.clone();
3394            for (derivative_var, order) in derivative_vars.iter().zip(orders) {
3395                for _ in 0..order {
3396                    param = param.derivative(*derivative_var);
3397                }
3398            }
3399
3400            param
3401        })
3402        .collect()
3403}
3404
3405pub(crate) fn params_for_derivative_order(singularity_order: u8) -> Vec<Atom> {
3406    let f = symbol!("f");
3407
3408    let f_0 = function!(f, GS.rescale_star);
3409    let eta_1 = function!(GS.eta, GS.rescale_star).derivative(GS.rescale_star);
3410
3411    let f_derivative_shape = shape_from_cut_cff_index(&CutCFFIndex {
3412        left_threshold_order: None,
3413        right_threshold_order: None,
3414        lu_cut_order: Some(singularity_order as usize),
3415    });
3416
3417    let f_parameters =
3418        ordered_f_derivative_params(f_0, &[GS.rescale_star], f_derivative_shape.as_ref());
3419    let mut eta_params = vec![eta_1.clone()];
3420
3421    for _ in 2..=singularity_order {
3422        let next_eta = eta_params.last().unwrap().derivative(GS.rescale_star);
3423        eta_params.push(next_eta);
3424    }
3425
3426    let mut result = vec![];
3427    result.extend(f_parameters);
3428    result.extend(eta_params);
3429    result
3430}
3431
3432pub(crate) fn params_for_iterated_threshold_ct(
3433    left_singularit_order: u8,
3434    right_singularity_order: u8,
3435) -> Vec<Atom> {
3436    let f = symbol!("f");
3437
3438    let cut_cff_index = CutCFFIndex {
3439        left_threshold_order: Some(left_singularit_order as usize),
3440        right_threshold_order: Some(right_singularity_order as usize),
3441        lu_cut_order: None,
3442    };
3443    let f_derivative_shape = shape_from_cut_cff_index(&cut_cff_index);
3444
3445    let f_base = function!(f, GS.radius_star_left, GS.radius_star_right);
3446    let derivative_vars = match (left_singularit_order > 1, right_singularity_order > 1) {
3447        (true, true) => vec![GS.radius_star_left, GS.radius_star_right],
3448        (true, false) => vec![GS.radius_star_left],
3449        (false, true) => vec![GS.radius_star_right],
3450        (false, false) => vec![],
3451    };
3452
3453    let eta_left_d1 = function!(GS.eta_left, GS.radius_star_left).derivative(GS.radius_star_left);
3454    let eta_right_d1 =
3455        function!(GS.eta_right, GS.radius_star_right).derivative(GS.radius_star_right);
3456
3457    let mut eta_left_params = vec![eta_left_d1.clone()];
3458    let mut eta_right_params = vec![eta_right_d1.clone()];
3459
3460    for _ in 2..=left_singularit_order {
3461        let next_eta_left = eta_left_params
3462            .last()
3463            .unwrap()
3464            .derivative(GS.radius_star_left);
3465
3466        eta_left_params.push(next_eta_left);
3467    }
3468
3469    for _ in 2..=right_singularity_order {
3470        let next_eta_right = eta_right_params
3471            .last()
3472            .unwrap()
3473            .derivative(GS.radius_star_right);
3474
3475        eta_right_params.push(next_eta_right);
3476    }
3477
3478    let f_product =
3479        ordered_f_derivative_params(f_base, &derivative_vars, f_derivative_shape.as_ref());
3480
3481    let mut result = vec![];
3482    result.extend(f_product);
3483    result.extend(eta_left_params);
3484    result.extend(eta_right_params);
3485    result
3486}
3487
3488#[cfg(test)]
3489mod tests {
3490    use std::{
3491        fs,
3492        path::PathBuf,
3493        time::{SystemTime, UNIX_EPOCH},
3494    };
3495
3496    use symbolica::{atom::AtomCore, function, symbol};
3497
3498    use crate::{
3499        cff::CutCFFIndex, dot, graph::parse::from_dot::IntoGraph, initialisation::test_initialise,
3500        utils::GS,
3501    };
3502    use linnet::half_edge::{
3503        involution::EdgeIndex,
3504        subgraph::{OrientedCut, SuBitGraph},
3505    };
3506
3507    fn threshold_association(
3508        cut_boundary_size: usize,
3509        threshold_boundary_size: usize,
3510    ) -> super::ThresholdCountertermAssociation {
3511        super::ThresholdCountertermAssociation {
3512            esurface_id: crate::cff::esurface::EsurfaceID(0),
3513            cut_boundary_edges: (0..cut_boundary_size).map(EdgeIndex::from).collect(),
3514            threshold_boundary_edges: (0..threshold_boundary_size)
3515                .map(|index| EdgeIndex::from(cut_boundary_size + index))
3516                .collect(),
3517            invariant_bound_is_applicable: true,
3518        }
3519    }
3520
3521    #[test]
3522    fn singleton_boundary_classification_uses_generic_mass_hierarchy() {
3523        use super::ThresholdCountertermStatus;
3524        use crate::utils::F;
3525
3526        let association = threshold_association(1, 2);
3527        let tolerance = F(1.0e-12);
3528
3529        assert_eq!(
3530            association.classify_from_invariant_bounds(&F(3.0), &F(4.0), Some(F(3.0)), &tolerance,),
3531            ThresholdCountertermStatus::ProvenNonExisting,
3532        );
3533        assert_eq!(
3534            association.classify_from_invariant_bounds(&F(4.0), &F(4.0), Some(F(4.0)), &tolerance,),
3535            ThresholdCountertermStatus::AlwaysPinched,
3536        );
3537        assert_eq!(
3538            association.classify_from_invariant_bounds(&F(5.0), &F(4.0), Some(F(5.0)), &tolerance,),
3539            ThresholdCountertermStatus::PotentiallyExisting,
3540        );
3541
3542        let fixed_association = threshold_association(1, 1);
3543        assert_eq!(
3544            fixed_association.classify_from_invariant_bounds(
3545                &F(3.0),
3546                &F(4.0),
3547                Some(F(3.0)),
3548                &tolerance,
3549            ),
3550            ThresholdCountertermStatus::ProvenNonExisting,
3551        );
3552        assert_eq!(
3553            fixed_association.classify_from_invariant_bounds(
3554                &F(5.0),
3555                &F(4.0),
3556                Some(F(5.0)),
3557                &tolerance,
3558            ),
3559            ThresholdCountertermStatus::ProvenNonExisting,
3560        );
3561    }
3562
3563    #[test]
3564    fn current_model_filter_is_controlled_only_by_generation_setting() {
3565        use super::ThresholdCountertermStatus;
3566
3567        assert!(ThresholdCountertermStatus::ProvenNonExisting.is_eligible_for_generation(false));
3568        assert!(!ThresholdCountertermStatus::ProvenNonExisting.is_eligible_for_generation(true));
3569        assert!(!ThresholdCountertermStatus::AlwaysPinched.is_eligible_for_generation(false));
3570        assert!(!ThresholdCountertermStatus::NoRadialDependence.is_eligible_for_generation(false));
3571        assert!(ThresholdCountertermStatus::PotentiallyExisting.is_eligible_for_generation(true));
3572    }
3573
3574    #[test]
3575    fn multiparticle_boundary_classification_is_conservative() {
3576        use super::ThresholdCountertermStatus;
3577        use crate::utils::F;
3578
3579        let association = threshold_association(2, 2);
3580        let tolerance = F(1.0e-12);
3581
3582        assert_eq!(
3583            association.classify_from_invariant_bounds(&F(4.0), &F(4.0), Some(F(8.0)), &tolerance,),
3584            ThresholdCountertermStatus::CanBecomePinched,
3585        );
3586        assert_eq!(
3587            association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(4.0)), &tolerance,),
3588            ThresholdCountertermStatus::ProvenNonExisting,
3589        );
3590        assert_eq!(
3591            association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(5.0)), &tolerance,),
3592            ThresholdCountertermStatus::AlwaysPinched,
3593        );
3594        assert_eq!(
3595            association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(8.0)), &tolerance,),
3596            ThresholdCountertermStatus::CanBecomePinched,
3597        );
3598        assert_eq!(
3599            association.classify_from_invariant_bounds(&F(5.0), &F(3.0), Some(F(8.0)), &tolerance,),
3600            ThresholdCountertermStatus::PotentiallyExisting,
3601        );
3602        assert_eq!(
3603            association.classify_from_invariant_bounds(&F(3.0), &F(5.0), None, &tolerance,),
3604            ThresholdCountertermStatus::PotentiallyExisting,
3605        );
3606    }
3607
3608    #[test]
3609    fn invariant_bound_rejects_mixed_or_unresolved_inputs_conservatively() {
3610        use super::ThresholdCountertermStatus;
3611        use crate::utils::F;
3612
3613        let mut association = threshold_association(2, 2);
3614        let tolerance = F(1.0e-12);
3615        association.invariant_bound_is_applicable = false;
3616        assert_eq!(
3617            association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(4.0)), &tolerance,),
3618            ThresholdCountertermStatus::PotentiallyExisting,
3619        );
3620
3621        association.invariant_bound_is_applicable = true;
3622        assert_eq!(
3623            association.classify_from_invariant_bounds(
3624                &F(f64::NAN),
3625                &F(5.0),
3626                Some(F(4.0)),
3627                &tolerance,
3628            ),
3629            ThresholdCountertermStatus::PotentiallyExisting,
3630        );
3631
3632        let masses: linnet::half_edge::involution::EdgeVec<Option<F<f64>>> =
3633            vec![Some(F(3.0)), None].into();
3634        assert_eq!(
3635            super::ThresholdCountertermAssociation::mass_sum(
3636                &[EdgeIndex::from(0), EdgeIndex::from(1)],
3637                &masses,
3638            ),
3639            None,
3640        );
3641    }
3642
3643    #[test]
3644    fn singleton_threshold_must_lie_strictly_inside_available_interval() {
3645        use super::ThresholdCountertermStatus;
3646        use crate::utils::F;
3647
3648        let association = threshold_association(2, 1);
3649        let tolerance = F(1.0e-12);
3650
3651        assert_eq!(
3652            association.classify_from_invariant_bounds(&F(5.0), &F(4.0), Some(F(9.0)), &tolerance,),
3653            ThresholdCountertermStatus::ProvenNonExisting,
3654        );
3655        assert_eq!(
3656            association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(9.0)), &tolerance,),
3657            ThresholdCountertermStatus::PotentiallyExisting,
3658        );
3659        assert_eq!(
3660            association.classify_from_invariant_bounds(&F(3.0), &F(9.0), Some(F(9.0)), &tolerance,),
3661            ThresholdCountertermStatus::AlwaysPinched,
3662        );
3663    }
3664
3665    fn fresh_temp_dir(name: &str) -> PathBuf {
3666        let unique = SystemTime::now()
3667            .duration_since(UNIX_EPOCH)
3668            .unwrap()
3669            .as_nanos();
3670        let path = std::env::temp_dir().join(format!(
3671            "gammalooprs-{name}-{}-{unique}",
3672            std::process::id()
3673        ));
3674        fs::create_dir_all(&path).unwrap();
3675        path
3676    }
3677
3678    #[test]
3679    fn cross_section_storage_path_stays_inside_process_folder() {
3680        let temp = fresh_temp_dir("cross-section-storage-path");
3681        let cross_section = super::CrossSection::new("NLO".to_string());
3682
3683        assert_eq!(cross_section.storage_path(&temp), temp.join("NLO"));
3684        fs::remove_dir_all(temp).unwrap();
3685    }
3686
3687    #[test]
3688    fn cross_section_lmb_cut_edge_exclusion_prefers_massive_then_fermion_then_id() {
3689        test_initialise().unwrap();
3690        let graph = dot!(
3691            digraph cut_edge_priority {
3692                edge [num=1]
3693                node [num=1]
3694                A -> B [id=0 particle="g"]
3695                A -> B [id=1 particle="d"]
3696                A -> B [id=2 particle="t" mass=1]
3697                A -> B [id=3 particle="a"]
3698                A -> B [id=4 mass=0]
3699            }
3700        )
3701        .unwrap();
3702
3703        assert_eq!(
3704            super::CrossSectionGraph::excluded_cut_edge_for_lmb_channel_in(
3705                &graph,
3706                &[EdgeIndex::from(0), EdgeIndex::from(1), EdgeIndex::from(2)]
3707            ),
3708            Some(EdgeIndex::from(2))
3709        );
3710        assert_eq!(
3711            super::CrossSectionGraph::excluded_cut_edge_for_lmb_channel_in(
3712                &graph,
3713                &[EdgeIndex::from(0), EdgeIndex::from(1), EdgeIndex::from(3)]
3714            ),
3715            Some(EdgeIndex::from(1))
3716        );
3717        assert_eq!(
3718            super::CrossSectionGraph::excluded_cut_edge_for_lmb_channel_in(
3719                &graph,
3720                &[EdgeIndex::from(0), EdgeIndex::from(3)]
3721            ),
3722            Some(EdgeIndex::from(0))
3723        );
3724
3725        // A structural zero and a DOT-overridden mass are resolved information for current-model
3726        // invariant bounds. Keep genuinely unresolved and complex masses conservative, but do not
3727        // let a massless edge disable a proof independent of model parameters.
3728        assert_eq!(
3729            graph.underlying[EdgeIndex::from(2)]
3730                .particle
3731                .mass_atom()
3732                .to_string(),
3733            "1"
3734        );
3735        let empty: SuBitGraph = graph.underlying.empty_subgraph();
3736        let cut = super::CrossSectionCut {
3737            cut: OrientedCut {
3738                left: empty.clone(),
3739                right: empty.clone(),
3740            },
3741            left: empty.clone(),
3742            right: empty,
3743        };
3744        let association = super::ThresholdCountertermAssociation {
3745            esurface_id: crate::cff::esurface::EsurfaceID(0),
3746            cut_boundary_edges: vec![EdgeIndex::from(4)],
3747            threshold_boundary_edges: vec![EdgeIndex::from(2)],
3748            invariant_bound_is_applicable: true,
3749        };
3750        assert_eq!(
3751            association.classify_for_model(
3752                &graph,
3753                &cut,
3754                &crate::model::Model::default(),
3755                &graph.param_builder,
3756                &crate::settings::RuntimeSettings::default(),
3757                1.0e-7,
3758            ),
3759            super::ThresholdCountertermStatus::ProvenNonExisting,
3760        );
3761    }
3762
3763    #[test]
3764    fn iterated_counterterms_use_the_right_threshold_count_as_row_stride() {
3765        let two_by_three = super::IteratedCtCollection::new((0..6).collect(), 2, 3);
3766        assert_eq!(
3767            two_by_three[(
3768                super::LeftThresholdId::from(0),
3769                super::RightThresholdId::from(0)
3770            )],
3771            0
3772        );
3773        assert_eq!(
3774            two_by_three[(
3775                super::LeftThresholdId::from(0),
3776                super::RightThresholdId::from(2)
3777            )],
3778            2
3779        );
3780        assert_eq!(
3781            two_by_three[(
3782                super::LeftThresholdId::from(1),
3783                super::RightThresholdId::from(0)
3784            )],
3785            3
3786        );
3787        assert_eq!(
3788            two_by_three[(
3789                super::LeftThresholdId::from(1),
3790                super::RightThresholdId::from(2)
3791            )],
3792            5
3793        );
3794
3795        let three_by_two = super::IteratedCtCollection::new((0..6).collect(), 3, 2);
3796        assert_eq!(
3797            three_by_two[(
3798                super::LeftThresholdId::from(0),
3799                super::RightThresholdId::from(1)
3800            )],
3801            1
3802        );
3803        assert_eq!(
3804            three_by_two[(
3805                super::LeftThresholdId::from(1),
3806                super::RightThresholdId::from(0)
3807            )],
3808            2
3809        );
3810        assert_eq!(
3811            three_by_two[(
3812                super::LeftThresholdId::from(2),
3813                super::RightThresholdId::from(1)
3814            )],
3815            5
3816        );
3817    }
3818
3819    #[test]
3820    fn max_dual_size_for_cut_cff_indices_tracks_mixed_threshold_shapes() {
3821        let cut_cff_indices = [
3822            CutCFFIndex {
3823                left_threshold_order: None,
3824                right_threshold_order: None,
3825                lu_cut_order: Some(2),
3826            },
3827            CutCFFIndex {
3828                left_threshold_order: Some(2),
3829                right_threshold_order: Some(2),
3830                lu_cut_order: Some(2),
3831            },
3832        ];
3833
3834        assert_eq!(
3835            super::max_dual_size_for_cut_cff_indices(cut_cff_indices.iter()),
3836            8
3837        );
3838    }
3839
3840    #[test]
3841    fn single_threshold_params_follow_effective_f_then_eta_contract() {
3842        let params = super::params_for_derivative_order(3);
3843        let f = symbol!("f");
3844        let f_base = function!(f, GS.rescale_star);
3845        let eta_base = function!(GS.eta, GS.rescale_star);
3846
3847        let expected = [
3848            f_base.clone(),
3849            f_base.clone().derivative(GS.rescale_star),
3850            f_base
3851                .derivative(GS.rescale_star)
3852                .derivative(GS.rescale_star),
3853            eta_base.clone().derivative(GS.rescale_star),
3854            eta_base
3855                .clone()
3856                .derivative(GS.rescale_star)
3857                .derivative(GS.rescale_star),
3858            eta_base
3859                .derivative(GS.rescale_star)
3860                .derivative(GS.rescale_star)
3861                .derivative(GS.rescale_star),
3862        ]
3863        .into_iter()
3864        .map(|atom| atom.to_string())
3865        .collect::<Vec<_>>();
3866
3867        let actual = params
3868            .iter()
3869            .map(|atom| atom.to_string())
3870            .collect::<Vec<_>>();
3871
3872        assert_eq!(actual, expected);
3873    }
3874
3875    #[test]
3876    fn iterated_threshold_f_params_follow_mixed_left_right_shape_order() {
3877        let params = super::params_for_iterated_threshold_ct(2, 2);
3878        let f = symbol!("f");
3879        let base = function!(f, GS.radius_star_left, GS.radius_star_right);
3880        let expected = [
3881            base.clone(),
3882            base.clone().derivative(GS.radius_star_left),
3883            base.clone().derivative(GS.radius_star_right),
3884            base.derivative(GS.radius_star_left)
3885                .derivative(GS.radius_star_right),
3886        ]
3887        .into_iter()
3888        .map(|atom| atom.to_string())
3889        .collect::<Vec<_>>();
3890        let actual = params[..expected.len()]
3891            .iter()
3892            .map(|atom| atom.to_string())
3893            .collect::<Vec<_>>();
3894
3895        assert_eq!(actual, expected);
3896    }
3897
3898    #[test]
3899    fn iterated_threshold_params_append_left_then_right_eta_families() {
3900        let params = super::params_for_iterated_threshold_ct(2, 2);
3901
3902        let expected = [
3903            function!(GS.eta_left, GS.radius_star_left).derivative(GS.radius_star_left),
3904            function!(GS.eta_left, GS.radius_star_left)
3905                .derivative(GS.radius_star_left)
3906                .derivative(GS.radius_star_left),
3907            function!(GS.eta_right, GS.radius_star_right).derivative(GS.radius_star_right),
3908            function!(GS.eta_right, GS.radius_star_right)
3909                .derivative(GS.radius_star_right)
3910                .derivative(GS.radius_star_right),
3911        ]
3912        .into_iter()
3913        .map(|atom| atom.to_string())
3914        .collect::<Vec<_>>();
3915
3916        let actual = params[4..]
3917            .iter()
3918            .map(|atom| atom.to_string())
3919            .collect::<Vec<_>>();
3920
3921        assert_eq!(actual, expected);
3922    }
3923
3924    #[test]
3925    fn iterated_threshold_helper_atom_matches_its_left_right_parameter_families() {
3926        test_initialise().unwrap();
3927
3928        let mut fn_map = super::FunctionMap::new();
3929        fn_map
3930            .add_aliases([(
3931                GS.pi.into(),
3932                super::Atom::num(super::Rational::try_from(std::f64::consts::PI).unwrap()),
3933            )])
3934            .unwrap();
3935
3936        for left_order in 1..=3 {
3937            for right_order in 1..=3 {
3938                let atom = super::CrossSectionGraph::iterated_th_prefactor_helper_atom(
3939                    left_order,
3940                    right_order,
3941                    1,
3942                    1,
3943                    true,
3944                );
3945                let params = super::CrossSectionGraph::iterated_th_prefactor_helper_params(
3946                    left_order,
3947                    right_order,
3948                );
3949
3950                super::GenericEvaluator::new_from_raw_params(
3951                    [atom],
3952                    &params,
3953                    &fn_map,
3954                    vec![],
3955                    super::OptimizationSettings::default(),
3956                    None,
3957                    &super::EvaluatorSettings::default(),
3958                )
3959                .unwrap_or_else(|error| {
3960                    panic!(
3961                        "iterated {left_order}x{right_order} threshold helper atom and parameter families must agree: {error}"
3962                    )
3963                });
3964            }
3965        }
3966    }
3967
3968    #[test]
3969    fn iterated_threshold_f_params_follow_active_single_axis_shape_order() {
3970        let params = super::params_for_iterated_threshold_ct(1, 3);
3971        let f = symbol!("f");
3972        let base = function!(f, GS.radius_star_left, GS.radius_star_right);
3973        let expected = [
3974            base.clone(),
3975            base.clone().derivative(GS.radius_star_right),
3976            base.derivative(GS.radius_star_right)
3977                .derivative(GS.radius_star_right),
3978        ]
3979        .into_iter()
3980        .map(|atom| atom.to_string())
3981        .collect::<Vec<_>>();
3982        let actual = params[..expected.len()]
3983            .iter()
3984            .map(|atom| atom.to_string())
3985            .collect::<Vec<_>>();
3986
3987        assert_eq!(actual, expected);
3988    }
3989}