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 sync::Mutex,
9};
10
11#[cfg(test)]
12mod gauge_sewing_tests;
13#[cfg(test)]
14mod sewing_tests;
15
16use bincode_trait_derive::{Decode, Encode};
18use color_eyre::Result;
19use itertools::Itertools;
20use rayon::{
21 ThreadPool,
22 iter::{IntoParallelRefMutIterator, ParallelIterator},
23};
24use spenso::algebra::algebraic_traits::IsZero;
25use tracing::info;
26use vakint::Vakint;
27
28use crate::{
29 DependentMomentaConstructor, GammaLoopContext, GammaLoopContextContainer,
30 cff::{
31 CffEnergyDegreeBoundReport, CutCFFIndex,
32 esurface::{RaisedEsurfaceData, RaisedEsurfaceGroup, RaisedEsurfaceId},
33 expression::{
34 OrientationID, normalize_cut_edge_support_with_raised_edge_groups,
35 normalize_three_d_expression_cut_support_with_raised_edge_groups,
36 },
37 surface::HybridSurfaceID,
38 },
39 debug_tags, define_index,
40 feyngen::GenerationType,
41 graph::{
42 GraphGroup, GroupId, LMBext, LmbChannelFallback, LmbIndex, LoopMomentumBasis,
43 ThresholdPinchStatus,
44 cuts::{CutSet, LuCutSelection, ResidueSelector},
45 edge::EdgeMass,
46 parse::complete_group_parsing,
47 threshold_counterterms::{ThresholdCountertermMultiplier, ThresholdCountertermVariant},
48 },
49 integrands::process::{
50 GenericEvaluator, LmbMultiChannelingSetup, ParamBuilder,
51 cross_section::CrossSectionIntegrandData, graph_to_group_id_for_group_structure,
52 },
53 model::ArcParticle,
54 momentum::{
55 Helicity,
56 sample::{ExternalIndex, SubspaceData},
57 },
58 processes::{
59 DotExportSettings, EvaluatorSettings, GraphCutSelectionSubject, GraphGenerationStats,
60 GraphGroupSelectionMode, GraphGroupSelectionPlan, GraphGroupSelectionSpec,
61 GraphSelectionSignatureInventory, GraphSelectionSubject, NamedGraphGenerationReport,
62 RaisedCutSignatureInventory,
63 },
64 settings::{
65 GlobalSettings, RuntimeSettings, global::GenerationSettings, runtime::LockedRuntimeSettings,
66 },
67 utils::{
68 DEFAULT_ESURFACE_EXISTENCE_THRESHOLD, F, GS, W_,
69 hyperdual_utils::{shape_from_cut_cff_index, simple_n_deriv_shape},
70 },
71 uv::{
72 approx::{CutStructure, OrientationProjection},
73 forest::ParametricIntegrands,
74 },
75};
76use eyre::{Context, eyre};
77use linnet::half_edge::{
78 involution::{EdgeIndex, EdgeVec, Orientation},
79 subgraph::{
80 HedgeNode, Inclusion, InternalSubGraph, ModifySubSet, OrientedCut, SuBitGraph,
81 SubGraphLike, SubSetLike, SubSetOps,
82 },
83};
84use serde::{Deserialize, Serialize};
85use symbolica::{domains::dual::HyperDual, prelude::*};
86use three_dimensional_reps::GeneratedThreeDExpression;
87use tracing::{debug, warn};
88use typed_index_collections::{TiVec, ti_vec};
89
90use super::generation_progress::{self, GenerationProcessKind, GenerationProgressPhase};
91use super::threshold_counterterms::{
92 ResolvedCutGroupThresholdCounterterms, ResolvedThresholdCountertermAssociation,
93 ResolvedThresholdCountertermVariant, ResolvedThresholdCounterterms, ThresholdCountertermOrigin,
94 ThresholdCountertermSide, ThresholdCountertermVariantId,
95};
96
97use crate::{
98 cff::esurface::{Esurface, EsurfaceID},
99 graph::{ExternalConnection, FeynmanGraph, Graph},
100 integrands::process::{
101 ProcessIntegrand,
102 cross_section::{CrossSectionGraphTerm, CrossSectionIntegrand},
103 },
104 model::Model,
105};
106
107use crate::processes::ProcessDefinition;
108
109#[derive(Clone, Debug, Encode, Decode)]
110pub struct IteratedCtCollection<T> {
111 data: Vec<T>,
112 num_right_thresholds: usize,
113}
114
115#[cfg(test)]
116#[path = "cross_section/ir_safe_threshold_fixture_tests.rs"]
117mod ir_safe_threshold_fixture_tests;
118
119impl<T> IteratedCtCollection<T> {
120 pub(crate) fn new(
121 data: Vec<T>,
122 num_left_thresholds: usize,
123 num_right_thresholds: usize,
124 ) -> Self {
125 let expected_len = num_left_thresholds
126 .checked_mul(num_right_thresholds)
127 .expect("iterated threshold-counterterm dimensions overflow usize");
128 assert_eq!(
129 data.len(),
130 expected_len,
131 "iterated threshold-counterterm data must contain one entry per left/right pair"
132 );
133 Self {
134 data,
135 num_right_thresholds,
136 }
137 }
138
139 pub fn map_ref<U, F>(&self, f: F) -> IteratedCtCollection<U>
140 where
141 F: Fn(&T) -> U,
142 {
143 let data = self.data.iter().map(f).collect();
144 IteratedCtCollection {
145 data,
146 num_right_thresholds: self.num_right_thresholds,
147 }
148 }
149
150 pub(crate) fn iter(&self) -> impl Iterator<Item = &T> {
151 self.data.iter()
152 }
153
154 pub(crate) fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
155 self.data.iter_mut()
156 }
157
158 pub(crate) fn num_right_thresholds(&self) -> usize {
159 self.num_right_thresholds
160 }
161}
162
163impl<T> Index<(LeftThresholdId, RightThresholdId)> for IteratedCtCollection<T> {
164 type Output = T;
165
166 fn index(&self, index: (LeftThresholdId, RightThresholdId)) -> &Self::Output {
167 let (left_id, right_id) = index;
168 &self.data[left_id.0 * self.num_right_thresholds + right_id.0]
169 }
170}
171
172impl<T> IndexMut<(LeftThresholdId, RightThresholdId)> for IteratedCtCollection<T> {
173 fn index_mut(&mut self, index: (LeftThresholdId, RightThresholdId)) -> &mut Self::Output {
174 let (left_id, right_id) = index;
175 &mut self.data[left_id.0 * self.num_right_thresholds + right_id.0]
176 }
177}
178
179#[derive(Clone, Encode, Decode)]
180#[trait_decode(trait = GammaLoopContext)]
181pub struct LUCounterTermData {
182 pub left_thresholds: TiVec<LeftThresholdId, RaisedEsurfaceGroup>,
183 pub right_thresholds: TiVec<RightThresholdId, RaisedEsurfaceGroup>,
184 pub left_variant_ids: TiVec<LeftThresholdId, ThresholdCountertermVariantId>,
185 pub right_variant_ids: TiVec<RightThresholdId, ThresholdCountertermVariantId>,
186 pub left_subspaces: TiVec<LeftThresholdId, SubspaceData>,
187 pub right_subspaces: TiVec<RightThresholdId, SubspaceData>,
188 pub left_atoms: TiVec<LeftThresholdId, ParametricIntegrands>,
189 pub right_atoms: TiVec<RightThresholdId, ParametricIntegrands>,
190 pub iterated: IteratedCtCollection<ParametricIntegrands>,
191}
192
193#[derive(Clone, Debug)]
194pub(crate) struct SingleThresholdPieces<T> {
195 pub(crate) local: T,
196 pub(crate) integrated: T,
197}
198
199#[derive(Clone, Debug)]
200pub(crate) struct IteratedThresholdPieces<T> {
201 pub(crate) local_local: T,
202 pub(crate) local_integrated: T,
203 pub(crate) integrated_local: T,
204 pub(crate) integrated_integrated: T,
205}
206
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub(crate) enum LUThresholdHelperOutputs {
209 Legacy,
210 Pieces,
211 LegacyAndPieces,
212}
213
214fn max_dual_size_for_cut_cff_indices<'a>(
215 cut_cff_indices: impl Iterator<Item = &'a CutCFFIndex>,
216) -> usize {
217 cut_cff_indices
218 .map(|cut_cff_index| {
219 shape_from_cut_cff_index(cut_cff_index)
220 .map(|shape| HyperDual::<F<f64>>::new(shape).values.len())
221 .unwrap_or(1)
222 })
223 .max()
224 .unwrap_or(1)
225}
226
227define_index! {pub struct RightThresholdId;}
228define_index! {pub struct LeftThresholdId;}
229define_index! {pub struct CutGroupId;}
230define_index! {pub struct TopologicalThresholdId;}
231
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234pub enum ThresholdCountertermStatus {
235 NoRadialDependence,
237 AlwaysPinched,
239 CanBecomePinched,
241 ProvenNonExisting,
243 PotentiallyExisting,
245}
246
247impl ThresholdCountertermStatus {
248 pub fn is_eligible_for_generation(self, check_current_model: bool) -> bool {
249 match self {
250 Self::NoRadialDependence | Self::AlwaysPinched => false,
251 Self::ProvenNonExisting => !check_current_model,
252 Self::CanBecomePinched | Self::PotentiallyExisting => true,
253 }
254 }
255}
256
257#[derive(Clone, Debug, Encode, Decode)]
259#[trait_decode(trait = GammaLoopContext)]
260pub struct ThresholdCountertermAssociation {
261 pub topological_threshold_id: TopologicalThresholdId,
262 pub esurface_id: EsurfaceID,
263 pub cut_boundary_edges: Vec<EdgeIndex>,
264 pub threshold_boundary_edges: Vec<EdgeIndex>,
265 pub invariant_bound_is_applicable: bool,
266}
267
268#[derive(Clone, Debug, Default, Encode, Decode)]
269#[trait_decode(trait = GammaLoopContext)]
270pub struct CutThresholdCountertermAssociations {
271 pub left: Vec<ThresholdCountertermAssociation>,
272 pub right: Vec<ThresholdCountertermAssociation>,
273}
274
275impl ThresholdCountertermAssociation {
276 fn mass_sum(edges: &[EdgeIndex], masses: &EdgeVec<Option<F<f64>>>) -> Option<F<f64>> {
277 edges.iter().try_fold(F(0.0), |sum, edge_id| {
278 masses[*edge_id].as_ref().map(|mass| sum + mass.abs())
279 })
280 }
281
282 fn incoming_invariant_mass(
283 graph: &Graph,
284 runtime_settings: &RuntimeSettings,
285 ) -> Option<F<f64>> {
286 let external_momenta = runtime_settings
287 .kinematics
288 .externals
289 .get_dependent_externals(DependentMomentaConstructor::CrossSection)
290 .ok()?;
291 if external_momenta.is_empty()
292 || external_momenta.len() != graph.get_edges_in_initial_state_cut().len()
293 {
294 return None;
295 }
296
297 let total = external_momenta
298 .into_iter()
299 .reduce(|sum, momentum| sum + momentum)?;
300 let invariant_squared = total.square();
301 if invariant_squared.is_nan()
302 || invariant_squared.is_infinite()
303 || invariant_squared <= F(0.0)
304 {
305 None
306 } else {
307 Some(invariant_squared.sqrt())
308 }
309 }
310
311 fn classify_from_invariant_bounds(
312 &self,
313 cut_boundary_mass: &F<f64>,
314 threshold_boundary_mass: &F<f64>,
315 maximum_cut_invariant: Option<F<f64>>,
316 tolerance: &F<f64>,
317 ) -> ThresholdCountertermStatus {
318 if !self.invariant_bound_is_applicable
319 || cut_boundary_mass.is_nan()
320 || cut_boundary_mass.is_infinite()
321 || threshold_boundary_mass.is_nan()
322 || threshold_boundary_mass.is_infinite()
323 || tolerance.is_nan()
324 || tolerance.is_infinite()
325 || maximum_cut_invariant
326 .as_ref()
327 .is_some_and(|bound| bound.is_nan() || bound.is_infinite())
328 {
329 return ThresholdCountertermStatus::PotentiallyExisting;
330 }
331
332 let minimum_margin = cut_boundary_mass * cut_boundary_mass
333 - threshold_boundary_mass * threshold_boundary_mass;
334 let masses_match = minimum_margin.abs() <= *tolerance;
335 let threshold_minimum_is_reachable = minimum_margin <= *tolerance;
336
337 if masses_match
338 && (self.cut_boundary_edges.len() <= 1 || self.threshold_boundary_edges.len() <= 1)
339 {
340 return ThresholdCountertermStatus::AlwaysPinched;
341 }
342
343 if self.cut_boundary_edges.len() == 1 && self.threshold_boundary_edges.len() == 1 {
344 return ThresholdCountertermStatus::ProvenNonExisting;
345 }
346
347 if self.cut_boundary_edges.len() > 1 && self.threshold_boundary_edges.len() == 1 {
348 let lower_margin = threshold_boundary_mass * threshold_boundary_mass
349 - cut_boundary_mass * cut_boundary_mass;
350 if lower_margin <= *tolerance {
351 return ThresholdCountertermStatus::ProvenNonExisting;
352 }
353 }
354
355 let Some(maximum_cut_invariant) = maximum_cut_invariant else {
356 return if masses_match
357 && self.cut_boundary_edges.len() > 1
358 && self.threshold_boundary_edges.len() > 1
359 {
360 ThresholdCountertermStatus::CanBecomePinched
361 } else {
362 ThresholdCountertermStatus::PotentiallyExisting
363 };
364 };
365
366 let maximum_margin = maximum_cut_invariant * maximum_cut_invariant
367 - threshold_boundary_mass * threshold_boundary_mass;
368 if maximum_margin.abs() <= *tolerance {
369 ThresholdCountertermStatus::AlwaysPinched
370 } else if maximum_margin < -*tolerance {
371 ThresholdCountertermStatus::ProvenNonExisting
372 } else if threshold_minimum_is_reachable
373 && self.cut_boundary_edges.len() > 1
374 && self.threshold_boundary_edges.len() > 1
375 {
376 ThresholdCountertermStatus::CanBecomePinched
377 } else {
378 ThresholdCountertermStatus::PotentiallyExisting
379 }
380 }
381
382 pub fn classify_for_model(
383 &self,
384 graph: &Graph,
385 cut: &CrossSectionCut,
386 model: &Model,
387 param_builder: &ParamBuilder,
388 runtime_settings: &RuntimeSettings,
389 normalized_margin_tolerance: f64,
390 ) -> ThresholdCountertermStatus {
391 if !self.invariant_bound_is_applicable {
392 return ThresholdCountertermStatus::PotentiallyExisting;
393 }
394
395 match graph
396 .classify_threshold_pinch(&self.cut_boundary_edges, &self.threshold_boundary_edges)
397 {
398 ThresholdPinchStatus::Always => {
399 return ThresholdCountertermStatus::AlwaysPinched;
400 }
401 ThresholdPinchStatus::CanBecome | ThresholdPinchStatus::NotProven => {}
402 }
403
404 let masses = graph.new_edgevec(|edge, _, _| {
407 if matches!(edge.mass, EdgeMass::Zero) {
408 Some(F(0.0))
409 } else {
410 edge.mass_value::<f64>(model, param_builder)
411 .and_then(|mass| IsZero::is_zero(&mass.im).then_some(mass.re))
412 }
413 });
414 let Some(cut_boundary_mass) = Self::mass_sum(&self.cut_boundary_edges, &masses) else {
415 return ThresholdCountertermStatus::PotentiallyExisting;
416 };
417 let Some(threshold_boundary_mass) = Self::mass_sum(&self.threshold_boundary_edges, &masses)
418 else {
419 return ThresholdCountertermStatus::PotentiallyExisting;
420 };
421 let e_cm = F(runtime_settings.kinematics.e_cm.abs());
422 let normalized_margin_tolerance = if normalized_margin_tolerance.is_finite() {
425 normalized_margin_tolerance.abs()
426 } else {
427 DEFAULT_ESURFACE_EXISTENCE_THRESHOLD
428 };
429 let tolerance = F(normalized_margin_tolerance) * e_cm * e_cm;
430
431 let maximum_cut_invariant = if self.cut_boundary_edges.len() == 1 {
432 Some(cut_boundary_mass)
433 } else {
434 let incoming_invariant = Self::incoming_invariant_mass(graph, runtime_settings);
435 let complement_mass = graph
436 .iter_edges_of(&cut.cut)
437 .map(|(_, edge_id, _)| edge_id)
438 .filter(|edge_id| !self.cut_boundary_edges.contains(edge_id))
439 .try_fold(F(0.0), |sum, edge_id| {
440 masses[edge_id].as_ref().map(|mass| sum + mass.abs())
441 });
442 incoming_invariant
443 .zip(complement_mass)
444 .and_then(|(invariant, complement_mass)| {
445 let maximum = invariant - complement_mass;
446 (maximum > F(0.0)).then_some(maximum)
447 })
448 };
449 self.classify_from_invariant_bounds(
450 &cut_boundary_mass,
451 &threshold_boundary_mass,
452 maximum_cut_invariant,
453 &tolerance,
454 )
455 }
456}
457
458#[derive(Clone)]
459struct TopologicalThresholdCandidate {
460 topological_threshold_id: TopologicalThresholdId,
461 left: SuBitGraph,
462 cut: OrientedCut,
463 right: SuBitGraph,
464 threshold_esurface: Esurface,
465 selected_orientation_esurface_id: Option<EsurfaceID>,
466}
467
468#[derive(Clone)]
469struct ResolvedThresholdCountertermDraft {
470 name: String,
471 group_id: Option<usize>,
472 origin: ThresholdCountertermOrigin,
473 disable: bool,
474 requested_subspace: Option<Vec<EdgeIndex>>,
475 requested_parent_lmb: Option<Vec<EdgeIndex>>,
476 multiplier: Option<ThresholdCountertermMultiplier>,
477 subspace: SubspaceData,
478 status: ThresholdCountertermStatus,
479}
480
481impl ResolvedThresholdCountertermDraft {
482 fn is_compatible_with(
483 &self,
484 other: &Self,
485 all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
486 ) -> bool {
487 self.name == other.name
488 && self.group_id == other.group_id
489 && self.disable == other.disable
490 && self.multiplier == other.multiplier
491 && self
492 .subspace
493 .has_equivalent_embedding(&other.subspace, all_lmbs)
494 }
495}
496
497#[derive(Clone)]
498struct PhysicalThresholdCountertermDraft {
499 cut_id: CutId,
500 cut_group_id: CutGroupId,
501 cut_edges: Vec<EdgeIndex>,
502 side: ThresholdCountertermSide,
503 esurface_id: EsurfaceID,
504 threshold_edges: Vec<EdgeIndex>,
505 raised_threshold_id: RaisedEsurfaceId,
506 raised_esurface_group: RaisedEsurfaceGroup,
507 variants: Vec<ResolvedThresholdCountertermDraft>,
508}
509
510use derive_more::{From, Into};
511#[derive(Clone, Encode, Decode)]
512#[trait_decode(trait = GammaLoopContext)]
513pub struct CrossSection {
514 pub name: String,
515 pub integrand: Option<ProcessIntegrand>,
516 pub supergraphs: Vec<CrossSectionGraph>,
517 pub external_particles: Vec<ArcParticle>,
518 pub external_connections: Vec<ExternalConnection>,
519 pub n_incmoming: usize,
520 pub graph_group_structure: TiVec<GroupId, GraphGroup>,
521}
522
523impl CrossSection {
524 pub fn plan_graph_group_selection(
525 &self,
526 spec: &GraphGroupSelectionSpec,
527 ) -> Result<GraphGroupSelectionPlan> {
528 if spec.mode() == GraphGroupSelectionMode::CrossSectionAmplitudeGraphs {
529 return Err(eyre!(
530 "`select --amplitude-graphs` for cross sections requires process and generation settings context."
531 ));
532 }
533 if spec.has_raised_cut_rules() {
534 return Err(eyre!(
535 "Raised-cut signature selection for cross sections requires process and generation settings context."
536 ));
537 }
538 spec.plan(&self.graph_group_structure, |graph_id| {
539 self.supergraphs.get(graph_id).map(|graph| &graph.graph)
540 })
541 }
542
543 pub fn plan_graph_group_selection_with_context(
544 &self,
545 spec: &GraphGroupSelectionSpec,
546 model: &Model,
547 process_definition: &ProcessDefinition,
548 generation_settings: &GenerationSettings,
549 ) -> Result<GraphGroupSelectionPlan> {
550 match spec.mode() {
551 GraphGroupSelectionMode::MasterGraphs => {
552 spec.plan_with_analysis_contexts(
553 &self.graph_group_structure,
554 |graph_id| self.supergraphs.get(graph_id).map(|graph| &graph.graph),
555 |_master_graph_id, master_graph| {
556 Ok(vec![GraphSelectionSubject::whole_graph(master_graph)])
557 },
558 |master_graph_id, master_graph| {
559 self.cut_selection_subjects(
560 master_graph_id,
561 master_graph,
562 model,
563 process_definition,
564 generation_settings,
565 )
566 },
567 "Graph-group selection structural filters have no graph analysis subjects.",
568 "Raised-cut signature selection found no process-valid Cutkosky cuts to analyse.",
569 )
570 }
571 GraphGroupSelectionMode::CrossSectionAmplitudeGraphs => {
572 spec.plan_with_analysis_contexts(
573 &self.graph_group_structure,
574 |graph_id| self.supergraphs.get(graph_id).map(|graph| &graph.graph),
575 |master_graph_id, master_graph| {
576 let cross_section_graph =
577 self.supergraphs.get(master_graph_id).ok_or_else(|| {
578 eyre!(
579 "Graph group refers to missing master supergraph id {}.",
580 master_graph_id
581 )
582 })?;
583 let cuts = cross_section_graph.process_valid_cuts(
584 model,
585 process_definition,
586 generation_settings,
587 )?;
588 Ok(cuts
589 .into_iter()
590 .flat_map(|cut| cut.amplitude_side_subjects(master_graph))
591 .collect())
592 },
593 |master_graph_id, master_graph| {
594 self.cut_selection_subjects(
595 master_graph_id,
596 master_graph,
597 model,
598 process_definition,
599 generation_settings,
600 )
601 },
602 "`select --amplitude-graphs` found no process-valid Cutkosky cuts to analyse for structural filters.",
603 "Raised-cut signature selection found no process-valid Cutkosky cuts to analyse.",
604 )
605 }
606 }
607 }
608
609 fn cut_selection_subjects<'a>(
610 &'a self,
611 master_graph_id: usize,
612 master_graph: &'a Graph,
613 model: &Model,
614 process_definition: &ProcessDefinition,
615 generation_settings: &GenerationSettings,
616 ) -> Result<Vec<GraphCutSelectionSubject<'a>>> {
617 let cross_section_graph = self.supergraphs.get(master_graph_id).ok_or_else(|| {
618 eyre!(
619 "Graph group refers to missing master supergraph id {}.",
620 master_graph_id
621 )
622 })?;
623 let cuts = cross_section_graph.process_valid_cuts(
624 model,
625 process_definition,
626 generation_settings,
627 )?;
628 Ok(cuts
629 .into_iter()
630 .map(|cut| GraphCutSelectionSubject::new(master_graph, cut.cut.as_subgraph()))
631 .collect())
632 }
633
634 pub fn amplitude_graph_signature_inventory(
635 &self,
636 model: &Model,
637 process_definition: &ProcessDefinition,
638 generation_settings: &GenerationSettings,
639 ) -> Result<GraphSelectionSignatureInventory> {
640 let subjects = self
641 .graph_group_structure
642 .iter()
643 .flat_map(|group| group.into_iter().next())
644 .map(|master_graph_id| {
645 let cross_section_graph =
646 self.supergraphs.get(master_graph_id).ok_or_else(|| {
647 eyre!(
648 "Graph group refers to missing master supergraph id {}.",
649 master_graph_id
650 )
651 })?;
652 let cuts = cross_section_graph.process_valid_cuts(
653 model,
654 process_definition,
655 generation_settings,
656 )?;
657 Ok(cuts
658 .into_iter()
659 .flat_map(|cut| cut.amplitude_side_subjects(&cross_section_graph.graph))
660 .collect::<Vec<_>>())
661 })
662 .collect::<Result<Vec<_>>>()?
663 .into_iter()
664 .flatten()
665 .collect::<Vec<_>>();
666
667 Ok(GraphSelectionSignatureInventory::from_analysis_subjects(
668 subjects,
669 ))
670 }
671
672 pub fn raised_cut_signature_inventory(
673 &self,
674 model: &Model,
675 process_definition: &ProcessDefinition,
676 generation_settings: &GenerationSettings,
677 ) -> Result<RaisedCutSignatureInventory> {
678 let subjects = self
679 .graph_group_structure
680 .iter()
681 .flat_map(|group| group.into_iter().next())
682 .map(|master_graph_id| {
683 let cross_section_graph =
684 self.supergraphs.get(master_graph_id).ok_or_else(|| {
685 eyre!(
686 "Graph group refers to missing master supergraph id {}.",
687 master_graph_id
688 )
689 })?;
690 let cuts = cross_section_graph.process_valid_cuts(
691 model,
692 process_definition,
693 generation_settings,
694 )?;
695 Ok(cuts
696 .into_iter()
697 .map(|cut| {
698 GraphCutSelectionSubject::new(
699 &cross_section_graph.graph,
700 cut.cut.as_subgraph(),
701 )
702 })
703 .collect::<Vec<_>>())
704 })
705 .collect::<Result<Vec<_>>>()?
706 .into_iter()
707 .flatten()
708 .collect::<Vec<_>>();
709
710 Ok(RaisedCutSignatureInventory::from_cut_subjects(subjects))
711 }
712
713 pub fn validate_graph_group_selection_plan(
714 &self,
715 plan: &GraphGroupSelectionPlan,
716 ) -> Result<()> {
717 for &old_group_id in plan.retained_group_ids() {
718 plan.new_group_id_for_old(old_group_id).ok_or_else(|| {
719 eyre!(
720 "Selection plan is missing compact group id for old group {}.",
721 old_group_id.0
722 )
723 })?;
724 if old_group_id.0 >= self.graph_group_structure.len() {
725 return Err(eyre!(
726 "Selection plan refers to missing graph group {}.",
727 old_group_id.0
728 ));
729 }
730 let group = &self.graph_group_structure[old_group_id];
731 for old_graph_id in group {
732 if old_graph_id >= self.supergraphs.len() {
733 return Err(eyre!(
734 "Graph group {} refers to missing graph id {}.",
735 old_group_id.0,
736 old_graph_id
737 ));
738 }
739 }
740 let master = group.master();
741 if master >= self.supergraphs.len() {
742 return Err(eyre!(
743 "Graph group {} refers to missing master graph id {}.",
744 old_group_id.0,
745 master
746 ));
747 }
748 }
749
750 Ok(())
751 }
752
753 pub fn apply_graph_group_selection(&mut self, plan: &GraphGroupSelectionPlan) -> Result<()> {
754 self.validate_graph_group_selection_plan(plan)?;
755
756 let mut old_graph_to_new_group = vec![None; self.supergraphs.len()];
757 let mut old_graph_is_master = vec![false; self.supergraphs.len()];
758 for &old_group_id in plan.retained_group_ids() {
759 let new_group_id = plan.new_group_id_for_old(old_group_id).ok_or_else(|| {
760 eyre!(
761 "Selection plan is missing compact group id for old group {}.",
762 old_group_id.0
763 )
764 })?;
765 let group = &self.graph_group_structure[old_group_id];
766 for old_graph_id in group {
767 old_graph_to_new_group[old_graph_id] = Some(new_group_id);
768 }
769 old_graph_is_master[group.master()] = true;
770 }
771
772 let mut new_supergraphs = self
773 .supergraphs
774 .iter()
775 .cloned()
776 .enumerate()
777 .filter_map(|(old_graph_id, mut graph)| {
778 let new_group_id = old_graph_to_new_group[old_graph_id]?;
779 graph.graph.group_id = Some(new_group_id);
780 graph.graph.is_group_master = old_graph_is_master[old_graph_id];
781 if let Some(multi_channeling_setup) = &mut graph.derived_data.multi_channeling_setup
782 {
783 multi_channeling_setup.graph.group_id = Some(new_group_id);
784 multi_channeling_setup.graph.is_group_master =
785 old_graph_is_master[old_graph_id];
786 }
787 Some(graph)
788 })
789 .collect::<Vec<_>>();
790
791 let mut parsed_graphs = new_supergraphs
792 .iter()
793 .map(|graph| graph.graph.clone())
794 .collect::<Vec<_>>();
795 let new_graph_group_structure = complete_group_parsing(&mut parsed_graphs)?;
796 for (graph, parsed_graph) in new_supergraphs.iter_mut().zip(parsed_graphs) {
797 graph.graph.group_id = parsed_graph.group_id;
798 graph.graph.is_group_master = parsed_graph.is_group_master;
799 if let Some(multi_channeling_setup) = &mut graph.derived_data.multi_channeling_setup {
800 multi_channeling_setup.graph.group_id = graph.graph.group_id;
801 multi_channeling_setup.graph.is_group_master = graph.graph.is_group_master;
802 }
803 }
804
805 self.supergraphs = new_supergraphs;
806 self.graph_group_structure = new_graph_group_structure;
807 self.integrand = None;
808 Ok(())
809 }
810
811 fn storage_path(&self, base: &Path) -> PathBuf {
812 base.join(&self.name)
813 }
814
815 pub fn export_standalone(
816 &self,
817 path: impl AsRef<Path>,
818 settings: &crate::processes::StandaloneExportSettings,
819 ) -> Result<()> {
820 if let Some(integrand) = &self.integrand {
821 integrand.export_standalone(path, settings)?;
822 } else {
823 return Err(eyre!(
824 "Cannot export standalone cross section {} without integrand",
825 self.name
826 ));
827 }
828
829 Ok(())
830 }
831
832 #[allow(dead_code)]
833 pub(crate) fn write_dot<W: std::io::Write>(
834 &self,
835 writer: &mut W,
836 settings: &DotExportSettings,
837 ) -> Result<(), std::io::Error> {
838 for graph in &self.supergraphs {
839 graph.write_dot(writer, settings)?;
840 writeln!(writer)?;
841 }
842 Ok(())
843 }
844
845 pub fn write_dot_fmt<W: std::fmt::Write>(
846 &self,
847 writer: &mut W,
848 settings: &DotExportSettings,
849 ) -> Result<(), std::fmt::Error> {
850 for graph in &self.supergraphs {
851 graph.write_dot_fmt(writer, settings)?;
852 writeln!(writer)?;
853 }
854 Ok(())
855 }
856
857 pub(crate) fn new(name: String) -> Self {
858 Self {
859 name,
860 integrand: None,
861 supergraphs: vec![],
862 external_connections: vec![],
863 external_particles: vec![],
864 n_incmoming: 0,
865 graph_group_structure: TiVec::new(),
866 }
867 }
868
869 pub fn from_graph_list(name: String, mut graphs: Vec<Graph>, _model: &Model) -> Result<Self> {
870 let mut cross_section = CrossSection::new(name);
871 cross_section.graph_group_structure = complete_group_parsing(&mut graphs)?;
872 for group in &cross_section.graph_group_structure {
875 for graph_id in group.into_iter().skip(1) {
876 let graph = &graphs[graph_id];
877 if !graph.threshold_counterterms.autogenerated {
878 return Err(eyre!(
879 "Cross-section graph '{}' is a non-master member of graph group '{}', but declares threshold_counterterms metadata. Threshold metadata on non-master cross-section graphs is unsupported: cross-section threshold solve groups are graph-local. Put the metadata on the master graph or import this graph in its own group.",
880 graph.name,
881 graphs[group.master()].name,
882 ));
883 }
884 }
885 }
886
887 for cross_section_graph in graphs {
888 cross_section.add_supergraph(cross_section_graph)?;
890 }
891 Ok(cross_section)
892 }
893
894 pub(crate) fn warm_up(&mut self, model: &Model) -> Result<()> {
895 if let Some(integrand) = &mut self.integrand {
896 integrand.warm_up(model)
897 } else {
898 Err(eyre!(
899 "Cannot warm up amplitude {} without integrand",
900 self.name
901 ))
902 }
903 }
904
905 fn add_supergraph(&mut self, supergraph: Graph) -> Result<()> {
906 if self.external_particles.is_empty() {
907 let external_particles = supergraph.get_external_partcles();
908 if !external_particles.len().is_multiple_of(2) {
909 return Err(eyre!(
910 "expected even number of externals for forward scattering graph"
911 ));
912 }
913 self.external_particles = external_particles;
914 self.n_incmoming = self.external_particles.len() / 2;
915 } else if self.external_particles != supergraph.get_external_partcles() {
916 return Err(eyre!(
917 "attempt to add supergraph with differnt external particles"
918 ));
919 }
920
921 let cross_section_graph = CrossSectionGraph::new(supergraph);
922 self.supergraphs.push(cross_section_graph);
923
924 Ok(())
926 }
927
928 pub fn preprocess(
929 &mut self,
930 model: &Model,
931 process_definition: &ProcessDefinition,
932 global_settings: &GenerationSettings,
933 runtime_default: LockedRuntimeSettings,
934 generation_pool: &ThreadPool,
935 ) -> Result<Vec<NamedGraphGenerationReport>> {
936 global_settings.validate_for_process(GenerationType::CrossSection)?;
937 let integrand_name = self.name.clone();
938 generation_progress::begin_phase(
939 GenerationProgressPhase::GraphPreprocessing,
940 GenerationProcessKind::CrossSection,
941 &process_definition.folder_name,
942 &integrand_name,
943 self.supergraphs.len(),
944 None,
945 );
946 generation_pool.install(|| {
947 self.supergraphs
948 .par_iter_mut()
949 .map(|supergraph| {
950 if crate::is_interrupted() {
951 return Err(eyre!("Generation interrupted by user"));
952 }
953 let graph_name = supergraph.graph.name.clone();
954 generation_progress::graph_started(
955 GenerationProcessKind::CrossSection,
956 &integrand_name,
957 &graph_name,
958 None,
959 );
960 let stats = supergraph.preprocess(
961 model,
962 process_definition,
963 global_settings,
964 runtime_default,
965 )?;
966 if crate::is_interrupted() {
967 return Err(eyre!("Generation interrupted by user"));
968 }
969 generation_progress::graph_finished(
970 GenerationProcessKind::CrossSection,
971 &integrand_name,
972 &graph_name,
973 &stats,
974 None,
975 );
976 Ok(NamedGraphGenerationReport {
977 integrand_name: integrand_name.clone(),
978 graph_name,
979 stats,
980 })
981 })
982 .collect::<Result<Vec<_>>>()
983 })
984 }
985
986 pub fn build_integrand(
987 &mut self,
988 model: &Model,
989 process_definition: &ProcessDefinition,
990 global_settings: &GlobalSettings,
991 runtime_default: LockedRuntimeSettings,
992 generation_pool: &ThreadPool,
993 ) -> Result<Vec<NamedGraphGenerationReport>> {
994 let process_name = process_definition.folder_name.as_str();
995 let started = std::time::Instant::now();
996 crate::debug_tags!(#generation, #profile, #graph, #summary;
997 stage = "cross_section_build_integrand_start",
998 integrand = %self.name,
999 graph_count = self.supergraphs.len(),
1000 "Generation timing milestone"
1001 );
1002 if crate::is_interrupted() {
1003 return Err(eyre!("Generation interrupted by user"));
1004 }
1005 let integrand_name = self.name.clone();
1006 let total_cuts = self
1007 .supergraphs
1008 .iter()
1009 .map(|sg| {
1010 sg.derived_data
1011 .cut_group_data
1012 .cut_groups
1013 .iter()
1014 .map(|cut_group| cut_group.cuts.len())
1015 .sum::<usize>()
1016 })
1017 .sum();
1018 generation_progress::begin_phase(
1019 GenerationProgressPhase::GraphGeneration,
1020 GenerationProcessKind::CrossSection,
1021 process_name,
1022 &integrand_name,
1023 self.supergraphs.len(),
1024 Some(total_cuts),
1025 );
1026 let mut graph_reports = Vec::new();
1027 let terms = generation_pool.install(|| {
1028 self.supergraphs
1029 .par_iter_mut()
1030 .map(|sg| {
1031 if crate::is_interrupted() {
1032 return Err(eyre!("Generation interrupted by user"));
1033 }
1034 let graph_started = std::time::Instant::now();
1035 crate::debug_tags!(#generation, #profile, #graph, #summary;
1036 stage = "generate_term_for_graph_start",
1037 integrand = %integrand_name,
1038 graph = %sg.graph.name,
1039 "Generation timing milestone"
1040 );
1041 generation_progress::graph_started(
1042 GenerationProcessKind::CrossSection,
1043 &integrand_name,
1044 &sg.graph.name,
1045 Some(sg.cuts.len()),
1046 );
1047 let _progress_context_guard = generation_progress::enter_progress_context(
1048 format!("{} / {} cuts", sg.graph.name, sg.cuts.len()),
1049 );
1050 let (term, mut stats) = sg.generate_term_for_graph(model, global_settings)?;
1051 if crate::is_interrupted() {
1052 return Err(eyre!("Generation interrupted by user"));
1053 }
1054 stats.total_time += graph_started.elapsed();
1055 crate::debug_tags!(#generation, #profile, #graph, #summary;
1056 stage = "generate_term_for_graph_done",
1057 integrand = %integrand_name,
1058 graph = %sg.graph.name,
1059 elapsed_ms = graph_started.elapsed().as_secs_f64() * 1000.0,
1060 "Generation timing milestone"
1061 );
1062 generation_progress::graph_finished(
1063 GenerationProcessKind::CrossSection,
1064 &integrand_name,
1065 &sg.graph.name,
1066 &stats,
1067 None,
1068 );
1069 Ok((
1070 term,
1071 NamedGraphGenerationReport {
1072 integrand_name: integrand_name.clone(),
1073 graph_name: sg.graph.name.clone(),
1074 stats,
1075 },
1076 ))
1077 })
1078 .collect::<Result<Vec<_>>>()
1079 })?;
1080 if crate::is_interrupted() {
1081 return Err(eyre!("Generation interrupted by user"));
1082 }
1083 for (_, report) in &terms {
1084 graph_reports.push(report.clone());
1085 }
1086 let mut terms = terms.into_iter().map(|(term, _)| term).collect::<Vec<_>>();
1087
1088 for group in self.graph_group_structure.iter() {
1089 let master = group.master();
1090 let mc_of_master = self.supergraphs[master]
1091 .derived_data
1092 .multi_channeling_setup
1093 .as_ref()
1094 .unwrap();
1095
1096 for graph_id in group.into_iter() {
1097 terms[graph_id].multi_channeling_setup = mc_of_master.clone();
1098 }
1099 }
1100
1101 let backend_started = std::time::Instant::now();
1102 let graph_count = terms.len();
1103 crate::debug_tags!(#generation, #profile, #compile, #graph, #summary;
1104 stage = "prepare_runtime_backends_start",
1105 integrand = %self.name,
1106 graph_count,
1107 elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1108 "Generation timing milestone"
1109 );
1110 generation_progress::backend_started(
1111 GenerationProcessKind::CrossSection,
1112 &self.name,
1113 graph_count,
1114 );
1115 let mut cross_section_integrand = CrossSectionIntegrand {
1116 settings: runtime_default.into(),
1117 data: CrossSectionIntegrandData {
1118 compilation: global_settings
1119 .generation
1120 .compile
1121 .frozen_mode(&global_settings.generation.evaluator),
1122 loop_cache_id: 0,
1123 external_cache_id: 0,
1124 base_external_cache_id: 0,
1125 rotations: None,
1126 symmetrize_left_right_states: process_definition.symmetrize_left_right_states,
1127 name: self.name.clone(),
1128 external_connections: self.external_connections.clone(),
1129 n_incoming: self.n_incmoming,
1130 graph_terms: terms,
1132 graph_group_structure: self.graph_group_structure.clone(),
1133 graph_to_group_id: graph_to_group_id_for_group_structure(
1134 &self.graph_group_structure,
1135 ),
1136 explicit_orientation_sum_only: global_settings
1137 .generation
1138 .explicit_orientation_sum_only,
1139 },
1140 event_processing_runtime: Default::default(),
1141 active_f64_backend: Default::default(),
1142 };
1143 let compile_times = cross_section_integrand
1144 .prepare_runtime_backends_after_generation_with_compile_times()?;
1145 crate::debug_tags!(#generation, #profile, #compile, #graph, #summary;
1146 stage = "prepare_runtime_backends_done",
1147 integrand = %self.name,
1148 graph_count,
1149 elapsed_ms = backend_started.elapsed().as_secs_f64() * 1000.0,
1150 total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1151 "Generation timing milestone"
1152 );
1153 generation_progress::backend_finished(
1154 GenerationProcessKind::CrossSection,
1155 &self.name,
1156 backend_started.elapsed(),
1157 );
1158 for (report, compile_time) in graph_reports.iter_mut().zip(compile_times) {
1159 report.stats.evaluator_compile_time += compile_time;
1160 report.stats.total_time += compile_time;
1161 }
1162
1163 self.integrand = Some(ProcessIntegrand::CrossSection(cross_section_integrand));
1164 crate::debug_tags!(#generation, #profile, #graph, #summary;
1165 stage = "cross_section_build_integrand_done",
1166 integrand = %self.name,
1167 graph_count = self.supergraphs.len(),
1168 elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1169 "Generation timing milestone"
1170 );
1171 Ok(graph_reports)
1172 }
1173
1174 pub fn compile(
1175 &mut self,
1176 path: impl AsRef<Path>,
1177 override_existing: bool,
1178 thread_pool: &ThreadPool,
1179 ) -> Result<Vec<NamedGraphGenerationReport>> {
1180 info!("Compiling cross section {}", self.name);
1181 let p = self.storage_path(path.as_ref());
1182
1183 let result = fs::create_dir_all(&p).with_context(|| {
1184 format!(
1185 "Trying to create directory to compile cross section {}",
1186 p.display()
1187 )
1188 });
1189
1190 if override_existing {
1191 result?;
1192 }
1193
1194 if let Some(integrand) = &mut self.integrand {
1195 let compile_times = integrand.compile(&p, override_existing, thread_pool)?;
1196 return Ok(compile_times
1197 .into_iter()
1198 .map(|(graph_name, duration)| NamedGraphGenerationReport {
1199 integrand_name: self.name.clone(),
1200 graph_name,
1201 stats: GraphGenerationStats {
1202 total_time: duration,
1203 evaluator_compile_time: duration,
1204 ..GraphGenerationStats::default()
1205 },
1206 })
1207 .collect());
1208 }
1209 Ok(Vec::new())
1210 }
1211
1212 pub fn save(&mut self, path: impl AsRef<Path>, override_existing: bool) -> Result<()> {
1213 let p = self.storage_path(path.as_ref());
1214 let r = fs::create_dir_all(&p).with_context(|| {
1215 format!(
1216 "Trying to create directory to save cross section {}",
1217 p.display()
1218 )
1219 });
1220
1221 if override_existing {
1222 r?;
1223 }
1224
1225 let integrand = self.integrand.take();
1226 if let Some(integrand) = &integrand {
1227 integrand.save(&p, override_existing)?;
1228 }
1229
1230 let binary = bincode::encode_to_vec(&(*self), bincode::config::standard())?;
1231 if override_existing {
1232 fs::write(p.join("cs.bin"), &binary)?;
1233 } else {
1234 let mut file = File::create_new(p.join("cs.bin"))?;
1235 file.write_all(&binary)?;
1236 }
1237
1238 self.integrand = integrand;
1239 Ok(())
1240 }
1241
1242 pub(crate) fn load(path: impl AsRef<Path>, context: GammaLoopContextContainer) -> Result<Self> {
1243 let binary = fs::read(path.as_ref().join("cs.bin"))?;
1244 let (mut cs, _): (Self, _) =
1245 bincode::decode_from_slice_with_context(&binary, bincode::config::standard(), context)?;
1246
1247 if path.as_ref().join("integrand").exists() {
1248 let integrand = CrossSectionIntegrand::load(path.as_ref().join("integrand"), context)?;
1249 cs.integrand = Some(ProcessIntegrand::CrossSection(integrand));
1250 }
1251
1252 Ok(cs)
1253 }
1254}
1255
1256#[derive(Clone, bincode::Encode, bincode::Decode)]
1257pub struct CrossSectionCut {
1258 pub cut: OrientedCut,
1259 pub left: SuBitGraph,
1260 pub right: SuBitGraph,
1261}
1262
1263impl CrossSectionCut {
1264 fn amplitude_side_subjects<'a>(&self, graph: &'a Graph) -> [GraphSelectionSubject<'a>; 2] {
1265 let cut_edges = self.cut.as_subgraph();
1266 [
1267 GraphSelectionSubject::cut_side_amplitude_subgraph(
1268 graph,
1269 self.left.subtract(&cut_edges),
1270 ),
1271 GraphSelectionSubject::cut_side_amplitude_subgraph(
1272 graph,
1273 self.right.subtract(&cut_edges),
1274 ),
1275 ]
1276 }
1277
1278 pub(crate) fn is_s_channel(&self, cross_section_graph: &CrossSectionGraph) -> Result<bool> {
1279 let nodes_of_left_cut: Vec<_> = cross_section_graph
1280 .graph
1281 .underlying
1282 .iter_nodes_of(&self.left)
1283 .map(|(nid, _, _)| nid)
1284 .collect();
1285
1286 let left_node = cross_section_graph
1287 .graph
1288 .underlying
1289 .combine_to_single_hedgenode(&nodes_of_left_cut);
1290 let res = left_node.includes(&cross_section_graph.source_nodes)
1291 && cross_section_graph.target_nodes.weakly_disjoint(&left_node);
1292
1293 if !res {
1294 warn!("s channel check wrong");
1295 }
1296
1297 Ok(true)
1298 }
1299
1300 pub(crate) fn is_valid_for_process(
1301 &self,
1302 cross_section_graph: &CrossSectionGraph,
1303 process: &ProcessDefinition,
1304 model: &Model,
1305 ) -> Result<bool> {
1306 if self.is_s_channel(cross_section_graph)? {
1307 let cut_content_builder = self
1308 .cut
1309 .iter_edges(&cross_section_graph.graph.underlying)
1310 .filter_map(|(orientation, edge_data)| {
1311 Some(if orientation == Orientation::Reversed {
1312 edge_data.data.particle()?.get_anti_particle(model)
1313 } else {
1314 edge_data.data.particle()?.clone()
1315 })
1316 })
1317 .collect_vec();
1318
1319 let covariant_states = process.covariant_cut_states(model)?;
1320 let any_pdg_list_passes = covariant_states
1321 .iter()
1322 .map(|x| {
1323 x.iter()
1324 .map(|pdg| model.get_particle_from_pdg(*pdg as isize))
1325 })
1326 .any(|particle_content| {
1327 let mut cut_content = cut_content_builder.clone();
1328 debug!(
1329 "cut content: {:?}",
1330 cut_content.iter().map(|p| p.name.clone()).collect_vec()
1331 );
1332
1333 for particle in particle_content {
1334 if let Some(index) = cut_content.iter().position(|p| p == &particle) {
1335 cut_content.remove(index);
1336 } else {
1337 debug!("wrong particles");
1338 return false;
1339 }
1340 }
1341
1342 let (n_unresolved, unresolved_cut_content) =
1343 process.unresolved_cut_content(model);
1344
1345 if cut_content.len() > n_unresolved {
1346 debug!(" too many unresolved particles");
1347 return false;
1348 }
1349
1350 if !cut_content
1351 .iter()
1352 .all(|particle| unresolved_cut_content.contains(particle))
1353 {
1354 debug!("wrong unresolved particles");
1355 return false;
1356 }
1357
1358 true
1359 });
1360
1361 if !any_pdg_list_passes {
1362 debug!("wrong pdg list");
1363 return Ok(false);
1364 }
1365
1366 let amplitude_couplings = process.amplitude_filters.get_coupling_orders();
1367 let amplitude_loop_count = process.amplitude_filters.get_loop_count_range();
1368
1369 if let Some((min_loop, max_loop)) = amplitude_loop_count {
1370 let loop_range = min_loop..=max_loop;
1371 let left_internal_subgraph = InternalSubGraph::cleaned_filter_pessimist(
1372 self.left.clone(),
1373 &cross_section_graph.graph.underlying,
1374 );
1375
1376 let right_internal_subgraph = InternalSubGraph::cleaned_filter_pessimist(
1377 self.right.clone(),
1378 &cross_section_graph.graph.underlying,
1379 );
1380
1381 let left_loop = cross_section_graph
1382 .graph
1383 .underlying
1384 .cyclotomatic_number(&left_internal_subgraph);
1385
1386 let right_loop = cross_section_graph
1387 .graph
1388 .underlying
1389 .cyclotomatic_number(&right_internal_subgraph);
1390
1391 let total_loops = left_loop + right_loop;
1392
1393 if !loop_range.contains(&total_loops) {
1394 debug!("incorrect loop count");
1395 return Ok(false);
1396 }
1397 }
1398
1399 if amplitude_couplings.is_some() {
1400 todo!("waiting for update")
1401 }
1402
1403 Ok(true)
1404 } else {
1405 debug!("cut is not s channel");
1406 Ok(false)
1407 }
1408 }
1409}
1410
1411#[derive(
1412 Debug, Clone, Serialize, Decode, Deserialize, From, Into, Hash, PartialEq, Copy, Eq, Encode,
1413)]
1414pub struct CutId(pub usize);
1415
1416impl Display for CutId {
1417 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1418 write!(f, "{}", self.0)
1419 }
1420}
1421
1422#[derive(Clone, Encode, Decode)]
1423#[trait_decode(trait = GammaLoopContext)]
1424pub struct CrossSectionGraph {
1425 pub graph: Graph,
1426 pub source_nodes: HedgeNode,
1427 pub target_nodes: HedgeNode,
1428 pub cuts: TiVec<CutId, CrossSectionCut>,
1429 pub cut_esurface: TiVec<CutId, Esurface>,
1430 pub cut_esurface_id_map: TiVec<CutId, EsurfaceID>,
1431 pub derived_data: CrossSectionDerivedData,
1432}
1433
1434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1435pub struct CutkoskyCutCount {
1436 pub candidate_st_cuts: usize,
1437 pub multi_edge_candidate_cuts: usize,
1438 pub process_compatible_cuts: usize,
1439 pub selected_cuts: usize,
1440}
1441
1442impl CrossSectionGraph {
1443 pub(crate) fn new(graph: Graph) -> Self {
1444 let (source_node, target_node) = graph.get_source_and_target();
1445
1446 Self {
1447 graph,
1448 source_nodes: source_node,
1449 target_nodes: target_node,
1450 cuts: TiVec::new(),
1451 cut_esurface: TiVec::new(),
1452 cut_esurface_id_map: TiVec::new(),
1453 derived_data: CrossSectionDerivedData::new_empty(),
1454 }
1455 }
1456
1457 pub(crate) fn apply_spin_sum(
1458 &mut self,
1459 model: &Model,
1460 generation_settings: &GenerationSettings,
1461 locked_runtime_settings: &LockedRuntimeSettings,
1462 ) -> Result<()> {
1463 for (extid, hel) in locked_runtime_settings.helicities().iter().enumerate() {
1464 let eid = self.graph.loop_momentum_basis.ext_edges[ExternalIndex(extid)];
1466
1467 let Some(p) = self.graph.underlying[eid].particle() else {
1468 continue;
1469 };
1470
1471 match hel {
1472 Helicity::Summed => {
1473 let Some(p) = p.polarization_sum(
1474 eid,
1475 false,
1476 generation_settings.vector_polarization_sum_gauge,
1477 )?
1478 else {
1479 continue;
1480 };
1481 self.graph.global_prefactor.projector =
1482 self.graph.global_prefactor.projector.replace_multiple(&[p]);
1483 }
1484 Helicity::SummedAveraged => {
1485 let Some(p) = p.polarization_sum(
1486 eid,
1487 true,
1488 generation_settings.vector_polarization_sum_gauge,
1489 )?
1490 else {
1491 continue;
1492 };
1493 self.graph.global_prefactor.projector =
1494 self.graph.global_prefactor.projector.replace_multiple(&[p]);
1495 }
1496 _ => {}
1497 }
1498 }
1499
1500 self.graph.polarizations = self.graph.global_prefactor.polarizations();
1501 self.graph.param_builder = ParamBuilder::new(
1502 &self.graph,
1503 model,
1504 &self.graph.loop_momentum_basis,
1505 &self.graph.param_builder.pairs.additional_params.params,
1506 )?;
1507 Ok(())
1508 }
1509
1510 pub(crate) fn preprocess(
1511 &mut self,
1512 model: &Model,
1513 process_definition: &ProcessDefinition,
1514 settings: &GenerationSettings,
1515 runtime_default: LockedRuntimeSettings,
1516 ) -> Result<GraphGenerationStats> {
1517 let preprocess_started = std::time::Instant::now();
1518 let mut stats = GraphGenerationStats::default();
1519 self.graph.validate_real_masses(model)?;
1520 process_definition.covariant_cut_states(model)?;
1521 self.derived_data.covariant_cut_representatives =
1522 process_definition.covariant_cut_representatives(model);
1523 self.apply_spin_sum(model, settings, &runtime_default)?;
1524 debug_tags!(#generation; "generating cuts");
1525 self.generate_cuts(model, process_definition, settings)?;
1526 debug_tags!(#generation; "generating esurfaces corresponding to cuts");
1527 self.generate_esurface_cuts();
1528 debug_tags!(#generation; "generating cff");
1529 stats.merge_in_place(&self.generate_cff(settings)?);
1530 debug_tags!(#generation; "building lmbs");
1531 self.build_lmbs()?;
1532 debug_tags!(#generation; "building multi channeling channels");
1533
1534 if self.graph.is_group_master {
1535 self.build_multi_channeling_channels(settings.override_lmb_heuristics)?;
1536 }
1537
1538 let vk = crate::utils::vakint()?;
1539 debug_tags!(#generation; "building parametric integrand");
1540 let cff_energy_degree_bound_reports = Mutex::new(Vec::new());
1541 self.build_parametric_integrand(settings, vk, &cff_energy_degree_bound_reports)?;
1542 let threshold_candidates = self.topological_threshold_candidates(settings)?;
1545 self.derived_data.threshold_candidate_esurface_ids = threshold_candidates
1546 .iter()
1547 .filter_map(|candidate| candidate.selected_orientation_esurface_id)
1548 .sorted()
1549 .dedup()
1550 .collect();
1551 self.derived_data.resolved_threshold_counterterms = None;
1552 self.derived_data.threshold_counterterms = TiVec::new();
1553 self.derived_data.subspace_data = TiVec::new();
1554
1555 self.derived_data.cut_threshold_associations =
1556 ti_vec![CutThresholdCountertermAssociations::default(); self.cuts.len()];
1557
1558 if settings.threshold_subtraction.enable_thresholds {
1559 debug_tags!(#generation, #subtraction; "building threshold counterterm");
1560 self.build_subspace_data()?;
1561 let runtime_settings: RuntimeSettings = runtime_default.into();
1562 if let Err(error) = self.build_threshold_counterterm(
1563 model,
1564 settings,
1565 &runtime_settings,
1566 &threshold_candidates,
1567 vk,
1568 &cff_energy_degree_bound_reports,
1569 ) {
1570 self.derived_data.resolved_threshold_counterterms = None;
1571 self.derived_data.threshold_counterterms = TiVec::new();
1572 self.derived_data.subspace_data = TiVec::new();
1573 self.derived_data.cut_threshold_associations =
1574 ti_vec![CutThresholdCountertermAssociations::default(); self.cuts.len()];
1575 return Err(error);
1576 }
1577 }
1578
1579 stats.cff_energy_degree_bound_reports = cff_energy_degree_bound_reports
1580 .into_inner()
1581 .unwrap_or_else(|poisoned| poisoned.into_inner());
1582
1583 stats.total_time += preprocess_started.elapsed();
1584 Ok(stats)
1585 }
1586
1587 #[allow(dead_code)]
1588 pub(crate) fn write_dot<W: std::io::Write>(
1589 &self,
1590 writer: &mut W,
1591 settings: &DotExportSettings,
1592 ) -> Result<(), std::io::Error> {
1593 if let Some(graph) = self.graph_with_materialized_threshold_counterterms(settings) {
1594 graph.dot_serialize_io(writer, settings)
1595 } else {
1596 self.graph.dot_serialize_io(writer, settings)
1597 }
1598 }
1599
1600 pub(crate) fn write_dot_fmt<W: std::fmt::Write>(
1601 &self,
1602 writer: &mut W,
1603 settings: &DotExportSettings,
1604 ) -> Result<(), std::fmt::Error> {
1605 if let Some(graph) = self.graph_with_materialized_threshold_counterterms(settings) {
1606 graph.dot_serialize_fmt(writer, settings)
1607 } else {
1608 self.graph.dot_serialize_fmt(writer, settings)
1609 }
1610 }
1611
1612 fn graph_with_materialized_threshold_counterterms(
1613 &self,
1614 settings: &DotExportSettings,
1615 ) -> Option<Graph> {
1616 if !settings.include_autogenerated_fields
1618 || (self.graph.group_id.is_some() && !self.graph.is_group_master)
1619 {
1620 return None;
1621 }
1622 let resolved = self.derived_data.resolved_threshold_counterterms.as_ref()?;
1623 let all_lmbs = self.derived_data.lmbs.as_ref()?;
1624 let mut graph = self.graph.clone();
1625 graph.threshold_counterterms = crate::graph::autogen::Autogen::explicit(
1626 resolved.materialized_spec(&self.graph.threshold_counterterms, all_lmbs),
1627 );
1628 Some(graph)
1629 }
1630
1631 fn generate_cff(&mut self, settings: &GenerationSettings) -> Result<GraphGenerationStats> {
1632 self.graph.set_medium_mode(settings.medium.mode)?;
1633 self.graph.ensure_energy_convergent_cycles(
1634 &self
1635 .graph
1636 .no_dummy()
1637 .subtract(&self.graph.initial_state_cut),
1638 )?;
1639 let canonize_esurface = self
1640 .graph
1641 .get_esurface_canonization(&self.graph.loop_momentum_basis);
1642
1643 let contract_edges = self
1644 .graph
1645 .iter_edges_of(
1646 &self
1647 .graph
1648 .tree_edges
1649 .subtract(&self.graph.initial_state_cut),
1650 )
1651 .map(|x| x.1)
1652 .collect_vec();
1653
1654 let options = self.graph.production_cff_3d_expression_options(settings)?;
1658 let mut global_cff = self.graph.generate_3d_expression_for_integrand(
1659 &contract_edges,
1660 &canonize_esurface,
1661 &options,
1662 None,
1663 )?;
1664 let raised_edge_groups = self.graph.get_raised_edge_groups();
1665 normalize_three_d_expression_cut_support_with_raised_edge_groups(
1666 &mut global_cff.expression,
1667 &raised_edge_groups,
1668 );
1669
1670 let generated_esurfaces = &global_cff.expression.surfaces.esurface_cache;
1671 let normalized_generated_esurfaces = generated_esurfaces
1672 .iter()
1673 .map(|esurface| {
1674 Graph::normalize_esurface_with_raised_edge_groups(esurface, &raised_edge_groups)
1675 })
1676 .collect::<TiVec<EsurfaceID, _>>();
1677 let cut_esurface_map = self
1678 .cut_esurface
1679 .iter()
1680 .map(|esurface| {
1681 let normalized =
1682 Graph::normalize_esurface_with_raised_edge_groups(esurface, &raised_edge_groups);
1683 generated_esurfaces
1684 .iter()
1685 .position(|candidate| candidate == esurface)
1686 .or_else(|| {
1687 normalized_generated_esurfaces
1688 .iter()
1689 .position(|candidate| candidate == &normalized)
1690 })
1691 .map(EsurfaceID)
1692 .ok_or_else(|| {
1693 eyre!(
1694 "Cutkosky-cut E-surface {esurface:?} for graph {} is absent from its generated CFF expression catalogue (normalized: {normalized:?})",
1695 self.graph.name
1696 )
1697 })
1698 })
1699 .collect::<Result<_>>()?;
1700
1701 self.cut_esurface_id_map = cut_esurface_map;
1702
1703 let esurface_raised_data = self
1704 .graph
1705 .determine_raised_esurfaces_from_expression(&global_cff.expression);
1706
1707 let (cut_group_data, cut_group_stats) = CutGroupData::new_from_esurface(
1708 &esurface_raised_data,
1709 &self.cut_esurface_id_map,
1710 &settings.evaluator,
1711 );
1712
1713 self.derived_data.global_cff_expression = Some(global_cff);
1714 self.derived_data.cut_group_data = cut_group_data;
1715
1716 Ok(cut_group_stats)
1717 }
1718
1719 pub(crate) fn process_valid_cuts(
1720 &self,
1721 model: &Model,
1722 process_definition: &ProcessDefinition,
1723 settings: &GenerationSettings,
1724 ) -> Result<TiVec<CutId, CrossSectionCut>> {
1725 if !self.cuts.is_empty() {
1726 return Ok(self.cuts.clone());
1727 }
1728 self.compute_process_valid_cuts(model, process_definition, settings)
1729 .map(|(_, cuts)| cuts)
1730 }
1731
1732 pub fn cutkosky_cut_count_for_process(
1733 &self,
1734 model: &Model,
1735 process_definition: &ProcessDefinition,
1736 settings: &GenerationSettings,
1737 ) -> Result<CutkoskyCutCount> {
1738 self.compute_process_valid_cuts(model, process_definition, settings)
1739 .map(|(cut_count, _)| cut_count)
1740 }
1741
1742 fn compute_process_valid_cuts(
1743 &self,
1744 model: &Model,
1745 process_definition: &ProcessDefinition,
1746 settings: &GenerationSettings,
1747 ) -> Result<(CutkoskyCutCount, TiVec<CutId, CrossSectionCut>)> {
1748 let all_st_cuts = self.graph.all_st_cuts_for_cs(
1749 self.source_nodes.clone(),
1750 self.target_nodes.clone(),
1751 &self.graph.get_initial_state_tree(),
1752 );
1753 let candidate_st_cuts = all_st_cuts.len();
1754
1755 let multi_edge_cuts: TiVec<CutId, CrossSectionCut> = all_st_cuts
1756 .into_iter()
1757 .map(|(left, cut, right)| CrossSectionCut { cut, left, right })
1758 .filter(|cut| cut.cut.nedges(&self.graph) > 1)
1759 .collect();
1760 let multi_edge_candidate_cuts = multi_edge_cuts.len();
1761
1762 let mut cuts: TiVec<CutId, CrossSectionCut> = multi_edge_cuts
1763 .into_iter()
1764 .filter_map(
1765 |cut| match cut.is_valid_for_process(self, process_definition, model) {
1766 Ok(true) => Some(Ok(cut)),
1767 Ok(false) => None,
1768 Err(e) => Some(Err(e)),
1769 },
1770 )
1771 .collect::<Result<_>>()?;
1772 let process_compatible_cuts = cuts.len();
1773
1774 cuts.sort_by(|a, b| a.cut.cmp(&b.cut));
1775
1776 if !settings.force_cuts.is_empty() {
1777 let force_cuts_sorted = settings
1778 .force_cuts
1779 .clone()
1780 .into_iter()
1781 .map(|cut_edges| cut_edges.into_iter().sorted().collect_vec())
1782 .collect_vec();
1783
1784 cuts.retain(|cut| {
1785 let edges_in_cut = self
1786 .graph
1787 .iter_edges_of(&cut.cut)
1788 .map(|(_, _, e)| e.data.name.value.clone())
1789 .sorted()
1790 .collect_vec();
1791
1792 force_cuts_sorted.contains(&edges_in_cut)
1793 });
1794 }
1795
1796 let cut_count = CutkoskyCutCount {
1797 candidate_st_cuts,
1798 multi_edge_candidate_cuts,
1799 process_compatible_cuts,
1800 selected_cuts: cuts.len(),
1801 };
1802 Ok((cut_count, cuts))
1803 }
1804
1805 fn generate_cuts(
1806 &mut self,
1807 model: &Model,
1808 process_definition: &ProcessDefinition,
1809 settings: &GenerationSettings,
1810 ) -> Result<()> {
1811 debug_tags!(#generation, #profile, #graph;
1812 stage = "cross_section_generate_cuts_start",
1813 graph = %self.graph.name,
1814 "Cut discovery timing milestone"
1815 );
1816 let started = std::time::Instant::now();
1817 let (cut_count, cuts) =
1818 self.compute_process_valid_cuts(model, process_definition, settings)?;
1819 self.cuts = cuts;
1820 debug_tags!(#generation, #profile, #graph;
1821 stage = "cross_section_generate_cuts_done",
1822 graph = %self.graph.name,
1823 st_cut_count = cut_count.candidate_st_cuts,
1824 valid_cut_count = self.cuts.len(),
1825 elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1826 "Cut discovery timing milestone"
1827 );
1828 generation_progress::cuts_discovered(
1829 "",
1830 &self.graph.name,
1831 cut_count.candidate_st_cuts,
1832 self.cuts.len(),
1833 );
1834
1835 Ok(())
1836 }
1837
1838 fn generate_esurface_cuts(&mut self) {
1839 debug!("generating esurfaces for cuts");
1840
1841 let esurfaces: TiVec<CutId, Esurface> = self
1842 .cuts
1843 .iter()
1844 .map(|cut| {
1845 Esurface::new_from_cut_left(
1846 &self.graph.underlying,
1847 cut,
1848 Some(&self.graph.initial_state_cut),
1849 )
1850 })
1851 .collect();
1852
1853 debug!("generated esurfaces {:?}", esurfaces);
1854
1855 self.cut_esurface = esurfaces;
1856 }
1857
1858 pub(crate) fn build_parametric_integrand(
1859 &mut self,
1860 settings: &GenerationSettings,
1861 vakint: &Vakint,
1862 cff_energy_degree_bound_reports: &Mutex<Vec<CffEnergyDegreeBoundReport>>,
1863 ) -> Result<()> {
1864 self.derived_data.cut_paramatric_integrand =
1865 self.build_integrand(settings, vakint, cff_energy_degree_bound_reports)?;
1866 Ok(())
1867 }
1868
1869 fn build_integrand(
1870 &mut self,
1871 settings: &GenerationSettings,
1872 vakint: &Vakint,
1873 cff_energy_degree_bound_reports: &Mutex<Vec<CffEnergyDegreeBoundReport>>,
1874 ) -> Result<TiVec<CutGroupId, ParametricIntegrands>> {
1875 let started = std::time::Instant::now();
1876 crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1877 stage = "supergraph_build_integrand_start",
1878 graph = %self.graph.name,
1879 subtract_uv = settings.uv.subtract_uv,
1880 generate_integrated = settings.uv.generate_integrated,
1881 "Generation timing milestone"
1882 );
1883 let max_order = self
1884 .derived_data
1885 .cut_group_data
1886 .cut_groups
1887 .iter()
1888 .map(|cut_group| cut_group.related_esurface_group.max_occurence)
1889 .max()
1890 .unwrap();
1891
1892 self.graph.param_builder.initialize_duals(max_order);
1893 crate::debug_tags!(#generation, #profile, #graph, #summary;
1894 stage = "supergraph_initialize_duals_done",
1895 graph = %self.graph.name,
1896 max_order,
1897 elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1898 "Generation timing milestone"
1899 );
1900
1901 let cuts = self
1902 .derived_data
1903 .cut_group_data
1904 .cut_groups
1905 .iter()
1906 .map(|cuts| CutSet {
1907 residue_selector: ResidueSelector {
1908 lu: Some(cuts.lu_cut_selection(&self.graph, &self.cuts)),
1909 left_th_cut: None,
1910 right_th_cut: None,
1911 },
1912 union: cuts
1913 .cuts
1914 .iter()
1915 .map(|cut_id| self.cuts[*cut_id].cut.as_subgraph())
1916 .reduce(|cut_1, cut_2| cut_1.union(&cut_2))
1917 .unwrap_or_else(|| self.graph.empty_subgraph()),
1918 canonicalize_external_shifts: false,
1919 })
1920 .collect();
1921
1922 let cut_structure = CutStructure { cuts };
1923 crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1924 stage = "supergraph_cutsets_done",
1925 graph = %self.graph.name,
1926 cut_count = cut_structure.cuts.len(),
1927 elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1928 "Generation timing milestone"
1929 );
1930
1931 let cff_options = self.graph.production_cff_3d_expression_options(settings)?;
1932 let production_expression = self
1933 .derived_data
1934 .global_cff_expression
1935 .as_ref()
1936 .expect("global_cff_expression should have been created");
1937
1938 let orchestration_started = std::time::Instant::now();
1939 let parametric_integrands = settings.uv.orchestrator.parametric_integrands(
1940 &mut self.graph,
1941 cut_structure,
1942 vakint,
1943 OrientationProjection::exact_expression(
1944 production_expression,
1945 &cff_options,
1946 &settings.orientation_pattern,
1947 settings.explicit_orientation_sum_only,
1948 )
1949 .with_vacuum_subtraction(settings.medium.vacuum_subtraction)
1950 .with_energy_degree_bound_reports(cff_energy_degree_bound_reports),
1951 settings,
1952 )?;
1953 crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1954 stage = "supergraph_parametric_orchestration_done",
1955 graph = %self.graph.name,
1956 parametric_integrand_count = parametric_integrands.len(),
1957 elapsed_ms = orchestration_started.elapsed().as_secs_f64() * 1000.0,
1958 total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1959 "Generation timing milestone"
1960 );
1961
1962 let finalize_started = std::time::Instant::now();
1963 let result = parametric_integrands
1964 .into_iter()
1965 .enumerate()
1966 .map(|(index, integrand)| {
1967 let group = &self.derived_data.cut_group_data.cut_groups[CutGroupId(index)];
1968 let lu_prefactor = group.lu_prefactor(&self.graph, &self.cuts)?;
1969 Ok(integrand.map(|a| a * &lu_prefactor))
1970 })
1971 .collect::<Result<_>>()?;
1972 crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1973 stage = "supergraph_build_integrand_done",
1974 graph = %self.graph.name,
1975 elapsed_ms = finalize_started.elapsed().as_secs_f64() * 1000.0,
1976 total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1977 "Generation timing milestone"
1978 );
1979 Ok(result)
1980 }
1981
1982 fn threshold_rstar_power(subspace_loop_count: usize, is_on_right: bool) -> Atom {
1983 let radius_star = if is_on_right {
1984 GS.radius_star_right
1985 } else {
1986 GS.radius_star_left
1987 };
1988 Atom::var(radius_star).pow(subspace_loop_count as i32 * 3 - 1)
1989 }
1990
1991 pub(crate) fn single_th_prefactor_helper_atoms(
1992 order: u8,
1993 subspace_loop_count: usize,
1994 is_on_right: bool,
1995 include_integrated: bool,
1996 ) -> (SingleThresholdPieces<Atom>, Atom) {
1997 let loop_3 = subspace_loop_count as i64 * 3;
1998
1999 let i = Atom::i();
2000
2001 let radius = if is_on_right {
2002 Atom::var(GS.radius_right)
2003 } else {
2004 Atom::var(GS.radius_left)
2005 };
2006
2007 let radius_star = if is_on_right {
2008 Atom::var(GS.radius_star_right)
2009 } else {
2010 Atom::var(GS.radius_star_left)
2011 };
2012 let uv_damp_plus = if is_on_right {
2013 Atom::var(GS.uv_damp_plus_right)
2014 } else {
2015 Atom::var(GS.uv_damp_plus_left)
2016 };
2017 let uv_damp_minus = if is_on_right {
2018 Atom::var(GS.uv_damp_minus_right)
2019 } else {
2020 Atom::var(GS.uv_damp_minus_left)
2021 };
2022 let hfunction = if is_on_right {
2023 Atom::var(GS.hfunction_right_th)
2024 } else {
2025 Atom::var(GS.hfunction_left_th)
2026 };
2027
2028 let laurent_coeff_indices = (1..=order).map(|i| -(i as i8));
2029
2030 let mut laurent_coeffs = laurent_coeff_indices.map(|laurent_coeff_index| {
2031 build_derivative_structure_atom(order, laurent_coeff_index)
2032 .replace(GS.rescale_star)
2033 .with(radius_star.clone())
2034 });
2035
2036 let delta_r_plus = &radius - &radius_star;
2037 let delta_r_minus = -&radius - &radius_star;
2038
2039 let jacobian_ratio = (Atom::one() / &radius).pow(loop_3 - 1);
2040
2041 let local_prefactor =
2042 &jacobian_ratio * (uv_damp_plus / &delta_r_plus + uv_damp_minus / &delta_r_minus);
2043
2044 let integrated_prefactor = if include_integrated {
2045 if is_on_right {
2048 i * Atom::var(GS.pi) * &jacobian_ratio * hfunction
2049 } else {
2050 -i * Atom::var(GS.pi) * &jacobian_ratio * hfunction
2051 }
2052 } else {
2053 Atom::zero()
2054 };
2055
2056 let leading_laurent_coeff = laurent_coeffs.next().unwrap();
2057 let mut legacy = (local_prefactor.clone() + integrated_prefactor.clone())
2060 * leading_laurent_coeff.clone();
2061 let mut local = local_prefactor * &leading_laurent_coeff;
2062 let integrated = integrated_prefactor * leading_laurent_coeff;
2063
2064 for pow in 2..=order {
2065 let raised = laurent_coeffs.next().unwrap()
2066 * &jacobian_ratio
2067 * (Atom::one() / delta_r_plus.pow(pow as i64)
2068 + Atom::one() / delta_r_minus.pow(pow as i64));
2069 legacy += raised.clone();
2070 local += raised;
2071 }
2072
2073 debug!(
2074 "Threshold counterterm helper atoms for order {} and loop number {}: local={}, integrated={}, legacy={}",
2075 order, subspace_loop_count, local, integrated, legacy,
2076 );
2077
2078 (SingleThresholdPieces { local, integrated }, legacy)
2079 }
2080
2081 fn iterated_th_prefactor_helper_atoms(
2082 left_order: u8,
2083 right_order: u8,
2084 left_subspace_loop_count: usize,
2085 right_subspace_loop_count: usize,
2086 include_integrated: bool,
2087 ) -> (IteratedThresholdPieces<Atom>, Atom) {
2088 let (left_prefactors, left_legacy) = Self::single_th_prefactor_helper_atoms(
2089 left_order,
2090 left_subspace_loop_count,
2091 false,
2092 include_integrated,
2093 );
2094 let left_local = left_prefactors
2095 .local
2096 .replace(GS.eta)
2097 .with(Atom::var(GS.eta_left));
2098 let left_integrated = left_prefactors
2099 .integrated
2100 .replace(GS.eta)
2101 .with(Atom::var(GS.eta_left));
2102
2103 let (right_prefactors, right_legacy) = Self::single_th_prefactor_helper_atoms(
2104 right_order,
2105 right_subspace_loop_count,
2106 true,
2107 include_integrated,
2108 );
2109 let right_local = right_prefactors
2110 .local
2111 .replace(GS.eta)
2112 .with(Atom::var(GS.eta_right));
2113 let right_integrated = right_prefactors
2114 .integrated
2115 .replace(GS.eta)
2116 .with(Atom::var(GS.eta_right));
2117
2118 let fuse = |left: Atom, right: Atom| {
2123 (left * right)
2124 .expand()
2125 .replace_multiple(Self::fuse_left_right_replacement())
2126 };
2127
2128 let pieces = IteratedThresholdPieces {
2129 local_local: fuse(left_local.clone(), right_local.clone()),
2130 local_integrated: fuse(left_local, right_integrated.clone()),
2131 integrated_local: fuse(left_integrated.clone(), right_local),
2132 integrated_integrated: fuse(left_integrated, right_integrated),
2133 };
2134 let legacy = fuse(
2135 left_legacy.replace(GS.eta).with(Atom::var(GS.eta_left)),
2136 right_legacy.replace(GS.eta).with(Atom::var(GS.eta_right)),
2137 );
2138
2139 (pieces, legacy)
2140 }
2141
2142 pub(crate) fn single_th_prefactor_helper_params(order: u8, is_on_right: bool) -> Vec<Atom> {
2143 let radius_star = if is_on_right {
2144 Atom::var(GS.radius_star_right)
2145 } else {
2146 Atom::var(GS.radius_star_left)
2147 };
2148
2149 let uv_damp_plus = if is_on_right {
2150 Atom::var(GS.uv_damp_plus_right)
2151 } else {
2152 Atom::var(GS.uv_damp_plus_left)
2153 };
2154 let uv_damp_minus = if is_on_right {
2155 Atom::var(GS.uv_damp_minus_right)
2156 } else {
2157 Atom::var(GS.uv_damp_minus_left)
2158 };
2159 let hfunction = if is_on_right {
2160 Atom::var(GS.hfunction_right_th)
2161 } else {
2162 Atom::var(GS.hfunction_left_th)
2163 };
2164
2165 let radius = if is_on_right {
2166 Atom::var(GS.radius_right)
2167 } else {
2168 Atom::var(GS.radius_left)
2169 };
2170
2171 let mut params = params_for_derivative_order(order)
2172 .into_iter()
2173 .map(|param| param.replace(GS.rescale_star).with(radius_star.clone()))
2174 .collect_vec();
2175
2176 params.push(radius);
2177 params.push(radius_star);
2178 params.push(uv_damp_plus);
2179 params.push(uv_damp_minus);
2180 params.push(hfunction);
2181 params
2182 }
2183
2184 pub(crate) fn iterated_th_prefactor_helper_params(
2185 left_order: u8,
2186 right_order: u8,
2187 ) -> Vec<Atom> {
2188 let mut iterated_params = params_for_iterated_threshold_ct(left_order, right_order);
2189 let left_radius_star = Atom::var(GS.radius_star_left);
2190 let right_radius_star = Atom::var(GS.radius_star_right);
2191 let left_radius = Atom::var(GS.radius_left);
2192 let right_radius = Atom::var(GS.radius_right);
2193 let left_uv_damp_plus = Atom::var(GS.uv_damp_plus_left);
2194 let left_uv_damp_minus = Atom::var(GS.uv_damp_minus_left);
2195 let right_uv_damp_plus = Atom::var(GS.uv_damp_plus_right);
2196 let right_uv_damp_minus = Atom::var(GS.uv_damp_minus_right);
2197 let left_hfunction = Atom::var(GS.hfunction_left_th);
2198 let right_hfunction = Atom::var(GS.hfunction_right_th);
2199
2200 iterated_params.push(left_radius);
2201 iterated_params.push(left_radius_star);
2202 iterated_params.push(left_uv_damp_plus);
2203 iterated_params.push(left_uv_damp_minus);
2204 iterated_params.push(left_hfunction);
2205 iterated_params.push(right_radius);
2206 iterated_params.push(right_radius_star);
2207 iterated_params.push(right_uv_damp_plus);
2208 iterated_params.push(right_uv_damp_minus);
2209 iterated_params.push(right_hfunction);
2210 iterated_params
2211 }
2212
2213 #[allow(clippy::too_many_arguments)]
2214 pub(crate) fn single_th_helper(
2215 &self,
2216 order: u8,
2217 subspace_loop_count: usize,
2218 is_on_right: bool,
2219 include_integrated: bool,
2220 outputs: LUThresholdHelperOutputs,
2221 dual_shape: Option<Vec<Vec<usize>>>,
2222 optimization_settings: OptimizationSettings,
2223 evaluator_settings: &EvaluatorSettings,
2224 ) -> Result<GenericEvaluator> {
2225 let (pieces, legacy) = Self::single_th_prefactor_helper_atoms(
2226 order,
2227 subspace_loop_count,
2228 is_on_right,
2229 include_integrated,
2230 );
2231 let atoms = match outputs {
2232 LUThresholdHelperOutputs::Legacy => vec![legacy],
2233 LUThresholdHelperOutputs::Pieces => vec![pieces.local, pieces.integrated],
2234 LUThresholdHelperOutputs::LegacyAndPieces => {
2235 vec![legacy, pieces.local, pieces.integrated]
2236 }
2237 };
2238 let params = Self::single_th_prefactor_helper_params(order, is_on_right);
2239
2240 let fn_map = FunctionMap::new();
2241
2242 let evaluator = GenericEvaluator::new_from_raw_params(
2243 atoms,
2244 ¶ms,
2245 &fn_map,
2246 vec![],
2247 optimization_settings,
2248 dual_shape.map(|shape| (shape, Vec::new())),
2249 evaluator_settings,
2250 )?
2251 .into_eager_only();
2252
2253 Ok(evaluator)
2254 }
2255
2256 #[allow(clippy::too_many_arguments)]
2257 pub(crate) fn iterated_th_helper(
2258 &self,
2259 left_order: u8,
2260 right_order: u8,
2261 left_subspace_loop_count: usize,
2262 right_subspace_loop_count: usize,
2263 include_integrated: bool,
2264 outputs: LUThresholdHelperOutputs,
2265 dual_shape: Option<Vec<Vec<usize>>>,
2266 optimization_settings: OptimizationSettings,
2267 evaluator_settings: &EvaluatorSettings,
2268 ) -> Result<GenericEvaluator> {
2269 let (pieces, legacy) = Self::iterated_th_prefactor_helper_atoms(
2270 left_order,
2271 right_order,
2272 left_subspace_loop_count,
2273 right_subspace_loop_count,
2274 include_integrated,
2275 );
2276 let atoms = match outputs {
2277 LUThresholdHelperOutputs::Legacy => vec![legacy],
2278 LUThresholdHelperOutputs::Pieces => vec![
2279 pieces.local_local,
2280 pieces.local_integrated,
2281 pieces.integrated_local,
2282 pieces.integrated_integrated,
2283 ],
2284 LUThresholdHelperOutputs::LegacyAndPieces => vec![
2285 legacy,
2286 pieces.local_local,
2287 pieces.local_integrated,
2288 pieces.integrated_local,
2289 pieces.integrated_integrated,
2290 ],
2291 };
2292
2293 let params = Self::iterated_th_prefactor_helper_params(left_order, right_order);
2294
2295 let fn_map = FunctionMap::new();
2296
2297 let evaluator = GenericEvaluator::new_from_raw_params(
2298 atoms,
2299 ¶ms,
2300 &fn_map,
2301 vec![],
2302 optimization_settings,
2303 dual_shape.map(|shape| (shape, Vec::new())),
2304 evaluator_settings,
2305 )?
2306 .into_eager_only();
2307
2308 Ok(evaluator)
2309 }
2310
2311 fn fuse_left_right_replacement() -> Vec<Replacement> {
2312 let f = symbol!("f");
2313
2314 vec![
2315 Replacement::new(
2316 (function!(f, GS.radius_star_left) * function!(f, GS.radius_star_right))
2317 .to_pattern(),
2318 function!(f, GS.radius_star_left, GS.radius_star_right),
2319 ),
2320 Replacement::new(
2321 (function!(f, GS.radius_star_left)
2322 * function!(Symbol::DERIVATIVE, W_.x_, f, GS.radius_star_right))
2323 .to_pattern(),
2324 function!(
2325 Symbol::DERIVATIVE,
2326 0,
2327 W_.x_,
2328 f,
2329 GS.radius_star_left,
2330 GS.radius_star_right
2331 ),
2332 ),
2333 Replacement::new(
2334 (function!(Symbol::DERIVATIVE, W_.x_, f, GS.radius_star_left)
2335 * function!(f, GS.radius_star_right))
2336 .to_pattern(),
2337 function!(
2338 Symbol::DERIVATIVE,
2339 W_.x_,
2340 0,
2341 f,
2342 GS.radius_star_left,
2343 GS.radius_star_right
2344 ),
2345 ),
2346 Replacement::new(
2347 (function!(Symbol::DERIVATIVE, W_.x_, f, GS.radius_star_left)
2348 * function!(Symbol::DERIVATIVE, W_.y_, f, GS.radius_star_right))
2349 .to_pattern(),
2350 function!(
2351 Symbol::DERIVATIVE,
2352 W_.x_,
2353 W_.y_,
2354 f,
2355 GS.radius_star_left,
2356 GS.radius_star_right
2357 ),
2358 ),
2359 ]
2360 }
2361 fn build_lmbs(&mut self) -> Result<()> {
2428 let mut lmbs: TiVec<LmbIndex, LoopMomentumBasis> = vec![].into();
2429
2430 let externals: SuBitGraph = self.graph.empty_subgraph();
2431 let full_filter = self.graph.full_filter();
2432 let cut_graph = full_filter.subtract(&self.graph.initial_state_cut.right);
2433
2434 for s in self.graph.all_spanning_forests_of(&cut_graph) {
2435 let mut lmb = self.graph.lmb_impl(&full_filter, &s, externals.clone())?;
2436 let mut exts = vec![];
2437
2438 for i in lmb.loop_edges.iter() {
2439 let (_, p) = &self.graph[i];
2440
2441 if self.graph.initial_state_cut.intersects(p) {
2442 exts.push(*i);
2443 }
2444 }
2445
2446 exts.sort();
2447
2448 for e in exts {
2449 let mut loopid = None;
2450 for (l, s) in lmb.edge_signatures[e].internal.iter_enumerated() {
2451 if s.is_non_zero() {
2452 if loopid.is_none() {
2453 loopid = Some(l);
2454 } else {
2455 panic!("external edge has multiple loop momenta")
2456 }
2457 }
2458 }
2459 lmb.put_loop_to_ext(loopid.unwrap());
2460 }
2461 let external_momentum_edge_order = self.graph.external_momentum_edge_order();
2462 lmb.canonicalize_external_order(&external_momentum_edge_order);
2463 lmbs.push(lmb);
2465 }
2466
2467 let sorted_graph_loop_edges = self
2468 .graph
2469 .loop_momentum_basis
2470 .loop_edges
2471 .iter()
2472 .copied()
2473 .sorted()
2474 .collect_vec();
2475
2476 let matching_lmb_index = lmbs.iter_enumerated().find_map(|(lmb_index, lmb)| {
2477 let sorted_loop_edges = lmb.loop_edges.iter().copied().sorted().collect_vec();
2478 (sorted_loop_edges == sorted_graph_loop_edges).then_some(lmb_index)
2479 });
2480
2481 if let Some(matching_lmb_index) = matching_lmb_index {
2482 {
2483 let matching_lmb = &mut lmbs[matching_lmb_index];
2484 for (target_pos, target_edge) in
2485 self.graph.loop_momentum_basis.loop_edges.iter_enumerated()
2486 {
2487 let current_pos = matching_lmb
2488 .loop_edges
2489 .iter()
2490 .find_position(|edge| *edge == target_edge)
2491 .map(|(pos, _)| pos)
2492 .unwrap();
2493
2494 if current_pos != target_pos.0 {
2495 matching_lmb.swap_loops(current_pos.into(), target_pos);
2496 }
2497 }
2498 }
2499
2500 let matching_lmb_pos: usize = matching_lmb_index.into();
2501 if matching_lmb_pos != 0 {
2502 lmbs.raw[..=matching_lmb_pos].rotate_right(1);
2503 }
2504 } else {
2505 warn!(
2506 "Could not match current graph LMB against generated LMBs for graph {}",
2507 self.graph.name
2508 );
2509 }
2510
2511 let sorted_graph_loop_edges = self
2512 .graph
2513 .loop_momentum_basis
2514 .loop_edges
2515 .iter()
2516 .copied()
2517 .sorted()
2518 .collect_vec();
2519
2520 let matching_lmb_index = lmbs.iter_enumerated().find_map(|(lmb_index, lmb)| {
2521 let sorted_loop_edges = lmb.loop_edges.iter().copied().sorted().collect_vec();
2522 (sorted_loop_edges == sorted_graph_loop_edges).then_some(lmb_index)
2523 });
2524
2525 if let Some(matching_lmb_index) = matching_lmb_index {
2526 {
2527 let matching_lmb = &mut lmbs[matching_lmb_index];
2528 for (target_pos, target_edge) in
2529 self.graph.loop_momentum_basis.loop_edges.iter_enumerated()
2530 {
2531 let current_pos = matching_lmb
2532 .loop_edges
2533 .iter()
2534 .find_position(|edge| *edge == target_edge)
2535 .map(|(pos, _)| pos)
2536 .unwrap();
2537
2538 if current_pos != target_pos.0 {
2539 matching_lmb.swap_loops(current_pos.into(), target_pos);
2540 }
2541 }
2542 }
2543
2544 let matching_lmb_pos: usize = matching_lmb_index.into();
2545 if matching_lmb_pos != 0 {
2546 lmbs.raw[..=matching_lmb_pos].rotate_right(1);
2547 }
2548 } else {
2549 warn!(
2550 "Could not match current graph LMB against generated LMBs for graph {}",
2551 self.graph.name
2552 );
2553 }
2554
2555 self.derived_data.lmbs = Some(lmbs);
2556 Ok(())
2557 }
2558
2559 fn build_multi_channeling_channels(&mut self, override_lmb_heuristics: bool) -> Result<()> {
2560 let lmbs = self.derived_data.lmbs.as_ref().unwrap();
2561 let channels = if override_lmb_heuristics {
2562 self.graph
2563 .build_multi_channeling_channels(lmbs, override_lmb_heuristics)
2564 } else {
2565 self.build_cross_section_multi_channeling_channels(lmbs)?
2566 };
2567
2568 self.derived_data.multi_channeling_setup = Some(channels);
2569 Ok(())
2570 }
2571
2572 fn build_cross_section_multi_channeling_channels(
2573 &self,
2574 lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
2575 ) -> Result<LmbMultiChannelingSetup> {
2576 let channels = self.select_cross_section_lmb_channel_indices(lmbs)?;
2577 debug!(
2578 "number of lmbs: {}, number of cross-section channels: {}",
2579 lmbs.len(),
2580 channels.len()
2581 );
2582 Ok(LmbMultiChannelingSetup {
2583 master_edge_masses: Default::default(),
2584 sampling_bridge: Default::default(),
2585 sampling_bridge_quad: Default::default(),
2586 sampling_bridge_fixed256: Default::default(),
2587 sampling_bridge_arb: Default::default(),
2588 sampling_source: Default::default(),
2589 sampling_catalogue: Default::default(),
2590 sampling_programs: Default::default(),
2591 lmb_basis_ids: channels,
2592 graph: self.graph.clone(),
2593 all_bases: lmbs.clone(),
2594 })
2595 }
2596
2597 fn select_cross_section_lmb_channel_indices(
2598 &self,
2599 lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
2600 ) -> Result<TiVec<crate::integrands::process::SamplingChannelId, LmbIndex>> {
2601 if self.cuts.is_empty() {
2602 return Ok(self.graph.select_amplitude_lmb_channel_indices(
2603 lmbs,
2604 false,
2605 LmbChannelFallback::CurrentGraphBasis,
2606 ));
2607 }
2608
2609 let mut lmb_index_by_loop_edges = BTreeMap::<Vec<EdgeIndex>, LmbIndex>::new();
2610 for (lmb_index, lmb) in lmbs.iter_enumerated() {
2611 lmb_index_by_loop_edges
2612 .entry(lmb.loop_edges.iter().copied().sorted().collect())
2613 .or_insert(lmb_index);
2614 }
2615
2616 let mut selected_loop_edge_sets = BTreeSet::<Vec<EdgeIndex>>::new();
2617 for cut in &self.cuts {
2618 let cut_edges = cut.cut.as_subgraph();
2619 let cut_edge_ids = self
2620 .graph
2621 .underlying
2622 .iter_edges_of(&cut_edges)
2623 .map(|(_, edge_id, _)| edge_id)
2624 .sorted()
2625 .collect_vec();
2626 let Some(excluded_cut_edge) = self.excluded_cut_edge_for_lmb_channel(&cut_edge_ids)
2627 else {
2628 continue;
2629 };
2630
2631 let fixed_cut_edges = cut_edge_ids
2632 .iter()
2633 .copied()
2634 .filter(|edge_id| *edge_id != excluded_cut_edge)
2635 .collect::<BTreeSet<_>>();
2636 let left_basis_edge_sets =
2637 self.selected_cut_side_lmb_edge_sets(cut.left.subtract(&cut_edges))?;
2638 let right_basis_edge_sets =
2639 self.selected_cut_side_lmb_edge_sets(cut.right.subtract(&cut_edges))?;
2640
2641 for left_basis_edges in &left_basis_edge_sets {
2642 for right_basis_edges in &right_basis_edge_sets {
2643 let loop_edges = fixed_cut_edges
2644 .iter()
2645 .copied()
2646 .chain(left_basis_edges.iter().copied())
2647 .chain(right_basis_edges.iter().copied())
2648 .collect::<BTreeSet<_>>()
2649 .into_iter()
2650 .collect_vec();
2651 selected_loop_edge_sets.insert(loop_edges);
2652 }
2653 }
2654 }
2655
2656 let mut channels = Vec::<LmbIndex>::new();
2657 for loop_edges in selected_loop_edge_sets {
2658 let lmb_index = lmb_index_by_loop_edges.get(&loop_edges).ok_or_else(|| {
2659 eyre!(
2660 "Could not find a generated cross-section LMB with loop edges [{}] for graph '{}'.",
2661 loop_edges.iter().map(|edge| edge.to_string()).join(", "),
2662 self.graph.name
2663 )
2664 })?;
2665 channels.push(*lmb_index);
2666 }
2667
2668 if channels.is_empty() {
2669 Ok(self.graph.select_amplitude_lmb_channel_indices(
2670 lmbs,
2671 false,
2672 LmbChannelFallback::CurrentGraphBasis,
2673 ))
2674 } else {
2675 Ok(channels.into_iter().sorted().dedup().collect())
2676 }
2677 }
2678
2679 fn excluded_cut_edge_for_lmb_channel(&self, cut_edge_ids: &[EdgeIndex]) -> Option<EdgeIndex> {
2680 Self::excluded_cut_edge_for_lmb_channel_in(&self.graph, cut_edge_ids)
2681 }
2682
2683 fn excluded_cut_edge_for_lmb_channel_in(
2684 graph: &Graph,
2685 cut_edge_ids: &[EdgeIndex],
2686 ) -> Option<EdgeIndex> {
2687 cut_edge_ids
2688 .iter()
2689 .copied()
2690 .filter(|edge_id| graph[*edge_id].particle.is_massive())
2691 .min()
2692 .or_else(|| {
2693 cut_edge_ids
2694 .iter()
2695 .copied()
2696 .filter(|edge_id| graph[*edge_id].particle.is_fermion())
2697 .min()
2698 })
2699 .or_else(|| cut_edge_ids.iter().copied().min())
2700 }
2701
2702 fn selected_cut_side_lmb_edge_sets(
2703 &self,
2704 mut side_subgraph: SuBitGraph,
2705 ) -> Result<Vec<Vec<EdgeIndex>>> {
2706 for (pair, _, edge) in self.graph.underlying.iter_edges() {
2707 if edge.data.is_dummy {
2708 side_subgraph.sub(pair);
2709 }
2710 }
2711
2712 if self.graph.underlying.cyclotomatic_number(&side_subgraph) == 0 {
2713 return Ok(vec![Vec::new()]);
2714 }
2715
2716 let side_lmbs = self.graph.generate_loop_momentum_bases_of(&side_subgraph);
2717 if side_lmbs.is_empty() {
2718 return Err(eyre!(
2719 "Could not generate cut-side LMBs for a non-tree side of graph '{}'.",
2720 self.graph.name
2721 ));
2722 }
2723
2724 let selected_side_lmbs = self.graph.select_amplitude_lmb_channel_indices(
2725 &side_lmbs,
2726 false,
2727 LmbChannelFallback::FirstBasis,
2728 );
2729 let mut edge_sets = BTreeSet::<Vec<EdgeIndex>>::new();
2730 for lmb_index in selected_side_lmbs {
2731 edge_sets.insert(
2732 side_lmbs[lmb_index]
2733 .loop_edges
2734 .iter()
2735 .copied()
2736 .sorted()
2737 .collect(),
2738 );
2739 }
2740
2741 if edge_sets.is_empty() {
2742 Ok(vec![Vec::new()])
2743 } else {
2744 Ok(edge_sets.into_iter().collect())
2745 }
2746 }
2747
2748 fn threshold_counterterm_association(
2749 &self,
2750 threshold_candidate: &TopologicalThresholdCandidate,
2751 sandwich: &SuBitGraph,
2752 cut: &OrientedCut,
2753 ) -> ThresholdCountertermAssociation {
2754 let boundary_edges = |boundary: &OrientedCut| {
2755 let boundary_filter = boundary.left.union(&boundary.right).intersection(sandwich);
2756 let edges = self
2757 .graph
2758 .iter_edges_of(&boundary_filter)
2759 .map(|(_, edge_id, _)| edge_id)
2760 .sorted()
2761 .collect();
2762 let has_left_orientation =
2763 boundary_filter.intersection(&boundary.left).n_included() > 0;
2764 let has_right_orientation =
2765 boundary_filter.intersection(&boundary.right).n_included() > 0;
2766 (edges, has_left_orientation != has_right_orientation)
2767 };
2768
2769 let (cut_boundary_edges, cut_bound_is_applicable) = boundary_edges(cut);
2770 let (threshold_boundary_edges, threshold_bound_is_applicable) =
2771 boundary_edges(&threshold_candidate.cut);
2772
2773 ThresholdCountertermAssociation {
2774 topological_threshold_id: threshold_candidate.topological_threshold_id,
2775 esurface_id: threshold_candidate
2776 .selected_orientation_esurface_id
2777 .expect("only selected-orientation threshold instances have associations"),
2778
2779 cut_boundary_edges,
2780 threshold_boundary_edges,
2781 invariant_bound_is_applicable: cut_bound_is_applicable && threshold_bound_is_applicable,
2782 }
2783 }
2784
2785 #[allow(clippy::too_many_arguments)]
2786 fn classify_threshold_counterterm_association(
2787 &self,
2788 association: &ThresholdCountertermAssociation,
2789 cut_id: CutId,
2790 cut: &CrossSectionCut,
2791 subspace: &SubspaceData,
2792 all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
2793 model: &Model,
2794 runtime_settings: &RuntimeSettings,
2795 settings: &GenerationSettings,
2796 ) -> ThresholdCountertermStatus {
2797 let esurface = &self.derived_data.topological_threshold_esurfaces
2798 [association.topological_threshold_id];
2799 if !esurface.has_radial_dependence_in_subspace(subspace, all_lmbs, &self.graph) {
2800 debug!(
2801 "Skipping graph '{}' cut {} threshold E-surface {} after cut-relative classification {:?}",
2802 self.graph.name,
2803 cut_id.0,
2804 association.topological_threshold_id.0,
2805 ThresholdCountertermStatus::NoRadialDependence,
2806 );
2807 return ThresholdCountertermStatus::NoRadialDependence;
2808 }
2809
2810 let structural_status = if association.invariant_bound_is_applicable {
2811 self.graph.classify_threshold_pinch(
2812 &association.cut_boundary_edges,
2813 &association.threshold_boundary_edges,
2814 )
2815 } else {
2816 ThresholdPinchStatus::NotProven
2817 };
2818 if structural_status == ThresholdPinchStatus::Always {
2819 debug!(
2820 "Skipping graph '{}' cut {} threshold E-surface {} after cut-relative classification {:?}",
2821 self.graph.name,
2822 cut_id.0,
2823 association.topological_threshold_id.0,
2824 ThresholdCountertermStatus::AlwaysPinched,
2825 );
2826 return ThresholdCountertermStatus::AlwaysPinched;
2827 }
2828
2829 let status = if settings.threshold_subtraction.check_esurface_at_generation {
2830 association.classify_for_model(
2831 &self.graph,
2832 cut,
2833 model,
2834 &self.graph.param_builder,
2835 runtime_settings,
2836 settings.threshold_subtraction.esurface_existence_threshold,
2837 )
2838 } else {
2839 match structural_status {
2840 ThresholdPinchStatus::CanBecome => ThresholdCountertermStatus::CanBecomePinched,
2841 ThresholdPinchStatus::NotProven => ThresholdCountertermStatus::PotentiallyExisting,
2842 ThresholdPinchStatus::Always => unreachable!(
2843 "identically pinched threshold associations return before classification"
2844 ),
2845 }
2846 };
2847 if !status
2848 .is_eligible_for_generation(settings.threshold_subtraction.check_esurface_at_generation)
2849 {
2850 debug!(
2851 "Skipping graph '{}' cut {} threshold E-surface {} after cut-relative classification {:?}",
2852 self.graph.name, cut_id.0, association.topological_threshold_id.0, status,
2853 );
2854 }
2855 status
2856 }
2857
2858 fn topological_threshold_candidates(
2859 &mut self,
2860 settings: &GenerationSettings,
2861 ) -> Result<Vec<TopologicalThresholdCandidate>> {
2862 let global_cff = self
2863 .derived_data
2864 .global_cff_expression
2865 .as_ref()
2866 .expect("global_cff_expression should have been created");
2867 let selected_orientations = global_cff
2870 .expression
2871 .orientations
2872 .iter()
2873 .filter(|orientation| settings.orientation_pattern.filter(*orientation))
2874 .collect_vec();
2875 let selected_cff_esurface_ids = selected_orientations
2876 .iter()
2877 .flat_map(|orientation| {
2878 orientation
2879 .iter_denominator_nodes()
2880 .filter_map(|node| match node.data {
2881 HybridSurfaceID::Esurface(esurface_id) => Some(esurface_id),
2882 _ => None,
2883 })
2884 })
2885 .collect::<BTreeSet<_>>();
2886
2887 let mut candidates = self.graph.all_st_cuts_for_cs(
2888 self.source_nodes.clone(),
2889 self.target_nodes.clone(),
2890 &self.graph.get_initial_state_tree(),
2891 );
2892 candidates.retain(|(_left, cut, _right)| cut.nedges(&self.graph) > 1);
2893 candidates.sort_by(|a, b| a.1.cmp(&b.1));
2894
2895 let mut topological_threshold_esurfaces = TiVec::new();
2896 let candidates: Vec<Result<TopologicalThresholdCandidate>> = candidates
2897 .into_iter()
2898 .map(|(left, cut, right)| {
2899 let threshold_esurface = Esurface::new_from_cut_left(
2900 &self.graph.underlying,
2901 &CrossSectionCut {
2902 cut: cut.clone(),
2903 left: left.clone(),
2904 right: right.clone(),
2905 },
2906 Some(&self.graph.initial_state_cut),
2907 );
2908 let has_selected_orientation_instance =
2909 selected_orientations.iter().any(|orientation| {
2910 let selected_orientation = &orientation.data.orientation;
2911 self.graph
2912 .iter_edges_of(&cut)
2913 .map(|(_, edge_id, _)| edge_id)
2914 .zip(cut.iter_edges(&self.graph).map(|(direction, _)| direction))
2915 .all(|(edge_id, direction)| {
2916 selected_orientation[edge_id] == direction
2917 })
2918 });
2919 if !has_selected_orientation_instance {
2920 debug!(
2921 "Keeping topology-discovered threshold surface {:?} dormant in graph '{}': no selected-orientation instance occurs",
2922 threshold_esurface.energies, self.graph.name,
2923 );
2924 let topological_threshold_id = TopologicalThresholdId(
2925 topological_threshold_esurfaces.len(),
2926 );
2927 topological_threshold_esurfaces.push(threshold_esurface.clone());
2928 return Ok(TopologicalThresholdCandidate {
2929 topological_threshold_id,
2930 left,
2931 cut,
2932 right,
2933 threshold_esurface,
2934 selected_orientation_esurface_id: None,
2935 });
2936 }
2937
2938 let esurface_id = match self
2939 .graph
2940 .surface_cache
2941 .esurface_cache
2942 .position(|esurface| esurface == &threshold_esurface)
2943 {
2944 Some(esurface_id) => esurface_id,
2945 None => {
2946 return Err(eyre!(
2947 "Topology-discovered threshold surface {:?} has a selected-orientation instance but is missing from graph '{}' selected CFF surface cache",
2948 threshold_esurface.energies,
2949 self.graph.name,
2950 ));
2951 }
2952 };
2953 if !selected_cff_esurface_ids.contains(&esurface_id) {
2954 return Err(eyre!(
2955 "Topology-discovered threshold surface {:?} has a selected-orientation instance in graph '{}' but E-surface {} does not occur in its selected CFF expression",
2956 threshold_esurface.energies,
2957 self.graph.name,
2958 esurface_id.0,
2959 ));
2960 }
2961
2962 let topological_threshold_id = TopologicalThresholdId(
2963 topological_threshold_esurfaces.len(),
2964 );
2965 topological_threshold_esurfaces.push(threshold_esurface.clone());
2966 Ok(TopologicalThresholdCandidate {
2967 topological_threshold_id,
2968 left,
2969 cut,
2970 right,
2971 threshold_esurface,
2972 selected_orientation_esurface_id: Some(esurface_id),
2973 })
2974
2975 })
2976 .collect();
2977 let candidates = candidates.into_iter().collect::<Result<Vec<_>>>()?;
2978 self.derived_data.topological_threshold_esurfaces = topological_threshold_esurfaces;
2979 Ok(candidates)
2980 }
2981
2982 fn physical_cut_edges(&self, cut_id: CutId) -> Vec<EdgeIndex> {
2983 self.graph
2984 .iter_edges_of(&self.cuts[cut_id].cut)
2985 .map(|(_, edge_id, _)| edge_id)
2986 .sorted()
2987 .collect()
2988 }
2989
2990 #[cfg(test)]
2991 fn threshold_energy_edges(&self, esurface_id: EsurfaceID) -> Vec<EdgeIndex> {
2992 self.graph.surface_cache.esurface_cache[esurface_id]
2993 .energies
2994 .iter()
2995 .copied()
2996 .sorted()
2997 .collect()
2998 }
2999
3000 fn cut_group_side_subgraphs(
3001 &self,
3002 cut_group_id: CutGroupId,
3003 ) -> Result<(SuBitGraph, SuBitGraph)> {
3004 let cut_group = &self.derived_data.cut_group_data.cut_groups[cut_group_id];
3005 let smallest_side = |side: ThresholdCountertermSide| {
3006 cut_group
3007 .cuts
3008 .iter()
3009 .map(|cut_id| match side {
3010 ThresholdCountertermSide::Amplitude => {
3011 unreachable!("amplitude side is not valid in cross-section resolution")
3012 }
3013 ThresholdCountertermSide::Left => self.cuts[*cut_id].left.clone(),
3014 ThresholdCountertermSide::Right => self.cuts[*cut_id].right.clone(),
3015 })
3016 .min_by_key(|subgraph| subgraph.nedges(&self.graph))
3017 .ok_or_else(|| {
3018 eyre!(
3019 "Graph '{}' cut group {} has no {:?} subgraph",
3020 self.graph.name,
3021 cut_group_id.0,
3022 side,
3023 )
3024 })
3025 };
3026
3027 Ok((
3028 smallest_side(ThresholdCountertermSide::Left)?,
3029 smallest_side(ThresholdCountertermSide::Right)?,
3030 ))
3031 }
3032
3033 fn cut_group_compatible_parent_lmbs(&self, cut_group_id: CutGroupId) -> Result<Vec<LmbIndex>> {
3034 let representative_cut_id = self.derived_data.cut_group_data.cut_groups[cut_group_id]
3035 .cuts
3036 .first()
3037 .copied()
3038 .ok_or_else(|| {
3039 eyre!(
3040 "Graph '{}' has an empty cut group {} while selecting threshold-counterterm parents",
3041 self.graph.name,
3042 cut_group_id.0,
3043 )
3044 })?;
3045 let all_lmbs = self
3046 .derived_data
3047 .lmbs
3048 .as_ref()
3049 .expect("threshold resolution requires loop-momentum bases");
3050 Ok(all_lmbs
3051 .iter_enumerated()
3052 .filter_map(|(lmb_index, lmb)| {
3053 let mut cut_edges = self
3054 .graph
3055 .iter_edges_of(&self.cuts[representative_cut_id].cut)
3056 .map(|(_, edge_id, _)| edge_id)
3057 .collect_vec();
3058 cut_edges.retain(|edge_id| !lmb.loop_edges.contains(edge_id));
3059 (cut_edges.len() == 1).then_some(lmb_index)
3060 })
3061 .collect())
3062 }
3063
3064 fn configured_threshold_variants(
3065 spec: &crate::graph::threshold_counterterms::ThresholdCountertermSpec,
3066 cut_edges: &[EdgeIndex],
3067 threshold_edges: &[EdgeIndex],
3068 ) -> Vec<(ThresholdCountertermVariant, ThresholdCountertermOrigin)> {
3069 let configured = spec
3070 .cuts
3071 .iter()
3072 .find(|cut| cut.edges == cut_edges)
3073 .and_then(|cut| {
3074 cut.thresholds
3075 .iter()
3076 .find(|threshold| threshold.edges == threshold_edges)
3077 })
3078 .filter(|threshold| !threshold.counterterms.is_empty());
3079
3080 match configured {
3081 Some(threshold) => threshold
3082 .counterterms
3083 .iter()
3084 .cloned()
3085 .map(|mut variant| {
3086 if let Some(multiplier) = &mut variant.multiplier {
3087 for (name, definition) in &spec.function_map {
3088 multiplier
3089 .function_map
3090 .entry(name.clone())
3091 .or_insert_with(|| definition.clone());
3092 }
3093 }
3094 (variant, ThresholdCountertermOrigin::Explicit)
3095 })
3096 .collect(),
3097 None => vec![(
3098 ThresholdCountertermVariant {
3099 name: Some("default".to_string()),
3100 subspace: None,
3101 parent_lmb: None,
3102 disable: false,
3103 multiplier: None,
3104 group_id: None,
3105 },
3106 ThresholdCountertermOrigin::Autogenerated,
3107 )],
3108 }
3109 }
3110
3111 fn resolve_threshold_variant_subspace(
3112 &self,
3113 variant: &ThresholdCountertermVariant,
3114 legacy_subspace: &SubspaceData,
3115 containing_subgraph: &SuBitGraph,
3116 context: &str,
3117 ) -> Result<SubspaceData> {
3118 let Some(requested_parent_lmb) = &variant.parent_lmb else {
3119 if variant.subspace.is_some() {
3120 return Err(eyre!(
3121 "{context} requires a full parent_lmb for its explicit subspace"
3122 ));
3123 }
3124 return Ok(legacy_subspace.clone());
3125 };
3126 let all_lmbs = self
3127 .derived_data
3128 .lmbs
3129 .as_ref()
3130 .expect("threshold resolution requires loop-momentum bases");
3131 let parent_lmb_index = all_lmbs
3132 .iter_enumerated()
3133 .find_map(|(lmb_index, lmb)| {
3134 lmb.loop_edges
3135 .iter()
3136 .eq(requested_parent_lmb.iter())
3137 .then_some(lmb_index)
3138 })
3139 .ok_or_else(|| {
3140 eyre!(
3141 "{context} requests parent_lmb {:?}, which is not among graph '{}' generated full LMBs",
3142 requested_parent_lmb,
3143 self.graph.name,
3144 )
3145 })?;
3146
3147 match &variant.subspace {
3148 Some(requested_basis_edges) => SubspaceData::new_from_parent_basis_edges(
3149 requested_basis_edges,
3150 containing_subgraph,
3151 parent_lmb_index,
3152 &self.graph,
3153 all_lmbs,
3154 )
3155 .or_else(|side_error| {
3156 SubspaceData::new_from_parent_basis_edges(
3157 requested_basis_edges,
3158 &self.graph.full_filter(),
3159 parent_lmb_index,
3160 &self.graph,
3161 all_lmbs,
3162 )
3163 .with_context(|| {
3164 format!("side-projected embedding was rejected with: {side_error:#}")
3165 })
3166 }),
3167 None => SubspaceData::new_with_user_selected_lmb(
3168 containing_subgraph.clone(),
3169 parent_lmb_index,
3170 &self.graph,
3171 all_lmbs,
3172 ),
3173 }
3174 .with_context(|| format!("{context} is incompatible with its requested parent_lmb"))
3175 }
3176
3177 fn threshold_subspaces_are_equivalent(
3178 left: &SubspaceData,
3179 right: &SubspaceData,
3180 all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
3181 ) -> bool {
3182 left.has_equivalent_embedding(right, all_lmbs)
3183 }
3184
3185 fn resolve_threshold_counterterm_directives(
3186 &self,
3187 model: &Model,
3188 settings: &GenerationSettings,
3189 runtime_settings: &RuntimeSettings,
3190 all_possible_thresholds: &[TopologicalThresholdCandidate],
3191 ) -> Result<(
3192 ResolvedThresholdCounterterms,
3193 TiVec<CutId, CutThresholdCountertermAssociations>,
3194 )> {
3195 let all_lmbs = self
3196 .derived_data
3197 .lmbs
3198 .as_ref()
3199 .expect("threshold resolution requires loop-momentum bases");
3200 let mut cut_group_by_cut = vec![None; self.cuts.len()];
3201 for (cut_group_id, cut_group) in self
3202 .derived_data
3203 .cut_group_data
3204 .cut_groups
3205 .iter_enumerated()
3206 {
3207 for cut_id in &cut_group.cuts {
3208 cut_group_by_cut[cut_id.0] = Some(cut_group_id);
3209 }
3210 }
3211
3212 let threshold_raised_data = self.graph.determine_raised_esurfaces_from_expression(
3213 &self
3214 .derived_data
3215 .global_cff_expression
3216 .as_ref()
3217 .expect("global_cff_expression should have been created")
3218 .expression,
3219 );
3220 let mut raised_threshold_ids: TiVec<EsurfaceID, Option<RaisedEsurfaceId>> =
3221 ti_vec![None; self.graph.surface_cache.esurface_cache.len()];
3222 for (raised_threshold_id, raised_group) in
3223 threshold_raised_data.raised_groups.iter_enumerated()
3224 {
3225 for &esurface_id in &raised_group.esurface_ids {
3226 raised_threshold_ids[esurface_id] = Some(raised_threshold_id);
3227 }
3228 }
3229
3230 let mut physical_drafts = Vec::new();
3231 let mut cut_threshold_associations: TiVec<CutId, CutThresholdCountertermAssociations> =
3232 ti_vec![CutThresholdCountertermAssociations::default(); self.cuts.len()];
3233 let mut legacy_equivalent = true;
3234 let mut matched_explicit_cuts = BTreeSet::new();
3235 let mut matched_explicit_thresholds = BTreeSet::new();
3236 let mut explicit_group_members = BTreeMap::<usize, Vec<_>>::new();
3237
3238 for (cut_id, cut) in self.cuts.iter_enumerated() {
3239 let cut_group_id = cut_group_by_cut[cut_id.0].ok_or_else(|| {
3240 eyre!(
3241 "Physical cut {} in graph '{}' is missing from the cut-group partition",
3242 cut_id.0,
3243 self.graph.name,
3244 )
3245 })?;
3246 let cut_edges = self.physical_cut_edges(cut_id);
3247 if self
3248 .graph
3249 .threshold_counterterms
3250 .cuts
3251 .iter()
3252 .any(|configured| configured.edges == cut_edges)
3253 {
3254 matched_explicit_cuts.insert(cut_edges.clone());
3255 }
3256 let (left_containing_subgraph, right_containing_subgraph) =
3257 self.cut_group_side_subgraphs(cut_group_id)?;
3258 let (legacy_left_subspace, legacy_right_subspace) =
3259 &self.derived_data.subspace_data[cut_group_id];
3260
3261 for threshold_candidate in all_possible_thresholds {
3262 let inferred = if cut.left.includes(&threshold_candidate.left) {
3263 let sandwich = cut.left.intersection(&threshold_candidate.right);
3264 self.graph.underlying.is_connected(&sandwich).then_some((
3265 ThresholdCountertermSide::Left,
3266 sandwich,
3267 legacy_left_subspace,
3268 &left_containing_subgraph,
3269 ))
3270 } else if cut.right.includes(&threshold_candidate.right) {
3271 let sandwich = cut.right.intersection(&threshold_candidate.left);
3272 self.graph.underlying.is_connected(&sandwich).then_some((
3273 ThresholdCountertermSide::Right,
3274 sandwich,
3275 legacy_right_subspace,
3276 &right_containing_subgraph,
3277 ))
3278 } else {
3279 None
3280 };
3281 let Some((side, sandwich, legacy_subspace, containing_subgraph)) = inferred else {
3282 continue;
3283 };
3284
3285 let threshold_edges = threshold_candidate
3286 .threshold_esurface
3287 .energies
3288 .iter()
3289 .copied()
3290 .sorted()
3291 .collect_vec();
3292 if self
3293 .graph
3294 .threshold_counterterms
3295 .cuts
3296 .iter()
3297 .find(|configured| configured.edges == cut_edges)
3298 .is_some_and(|configured| {
3299 configured
3300 .thresholds
3301 .iter()
3302 .any(|threshold| threshold.edges == threshold_edges)
3303 })
3304 {
3305 matched_explicit_thresholds
3306 .insert((cut_edges.clone(), threshold_edges.clone()));
3307 }
3308 for (variant, _) in Self::configured_threshold_variants(
3311 &self.graph.threshold_counterterms,
3312 &cut_edges,
3313 &threshold_edges,
3314 ) {
3315 let Some(group_id) = variant.group_id else {
3316 continue;
3317 };
3318 let context = format!(
3319 "graph '{}' cut {:?} threshold {:?} variant '{}'",
3320 self.graph.name,
3321 cut_edges.iter().map(|edge| edge.0).collect_vec(),
3322 threshold_edges.iter().map(|edge| edge.0).collect_vec(),
3323 variant.name.as_deref().unwrap_or("default"),
3324 );
3325 let subspace = self.resolve_threshold_variant_subspace(
3326 &variant,
3327 legacy_subspace,
3328 containing_subgraph,
3329 &context,
3330 )?;
3331 let signature = subspace.solve_signature(all_lmbs);
3332 let signed_cycles = signature
3333 .iter()
3334 .map(|(edge, cycle)| {
3335 (
3336 edge.0,
3337 cycle
3338 .iter()
3339 .map(|(edge, sign)| (edge.0, sign))
3340 .collect_vec(),
3341 )
3342 })
3343 .collect_vec();
3344 let description = format!(
3345 " {context}, cut_group={}, cut={}, side={side:?}, parent={:?}, signed_cycles={signed_cycles:?}, disabled={}",
3346 cut_group_id.0,
3347 cut_id.0,
3348 subspace
3349 .get_lmb(all_lmbs)
3350 .loop_edges
3351 .iter()
3352 .map(|edge| edge.0)
3353 .collect_vec(),
3354 variant.disable,
3355 );
3356 explicit_group_members
3357 .entry(group_id)
3358 .or_default()
3359 .push((signature, description));
3360 }
3361 if threshold_candidate
3362 .selected_orientation_esurface_id
3363 .is_none()
3364 {
3365 debug!(
3366 "Leaving graph '{}' cut {:?} threshold {:?} dormant because it has no selected-orientation instance",
3367 self.graph.name, cut_edges, threshold_edges,
3368 );
3369 continue;
3370 }
3371
3372 let association = self.threshold_counterterm_association(
3373 threshold_candidate,
3374 &sandwich,
3375 &cut.cut,
3376 );
3377 if settings.threshold_subtraction.skip_thresholds_that_are_cuts
3378 && self
3379 .cuts
3380 .iter()
3381 .any(|physical_cut| physical_cut.cut == threshold_candidate.cut)
3382 {
3383 continue;
3384 }
3385 let configured_variants = Self::configured_threshold_variants(
3386 &self.graph.threshold_counterterms,
3387 &cut_edges,
3388 &threshold_edges,
3389 );
3390 let mut variants = Vec::with_capacity(configured_variants.len());
3391 for (variant, origin) in configured_variants {
3392 if !variant.disable
3393 && variant
3394 .multiplier
3395 .as_ref()
3396 .is_some_and(|multiplier| multiplier.symmetrize)
3397 {
3398 unimplemented!(
3399 "symmetrized threshold-counterterm multipliers are not implemented (graph '{}', cut {:?}, threshold {:?}, variant '{}')",
3400 self.graph.name,
3401 cut_edges,
3402 threshold_edges,
3403 variant.name.as_deref().unwrap_or("default"),
3404 );
3405 }
3406 let name = variant
3407 .name
3408 .clone()
3409 .unwrap_or_else(|| "default".to_string());
3410 let context = format!(
3411 "graph '{}' cut {:?} threshold {:?} variant '{}'",
3412 self.graph.name,
3413 cut_edges.iter().map(|edge| edge.0).collect_vec(),
3414 threshold_edges.iter().map(|edge| edge.0).collect_vec(),
3415 name,
3416 );
3417 if origin == ThresholdCountertermOrigin::Explicit
3418 && variant.parent_lmb.is_none()
3419 {
3420 return Err(eyre!("{context} requires a full parent_lmb"));
3421 }
3422 let subspace = self.resolve_threshold_variant_subspace(
3423 &variant,
3424 legacy_subspace,
3425 containing_subgraph,
3426 &context,
3427 )?;
3428 let status = self.classify_threshold_counterterm_association(
3429 &association,
3430 cut_id,
3431 cut,
3432 &subspace,
3433 all_lmbs,
3434 model,
3435 runtime_settings,
3436 settings,
3437 );
3438 variants.push(ResolvedThresholdCountertermDraft {
3439 name,
3440 group_id: variant.group_id,
3441 origin,
3442 disable: variant.disable,
3443 requested_subspace: variant.subspace,
3444 requested_parent_lmb: variant.parent_lmb,
3445 multiplier: variant.multiplier,
3446 subspace,
3447 status,
3448 });
3449 }
3450
3451 let is_eligible = |status: ThresholdCountertermStatus| {
3452 status.is_eligible_for_generation(
3453 settings.threshold_subtraction.check_esurface_at_generation,
3454 )
3455 };
3456 let legacy_status = self.classify_threshold_counterterm_association(
3457 &association,
3458 cut_id,
3459 cut,
3460 legacy_subspace,
3461 all_lmbs,
3462 model,
3463 runtime_settings,
3464 settings,
3465 );
3466 if is_eligible(legacy_status)
3467 && (variants.len() != 1
3468 || variants[0].disable
3469 || variants[0].group_id.is_some()
3470 || variants[0].multiplier.is_some()
3471 || !Self::threshold_subspaces_are_equivalent(
3472 &variants[0].subspace,
3473 legacy_subspace,
3474 all_lmbs,
3475 ))
3476 {
3477 legacy_equivalent = false;
3478 }
3479
3480 let has_active_variant = variants
3481 .iter()
3482 .any(|variant| !variant.disable && is_eligible(variant.status));
3483 if has_active_variant {
3484 match side {
3485 ThresholdCountertermSide::Amplitude => {
3486 unreachable!("amplitude side is not valid in cross-section resolution")
3487 }
3488 ThresholdCountertermSide::Left => cut_threshold_associations[cut_id]
3489 .left
3490 .push(association.clone()),
3491 ThresholdCountertermSide::Right => cut_threshold_associations[cut_id]
3492 .right
3493 .push(association.clone()),
3494 }
3495 }
3496
3497 let raised_threshold_id = raised_threshold_ids[association.esurface_id]
3498 .ok_or_else(|| {
3499 eyre!(
3500 "Graph '{}' threshold E-surface {} is missing from raised-threshold data",
3501 self.graph.name,
3502 association.esurface_id.0,
3503 )
3504 })?;
3505 physical_drafts.push(PhysicalThresholdCountertermDraft {
3506 cut_id,
3507 cut_group_id,
3508 cut_edges: cut_edges.clone(),
3509 side,
3510 esurface_id: association.esurface_id,
3511 threshold_edges,
3512 raised_threshold_id,
3513 raised_esurface_group: threshold_raised_data.raised_groups[raised_threshold_id]
3514 .clone(),
3515 variants,
3516 });
3517 }
3518 }
3519
3520 for configured_cut in &self.graph.threshold_counterterms.cuts {
3521 if !matched_explicit_cuts.contains(&configured_cut.edges) {
3522 return Err(eyre!(
3523 "Graph '{}' threshold_counterterms cut {:?} does not match any current physical cut",
3524 self.graph.name,
3525 configured_cut.edges,
3526 ));
3527 }
3528 for configured_threshold in &configured_cut.thresholds {
3529 if !matched_explicit_thresholds.contains(&(
3530 configured_cut.edges.clone(),
3531 configured_threshold.edges.clone(),
3532 )) {
3533 return Err(eyre!(
3534 "Graph '{}' threshold_counterterms cut {:?} threshold {:?} does not match a topology-discovered association",
3535 self.graph.name,
3536 configured_cut.edges,
3537 configured_threshold.edges,
3538 ));
3539 }
3540 }
3541 }
3542
3543 let conflicts = explicit_group_members
3547 .into_iter()
3548 .filter_map(|(group_id, members)| {
3549 if members
3550 .iter()
3551 .all(|(signature, _)| signature == &members[0].0)
3552 {
3553 return None;
3554 }
3555 Some(format!(
3556 "group_id={group_id} contains incompatible solve subspaces:\n{}",
3557 members
3558 .into_iter()
3559 .map(|(_, description)| description)
3560 .join("\n"),
3561 ))
3562 })
3563 .collect_vec();
3564 if !conflicts.is_empty() {
3565 return Err(eyre!(
3566 "Graph '{}' has incompatible explicit threshold groups. A group_id may only refine one automatic signed-cycle subspace. All members of each conflicting group:\n{}",
3567 self.graph.name,
3568 conflicts.join("\n"),
3569 ));
3570 }
3571
3572 let mut grouped = BTreeMap::<
3573 (CutGroupId, RaisedEsurfaceId),
3574 Vec<PhysicalThresholdCountertermDraft>,
3575 >::new();
3576 for draft in physical_drafts {
3577 grouped
3578 .entry((draft.cut_group_id, draft.raised_threshold_id))
3579 .or_default()
3580 .push(draft);
3581 }
3582
3583 let mut aggregated = Vec::new();
3584 for ((cut_group_id, raised_threshold_id), mut drafts) in grouped {
3585 let raised_group_is_legacy_equivalent = drafts.iter().all(|draft| {
3586 let legacy_subspace = match draft.side {
3587 ThresholdCountertermSide::Amplitude => return false,
3588 ThresholdCountertermSide::Left => {
3589 &self.derived_data.subspace_data[cut_group_id].0
3590 }
3591 ThresholdCountertermSide::Right => {
3592 &self.derived_data.subspace_data[cut_group_id].1
3593 }
3594 };
3595 draft.variants.len() == 1
3596 && !draft.variants[0].disable
3597 && draft.variants[0].group_id.is_none()
3598 && draft.variants[0].multiplier.is_none()
3599 && Self::threshold_subspaces_are_equivalent(
3600 &draft.variants[0].subspace,
3601 legacy_subspace,
3602 all_lmbs,
3603 )
3604 });
3605 let has_left = drafts
3606 .iter()
3607 .any(|draft| draft.side == ThresholdCountertermSide::Left);
3608 let has_right = drafts
3609 .iter()
3610 .any(|draft| draft.side == ThresholdCountertermSide::Right);
3611 if has_left && has_right {
3612 if raised_group_is_legacy_equivalent {
3613 drafts.retain(|draft| draft.side == ThresholdCountertermSide::Left);
3616 } else {
3617 return Err(eyre!(
3618 "Graph '{}' cut group {} raised threshold {:?} is inferred on both sides; non-legacy directives must resolve compatibly across raised physical cuts",
3619 self.graph.name,
3620 cut_group_id.0,
3621 threshold_raised_data.raised_groups[raised_threshold_id].esurface_ids,
3622 ));
3623 }
3624 }
3625
3626 let representative = drafts.first().expect("grouped drafts are non-empty");
3627 if drafts.iter().skip(1).any(|draft| {
3628 draft.variants.len() != representative.variants.len()
3629 || draft
3630 .variants
3631 .iter()
3632 .zip(&representative.variants)
3633 .any(|(variant, expected)| !variant.is_compatible_with(expected, all_lmbs))
3634 }) {
3635 let descriptions = drafts.iter().flat_map(|draft| {
3636 draft.variants.iter().map(|variant| format!(
3637 " cut={}, cut_edges={:?}, threshold_edges={:?}, variant='{}', group_id={:?}, parent={:?}, signed_cycles={:?}, disabled={}, multiplier={:?}",
3638 draft.cut_id.0,
3639 draft.cut_edges,
3640 draft.threshold_edges,
3641 variant.name,
3642 variant.group_id,
3643 variant.subspace.get_lmb(all_lmbs).loop_edges,
3644 variant.subspace.solve_signature(all_lmbs),
3645 variant.disable,
3646 variant.multiplier,
3647 ))
3648 }).join("\n");
3649 return Err(eyre!(
3650 "Graph '{}' cut group {} raised threshold {:?} resolves incompatibly across physical cuts. Corresponding counterterm variants of one merged raised residue must have identical directives, group_id and signed solve cycles. All physical members:\n{}",
3651 self.graph.name,
3652 cut_group_id.0,
3653 representative.raised_esurface_group.esurface_ids,
3654 descriptions,
3655 ));
3656 }
3657 aggregated.push(drafts);
3658 }
3659
3660 aggregated.sort_by_key(|drafts| {
3661 let representative = &drafts[0];
3662 (
3663 representative.cut_group_id.0,
3664 match representative.side {
3665 ThresholdCountertermSide::Amplitude => 2,
3666 ThresholdCountertermSide::Left => 0,
3667 ThresholdCountertermSide::Right => 1,
3668 },
3669 representative
3670 .raised_esurface_group
3671 .esurface_ids
3672 .iter()
3673 .map(|id| id.0)
3674 .collect_vec(),
3675 )
3676 });
3677
3678 let mut variants =
3679 TiVec::<ThresholdCountertermVariantId, ResolvedThresholdCountertermVariant>::new();
3680 let mut cut_groups = ti_vec![
3681 ResolvedCutGroupThresholdCounterterms::default();
3682 self.derived_data.cut_group_data.cut_groups.len()
3683 ];
3684 let is_eligible = |status: ThresholdCountertermStatus| {
3685 status.is_eligible_for_generation(
3686 settings.threshold_subtraction.check_esurface_at_generation,
3687 )
3688 };
3689
3690 for drafts in aggregated {
3691 let representative = &drafts[0];
3692 let mut active_variant_count = 0;
3693 for variant_index in 0..representative.variants.len() {
3694 let representative_variant = &representative.variants[variant_index];
3695 if representative_variant.disable {
3696 continue;
3697 }
3698 let eligible_cut_ids = drafts
3699 .iter()
3700 .filter(|draft| is_eligible(draft.variants[variant_index].status))
3701 .map(|draft| draft.cut_id)
3702 .sorted_by_key(|cut_id| cut_id.0)
3703 .dedup()
3704 .collect_vec();
3705 if eligible_cut_ids.is_empty() {
3706 continue;
3707 }
3708
3709 active_variant_count += 1;
3710 let associations = drafts
3711 .iter()
3712 .map(|draft| {
3713 let resolved_variant = &draft.variants[variant_index];
3714 let legacy_subspace = match draft.side {
3715 ThresholdCountertermSide::Amplitude => unreachable!(
3716 "amplitude side is not valid in cross-section resolution"
3717 ),
3718 ThresholdCountertermSide::Left => {
3719 &self.derived_data.subspace_data[draft.cut_group_id].0
3720 }
3721 ThresholdCountertermSide::Right => {
3722 &self.derived_data.subspace_data[draft.cut_group_id].1
3723 }
3724 };
3725 ResolvedThresholdCountertermAssociation {
3726 cut_id: Some(draft.cut_id),
3727 cut_edges: draft.cut_edges.clone(),
3728 threshold_edges: draft.threshold_edges.clone(),
3729 esurface_id: draft.esurface_id,
3730 subspace: resolved_variant.subspace.clone(),
3731 requires_explicit_parent_lmb: resolved_variant
3732 .subspace
3733 .parent_lmb_index()
3734 != legacy_subspace.parent_lmb_index(),
3735 eligible: is_eligible(resolved_variant.status),
3736 origin: resolved_variant.origin,
3737 }
3738 })
3739 .sorted_by_key(|association| {
3740 (
3741 association.cut_id.map(|cut_id| cut_id.0),
3742 association.esurface_id.0,
3743 association.threshold_edges.clone(),
3744 )
3745 })
3746 .collect();
3747 let parent_lmb_index = representative_variant.subspace.parent_lmb_index();
3748 let variant_id = ThresholdCountertermVariantId::from(variants.len());
3749 variants.push(ResolvedThresholdCountertermVariant {
3750 name: representative_variant.name.clone(),
3751 group_id: representative_variant.group_id,
3752 cut_group_id: Some(representative.cut_group_id),
3753 associations,
3754 side: representative.side,
3755 threshold_esurface_ids: representative
3756 .raised_esurface_group
3757 .esurface_ids
3758 .clone(),
3759 raised_esurface_group: representative.raised_esurface_group.clone(),
3760 requested_subspace: representative_variant.requested_subspace.clone(),
3761 requested_parent_lmb: representative_variant.requested_parent_lmb.clone(),
3762 resolved_parent_lmb: all_lmbs[parent_lmb_index].loop_edges.clone().into(),
3763 subspace: representative_variant.subspace.clone(),
3764 subspace_loop_count: representative_variant.subspace.loopcount(),
3765 multiplier: representative_variant.multiplier.clone(),
3766 });
3767 match representative.side {
3768 ThresholdCountertermSide::Amplitude => {
3769 unreachable!("amplitude side is not valid in cross-section resolution")
3770 }
3771 ThresholdCountertermSide::Left => cut_groups[representative.cut_group_id]
3772 .left
3773 .push(variant_id),
3774 ThresholdCountertermSide::Right => cut_groups[representative.cut_group_id]
3775 .right
3776 .push(variant_id),
3777 }
3778 }
3779
3780 let legacy_subspace = match representative.side {
3781 ThresholdCountertermSide::Amplitude => {
3782 unreachable!("amplitude side is not valid in cross-section resolution")
3783 }
3784 ThresholdCountertermSide::Left => {
3785 &self.derived_data.subspace_data[representative.cut_group_id].0
3786 }
3787 ThresholdCountertermSide::Right => {
3788 &self.derived_data.subspace_data[representative.cut_group_id].1
3789 }
3790 };
3791 if active_variant_count > 0
3792 && (active_variant_count != 1
3793 || representative.variants.len() != 1
3794 || representative.variants[0].multiplier.is_some()
3795 || representative.variants[0].group_id.is_some()
3796 || !Self::threshold_subspaces_are_equivalent(
3797 &representative.variants[0].subspace,
3798 legacy_subspace,
3799 all_lmbs,
3800 ))
3801 {
3802 legacy_equivalent = false;
3803 }
3804 }
3805
3806 for (cut_group_id, cut_group) in cut_groups.iter_enumerated() {
3807 for (left_variant_id, right_variant_id) in cut_group.iter_pairs() {
3808 let left = &variants[left_variant_id];
3809 let right = &variants[right_variant_id];
3810 if !left.subspace.is_mergable_with(&right.subspace, all_lmbs) {
3811 return Err(eyre!(
3812 "Graph '{}' cut group {} has incompatible iterated threshold projections. Left variant '{}': associations={:?}, parent={:?}, signed_cycles={:?}; right variant '{}': associations={:?}, parent={:?}, signed_cycles={:?}. Each selected cycle must have zero response on the other variant's defining edges",
3813 self.graph.name,
3814 cut_group_id.0,
3815 left.name,
3816 left.associations
3817 .iter()
3818 .map(|association| (
3819 &association.cut_edges,
3820 &association.threshold_edges
3821 ))
3822 .collect_vec(),
3823 left.subspace.get_lmb(all_lmbs).loop_edges,
3824 left.subspace.solve_signature(all_lmbs),
3825 right.name,
3826 right
3827 .associations
3828 .iter()
3829 .map(|association| (
3830 &association.cut_edges,
3831 &association.threshold_edges
3832 ))
3833 .collect_vec(),
3834 right.subspace.get_lmb(all_lmbs).loop_edges,
3835 right.subspace.solve_signature(all_lmbs),
3836 ));
3837 }
3838 }
3839 }
3840
3841 Ok((
3842 ResolvedThresholdCounterterms {
3843 legacy_equivalent,
3844 variants,
3845 cross_section_cut_groups: cut_groups,
3846 },
3847 cut_threshold_associations,
3848 ))
3849 }
3850
3851 fn build_threshold_counterterm(
3852 &mut self,
3853 model: &Model,
3854 settings: &GenerationSettings,
3855 runtime_settings: &RuntimeSettings,
3856 all_possible_thresholds: &[TopologicalThresholdCandidate],
3857 vakint: &Vakint,
3858 cff_energy_degree_bound_reports: &Mutex<Vec<CffEnergyDegreeBoundReport>>,
3859 ) -> Result<()> {
3860 let (resolved, cut_threshold_associations) = self
3861 .resolve_threshold_counterterm_directives(
3862 model,
3863 settings,
3864 runtime_settings,
3865 all_possible_thresholds,
3866 )?;
3867
3868 let mut cut_structure = vec![];
3869 let expression_esurfaces = &self
3870 .derived_data
3871 .global_cff_expression
3872 .as_ref()
3873 .expect("global_cff_expression should have been created")
3874 .expression
3875 .surfaces
3876 .esurface_cache;
3877 for (cut_group_id, cut_group) in self
3878 .derived_data
3879 .cut_group_data
3880 .cut_groups
3881 .iter_enumerated()
3882 {
3883 let resolved_group = &resolved.cross_section_cut_groups[cut_group_id];
3884 let lu_cut_selection = cut_group.lu_cut_selection(&self.graph, &self.cuts);
3885 let cutkosky_cut_union = cut_group
3886 .cuts
3887 .iter()
3888 .map(|cut_id| self.cuts[*cut_id].cut.as_subgraph())
3889 .reduce(|a, b| a.union(&b))
3890 .unwrap_or(self.graph.empty_subgraph());
3891
3892 let add_threshold_group_to_union =
3893 |base: SuBitGraph, raised_group: &RaisedEsurfaceGroup| {
3894 let representative_esurface =
3895 &expression_esurfaces[raised_group.esurface_ids[0]];
3896
3897 representative_esurface
3898 .energies
3899 .iter()
3900 .map(|edge_id| self.graph.get_edge_subgraph(*edge_id))
3901 .fold(base, |acc, subgraph| acc.union(&subgraph))
3902 };
3903
3904 for &variant_id in &resolved_group.left {
3905 let raised_esurface_group = &resolved.variants[variant_id].raised_esurface_group;
3906 let esurface_cut_union =
3907 add_threshold_group_to_union(cutkosky_cut_union.clone(), raised_esurface_group);
3908
3909 cut_structure.push(CutSet {
3910 residue_selector: ResidueSelector {
3911 lu: Some(lu_cut_selection.clone()),
3912 left_th_cut: Some(raised_esurface_group.clone()),
3913 right_th_cut: None,
3914 },
3915 union: esurface_cut_union,
3916 canonicalize_external_shifts: false,
3917 });
3918 }
3919
3920 for &variant_id in &resolved_group.right {
3921 let raised_esurface_group = &resolved.variants[variant_id].raised_esurface_group;
3922 let esurface_cut_union =
3923 add_threshold_group_to_union(cutkosky_cut_union.clone(), raised_esurface_group);
3924
3925 cut_structure.push(CutSet {
3926 residue_selector: ResidueSelector {
3927 lu: Some(lu_cut_selection.clone()),
3928 left_th_cut: None,
3929 right_th_cut: Some(raised_esurface_group.clone()),
3930 },
3931 union: esurface_cut_union,
3932 canonicalize_external_shifts: false,
3933 });
3934 }
3935
3936 for (left_variant_id, right_variant_id) in resolved_group.iter_pairs() {
3937 let left_raised_esurface_group =
3938 &resolved.variants[left_variant_id].raised_esurface_group;
3939 let right_raised_esurface_group =
3940 &resolved.variants[right_variant_id].raised_esurface_group;
3941 let esurface_cut_union = add_threshold_group_to_union(
3942 add_threshold_group_to_union(
3943 cutkosky_cut_union.clone(),
3944 left_raised_esurface_group,
3945 ),
3946 right_raised_esurface_group,
3947 );
3948
3949 cut_structure.push(CutSet {
3950 residue_selector: ResidueSelector {
3951 lu: Some(lu_cut_selection.clone()),
3952 left_th_cut: Some(left_raised_esurface_group.clone()),
3953 right_th_cut: Some(right_raised_esurface_group.clone()),
3954 },
3955 union: esurface_cut_union,
3956 canonicalize_external_shifts: false,
3957 });
3958 }
3959 }
3960
3961 let cut_structure = CutStructure {
3962 cuts: cut_structure,
3963 };
3964
3965 let cff_options = self.graph.production_cff_3d_expression_options(settings)?;
3966 let production_expression = self
3967 .derived_data
3968 .global_cff_expression
3969 .as_ref()
3970 .expect("global_cff_expression should have been created");
3971
3972 let mut threshold_counterterms = settings
3973 .uv
3974 .orchestrator
3975 .parametric_integrands(
3976 &mut self.graph,
3977 cut_structure,
3978 vakint,
3979 OrientationProjection::exact_expression(
3980 production_expression,
3981 &cff_options,
3982 &settings.orientation_pattern,
3983 settings.explicit_orientation_sum_only,
3984 )
3985 .with_vacuum_subtraction(settings.medium.vacuum_subtraction)
3986 .with_energy_degree_bound_reports(cff_energy_degree_bound_reports),
3987 settings,
3988 )?
3989 .into_iter();
3990
3991 let mut result = TiVec::<CutGroupId, LUCounterTermData>::new();
3992 for (cut_group_id, _) in self
3993 .derived_data
3994 .cut_group_data
3995 .cut_groups
3996 .iter_enumerated()
3997 {
3998 let resolved_group = &resolved.cross_section_cut_groups[cut_group_id];
3999 let lu_prefactor = self.derived_data.cut_group_data.cut_groups[cut_group_id]
4000 .lu_prefactor(&self.graph, &self.cuts)?;
4001 let left_variant_ids: TiVec<LeftThresholdId, ThresholdCountertermVariantId> =
4002 resolved_group.left.iter().copied().collect();
4003 let right_variant_ids: TiVec<RightThresholdId, ThresholdCountertermVariantId> =
4004 resolved_group.right.iter().copied().collect();
4005 let left_thresholds = left_variant_ids
4006 .iter()
4007 .map(|variant_id| resolved.variants[*variant_id].raised_esurface_group.clone())
4008 .collect();
4009 let right_thresholds = right_variant_ids
4010 .iter()
4011 .map(|variant_id| resolved.variants[*variant_id].raised_esurface_group.clone())
4012 .collect();
4013 let left_subspaces = left_variant_ids
4014 .iter()
4015 .map(|variant_id| resolved.variants[*variant_id].subspace.clone())
4016 .collect();
4017 let right_subspaces = right_variant_ids
4018 .iter()
4019 .map(|variant_id| resolved.variants[*variant_id].subspace.clone())
4020 .collect();
4021
4022 let mut left_atoms = TiVec::<LeftThresholdId, _>::new();
4023 let mut right_atoms = TiVec::<RightThresholdId, _>::new();
4024 let mut iterated_atoms = vec![];
4025
4026 for variant_id in &left_variant_ids {
4027 let variant = &resolved.variants[*variant_id];
4028 let rstar_pow = Self::threshold_rstar_power(variant.subspace_loop_count, false);
4029 let counterterm = threshold_counterterms.next().ok_or_else(|| {
4030 eyre!(
4031 "Threshold orchestrator returned too few single-left integrands for graph '{}' cut group {}",
4032 self.graph.name,
4033 cut_group_id.0,
4034 )
4035 })?;
4036 left_atoms.push(counterterm.map(|x| x * &lu_prefactor * &rstar_pow));
4037 }
4038
4039 for variant_id in &right_variant_ids {
4040 let variant = &resolved.variants[*variant_id];
4041 let rstar_pow = Self::threshold_rstar_power(variant.subspace_loop_count, true);
4042 let counterterm = threshold_counterterms.next().ok_or_else(|| {
4043 eyre!(
4044 "Threshold orchestrator returned too few single-right integrands for graph '{}' cut group {}",
4045 self.graph.name,
4046 cut_group_id.0,
4047 )
4048 })?;
4049 right_atoms.push(counterterm.map(|x| x * &lu_prefactor * &rstar_pow));
4050 }
4051
4052 for (left_variant_id, right_variant_id) in resolved_group.iter_pairs() {
4053 let left_variant = &resolved.variants[left_variant_id];
4054 let right_variant = &resolved.variants[right_variant_id];
4055 let left_rstar_pow =
4056 Self::threshold_rstar_power(left_variant.subspace_loop_count, false);
4057 let right_rstar_pow =
4058 Self::threshold_rstar_power(right_variant.subspace_loop_count, true);
4059 let counterterm = threshold_counterterms.next().ok_or_else(|| {
4060 eyre!(
4061 "Threshold orchestrator returned too few iterated integrands for graph '{}' cut group {}",
4062 self.graph.name,
4063 cut_group_id.0,
4064 )
4065 })?;
4066 iterated_atoms.push(
4067 counterterm.map(|x| x * &lu_prefactor * &left_rstar_pow * &right_rstar_pow),
4068 );
4069 }
4070
4071 let iterated_collection =
4072 IteratedCtCollection::new(iterated_atoms, left_atoms.len(), right_atoms.len());
4073
4074 let counterterm_data = LUCounterTermData {
4075 left_thresholds,
4076 right_thresholds,
4077 left_variant_ids,
4078 right_variant_ids,
4079 left_subspaces,
4080 right_subspaces,
4081 left_atoms,
4082 right_atoms,
4083 iterated: iterated_collection,
4084 };
4085 result.push(counterterm_data);
4086 }
4087 if threshold_counterterms.next().is_some() {
4088 return Err(eyre!(
4089 "Threshold orchestrator returned too many integrands for graph '{}'",
4090 self.graph.name,
4091 ));
4092 }
4093
4094 let max_dual_size =
4095 max_dual_size_for_cut_cff_indices(result.iter().flat_map(|counterterm_data| {
4096 counterterm_data
4097 .left_atoms
4098 .iter()
4099 .chain(counterterm_data.right_atoms.iter())
4100 .chain(counterterm_data.iterated.iter())
4101 .flat_map(|integrands| integrands.integrands.iter().map(|(index, _)| index))
4102 }));
4103 self.graph.param_builder.initialize_duals(max_dual_size);
4104
4105 self.derived_data.cut_threshold_associations = cut_threshold_associations;
4106 self.derived_data.threshold_counterterms = result;
4107 self.derived_data.resolved_threshold_counterterms = Some(resolved);
4108 Ok(())
4109 }
4110
4111 fn build_subspace_data(&mut self) -> Result<()> {
4112 let all_lmbs = self.derived_data.lmbs.as_ref().unwrap();
4113
4114 let subspace_data = self
4115 .derived_data
4116 .cut_group_data
4117 .cut_groups
4118 .iter_enumerated()
4119 .map(|(cut_group_id, _)| {
4120 let valid_subspace_lmbs =
4121 self.cut_group_compatible_parent_lmbs(cut_group_id)?;
4122 let (smallest_left_subgraph, smallest_right_subgraph) =
4123 self.cut_group_side_subgraphs(cut_group_id)?;
4124
4125 let mut possible_subspaces = Vec::new();
4126 let mut rejected_lmbs = Vec::new();
4127 for lmb_index in valid_subspace_lmbs {
4128 let left = SubspaceData::new_with_user_selected_lmb(
4129 smallest_left_subgraph.clone(),
4130 lmb_index,
4131 &self.graph,
4132 all_lmbs,
4133 );
4134 let right = SubspaceData::new_with_user_selected_lmb(
4135 smallest_right_subgraph.clone(),
4136 lmb_index,
4137 &self.graph,
4138 all_lmbs,
4139 );
4140
4141 match (left, right) {
4142 (Ok(left), Ok(right)) if left.is_mergable_with(&right, all_lmbs) => {
4143 possible_subspaces.push((left, right));
4144 }
4145 (Ok(left), Ok(right)) => rejected_lmbs.push(format!(
4146 "LMB {} produced non-disjoint subspaces: left={:?}, right={:?}",
4147 usize::from(lmb_index),
4148 left.iter_lmb_indices().collect_vec(),
4149 right.iter_lmb_indices().collect_vec(),
4150 )),
4151 (left, right) => rejected_lmbs.push(format!(
4152 "LMB {}: left={}, right={}",
4153 usize::from(lmb_index),
4154 left.err()
4155 .map(|error| format!("{error:#}"))
4156 .unwrap_or_else(|| "compatible".to_string()),
4157 right
4158 .err()
4159 .map(|error| format!("{error:#}"))
4160 .unwrap_or_else(|| "compatible".to_string()),
4161 )),
4162 }
4163 }
4164
4165 possible_subspaces.sort_by_key(|(left, right)| {
4166 (
4167 left.iter_basis_edges(all_lmbs).collect_vec(),
4168 right.iter_basis_edges(all_lmbs).collect_vec(),
4169 )
4170 });
4171
4172 possible_subspaces.first().cloned().ok_or_else(|| {
4173 eyre!(
4174 "No topology-compatible parent LMB found for graph '{}' cut group {}. Rejections:\n{}",
4175 self.graph.name,
4176 cut_group_id.0,
4177 rejected_lmbs.join("\n"),
4178 )
4179 })
4180 })
4181 .collect::<Result<_>>()?;
4182
4183 let _: () = self.derived_data.subspace_data = subspace_data;
4184 Ok(())
4185 }
4186
4187 fn generate_term_for_graph(
4188 &self,
4189 _model: &Model,
4190 settings: &GlobalSettings,
4191 ) -> Result<(CrossSectionGraphTerm, GraphGenerationStats)> {
4192 CrossSectionGraphTerm::from_cross_section_graph(self, settings)
4193 }
4194}
4195
4196#[derive(Clone, Encode, Decode)]
4197#[trait_decode(trait = GammaLoopContext)]
4198pub struct CrossSectionDerivedData {
4199 pub covariant_cut_representatives: BTreeMap<isize, isize>,
4202 pub orientations: Option<TiVec<OrientationID, EdgeVec<Orientation>>>,
4203 pub cut_paramatric_integrand: TiVec<CutGroupId, ParametricIntegrands>,
4204 pub global_cff_expression: Option<
4205 GeneratedThreeDExpression<crate::cff::esurface::Esurface, crate::cff::hsurface::Hsurface>,
4206 >,
4207 pub lmbs: Option<TiVec<LmbIndex, LoopMomentumBasis>>,
4208 pub multi_channeling_setup: Option<LmbMultiChannelingSetup>,
4209 pub threshold_counterterms: TiVec<CutGroupId, LUCounterTermData>,
4210 pub resolved_threshold_counterterms: Option<ResolvedThresholdCounterterms>,
4211 pub topological_threshold_esurfaces: TiVec<TopologicalThresholdId, Esurface>,
4212 pub threshold_candidate_esurface_ids: Vec<EsurfaceID>,
4216
4217 pub cut_threshold_associations: TiVec<CutId, CutThresholdCountertermAssociations>,
4221 pub subspace_data: TiVec<CutGroupId, (SubspaceData, SubspaceData)>,
4222 pub cut_group_data: CutGroupData,
4223}
4224
4225#[derive(Clone, Encode, Decode, Debug)]
4227#[trait_decode(trait = GammaLoopContext)]
4228pub struct CutGroupData {
4229 pub cut_groups: TiVec<CutGroupId, CutGroup>,
4230 pub dual_shapes: Vec<Vec<Vec<usize>>>,
4231 pub pass_two_evaluators: Vec<GenericEvaluator>,
4232}
4233
4234#[derive(Clone, Encode, Decode, Debug)]
4236#[trait_decode(trait = GammaLoopContext)]
4237pub struct CutGroup {
4238 pub cuts: Vec<CutId>,
4239 pub related_esurface_group: RaisedEsurfaceGroup,
4240}
4241
4242impl CutGroup {
4243 pub(crate) fn lu_prefactor(
4244 &self,
4245 graph: &Graph,
4246 cuts: &TiVec<CutId, CrossSectionCut>,
4247 ) -> Result<Atom> {
4248 let loop_number =
4249 graph.cyclotomatic_number(&graph.full_filter()) - graph.initial_state_cut.nedges(graph);
4250
4251 let loop_3 = loop_number as i64 * 3;
4252 let component_parities = self
4257 .cuts
4258 .iter()
4259 .map(|cut| {
4260 graph
4261 .underlying
4262 .count_connected_components(&cuts[*cut].right)
4263 % 2
4264 })
4265 .unique()
4266 .collect_vec();
4267 let [parity] = component_parities.as_slice() else {
4268 return Err(eyre!(
4269 "Graph '{}' has incompatible RHS marking signs in raised cut group {:?}",
4270 graph.name,
4271 self.cuts
4272 ));
4273 };
4274 let energy_conservation_delta_factor =
4279 Atom::num(2) * Atom::var(GS.pi) * Atom::i() * Atom::num(-1).pow(*parity as i64);
4280
4281 crate::debug_tags!(#generation, #normalization, #lu, #graph, #summary;
4282 stage = "cross_section_lu_prefactor",
4283 graph = %graph.name,
4284 loop_number,
4285 loop_3,
4286 "Cross-section LU prefactor normalization"
4287 );
4288
4289 let tstar = Atom::var(GS.rescale_star);
4290 let tsrat_pow = tstar.pow(loop_3);
4291 let hfunction = Atom::var(GS.hfunction_lu_cut);
4292 Ok(tsrat_pow * hfunction * energy_conservation_delta_factor)
4293 }
4294
4295 pub(crate) fn lu_cut_selection(
4296 &self,
4297 graph: &Graph,
4298 cuts: &TiVec<CutId, CrossSectionCut>,
4299 ) -> LuCutSelection {
4300 let raised_edge_groups = graph.get_raised_edge_groups();
4301 let cut_edge_alternatives = self
4302 .cuts
4303 .iter()
4304 .map(|cut_id| {
4305 let cut_edges = graph
4306 .iter_edges_of(&cuts[*cut_id].cut.as_subgraph())
4307 .map(|(_, edge_id, _)| edge_id)
4308 .collect_vec();
4309 normalize_cut_edge_support_with_raised_edge_groups(&cut_edges, &raised_edge_groups)
4310 })
4311 .collect();
4312 LuCutSelection {
4313 raised_group: self.related_esurface_group.clone(),
4314 cut_edge_alternatives,
4315 }
4316 }
4317}
4318
4319impl Default for CutGroupData {
4320 fn default() -> Self {
4321 Self::new()
4322 }
4323}
4324
4325impl CutGroupData {
4326 pub fn new() -> Self {
4327 CutGroupData {
4328 cut_groups: TiVec::new(),
4329 dual_shapes: vec![],
4330 pass_two_evaluators: vec![],
4331 }
4332 }
4333
4334 pub fn new_from_esurface(
4335 raised_esurface_data: &RaisedEsurfaceData,
4336 cut_esurface_map: &TiVec<CutId, EsurfaceID>,
4337 evaluator_settings: &EvaluatorSettings,
4338 ) -> (Self, GraphGenerationStats) {
4339 let mut stats = GraphGenerationStats::default();
4340 let mut groups = TiVec::new();
4341
4342 for (_raised_esurface_id, raised_esurface_group) in
4343 raised_esurface_data.raised_groups.iter_enumerated()
4344 {
4345 let cuts = cut_esurface_map
4346 .iter_enumerated()
4347 .filter_map(|(cut_id, esurface_id)| {
4348 raised_esurface_group
4349 .esurface_ids
4350 .contains(esurface_id)
4351 .then_some(cut_id)
4352 })
4353 .collect::<Vec<_>>();
4354
4355 if cuts.is_empty() {
4356 continue;
4357 }
4358
4359 groups.push(CutGroup {
4360 cuts,
4361 related_esurface_group: raised_esurface_group.clone(),
4362 });
4363 }
4364
4365 let global_max_occurence = groups
4366 .iter()
4367 .map(|group| group.related_esurface_group.max_occurence)
4368 .max()
4369 .unwrap_or_else(|| {
4370 println!("corrupted groups");
4371 panic!();
4372 });
4373
4374 let dual_shapes = (1..global_max_occurence)
4375 .map(simple_n_deriv_shape)
4376 .collect();
4377
4378 let pass_two_evaluators = (1..=global_max_occurence)
4379 .map(|i| {
4380 let evaluator_started = std::time::Instant::now();
4381 let evaluator = build_derivative_structure(i as u8, -1, evaluator_settings);
4382 stats.evaluator_symbolica_time += evaluator_started.elapsed();
4383 stats.evaluator_count += 1;
4384 evaluator
4385 })
4386 .collect();
4387
4388 (
4389 Self {
4390 cut_groups: groups,
4391 dual_shapes,
4392 pass_two_evaluators,
4393 },
4394 stats,
4395 )
4396 }
4397}
4398
4399impl CrossSectionDerivedData {
4400 fn new_empty() -> Self {
4401 Self {
4402 covariant_cut_representatives: BTreeMap::new(),
4403 orientations: None,
4404 global_cff_expression: None,
4405 cut_paramatric_integrand: TiVec::new(),
4406 lmbs: None,
4407 multi_channeling_setup: None,
4408 threshold_counterterms: TiVec::new(),
4409 resolved_threshold_counterterms: None,
4410 topological_threshold_esurfaces: TiVec::new(),
4411 threshold_candidate_esurface_ids: Vec::new(),
4412
4413 cut_threshold_associations: TiVec::new(),
4414 subspace_data: TiVec::new(),
4415 cut_group_data: CutGroupData::new(),
4416 }
4417 }
4418}
4419
4420pub(crate) fn build_derivative_structure_atom(
4421 singularity_order: u8,
4422 laurent_coefficient: i8,
4423) -> Atom {
4424 assert!(
4425 laurent_coefficient <= -1,
4426 "only laurent coefficients up to -1 are supported"
4427 );
4428
4429 assert!(
4430 singularity_order >= 1,
4431 "eta order must be at least 1, got {singularity_order}"
4432 );
4433 assert!(
4434 singularity_order >= -laurent_coefficient as u8,
4435 "eta order must be at least the negative of the laurent coefficient, got {singularity_order} for laurent coefficient {laurent_coefficient}"
4436 );
4437
4438 let order = singularity_order as i32;
4439 let laurent_coefficient = laurent_coefficient as i32;
4440 let f = symbol!("f");
4441
4442 let expansion = function!(GS.eta, GS.rescale)
4443 .series(GS.rescale, Atom::var(GS.rescale_star), (order, 1))
4444 .unwrap()
4445 .to_atom()
4446 .replace(function!(GS.eta, GS.rescale_star))
4447 .min_level(0)
4448 .max_level(Some(0))
4449 .with(0);
4450
4451 let mut expression_to_derive = function!(f, GS.rescale)
4452 * expansion.pow(-order)
4453 * (GS.rescale - GS.rescale_star).pow(order);
4454
4455 for _ in 1..=(order + laurent_coefficient) {
4456 expression_to_derive = expression_to_derive.derivative(GS.rescale);
4457 }
4458
4459 expression_to_derive = expression_to_derive
4460 .replace(GS.rescale - GS.rescale_star)
4461 .with(parse!("delta_t"));
4462
4463 let polynomial_in_delta_t = expression_to_derive
4464 .series(symbol!("delta_t"), Atom::num(0), (0, 1))
4465 .unwrap();
4466
4467 let factorial_prefactor = (2..=(order + laurent_coefficient)).product::<i32>();
4468 debug!("factorial prefactor: {}", factorial_prefactor);
4469 let mut expression_to_derive = polynomial_in_delta_t.to_atom() / Atom::num(factorial_prefactor);
4470
4471 expression_to_derive = expression_to_derive
4472 .replace(GS.rescale)
4473 .with(GS.rescale_star);
4474
4475 expression_to_derive
4476}
4477
4478pub(crate) fn build_derivative_structure(
4479 singularity_order: u8,
4480 laurent_coefficient: i8,
4481 evaluator_settings: &EvaluatorSettings,
4482) -> GenericEvaluator {
4483 let expression_to_derive =
4484 build_derivative_structure_atom(singularity_order, laurent_coefficient);
4485
4486 let params = params_for_derivative_order(singularity_order);
4487
4488 GenericEvaluator::new_from_raw_params(
4489 [expression_to_derive],
4490 ¶ms,
4491 &FunctionMap::default(),
4492 vec![],
4493 evaluator_settings.optimization_settings(),
4494 None,
4495 evaluator_settings,
4496 )
4497 .unwrap()
4498 .into_eager_only()
4499}
4500
4501fn ordered_f_derivative_params(
4502 base: Atom,
4503 derivative_vars: &[Symbol],
4504 derivative_shape: Option<&Vec<Vec<usize>>>,
4505) -> Vec<Atom> {
4506 derivative_shape
4507 .cloned()
4508 .unwrap_or_else(|| vec![vec![0; derivative_vars.len()]])
4509 .into_iter()
4510 .map(|orders| {
4511 debug_assert_eq!(orders.len(), derivative_vars.len());
4512
4513 let mut param = base.clone();
4514 for (derivative_var, order) in derivative_vars.iter().zip(orders) {
4515 for _ in 0..order {
4516 param = param.derivative(*derivative_var);
4517 }
4518 }
4519
4520 param
4521 })
4522 .collect()
4523}
4524
4525pub(crate) fn params_for_derivative_order(singularity_order: u8) -> Vec<Atom> {
4526 let f = symbol!("f");
4527
4528 let f_0 = function!(f, GS.rescale_star);
4529 let eta_1 = function!(GS.eta, GS.rescale_star).derivative(GS.rescale_star);
4530
4531 let f_derivative_shape = shape_from_cut_cff_index(&CutCFFIndex {
4532 left_threshold_order: None,
4533 right_threshold_order: None,
4534 lu_cut_order: Some(singularity_order as usize),
4535 });
4536
4537 let f_parameters =
4538 ordered_f_derivative_params(f_0, &[GS.rescale_star], f_derivative_shape.as_ref());
4539 let mut eta_params = vec![eta_1.clone()];
4540
4541 for _ in 2..=singularity_order {
4542 let next_eta = eta_params.last().unwrap().derivative(GS.rescale_star);
4543 eta_params.push(next_eta);
4544 }
4545
4546 let mut result = vec![];
4547 result.extend(f_parameters);
4548 result.extend(eta_params);
4549 result
4550}
4551
4552pub(crate) fn params_for_iterated_threshold_ct(
4553 left_singularit_order: u8,
4554 right_singularity_order: u8,
4555) -> Vec<Atom> {
4556 let f = symbol!("f");
4557
4558 let cut_cff_index = CutCFFIndex {
4559 left_threshold_order: Some(left_singularit_order as usize),
4560 right_threshold_order: Some(right_singularity_order as usize),
4561 lu_cut_order: None,
4562 };
4563 let f_derivative_shape = shape_from_cut_cff_index(&cut_cff_index);
4564
4565 let f_base = function!(f, GS.radius_star_left, GS.radius_star_right);
4566 let derivative_vars = match (left_singularit_order > 1, right_singularity_order > 1) {
4567 (true, true) => vec![GS.radius_star_left, GS.radius_star_right],
4568 (true, false) => vec![GS.radius_star_left],
4569 (false, true) => vec![GS.radius_star_right],
4570 (false, false) => vec![],
4571 };
4572
4573 let eta_left_d1 = function!(GS.eta_left, GS.radius_star_left).derivative(GS.radius_star_left);
4574 let eta_right_d1 =
4575 function!(GS.eta_right, GS.radius_star_right).derivative(GS.radius_star_right);
4576
4577 let mut eta_left_params = vec![eta_left_d1.clone()];
4578 let mut eta_right_params = vec![eta_right_d1.clone()];
4579
4580 for _ in 2..=left_singularit_order {
4581 let next_eta_left = eta_left_params
4582 .last()
4583 .unwrap()
4584 .derivative(GS.radius_star_left);
4585
4586 eta_left_params.push(next_eta_left);
4587 }
4588
4589 for _ in 2..=right_singularity_order {
4590 let next_eta_right = eta_right_params
4591 .last()
4592 .unwrap()
4593 .derivative(GS.radius_star_right);
4594
4595 eta_right_params.push(next_eta_right);
4596 }
4597
4598 let f_product =
4599 ordered_f_derivative_params(f_base, &derivative_vars, f_derivative_shape.as_ref());
4600
4601 let mut result = vec![];
4602 result.extend(f_product);
4603 result.extend(eta_left_params);
4604 result.extend(eta_right_params);
4605 result
4606}
4607
4608#[cfg(test)]
4609mod tests {
4610 use std::{
4611 fs,
4612 path::PathBuf,
4613 time::{SystemTime, UNIX_EPOCH},
4614 };
4615
4616 use symbolica::{atom::AtomCore, function, symbol};
4617
4618 use crate::{
4619 cff::CutCFFIndex,
4620 dot,
4621 graph::parse::from_dot::IntoGraph,
4622 initialisation::test_initialise,
4623 model::InputParamCard,
4624 processes::cross_section::{EsurfaceID, RaisedEsurfaceData, RaisedEsurfaceGroup},
4625 settings::global::{GenerationSettings, OrientationPattern},
4626 utils::{GS, load_generic_model},
4627 };
4628 use linnet::half_edge::{
4629 involution::EdgeIndex,
4630 subgraph::{OrientedCut, SuBitGraph},
4631 };
4632
4633 fn threshold_association(
4634 cut_boundary_size: usize,
4635 threshold_boundary_size: usize,
4636 ) -> super::ThresholdCountertermAssociation {
4637 super::ThresholdCountertermAssociation {
4638 topological_threshold_id: super::TopologicalThresholdId(0),
4639 esurface_id: super::EsurfaceID(0),
4640 cut_boundary_edges: (0..cut_boundary_size).map(EdgeIndex::from).collect(),
4641 threshold_boundary_edges: (0..threshold_boundary_size)
4642 .map(|index| EdgeIndex::from(cut_boundary_size + index))
4643 .collect(),
4644 invariant_bound_is_applicable: true,
4645 }
4646 }
4647
4648 #[test]
4649 fn cut_groups_include_all_physical_cuts_in_a_raised_group() {
4650 test_initialise().unwrap();
4651 let raised_esurface_data = RaisedEsurfaceData {
4652 raised_groups: [
4653 RaisedEsurfaceGroup {
4654 esurface_ids: vec![EsurfaceID(1), EsurfaceID(0)],
4655 max_occurence: 1,
4656 },
4657 RaisedEsurfaceGroup {
4658 esurface_ids: vec![EsurfaceID(2)],
4659 max_occurence: 1,
4660 },
4661 ]
4662 .into_iter()
4663 .collect(),
4664 pass_two_evaluator: None,
4665 };
4666 let cut_esurface_map = [EsurfaceID(0), EsurfaceID(0)].into_iter().collect();
4667
4668 let (cut_group_data, _) = super::CutGroupData::new_from_esurface(
4669 &raised_esurface_data,
4670 &cut_esurface_map,
4671 &super::EvaluatorSettings::default(),
4672 );
4673
4674 let mut cut_partitions: Vec<Vec<super::CutId>> = cut_group_data
4675 .cut_groups
4676 .iter()
4677 .filter(|group| !group.cuts.is_empty())
4678 .map(|group| {
4679 let mut cuts = group.cuts.clone();
4680 cuts.sort_unstable_by_key(|cut| cut.0);
4681 cuts
4682 })
4683 .collect();
4684 cut_partitions.sort_unstable_by(|left, right| {
4685 left.iter()
4686 .map(|cut| cut.0)
4687 .cmp(right.iter().map(|cut| cut.0))
4688 });
4689 assert_eq!(cut_partitions, vec![vec![super::CutId(0), super::CutId(1)]]);
4690 }
4691
4692 #[test]
4693 fn scalar_cut_residue_keeps_the_propagator_i_exactly_once() {
4694 use crate::graph::{FeynmanGraph, Graph};
4695 use symbolica::atom::Atom;
4696
4697 test_initialise().unwrap();
4698 let graph: Graph = dot!(
4702 digraph scalar_cut_line {
4703 node [num=1];
4704 edge [particle="scalar_0", num="1𝑖"];
4705 ext [style=invis];
4706 ext -> left [is_cut=0];
4707 right -> ext [is_cut=0];
4708 left -> right;
4709 }, "scalars"
4710 )
4711 .unwrap();
4712 let mut graph = super::CrossSectionGraph::new(graph);
4713 graph.cuts = graph
4714 .graph
4715 .all_st_cuts_for_cs(
4716 graph.source_nodes.clone(),
4717 graph.target_nodes.clone(),
4718 &graph.graph.get_initial_state_tree(),
4719 )
4720 .into_iter()
4721 .map(|(left, cut, right)| super::CrossSectionCut { cut, left, right })
4722 .collect();
4723 assert_eq!(graph.cuts.len(), 1);
4724 let group = super::CutGroup {
4725 cuts: vec![super::CutId(0)],
4726 related_esurface_group: RaisedEsurfaceGroup {
4727 esurface_ids: vec![EsurfaceID(0)],
4728 max_occurence: 1,
4729 },
4730 };
4731 let conversion = group
4732 .lu_prefactor(&graph.graph, &graph.cuts)
4733 .unwrap()
4734 .replace(Atom::var(GS.rescale_star))
4735 .with(Atom::one())
4736 .replace(Atom::var(GS.hfunction_lu_cut))
4737 .with(Atom::one());
4738 let two_pi = Atom::num(2) * Atom::var(GS.pi);
4739 assert_eq!(conversion, -&two_pi * Atom::i());
4740
4741 assert_eq!(&conversion * Atom::i(), two_pi);
4744 for energy in [1, 2, 5] {
4745 let q0 = Atom::var(symbol!("scalar_cut_residue_q0"));
4746 let energy = Atom::num(energy);
4747 let positive_pole_residue = (Atom::i() / ((&q0 - &energy) * (&q0 + &energy))
4748 * (&q0 - &energy))
4749 .replace(q0)
4750 .with(energy.to_pattern());
4751 assert_eq!(
4754 &conversion * positive_pole_residue,
4755 Atom::var(GS.pi) / energy
4756 );
4757 }
4758 }
4759
4760 #[test]
4761 fn singleton_boundary_classification_uses_generic_mass_hierarchy() {
4762 use super::ThresholdCountertermStatus;
4763 use crate::utils::F;
4764
4765 let association = threshold_association(1, 2);
4766 let tolerance = F(1.0e-12);
4767
4768 assert_eq!(
4769 association.classify_from_invariant_bounds(&F(3.0), &F(4.0), Some(F(3.0)), &tolerance,),
4770 ThresholdCountertermStatus::ProvenNonExisting,
4771 );
4772 assert_eq!(
4773 association.classify_from_invariant_bounds(&F(4.0), &F(4.0), Some(F(4.0)), &tolerance,),
4774 ThresholdCountertermStatus::AlwaysPinched,
4775 );
4776 assert_eq!(
4777 association.classify_from_invariant_bounds(&F(5.0), &F(4.0), Some(F(5.0)), &tolerance,),
4778 ThresholdCountertermStatus::PotentiallyExisting,
4779 );
4780
4781 let fixed_association = threshold_association(1, 1);
4782 assert_eq!(
4783 fixed_association.classify_from_invariant_bounds(
4784 &F(3.0),
4785 &F(4.0),
4786 Some(F(3.0)),
4787 &tolerance,
4788 ),
4789 ThresholdCountertermStatus::ProvenNonExisting,
4790 );
4791 assert_eq!(
4792 fixed_association.classify_from_invariant_bounds(
4793 &F(5.0),
4794 &F(4.0),
4795 Some(F(5.0)),
4796 &tolerance,
4797 ),
4798 ThresholdCountertermStatus::ProvenNonExisting,
4799 );
4800 }
4801
4802 #[test]
4803 fn current_model_filter_is_controlled_only_by_generation_setting() {
4804 use super::ThresholdCountertermStatus;
4805
4806 assert!(ThresholdCountertermStatus::ProvenNonExisting.is_eligible_for_generation(false));
4807 assert!(!ThresholdCountertermStatus::ProvenNonExisting.is_eligible_for_generation(true));
4808 assert!(!ThresholdCountertermStatus::AlwaysPinched.is_eligible_for_generation(false));
4809 assert!(!ThresholdCountertermStatus::NoRadialDependence.is_eligible_for_generation(false));
4810 assert!(ThresholdCountertermStatus::PotentiallyExisting.is_eligible_for_generation(true));
4811 }
4812
4813 #[test]
4814 fn multiparticle_boundary_classification_is_conservative() {
4815 use super::ThresholdCountertermStatus;
4816 use crate::utils::F;
4817
4818 let association = threshold_association(2, 2);
4819 let tolerance = F(1.0e-12);
4820
4821 assert_eq!(
4822 association.classify_from_invariant_bounds(&F(4.0), &F(4.0), Some(F(8.0)), &tolerance,),
4823 ThresholdCountertermStatus::CanBecomePinched,
4824 );
4825 assert_eq!(
4826 association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(4.0)), &tolerance,),
4827 ThresholdCountertermStatus::ProvenNonExisting,
4828 );
4829 assert_eq!(
4830 association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(5.0)), &tolerance,),
4831 ThresholdCountertermStatus::AlwaysPinched,
4832 );
4833 assert_eq!(
4834 association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(8.0)), &tolerance,),
4835 ThresholdCountertermStatus::CanBecomePinched,
4836 );
4837 assert_eq!(
4838 association.classify_from_invariant_bounds(&F(5.0), &F(3.0), Some(F(8.0)), &tolerance,),
4839 ThresholdCountertermStatus::PotentiallyExisting,
4840 );
4841 assert_eq!(
4842 association.classify_from_invariant_bounds(&F(3.0), &F(5.0), None, &tolerance,),
4843 ThresholdCountertermStatus::PotentiallyExisting,
4844 );
4845 }
4846
4847 #[test]
4848 fn invariant_bound_rejects_mixed_or_unresolved_inputs_conservatively() {
4849 use super::ThresholdCountertermStatus;
4850 use crate::utils::F;
4851
4852 let mut association = threshold_association(2, 2);
4853 let tolerance = F(1.0e-12);
4854 association.invariant_bound_is_applicable = false;
4855 assert_eq!(
4856 association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(4.0)), &tolerance,),
4857 ThresholdCountertermStatus::PotentiallyExisting,
4858 );
4859
4860 association.invariant_bound_is_applicable = true;
4861 assert_eq!(
4862 association.classify_from_invariant_bounds(
4863 &F(f64::NAN),
4864 &F(5.0),
4865 Some(F(4.0)),
4866 &tolerance,
4867 ),
4868 ThresholdCountertermStatus::PotentiallyExisting,
4869 );
4870
4871 let masses: linnet::half_edge::involution::EdgeVec<Option<F<f64>>> =
4872 vec![Some(F(3.0)), None].into();
4873 assert_eq!(
4874 super::ThresholdCountertermAssociation::mass_sum(
4875 &[EdgeIndex::from(0), EdgeIndex::from(1)],
4876 &masses,
4877 ),
4878 None,
4879 );
4880 }
4881
4882 #[test]
4883 fn singleton_threshold_must_lie_strictly_inside_available_interval() {
4884 use super::ThresholdCountertermStatus;
4885 use crate::utils::F;
4886
4887 let association = threshold_association(2, 1);
4888 let tolerance = F(1.0e-12);
4889
4890 assert_eq!(
4891 association.classify_from_invariant_bounds(&F(5.0), &F(4.0), Some(F(9.0)), &tolerance,),
4892 ThresholdCountertermStatus::ProvenNonExisting,
4893 );
4894 assert_eq!(
4895 association.classify_from_invariant_bounds(&F(3.0), &F(5.0), Some(F(9.0)), &tolerance,),
4896 ThresholdCountertermStatus::PotentiallyExisting,
4897 );
4898 assert_eq!(
4899 association.classify_from_invariant_bounds(&F(3.0), &F(9.0), Some(F(9.0)), &tolerance,),
4900 ThresholdCountertermStatus::AlwaysPinched,
4901 );
4902 }
4903
4904 fn fresh_temp_dir(name: &str) -> PathBuf {
4905 let unique = SystemTime::now()
4906 .duration_since(UNIX_EPOCH)
4907 .unwrap()
4908 .as_nanos();
4909 let path = std::env::temp_dir().join(format!(
4910 "gammalooprs-{name}-{}-{unique}",
4911 std::process::id()
4912 ));
4913 fs::create_dir_all(&path).unwrap();
4914 path
4915 }
4916
4917 #[test]
4918 fn non_master_cross_section_threshold_metadata_is_rejected_at_import() {
4919 test_initialise().unwrap();
4920 let model = load_generic_model("sm");
4921 let mut master: crate::graph::Graph =
4922 include_str!("../../../../tests/resources/graphs/ir_safe_thresholds/GL638.dot")
4923 .into_graph(&model)
4924 .unwrap();
4925 master.name = "threshold_group_master".to_string();
4926 master.group_id = Some(super::GroupId(0));
4927 master.is_group_master = true;
4928 let mut member = master.clone();
4929 member.name = "threshold_group_member".to_string();
4930 member.is_group_master = false;
4931 member.threshold_counterterms.autogenerated = false;
4932
4933 let error = match super::CrossSection::from_graph_list(
4934 "threshold_group".to_string(),
4935 vec![master, member],
4936 &model,
4937 ) {
4938 Ok(_) => panic!("non-master threshold metadata must not be interpreted graph-locally"),
4939 Err(error) => error.to_string(),
4940 };
4941 assert!(error.contains("threshold_group_member"));
4942 assert!(error.contains("threshold_group_master"));
4943 assert!(error.contains("non-master cross-section graphs is unsupported"));
4944 }
4945
4946 #[test]
4947 fn cross_section_storage_path_stays_inside_process_folder() {
4948 let temp = fresh_temp_dir("cross-section-storage-path");
4949 let cross_section = super::CrossSection::new("NLO".to_string());
4950
4951 assert_eq!(cross_section.storage_path(&temp), temp.join("NLO"));
4952 fs::remove_dir_all(temp).unwrap();
4953 }
4954
4955 #[test]
4956 fn selected_orientation_keeps_only_selected_threshold_instances_active() {
4957 use crate::graph::FeynmanGraph;
4958
4959 std::thread::Builder::new()
4960 .name("selected-orientation-threshold-test".to_string())
4961 .stack_size(64 * 1024 * 1024)
4962 .spawn(|| {
4963 test_initialise().unwrap();
4964 let model = load_generic_model("sm");
4965 let graph =
4966 include_str!("../../../../tests/resources/graphs/ir_safe_thresholds/GL638.dot")
4967 .into_graph(&model)
4968 .unwrap();
4969 let mut cross_section = super::CrossSectionGraph::new(graph);
4970 let target_cut_edges =
4971 vec![EdgeIndex::from(2), EdgeIndex::from(4), EdgeIndex::from(12)];
4972 let (left, cut, right) = cross_section
4973 .graph
4974 .all_st_cuts_for_cs(
4975 cross_section.source_nodes.clone(),
4976 cross_section.target_nodes.clone(),
4977 &cross_section.graph.get_initial_state_tree(),
4978 )
4979 .into_iter()
4980 .find(|(_, candidate, _)| {
4981 let mut edges = cross_section
4982 .graph
4983 .iter_edges_of(candidate)
4984 .map(|(_, edge_id, _)| edge_id)
4985 .collect::<Vec<_>>();
4986 edges.sort();
4987 edges == target_cut_edges
4988 })
4989 .expect("GL638 must contain the target physical cut");
4990 cross_section
4991 .cuts
4992 .push(super::CrossSectionCut { cut, left, right });
4993 let settings = GenerationSettings {
4994 orientation_pattern: OrientationPattern::from_user_pattern(
4995 "(+,+,+,+,-,-,-,+,-,0,+,0,+,-,+)",
4996 )
4997 .unwrap(),
4998 ..Default::default()
4999 };
5000 cross_section.generate_esurface_cuts();
5001 cross_section.generate_cff(&settings).unwrap();
5002
5003 let topology_candidates = cross_section
5004 .topological_threshold_candidates(&settings)
5005 .unwrap();
5006 let selected_orientation_energy_sets = topology_candidates
5007 .iter()
5008 .filter_map(|candidate| {
5009 candidate
5010 .selected_orientation_esurface_id
5011 .map(|esurface_id| cross_section.threshold_energy_edges(esurface_id))
5012 })
5013 .collect::<Vec<_>>();
5014 for expected in [
5015 vec![EdgeIndex::from(7), EdgeIndex::from(8)],
5016 vec![EdgeIndex::from(8), EdgeIndex::from(12), EdgeIndex::from(14)],
5017 vec![EdgeIndex::from(5), EdgeIndex::from(10)],
5018 vec![EdgeIndex::from(5), EdgeIndex::from(12), EdgeIndex::from(13)],
5019 ] {
5020 assert!(selected_orientation_energy_sets.contains(&expected));
5021 }
5022 let orientation_excluded_edges = vec![
5023 EdgeIndex::from(8),
5024 EdgeIndex::from(10),
5025 EdgeIndex::from(13),
5026 EdgeIndex::from(14),
5027 ];
5028 assert!(!selected_orientation_energy_sets.contains(&orientation_excluded_edges));
5029 assert!(topology_candidates.iter().any(|candidate| {
5030 candidate.threshold_esurface.energies == orientation_excluded_edges
5031 && candidate.selected_orientation_esurface_id.is_none()
5032 }));
5033
5034 cross_section.graph.surface_cache.esurface_cache.raw.clear();
5037 let error = cross_section
5038 .topological_threshold_candidates(&settings)
5039 .err()
5040 .expect("a corrupted selected CFF cache must be rejected");
5041 assert!(
5042 error
5043 .to_string()
5044 .contains("has a selected-orientation instance but is missing")
5045 );
5046 })
5047 .unwrap()
5048 .join()
5049 .unwrap();
5050 }
5051
5052 #[test]
5053 fn orientation_excluded_explicit_threshold_is_dormant_not_unmatched() {
5054 std::thread::Builder::new()
5055 .name("dormant-orientation-threshold-test".to_string())
5056 .stack_size(64 * 1024 * 1024)
5057 .spawn(|| {
5058 use crate::graph::{
5059 FeynmanGraph,
5060 autogen::Autogen,
5061 threshold_counterterms::{
5062 ThresholdCountertermCut, ThresholdCountertermMultiplier,
5063 ThresholdCountertermSpec, ThresholdCountertermThreshold,
5064 ThresholdCountertermVariant,
5065 },
5066 };
5067
5068 test_initialise().unwrap();
5069 let model = load_generic_model("sm");
5070 let graph =
5071 include_str!("../../../../tests/resources/graphs/ir_safe_thresholds/GL638.dot")
5072 .into_graph(&model)
5073 .unwrap();
5074 let mut cross_section = super::CrossSectionGraph::new(graph);
5075 let target_cut_edges =
5076 vec![EdgeIndex::from(2), EdgeIndex::from(4), EdgeIndex::from(12)];
5077 let (left, cut, right) = cross_section
5078 .graph
5079 .all_st_cuts_for_cs(
5080 cross_section.source_nodes.clone(),
5081 cross_section.target_nodes.clone(),
5082 &cross_section.graph.get_initial_state_tree(),
5083 )
5084 .into_iter()
5085 .find(|(_, candidate, _)| {
5086 let mut edges = cross_section
5087 .graph
5088 .iter_edges_of(candidate)
5089 .map(|(_, edge_id, _)| edge_id)
5090 .collect::<Vec<_>>();
5091 edges.sort();
5092 edges == target_cut_edges
5093 })
5094 .expect("GL638 must contain the target physical cut");
5095 cross_section
5096 .cuts
5097 .push(super::CrossSectionCut { cut, left, right });
5098
5099 let dormant_threshold_edges = vec![EdgeIndex::from(5), EdgeIndex::from(10)];
5100 cross_section.graph.threshold_counterterms =
5101 Autogen::explicit(ThresholdCountertermSpec {
5102 schema_version: 1,
5103 function_map: Default::default(),
5104 cuts: vec![ThresholdCountertermCut {
5105 edges: target_cut_edges,
5106 thresholds: vec![ThresholdCountertermThreshold {
5107 edges: dormant_threshold_edges.clone(),
5108 counterterms: vec![ThresholdCountertermVariant {
5109 group_id: None,
5110 name: Some("dormant".to_string()),
5111 subspace: None,
5112 parent_lmb: Some(vec![
5113 EdgeIndex(3),
5114 EdgeIndex(4),
5115 EdgeIndex(7),
5116 EdgeIndex(10),
5117 ]),
5118 disable: false,
5119 multiplier: Some(ThresholdCountertermMultiplier {
5120 expression: "1".to_string(),
5121 function_map: Default::default(),
5122 symmetrize: true,
5123 opaque_derivatives: true,
5124 }),
5125 }],
5126 }],
5127 }],
5128 });
5129
5130 let settings = GenerationSettings {
5133 orientation_pattern: OrientationPattern::from_user_pattern(
5134 "(+,+,+,+,-,+,-,+,-,0,+,0,+,-,+)",
5135 )
5136 .unwrap(),
5137 ..Default::default()
5138 };
5139 cross_section.generate_esurface_cuts();
5140 cross_section.generate_cff(&settings).unwrap();
5141 cross_section.build_lmbs().unwrap();
5142 cross_section.build_subspace_data().unwrap();
5143 let topology_candidates = cross_section
5144 .topological_threshold_candidates(&settings)
5145 .unwrap();
5146 assert!(topology_candidates.iter().any(|candidate| {
5147 candidate.threshold_esurface.energies == dormant_threshold_edges
5148 && candidate.selected_orientation_esurface_id.is_none()
5149 }));
5150
5151 let (resolved, _) = cross_section
5152 .resolve_threshold_counterterm_directives(
5153 &model,
5154 &settings,
5155 &crate::settings::RuntimeSettings::default(),
5156 &topology_candidates,
5157 )
5158 .unwrap();
5159 assert!(resolved.variants.iter().all(|variant| {
5160 variant.name != "dormant"
5161 && variant.associations.iter().all(|association| {
5162 association.threshold_edges != dormant_threshold_edges
5163 })
5164 }));
5165 assert!(
5166 resolved
5167 .cross_section_cut_groups
5168 .iter()
5169 .any(|cut_group| !cut_group.right.is_empty()),
5170 "the orientation retains its other right-side threshold"
5171 );
5172 })
5173 .unwrap()
5174 .join()
5175 .unwrap();
5176 }
5177
5178 #[test]
5179 fn cross_section_lmb_cut_edge_exclusion_prefers_massive_then_fermion_then_id() {
5180 test_initialise().unwrap();
5181 let mut model = load_generic_model("sm");
5182 let mut parameters = InputParamCard::from_str(
5183 include_str!("../../../../assets/models/json/sm/restrict_light_quarks_massless.json")
5184 .to_owned(),
5185 "json",
5186 )
5187 .unwrap();
5188 model.simplify(&mut parameters).unwrap();
5189 let graph = dot!(
5191 digraph cut_edge_priority {
5192 edge [num=1]
5193 node [num=1]
5194 A -> B [id=0 particle="g"]
5195 A -> B [id=1 particle="d"]
5196 A -> B [id=2 particle="t" mass=1]
5197 A -> B [id=3 particle="a"]
5198 A -> B [id=4 mass=0]
5199 },
5200 &model
5201 )
5202 .unwrap();
5203
5204 assert_eq!(
5205 super::CrossSectionGraph::excluded_cut_edge_for_lmb_channel_in(
5206 &graph,
5207 &[EdgeIndex::from(0), EdgeIndex::from(1), EdgeIndex::from(2)]
5208 ),
5209 Some(EdgeIndex::from(2))
5210 );
5211 assert_eq!(
5212 super::CrossSectionGraph::excluded_cut_edge_for_lmb_channel_in(
5213 &graph,
5214 &[EdgeIndex::from(0), EdgeIndex::from(1), EdgeIndex::from(3)]
5215 ),
5216 Some(EdgeIndex::from(1))
5217 );
5218 assert_eq!(
5219 super::CrossSectionGraph::excluded_cut_edge_for_lmb_channel_in(
5220 &graph,
5221 &[EdgeIndex::from(0), EdgeIndex::from(3)]
5222 ),
5223 Some(EdgeIndex::from(0))
5224 );
5225
5226 assert_eq!(
5230 graph.underlying[EdgeIndex::from(2)]
5231 .particle
5232 .mass_atom()
5233 .to_string(),
5234 "1"
5235 );
5236 let empty: SuBitGraph = graph.underlying.empty_subgraph();
5237 let cut = super::CrossSectionCut {
5238 cut: OrientedCut {
5239 left: empty.clone(),
5240 right: empty.clone(),
5241 },
5242 left: empty.clone(),
5243 right: empty,
5244 };
5245 let association = super::ThresholdCountertermAssociation {
5246 topological_threshold_id: super::TopologicalThresholdId(0),
5247 esurface_id: super::EsurfaceID(0),
5248 cut_boundary_edges: vec![EdgeIndex::from(4)],
5249 threshold_boundary_edges: vec![EdgeIndex::from(2)],
5250 invariant_bound_is_applicable: true,
5251 };
5252 assert_eq!(
5253 association.classify_for_model(
5254 &graph,
5255 &cut,
5256 &crate::model::Model::default(),
5257 &graph.param_builder,
5258 &crate::settings::RuntimeSettings::default(),
5259 1.0e-7,
5260 ),
5261 super::ThresholdCountertermStatus::ProvenNonExisting,
5262 );
5263 }
5264
5265 #[test]
5266 fn iterated_counterterms_use_the_right_threshold_count_as_row_stride() {
5267 let two_by_three = super::IteratedCtCollection::new((0..6).collect(), 2, 3);
5268 assert_eq!(
5269 two_by_three[(
5270 super::LeftThresholdId::from(0),
5271 super::RightThresholdId::from(0)
5272 )],
5273 0
5274 );
5275 assert_eq!(
5276 two_by_three[(
5277 super::LeftThresholdId::from(0),
5278 super::RightThresholdId::from(2)
5279 )],
5280 2
5281 );
5282 assert_eq!(
5283 two_by_three[(
5284 super::LeftThresholdId::from(1),
5285 super::RightThresholdId::from(0)
5286 )],
5287 3
5288 );
5289 assert_eq!(
5290 two_by_three[(
5291 super::LeftThresholdId::from(1),
5292 super::RightThresholdId::from(2)
5293 )],
5294 5
5295 );
5296
5297 let three_by_two = super::IteratedCtCollection::new((0..6).collect(), 3, 2);
5298 assert_eq!(
5299 three_by_two[(
5300 super::LeftThresholdId::from(0),
5301 super::RightThresholdId::from(1)
5302 )],
5303 1
5304 );
5305 assert_eq!(
5306 three_by_two[(
5307 super::LeftThresholdId::from(1),
5308 super::RightThresholdId::from(0)
5309 )],
5310 2
5311 );
5312 assert_eq!(
5313 three_by_two[(
5314 super::LeftThresholdId::from(2),
5315 super::RightThresholdId::from(1)
5316 )],
5317 5
5318 );
5319 }
5320
5321 #[test]
5322 fn directive_resolution_defaults_and_explicit_lists_are_authoritative() {
5323 use crate::{
5324 graph::threshold_counterterms::{
5325 ThresholdCountertermCut, ThresholdCountertermSpec, ThresholdCountertermThreshold,
5326 ThresholdCountertermVariant,
5327 },
5328 processes::threshold_counterterms::ThresholdCountertermOrigin,
5329 };
5330
5331 let cut_edges = vec![EdgeIndex::from(2), EdgeIndex::from(4)];
5332 let threshold_edges = vec![EdgeIndex::from(7), EdgeIndex::from(8)];
5333 let mut spec = ThresholdCountertermSpec {
5334 schema_version: 1,
5335 function_map: Default::default(),
5336 cuts: vec![ThresholdCountertermCut {
5337 edges: cut_edges.clone(),
5338 thresholds: vec![ThresholdCountertermThreshold {
5339 edges: threshold_edges.clone(),
5340 counterterms: Vec::new(),
5341 }],
5342 }],
5343 };
5344
5345 let implicit = super::CrossSectionGraph::configured_threshold_variants(
5346 &spec,
5347 &cut_edges,
5348 &threshold_edges,
5349 );
5350 assert_eq!(implicit.len(), 1);
5351 assert_eq!(implicit[0].0.name.as_deref(), Some("default"));
5352 assert_eq!(implicit[0].1, ThresholdCountertermOrigin::Autogenerated);
5353
5354 spec.cuts[0].thresholds[0].counterterms = vec![
5355 ThresholdCountertermVariant {
5356 group_id: None,
5357 name: Some("disabled".to_string()),
5358 subspace: None,
5359 parent_lmb: Some(vec![EdgeIndex(7), EdgeIndex(8)]),
5360 disable: true,
5361 multiplier: None,
5362 },
5363 ThresholdCountertermVariant {
5364 group_id: None,
5365 name: Some("duplicate".to_string()),
5366 subspace: Some(vec![EdgeIndex::from(7)]),
5367 parent_lmb: Some(vec![EdgeIndex(7), EdgeIndex(8)]),
5368 disable: false,
5369 multiplier: None,
5370 },
5371 ];
5372 let explicit = super::CrossSectionGraph::configured_threshold_variants(
5373 &spec,
5374 &cut_edges,
5375 &threshold_edges,
5376 );
5377 assert_eq!(explicit.len(), 2);
5378 assert!(explicit[0].0.disable);
5379 assert_eq!(explicit[1].0.name.as_deref(), Some("duplicate"));
5380 assert!(
5381 explicit
5382 .iter()
5383 .all(|(_, origin)| *origin == ThresholdCountertermOrigin::Explicit)
5384 );
5385 }
5386
5387 #[test]
5388 fn explicit_group_conflicts_report_all_members_across_cut_sides() {
5389 use itertools::Itertools;
5390
5391 std::thread::Builder::new()
5392 .name("threshold-group-conflicts".to_string())
5393 .stack_size(64 * 1024 * 1024)
5394 .spawn(|| {
5395 use crate::graph::{
5396 FeynmanGraph,
5397 autogen::Autogen,
5398 threshold_counterterms::{
5399 ThresholdCountertermCut, ThresholdCountertermSpec,
5400 ThresholdCountertermThreshold, ThresholdCountertermVariant,
5401 },
5402 };
5403
5404 test_initialise().unwrap();
5405 let model = load_generic_model("sm");
5406 let graph =
5407 include_str!("../../../../tests/resources/graphs/ir_safe_thresholds/GL297.dot")
5408 .into_graph(&model)
5409 .unwrap();
5410 let mut graph = super::CrossSectionGraph::new(graph);
5411 let declarations = [
5412 ([2, 4, 9], [5, 7], [4, 5, 9, 11], 5, "left"),
5413 ([2, 6, 7], [3, 9], [3, 6, 7, 11], 3, "right"),
5414 ];
5415 graph.cuts = graph
5416 .graph
5417 .all_st_cuts_for_cs(
5418 graph.source_nodes.clone(),
5419 graph.target_nodes.clone(),
5420 &graph.graph.get_initial_state_tree(),
5421 )
5422 .into_iter()
5423 .filter(|(_, cut, _)| {
5424 let edges = graph
5425 .graph
5426 .iter_edges_of(cut)
5427 .map(|(_, edge, _)| edge.0)
5428 .sorted()
5429 .collect_vec();
5430 declarations.iter().any(|(cut, ..)| edges.as_slice() == cut)
5431 })
5432 .map(|(left, cut, right)| super::CrossSectionCut { cut, left, right })
5433 .collect();
5434 assert_eq!(graph.cuts.len(), 2);
5435 graph.graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
5436 schema_version: 1,
5437 function_map: Default::default(),
5438 cuts: declarations
5439 .into_iter()
5440 .map(
5441 |(cut, threshold, parent, active, side)| ThresholdCountertermCut {
5442 edges: cut.into_iter().map(EdgeIndex).collect(),
5443 thresholds: vec![ThresholdCountertermThreshold {
5444 edges: threshold.into_iter().map(EdgeIndex).collect(),
5445 counterterms: (0..2)
5446 .map(|group_id| ThresholdCountertermVariant {
5447 name: Some(format!("{side}_group_{group_id}")),
5448 group_id: Some(group_id),
5449 subspace: Some(vec![EdgeIndex(active)]),
5450 parent_lmb: Some(
5451 parent.into_iter().map(EdgeIndex).collect(),
5452 ),
5453 disable: true,
5454 multiplier: None,
5455 })
5456 .collect(),
5457 }],
5458 },
5459 )
5460 .collect(),
5461 });
5462 let settings = GenerationSettings {
5463 orientation_pattern: OrientationPattern::from_user_pattern(
5464 "(+,+,-,+,+,+,+,-,0,-,0,-,-,+,+)",
5465 )
5466 .unwrap(),
5467 ..Default::default()
5468 };
5469 graph.generate_esurface_cuts();
5470 graph.generate_cff(&settings).unwrap();
5471 graph.build_lmbs().unwrap();
5472 graph.build_subspace_data().unwrap();
5473 let topology = graph.topological_threshold_candidates(&settings).unwrap();
5474 let error = graph
5475 .resolve_threshold_counterterm_directives(
5476 &model,
5477 &settings,
5478 &crate::settings::RuntimeSettings::default(),
5479 &topology,
5480 )
5481 .expect_err("explicit groups cannot join different cut-side cycles");
5482 let diagnostic = error.to_string();
5483 for expected in [
5484 "group_id=0",
5485 "group_id=1",
5486 "left_group_0",
5487 "right_group_0",
5488 "left_group_1",
5489 "right_group_1",
5490 "signed_cycles=",
5491 "parent=",
5492 "cut [2, 4, 9]",
5493 "cut [2, 6, 7]",
5494 "disabled=true",
5495 ] {
5496 assert!(
5497 diagnostic.contains(expected),
5498 "missing {expected}: {diagnostic}"
5499 );
5500 }
5501 })
5502 .unwrap()
5503 .join()
5504 .unwrap();
5505 }
5506
5507 #[test]
5508 fn raised_member_compatibility_uses_directives_and_embedding_not_provenance() {
5509 use crate::{
5510 graph::{Graph, LMBext},
5511 momentum::sample::SubspaceData,
5512 processes::threshold_counterterms::ThresholdCountertermOrigin,
5513 };
5514
5515 test_initialise().unwrap();
5516 let graph: Graph = dot!(digraph raised_compatibility {
5517 ext [style=invis]
5518 edge [num=1 mass=0]
5519 node [num=1]
5520 ext->v1:0 [id=0]
5521 v1->v2 [id=1]
5522 v2->v1 [id=2]
5523 v2->v3 [id=3]
5524 v3->v2 [id=4]
5525 ext->v3:1 [id=5]
5526 })
5527 .unwrap();
5528 let all_lmbs = graph.generate_loop_momentum_bases();
5529 let subspaces = all_lmbs
5530 .iter_enumerated()
5531 .filter_map(|(lmb_index, _)| {
5532 SubspaceData::new_from_parent_basis_edges(
5533 &[EdgeIndex(1)],
5534 &graph.full_filter(),
5535 lmb_index,
5536 &graph,
5537 &all_lmbs,
5538 )
5539 .ok()
5540 })
5541 .collect::<Vec<_>>();
5542 let (subspace, raised_subspace) = subspaces
5543 .iter()
5544 .enumerate()
5545 .flat_map(|(left_index, left)| {
5546 subspaces
5547 .iter()
5548 .skip(left_index + 1)
5549 .map(move |right| (left, right))
5550 })
5551 .find(|(left, right)| {
5552 left.parent_lmb_index() != right.parent_lmb_index()
5553 && left.has_equivalent_embedding(right, &all_lmbs)
5554 })
5555 .expect("raised fixture must have equivalent distinct parent embeddings");
5556 let requested = subspace.iter_basis_edges(&all_lmbs).collect::<Vec<_>>();
5557 let first = super::ResolvedThresholdCountertermDraft {
5558 name: "v".to_string(),
5559 group_id: None,
5560 origin: ThresholdCountertermOrigin::Explicit,
5561 disable: false,
5562 requested_subspace: Some(requested),
5563 requested_parent_lmb: Some(subspace.get_lmb(&all_lmbs).loop_edges.raw.clone()),
5564 multiplier: None,
5565 subspace: subspace.clone(),
5566 status: super::ThresholdCountertermStatus::PotentiallyExisting,
5567 };
5568 let mut raised_member = first.clone();
5569 raised_member.origin = ThresholdCountertermOrigin::Autogenerated;
5570 raised_member.status = super::ThresholdCountertermStatus::ProvenNonExisting;
5571 raised_member.requested_subspace = None;
5572 raised_member.requested_parent_lmb = Some(
5573 raised_subspace
5574 .get_lmb(&all_lmbs)
5575 .loop_edges
5576 .iter()
5577 .copied()
5578 .collect(),
5579 );
5580 raised_member.subspace = raised_subspace.clone();
5581
5582 assert!(
5585 super::CrossSectionGraph::threshold_subspaces_are_equivalent(
5586 subspace,
5587 raised_subspace,
5588 &all_lmbs,
5589 )
5590 );
5591 assert!(first.is_compatible_with(&raised_member, &all_lmbs));
5592 raised_member.name = "different".to_string();
5593 assert!(!first.is_compatible_with(&raised_member, &all_lmbs));
5594 }
5595
5596 #[test]
5597 fn threshold_radial_power_uses_each_variant_loop_count() {
5598 let one_loop = super::CrossSectionGraph::threshold_rstar_power(1, false);
5599 let two_loop = super::CrossSectionGraph::threshold_rstar_power(2, true);
5600
5601 assert!(
5602 (one_loop - super::Atom::var(GS.radius_star_left).pow(2))
5603 .expand()
5604 .is_zero()
5605 );
5606 assert!(
5607 (two_loop - super::Atom::var(GS.radius_star_right).pow(5))
5608 .expand()
5609 .is_zero()
5610 );
5611 }
5612
5613 #[test]
5614 fn max_dual_size_for_cut_cff_indices_tracks_mixed_threshold_shapes() {
5615 let cut_cff_indices = [
5616 CutCFFIndex {
5617 left_threshold_order: None,
5618 right_threshold_order: None,
5619 lu_cut_order: Some(2),
5620 },
5621 CutCFFIndex {
5622 left_threshold_order: Some(2),
5623 right_threshold_order: Some(2),
5624 lu_cut_order: Some(2),
5625 },
5626 ];
5627
5628 assert_eq!(
5629 super::max_dual_size_for_cut_cff_indices(cut_cff_indices.iter()),
5630 8
5631 );
5632 }
5633
5634 #[test]
5635 fn single_threshold_params_follow_effective_f_then_eta_contract() {
5636 let params = super::params_for_derivative_order(3);
5637 let f = symbol!("f");
5638 let f_base = function!(f, GS.rescale_star);
5639 let eta_base = function!(GS.eta, GS.rescale_star);
5640
5641 let expected = [
5642 f_base.clone(),
5643 f_base.clone().derivative(GS.rescale_star),
5644 f_base
5645 .derivative(GS.rescale_star)
5646 .derivative(GS.rescale_star),
5647 eta_base.clone().derivative(GS.rescale_star),
5648 eta_base
5649 .clone()
5650 .derivative(GS.rescale_star)
5651 .derivative(GS.rescale_star),
5652 eta_base
5653 .derivative(GS.rescale_star)
5654 .derivative(GS.rescale_star)
5655 .derivative(GS.rescale_star),
5656 ]
5657 .into_iter()
5658 .map(|atom| atom.to_string())
5659 .collect::<Vec<_>>();
5660
5661 let actual = params
5662 .iter()
5663 .map(|atom| atom.to_string())
5664 .collect::<Vec<_>>();
5665
5666 assert_eq!(actual, expected);
5667 }
5668
5669 #[test]
5670 fn iterated_threshold_f_params_follow_mixed_left_right_shape_order() {
5671 let params = super::params_for_iterated_threshold_ct(2, 2);
5672 let f = symbol!("f");
5673 let base = function!(f, GS.radius_star_left, GS.radius_star_right);
5674 let expected = [
5675 base.clone(),
5676 base.clone().derivative(GS.radius_star_left),
5677 base.clone().derivative(GS.radius_star_right),
5678 base.derivative(GS.radius_star_left)
5679 .derivative(GS.radius_star_right),
5680 ]
5681 .into_iter()
5682 .map(|atom| atom.to_string())
5683 .collect::<Vec<_>>();
5684 let actual = params[..expected.len()]
5685 .iter()
5686 .map(|atom| atom.to_string())
5687 .collect::<Vec<_>>();
5688
5689 assert_eq!(actual, expected);
5690 }
5691
5692 #[test]
5693 fn iterated_threshold_params_append_left_then_right_eta_families() {
5694 let params = super::params_for_iterated_threshold_ct(2, 2);
5695
5696 let expected = [
5697 function!(GS.eta_left, GS.radius_star_left).derivative(GS.radius_star_left),
5698 function!(GS.eta_left, GS.radius_star_left)
5699 .derivative(GS.radius_star_left)
5700 .derivative(GS.radius_star_left),
5701 function!(GS.eta_right, GS.radius_star_right).derivative(GS.radius_star_right),
5702 function!(GS.eta_right, GS.radius_star_right)
5703 .derivative(GS.radius_star_right)
5704 .derivative(GS.radius_star_right),
5705 ]
5706 .into_iter()
5707 .map(|atom| atom.to_string())
5708 .collect::<Vec<_>>();
5709
5710 let actual = params[4..]
5711 .iter()
5712 .map(|atom| atom.to_string())
5713 .collect::<Vec<_>>();
5714
5715 assert_eq!(actual, expected);
5716 }
5717
5718 #[test]
5719 fn iterated_threshold_helpers_match_signed_nonlinear_pole_distributions() {
5720 use symbolica::atom::Atom;
5721
5722 test_initialise().unwrap();
5723 let residues = [Atom::one(), Atom::one(), Atom::num((-3, 4))];
5730 let principal_values = [Atom::num((4, 3)), Atom::num((17, 9)), Atom::num((-89, 216))];
5731 let f_jets = [2_i64, 7, 11];
5732 let eta_jets = [2_i64, 3, 5];
5733 let f = symbol!("f");
5734 for left_order in 1..=3_u8 {
5735 for right_order in 1..=3_u8 {
5736 let (_, helper) = super::CrossSectionGraph::iterated_th_prefactor_helper_atoms(
5737 left_order,
5738 right_order,
5739 1,
5740 2,
5741 true,
5742 );
5743 let mut sampled = helper;
5744 for (left_degree, left_jet) in f_jets.iter().enumerate() {
5747 for (right_degree, right_jet) in f_jets.iter().enumerate() {
5748 let mut derivative =
5749 function!(f, GS.radius_star_left, GS.radius_star_right);
5750 for _ in 0..left_degree {
5751 derivative = derivative.derivative(GS.radius_star_left);
5752 }
5753 for _ in 0..right_degree {
5754 derivative = derivative.derivative(GS.radius_star_right);
5755 }
5756 sampled = sampled
5757 .replace(derivative)
5758 .with(Atom::num(left_jet * right_jet));
5759 }
5760 }
5761 for (eta, radius_star) in [
5762 (GS.eta_left, GS.radius_star_left),
5763 (GS.eta_right, GS.radius_star_right),
5764 ] {
5765 let mut derivative = function!(eta, radius_star);
5766 for jet in eta_jets {
5767 derivative = derivative.derivative(radius_star);
5768 sampled = sampled.replace(derivative.clone()).with(Atom::num(jet));
5769 }
5770 }
5771 for (parameter, value) in [
5772 (GS.radius_left, 2),
5773 (GS.radius_right, 2),
5774 (GS.radius_star_left, 1),
5775 (GS.radius_star_right, 1),
5776 (GS.uv_damp_plus_left, 3),
5777 (GS.uv_damp_plus_right, 3),
5778 (GS.uv_damp_minus_left, 5),
5779 (GS.uv_damp_minus_right, 5),
5780 (GS.pi, 3),
5781 ] {
5782 sampled = sampled.replace(parameter).with(Atom::num(value));
5783 }
5784 for (left_integrated, right_integrated) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
5785 let actual = sampled
5786 .replace(GS.hfunction_left_th)
5787 .with(Atom::num(left_integrated))
5788 .replace(GS.hfunction_right_th)
5789 .with(Atom::num(right_integrated));
5790 let left = (&principal_values[left_order as usize - 1]
5795 - Atom::i()
5796 * Atom::num(3 * left_integrated)
5797 * &residues[left_order as usize - 1])
5798 * Atom::num((1, 4));
5799 let right = (&principal_values[right_order as usize - 1]
5800 + Atom::i()
5801 * Atom::num(3 * right_integrated)
5802 * &residues[right_order as usize - 1])
5803 * Atom::num((1, 32));
5804 assert_eq!(
5805 actual,
5806 left * right,
5807 "orders {left_order}x{right_order}, integrated {left_integrated}/{right_integrated}"
5808 );
5809 }
5810 }
5811 }
5812 }
5813
5814 #[test]
5815 fn iterated_threshold_helper_atom_matches_its_left_right_parameter_families() {
5816 std::thread::Builder::new()
5817 .name("iterated-threshold-helper-test".to_string())
5818 .stack_size(16 * 1024 * 1024)
5819 .spawn(|| {
5820 test_initialise().unwrap();
5821
5822 let mut fn_map = super::FunctionMap::new();
5823 fn_map
5824 .add_aliases([(
5825 GS.pi.into(),
5826 super::Atom::num(
5827 super::Rational::try_from(std::f64::consts::PI).unwrap(),
5828 ),
5829 )])
5830 .unwrap();
5831
5832 for left_order in 1..=3 {
5833 for right_order in 1..=3 {
5834 let (pieces, legacy) =
5835 super::CrossSectionGraph::iterated_th_prefactor_helper_atoms(
5836 left_order,
5837 right_order,
5838 1,
5839 1,
5840 true,
5841 );
5842 let params = super::CrossSectionGraph::iterated_th_prefactor_helper_params(
5843 left_order,
5844 right_order,
5845 );
5846
5847 super::GenericEvaluator::new_from_raw_params(
5848 [
5849 legacy,
5850 pieces.local_local,
5851 pieces.local_integrated,
5852 pieces.integrated_local,
5853 pieces.integrated_integrated,
5854 ],
5855 ¶ms,
5856 &fn_map,
5857 vec![],
5858 super::OptimizationSettings::default(),
5859 None,
5860 &super::EvaluatorSettings::default(),
5861 )
5862 .unwrap_or_else(|error| {
5863 panic!(
5864 "iterated {left_order}x{right_order} threshold helper atom and parameter families must agree: {error}"
5865 )
5866 });
5867 }
5868 }
5869 })
5870 .expect("iterated threshold-helper test thread must start")
5871 .join()
5872 .expect("iterated threshold-helper test thread must finish successfully");
5873 }
5874
5875 #[test]
5876 fn single_threshold_helper_keeps_raised_poles_in_the_local_piece() {
5877 let (with_integrated, legacy) =
5878 super::CrossSectionGraph::single_th_prefactor_helper_atoms(3, 1, false, true);
5879 let (without_integrated, _) =
5880 super::CrossSectionGraph::single_th_prefactor_helper_atoms(3, 1, false, false);
5881
5882 assert!(
5883 (with_integrated.local.clone() - without_integrated.local)
5884 .expand()
5885 .is_zero()
5886 );
5887 assert!(without_integrated.integrated.is_zero());
5888 assert!(!with_integrated.integrated.is_zero());
5889 assert!(
5890 (legacy - with_integrated.local - with_integrated.integrated)
5891 .expand()
5892 .is_zero()
5893 );
5894 }
5895
5896 #[test]
5897 fn iterated_threshold_helper_pieces_sum_to_the_legacy_product() {
5898 let (left, _) =
5899 super::CrossSectionGraph::single_th_prefactor_helper_atoms(3, 1, false, true);
5900 let left_local = left
5901 .local
5902 .replace(GS.eta)
5903 .with(super::Atom::var(GS.eta_left));
5904 let left_integrated = left
5905 .integrated
5906 .replace(GS.eta)
5907 .with(super::Atom::var(GS.eta_left));
5908 let (right, _) =
5909 super::CrossSectionGraph::single_th_prefactor_helper_atoms(2, 1, true, true);
5910 let right_local = right
5911 .local
5912 .replace(GS.eta)
5913 .with(super::Atom::var(GS.eta_right));
5914 let right_integrated = right
5915 .integrated
5916 .replace(GS.eta)
5917 .with(super::Atom::var(GS.eta_right));
5918
5919 let legacy_product = ((left_local + left_integrated) * (right_local + right_integrated))
5920 .expand()
5921 .replace_multiple(super::CrossSectionGraph::fuse_left_right_replacement());
5922 let (pieces, legacy) =
5923 super::CrossSectionGraph::iterated_th_prefactor_helper_atoms(3, 2, 1, 1, true);
5924 let piece_sum = pieces.local_local
5925 + pieces.local_integrated
5926 + pieces.integrated_local
5927 + pieces.integrated_integrated;
5928
5929 assert!((legacy_product - &legacy).expand().is_zero());
5930 assert!((legacy - piece_sum).expand().is_zero());
5931 }
5932
5933 #[test]
5934 fn iterated_threshold_f_params_follow_active_single_axis_shape_order() {
5935 let params = super::params_for_iterated_threshold_ct(1, 3);
5936 let f = symbol!("f");
5937 let base = function!(f, GS.radius_star_left, GS.radius_star_right);
5938 let expected = [
5939 base.clone(),
5940 base.clone().derivative(GS.radius_star_right),
5941 base.derivative(GS.radius_star_right)
5942 .derivative(GS.radius_star_right),
5943 ]
5944 .into_iter()
5945 .map(|atom| atom.to_string())
5946 .collect::<Vec<_>>();
5947 let actual = params[..expected.len()]
5948 .iter()
5949 .map(|atom| atom.to_string())
5950 .collect::<Vec<_>>();
5951
5952 assert_eq!(actual, expected);
5953 }
5954}