Skip to main content

gammalooprs/processes/
amplitude.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    fmt,
4    fs::{self, File},
5    io::Write,
6    iter,
7    path::Path,
8    sync::Arc,
9};
10
11use ahash::AHashSet;
12// use bincode::{Decode, Encode};
13use bincode_trait_derive::{Decode, Encode};
14use color_eyre::Result;
15use momtrop::SampleGenerator;
16
17use idenso::dirac::GammaSimplifier;
18use rayon::{
19    ThreadPool,
20    iter::{IndexedParallelIterator, IntoParallelRefMutIterator, ParallelIterator},
21};
22use spenso::algebra::complex::Complex;
23use tracing::{info_span, instrument};
24use tracing_indicatif::{span_ext::IndicatifSpanExt, style::ProgressStyle};
25use vakint::{EvaluationMethod, NumericalEvaluationResult, Vakint, vakint_symbol};
26
27use crate::{
28    GammaLoopContext, GammaLoopContextContainer,
29    cff::{
30        CutCFFIndex,
31        esurface::{GroupEsurfaceId, RaisedEsurfaceData, RaisedEsurfaceGroup, RaisedEsurfaceId},
32    },
33    feyngen::GenerationType,
34    graph::{
35        GraphGroup, GraphGroupPosition, GroupId, LMBext, LmbIndex, LoopMomentumBasis,
36        cuts::{CutSet, ResidueSelector},
37        threshold_counterterms::{
38            ThresholdCountertermMultiplier, ThresholdCountertermSpec, ThresholdCountertermVariant,
39        },
40    },
41    integrands::process::{
42        GenericEvaluator, LmbMultiChannelingSetup,
43        amplitude::{AmplitudeGraphTerm, AmplitudeIntegrand, AmplitudeIntegrandData},
44        graph_to_group_id_for_group_structure,
45        param_builder::FnMapEntry,
46    },
47    model::ArcParticle,
48    momentum::{
49        sample::{ExternalIndex, SubspaceData},
50        signature::SignatureLike,
51    },
52    processes::{
53        DotExportSettings, EvaluatorSettings, GraphGenerationStats, GraphGroupSelectionPlan,
54        GraphGroupSelectionSpec, NamedGraphGenerationReport,
55        ResolvedThresholdCountertermAssociation, ResolvedThresholdCountertermVariant,
56        ResolvedThresholdCounterterms, SingleThresholdPieces, StandaloneExportSettings,
57        ThresholdCountertermOrigin, ThresholdCountertermSide, ThresholdCountertermVariantId,
58        build_derivative_structure_atom, params_for_derivative_order,
59    },
60    settings::{
61        GlobalSettings, RuntimeSettings, global::OrientationPattern, runtime::LockedRuntimeSettings,
62    },
63    subtraction::amplitude_counterterm::AmplitudeCountertermAtom,
64    utils::{F, GS, Length, W_},
65    uv::{
66        Integrands, RenormalizationPart, UVgenerationSettings, UltravioletGraph,
67        approx::{CutStructure, OrientationProjection, integrated::to_vakint_integrand},
68        settings::VakintSettings,
69    },
70};
71use eyre::{Context, eyre};
72use itertools::Itertools;
73use linnet::{
74    half_edge::{
75        involution::{EdgeIndex, Flow, HedgePair},
76        subgraph::{ModifySubSet, SuBitGraph, SubGraphLike, SubSetOps},
77    },
78    parser::DotGraph,
79};
80use spenso::shadowing::symbolica_utils::LogPrint;
81use symbolica::{atom::Var, prelude::*};
82use three_dimensional_reps::GeneratedThreeDExpression;
83use tracing::{debug, info};
84use typed_index_collections::{TiVec, ti_vec};
85
86use super::generation_progress::{self, GenerationProcessKind, GenerationProgressPhase};
87
88use crate::{
89    cff::esurface::EsurfaceID,
90    graph::{FeynmanGraph, Graph},
91    integrands::process::ProcessIntegrand,
92    model::Model,
93    settings::global::{GenerationSettings, MediumMode},
94};
95
96use crate::graph::parse::complete_group_parsing;
97
98#[derive(Clone, Encode, Decode)]
99#[trait_decode(trait = GammaLoopContext)]
100pub struct Amplitude {
101    pub name: String,
102    pub integrand: Option<ProcessIntegrand>,
103    pub graphs: Vec<AmplitudeGraph>,
104    pub graph_group_structure: TiVec<GroupId, GraphGroup>,
105    pub external_particles: Vec<ArcParticle>,
106    pub external_signature: SignatureLike<ExternalIndex>,
107    pub group_derived_data: TiVec<GroupId, GroupDerivedData>,
108}
109
110#[derive(Clone, Encode, Decode)]
111#[trait_decode(trait = GammaLoopContext)]
112pub struct GroupDerivedData {
113    pub esurface_map: TiVec<GroupEsurfaceId, TiVec<GraphGroupPosition, Option<RaisedEsurfaceId>>>,
114    pub esurface_atoms: TiVec<GroupEsurfaceId, Atom>,
115}
116
117impl Amplitude {
118    pub fn plan_graph_group_selection(
119        &self,
120        spec: &GraphGroupSelectionSpec,
121    ) -> Result<GraphGroupSelectionPlan> {
122        spec.plan(&self.graph_group_structure, |graph_id| {
123            self.graphs.get(graph_id).map(|graph| &graph.graph)
124        })
125    }
126
127    pub fn validate_graph_group_selection_plan(
128        &self,
129        plan: &GraphGroupSelectionPlan,
130    ) -> Result<()> {
131        if !self.group_derived_data.is_empty()
132            && self.group_derived_data.len() != self.graph_group_structure.len()
133        {
134            return Err(eyre!(
135                "Amplitude '{}' has {} group-derived-data entries for {} graph groups.",
136                self.name,
137                self.group_derived_data.len(),
138                self.graph_group_structure.len()
139            ));
140        }
141
142        for &old_group_id in plan.retained_group_ids() {
143            plan.new_group_id_for_old(old_group_id).ok_or_else(|| {
144                eyre!(
145                    "Selection plan is missing compact group id for old group {}.",
146                    old_group_id.0
147                )
148            })?;
149            if old_group_id.0 >= self.graph_group_structure.len() {
150                return Err(eyre!(
151                    "Selection plan refers to missing graph group {}.",
152                    old_group_id.0
153                ));
154            }
155            let group = &self.graph_group_structure[old_group_id];
156            for old_graph_id in group {
157                if old_graph_id >= self.graphs.len() {
158                    return Err(eyre!(
159                        "Graph group {} refers to missing graph id {}.",
160                        old_group_id.0,
161                        old_graph_id
162                    ));
163                }
164            }
165            let master = group.master();
166            if master >= self.graphs.len() {
167                return Err(eyre!(
168                    "Graph group {} refers to missing master graph id {}.",
169                    old_group_id.0,
170                    master
171                ));
172            }
173        }
174
175        Ok(())
176    }
177
178    pub fn apply_graph_group_selection(&mut self, plan: &GraphGroupSelectionPlan) -> Result<()> {
179        self.validate_graph_group_selection_plan(plan)?;
180
181        let mut old_graph_to_new_group = vec![None; self.graphs.len()];
182        let mut old_graph_is_master = vec![false; self.graphs.len()];
183        for &old_group_id in plan.retained_group_ids() {
184            let new_group_id = plan.new_group_id_for_old(old_group_id).ok_or_else(|| {
185                eyre!(
186                    "Selection plan is missing compact group id for old group {}.",
187                    old_group_id.0
188                )
189            })?;
190            let group = &self.graph_group_structure[old_group_id];
191            for old_graph_id in group {
192                old_graph_to_new_group[old_graph_id] = Some(new_group_id);
193            }
194            old_graph_is_master[group.master()] = true;
195        }
196
197        let mut new_graphs = self
198            .graphs
199            .iter()
200            .cloned()
201            .enumerate()
202            .filter_map(|(old_graph_id, mut graph)| {
203                let new_group_id = old_graph_to_new_group[old_graph_id]?;
204                graph.graph.group_id = Some(new_group_id);
205                graph.graph.is_group_master = old_graph_is_master[old_graph_id];
206                if let Some(multi_channeling_setup) = &mut graph.derived_data.multi_channeling_setup
207                {
208                    multi_channeling_setup.graph.group_id = Some(new_group_id);
209                    multi_channeling_setup.graph.is_group_master =
210                        old_graph_is_master[old_graph_id];
211                }
212                Some(graph)
213            })
214            .collect::<Vec<_>>();
215
216        let mut parsed_graphs = new_graphs
217            .iter()
218            .map(|graph| graph.graph.clone())
219            .collect::<Vec<_>>();
220        let new_graph_group_structure = complete_group_parsing(&mut parsed_graphs)?;
221        for (graph, parsed_graph) in new_graphs.iter_mut().zip(parsed_graphs) {
222            graph.graph.group_id = parsed_graph.group_id;
223            graph.graph.is_group_master = parsed_graph.is_group_master;
224            if let Some(multi_channeling_setup) = &mut graph.derived_data.multi_channeling_setup {
225                multi_channeling_setup.graph.group_id = graph.graph.group_id;
226                multi_channeling_setup.graph.is_group_master = graph.graph.is_group_master;
227            }
228        }
229
230        let new_group_derived_data = if self.group_derived_data.is_empty() {
231            TiVec::new()
232        } else {
233            plan.retained_group_ids()
234                .iter()
235                .map(|&old_group_id| self.group_derived_data[old_group_id].clone())
236                .collect::<TiVec<GroupId, _>>()
237        };
238
239        self.graphs = new_graphs;
240        self.graph_group_structure = new_graph_group_structure;
241        self.group_derived_data = new_group_derived_data;
242        self.integrand = None;
243        Ok(())
244    }
245
246    pub fn export_standalone(
247        &self,
248        path: impl AsRef<Path>,
249        settings: &StandaloneExportSettings,
250    ) -> Result<()> {
251        if let Some(integrand) = &self.integrand {
252            integrand.export_standalone(path, settings)?
253        } else {
254            return Err(eyre!(
255                "Cannot warm up amplitude {} without integrand",
256                self.name
257            ));
258        }
259
260        Ok(())
261    }
262
263    #[instrument(
264          skip_all,
265          fields(
266              amplitude.name = %self.name,
267          )
268    )]
269    pub(crate) fn warm_up(&mut self, model: &Model) -> Result<()> {
270        if let Some(integrand) = &mut self.integrand {
271            integrand.warm_up(model)
272        } else {
273            Err(eyre!(
274                "Cannot warm up amplitude {} without integrand",
275                self.name
276            ))
277        }
278    }
279
280    #[instrument(
281          skip_all,
282          fields(
283              path = %path.as_ref().display(),
284          )
285    )]
286    pub(crate) fn load(path: impl AsRef<Path>, context: GammaLoopContextContainer) -> Result<Self> {
287        let binary = fs::read(path.as_ref().join("amp.bin"))?;
288        let (mut amp, _): (Self, _) =
289            bincode::decode_from_slice_with_context(&binary, bincode::config::standard(), context)?;
290
291        if path.as_ref().join("integrand").exists() {
292            let integrand = AmplitudeIntegrand::load(path.as_ref().join("integrand"), context)?;
293            amp.integrand = Some(ProcessIntegrand::Amplitude(integrand));
294        }
295
296        Ok(amp)
297    }
298
299    #[instrument(
300          skip_all,
301          fields(
302              amplitude.name = %self.name,
303          )
304    )]
305    pub fn compile(
306        &mut self,
307        path: impl AsRef<Path>,
308        override_existing: bool,
309        thread_pool: &ThreadPool,
310    ) -> Result<Vec<NamedGraphGenerationReport>> {
311        info!("Compiling amplitude {}", self.name);
312        let p = path.as_ref().join(&self.name);
313
314        let r = fs::create_dir_all(&p).with_context(|| {
315            format!(
316                "Trying to create directory to save amplitude {}",
317                p.display()
318            )
319        });
320        if override_existing {
321            r?;
322        }
323        if let Some(integrand) = &mut self.integrand {
324            let compile_times = integrand.compile(&p, override_existing, thread_pool)?;
325            return Ok(compile_times
326                .into_iter()
327                .map(|(graph_name, duration)| NamedGraphGenerationReport {
328                    integrand_name: self.name.clone(),
329                    graph_name,
330                    stats: GraphGenerationStats {
331                        total_time: duration,
332                        evaluator_compile_time: duration,
333                        ..GraphGenerationStats::default()
334                    },
335                })
336                .collect());
337        };
338        Ok(Vec::new())
339    }
340
341    #[instrument(
342          skip_all,
343          fields(
344              amplitude.name = %self.name,
345              path = %path.as_ref().display(),
346          )
347    )]
348    pub fn save(&mut self, path: impl AsRef<Path>, override_existing: bool) -> Result<()> {
349        let p = path.as_ref().join(&self.name);
350
351        let r = fs::create_dir_all(&p).with_context(|| {
352            format!(
353                "Trying to create directory to save amplitude {}",
354                p.display()
355            )
356        });
357        if override_existing {
358            r?;
359        }
360
361        let integrand = self.integrand.take();
362        if let Some(integrand) = &integrand {
363            integrand.save(&p, override_existing)?;
364        };
365
366        let binary = bincode::encode_to_vec(&(*self), bincode::config::standard())?;
367        if override_existing {
368            fs::write(p.join("amp.bin"), binary)?;
369        } else {
370            let mut file = File::create_new(p.join("amp.bin"))?;
371            file.write_all(&binary)?;
372        }
373
374        self.integrand = integrand;
375        Ok(())
376    }
377
378    #[instrument(
379        skip_all,
380        fields(
381             amplitude.name = %self.name,
382        )
383    )]
384    pub fn preprocess(
385        &mut self,
386        model: &Model,
387        settings: &GenerationSettings,
388        locked_runtime_settings: &LockedRuntimeSettings,
389        thread_pool: &ThreadPool,
390    ) -> Result<Vec<NamedGraphGenerationReport>> {
391        settings.validate_for_process(GenerationType::Amplitude)?;
392        // preprocess each graph individually
393        // Threshold directives of every member use the master's edge IDs and topology.
394        // The supplied graph group is responsible for aligning their physical meaning.
395        for group in &self.graph_group_structure {
396            if group.into_iter().all(|id| {
397                self.graphs[id]
398                    .graph
399                    .threshold_counterterms
400                    .is_legacy_equivalent()
401            }) {
402                // Metadata-free groups retain their native member geometry and shared full-space solve.
403                for graph_id in group {
404                    self.graphs[graph_id].derived_data.threshold_topology = None;
405                }
406                continue;
407            }
408            let master = &self.graphs[group.master()].graph;
409            let topology = (
410                master.clone(),
411                master.generate_loop_momentum_bases_of(&master.no_dummy()),
412            );
413            for graph_id in group {
414                self.graphs[graph_id]
415                    .graph
416                    .threshold_counterterms
417                    .validate_for_graph(&topology.0)?;
418                self.graphs[graph_id].derived_data.threshold_topology = Some(topology.clone());
419            }
420            let mut explicit = BTreeMap::new();
421            let all_lmbs = &self.graphs[group.master()]
422                .derived_data
423                .threshold_topology
424                .as_ref()
425                .unwrap()
426                .1;
427            for id in group {
428                for (label, subspace, description) in
429                    self.graphs[id].explicit_threshold_group_members()?
430                {
431                    explicit
432                        .entry(label)
433                        .or_insert_with(Vec::new)
434                        .push((subspace.solve_signature(all_lmbs), description));
435                }
436            }
437            let conflicts = explicit
438                .into_iter()
439                .filter_map(|(label, members)| {
440                    (members.iter().map(|(key, _)| key).unique().count() > 1).then(|| {
441                        format!(
442                            "group_id={label}: {}",
443                            members
444                                .into_iter()
445                                .map(|(key, description)| format!(
446                                    "{description}, signed cycles {key:?}"
447                                ))
448                                .join("; ")
449                        )
450                    })
451                })
452                .collect_vec();
453            if !conflicts.is_empty() {
454                return Err(eyre!(
455                    "Amplitude group master '{}' has incompatible threshold solve groups:\n{}",
456                    self.graphs[group.master()].graph.name,
457                    conflicts.join("\n")
458                ));
459            }
460        }
461        let integrand_name = self.name.clone();
462
463        let preprocess_span = if generation_progress::detailed_progress_enabled() {
464            let span = info_span!("Preprocessing graphs", indicatif.pb_show = true);
465            span.pb_set_style(&ProgressStyle::with_template(
466                "{wide_bar} {pos}/{len} {msg}",
467            )?);
468            span.pb_set_length(self.graphs.len() as u64);
469            span.pb_set_message("Preprocessing graphs");
470            Some(span)
471        } else {
472            None
473        };
474        let preprocess_span_enter = preprocess_span.as_ref().map(|span| span.enter());
475
476        let preprocess_reports = thread_pool.install(|| {
477            let parent = preprocess_span.clone();
478            self.graphs
479                .par_iter_mut()
480                .map(|amplitude_graph| {
481                    if crate::is_interrupted() {
482                        return Err(eyre!("Generation interrupted by user"));
483                    }
484                    let graph_name = amplitude_graph.graph.name.clone();
485                    generation_progress::graph_started(
486                        GenerationProcessKind::Amplitude,
487                        &integrand_name,
488                        &graph_name,
489                        None,
490                    );
491                    let _guard = parent.as_ref().map(|span| span.enter());
492                    let stats =
493                        amplitude_graph.preprocess(model, settings, locked_runtime_settings);
494                    if let Some(span) = &parent {
495                        span.pb_inc(1);
496                    }
497
498                    let stats = stats?;
499                    if crate::is_interrupted() {
500                        return Err(eyre!("Generation interrupted by user"));
501                    }
502                    generation_progress::graph_finished(
503                        GenerationProcessKind::Amplitude,
504                        &integrand_name,
505                        &graph_name,
506                        &stats,
507                        None,
508                    );
509
510                    Ok(NamedGraphGenerationReport {
511                        integrand_name: integrand_name.clone(),
512                        graph_name,
513                        stats,
514                    })
515                })
516                .collect::<Result<Vec<_>>>()
517        })?;
518
519        drop(preprocess_span_enter);
520        drop(preprocess_span);
521
522        // Groups containing non-default variants need the variant evaluator lane throughout,
523        // including members whose directives reproduce their defaults.
524        for group in &self.graph_group_structure {
525            if group.into_iter().any(|id| {
526                self.graphs[id]
527                    .derived_data
528                    .resolved_threshold_counterterms
529                    .as_ref()
530                    .is_some_and(|resolved| !resolved.legacy_equivalent)
531            }) {
532                for id in group {
533                    if let Some(resolved) =
534                        &mut self.graphs[id].derived_data.resolved_threshold_counterterms
535                    {
536                        resolved.legacy_equivalent = false;
537                    }
538                }
539            }
540        }
541        self.generate_grouped_derived_data()?;
542
543        Ok(preprocess_reports)
544    }
545
546    #[instrument(
547        skip_all,
548          fields(
549              amplitude.name = %self.name,
550          )
551      )]
552    pub fn build_integrand(
553        &mut self,
554        model: &Model,
555        process_name: &str,
556        global_settings: &GlobalSettings,
557        runtime_default: LockedRuntimeSettings,
558        thread_pool: &ThreadPool,
559    ) -> Result<Vec<NamedGraphGenerationReport>> {
560        let started = std::time::Instant::now();
561        crate::debug_tags!(#generation, #profile, #graph, #summary;
562            stage = "amplitude_build_integrand_start",
563            integrand = %self.name,
564            graph_count = self.graphs.len(),
565            "Generation timing milestone"
566        );
567        if crate::is_interrupted() {
568            return Err(eyre!("Generation interrupted by user"));
569        }
570        let integrand_name = self.name.clone();
571        generation_progress::begin_phase(
572            GenerationProgressPhase::GraphGeneration,
573            GenerationProcessKind::Amplitude,
574            process_name,
575            &integrand_name,
576            self.graphs.len(),
577            None,
578        );
579        let mut graph_reports = Vec::new();
580        let terms: Vec<_> = thread_pool.install(|| {
581            self.graphs
582                .par_iter_mut()
583                .enumerate()
584                .map(|(graph_id, graph)| {
585                    if crate::is_interrupted() {
586                        return Err(eyre!("Generation interrupted by user"));
587                    }
588                    let graph_started = std::time::Instant::now();
589                    let group_id = graph.graph.group_id.unwrap(); // should always be set
590                    let esurface_map = &self.group_derived_data[group_id].esurface_map;
591                    let group_pos = self.graph_group_structure[group_id]
592                        .find_position(graph_id)
593                        .unwrap();
594
595                    crate::debug_tags!(#generation, #profile, #graph, #summary;
596                        stage = "amplitude_generate_term_for_graph_start",
597                        integrand = %integrand_name,
598                        graph = %graph.graph.name,
599                        graph_id,
600                        group_id = %group_id.0,
601                        "Generation timing milestone"
602                    );
603                    generation_progress::graph_started(
604                        GenerationProcessKind::Amplitude,
605                        &integrand_name,
606                        &graph.graph.name,
607                        None,
608                    );
609                    let _progress_context_guard =
610                        generation_progress::enter_progress_context(graph.graph.name.clone());
611                    let (term, mut stats) = graph.generate_term_for_graph(
612                        model,
613                        group_pos,
614                        esurface_map.clone(),
615                        global_settings,
616                    )?;
617                    if crate::is_interrupted() {
618                        return Err(eyre!("Generation interrupted by user"));
619                    }
620                    stats.evaluator_count = term.generic_evaluator_count();
621                    stats.total_time += graph_started.elapsed();
622                    crate::debug_tags!(#generation, #profile, #graph, #summary;
623                        stage = "amplitude_generate_term_for_graph_done",
624                        integrand = %integrand_name,
625                        graph = %graph.graph.name,
626                        graph_id,
627                        group_id = %group_id.0,
628                        elapsed_ms = graph_started.elapsed().as_secs_f64() * 1000.0,
629                        "Generation timing milestone"
630                    );
631                    generation_progress::graph_finished(
632                        GenerationProcessKind::Amplitude,
633                        &integrand_name,
634                        &graph.graph.name,
635                        &stats,
636                        None,
637                    );
638                    Ok((
639                        term,
640                        NamedGraphGenerationReport {
641                            integrand_name: integrand_name.clone(),
642                            graph_name: graph.graph.name.clone(),
643                            stats,
644                        },
645                    ))
646                })
647                .collect::<Result<Vec<_>>>()
648        })?;
649        if crate::is_interrupted() {
650            return Err(eyre!("Generation interrupted by user"));
651        }
652        for (_, report) in &terms {
653            graph_reports.push(report.clone());
654        }
655        let mut terms = terms.into_iter().map(|(term, _)| term).collect::<Vec<_>>();
656
657        for group in self.graph_group_structure.iter() {
658            let master_graph_id = group.master();
659            let mc_of_master = self.graphs[master_graph_id]
660                .derived_data
661                .multi_channeling_setup
662                .as_ref()
663                .unwrap();
664            let master_graph = &self.graphs[master_graph_id].graph;
665            let master_external_signature = master_graph.get_external_signature();
666            let master_external_pdgs = master_graph
667                .get_external_partcles()
668                .into_iter()
669                .map(|particle| particle.pdg_code)
670                .collect_vec();
671
672            for graph_id in group.into_iter() {
673                terms[graph_id].multi_channeling_setup = mc_of_master.clone();
674                terms[graph_id].master_external_signature = master_external_signature.clone();
675                terms[graph_id].master_external_pdgs = master_external_pdgs.clone();
676            }
677        }
678
679        let backend_started = std::time::Instant::now();
680        let graph_count = terms.len();
681        crate::debug_tags!(#generation, #profile, #compile, #graph, #summary;
682            stage = "amplitude_prepare_runtime_backends_start",
683            integrand = %self.name,
684            graph_count,
685            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
686            "Generation timing milestone"
687        );
688        generation_progress::backend_started(
689            GenerationProcessKind::Amplitude,
690            &self.name,
691            graph_count,
692        );
693        let mut amplitude_integrand = AmplitudeIntegrand {
694            settings: runtime_default.into_with_modified_kinematics(
695                &self.external_signature,
696                &self.graphs[0].graph.get_external_masses(model),
697            )?,
698            data: AmplitudeIntegrandData {
699                name: self.name.clone(),
700                compilation: global_settings
701                    .generation
702                    .compile
703                    .frozen_mode(&global_settings.generation.evaluator),
704                rotations: None,
705                loop_cache_id: 0,
706                external_cache_id: 0,
707                base_external_cache_id: 0,
708                graph_terms: terms,
709                external_signature: self.external_signature.clone(),
710                graph_group_structure: self.graph_group_structure.clone(),
711                graph_to_group_id: graph_to_group_id_for_group_structure(
712                    &self.graph_group_structure,
713                ),
714                group_derived_data: self.group_derived_data.clone(),
715                explicit_orientation_sum_only: global_settings
716                    .generation
717                    .explicit_orientation_sum_only,
718            },
719            event_processing_runtime: Default::default(),
720            active_f64_backend: Default::default(),
721        };
722        let compile_times =
723            amplitude_integrand.prepare_runtime_backends_after_generation_with_compile_times()?;
724        crate::debug_tags!(#generation, #profile, #compile, #graph, #summary;
725            stage = "amplitude_prepare_runtime_backends_done",
726            integrand = %self.name,
727            graph_count,
728            elapsed_ms = backend_started.elapsed().as_secs_f64() * 1000.0,
729            total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
730            "Generation timing milestone"
731        );
732        generation_progress::backend_finished(
733            GenerationProcessKind::Amplitude,
734            &self.name,
735            backend_started.elapsed(),
736        );
737        for (report, compile_time) in graph_reports.iter_mut().zip(compile_times) {
738            report.stats.evaluator_compile_time += compile_time;
739            report.stats.total_time += compile_time;
740        }
741        self.integrand = Some(ProcessIntegrand::Amplitude(amplitude_integrand));
742        crate::debug_tags!(#generation, #profile, #graph, #summary;
743            stage = "amplitude_build_integrand_done",
744            integrand = %self.name,
745            graph_count = self.graphs.len(),
746            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
747            "Generation timing milestone"
748        );
749        Ok(graph_reports)
750    }
751
752    #[instrument(
753        skip_all,
754          fields(
755              amplitude.name = %self.name,
756          )
757      )]
758    #[allow(dead_code)]
759    pub(crate) fn write_dot<W: std::io::Write>(
760        &self,
761        writer: &mut W,
762        settings: &DotExportSettings,
763    ) -> Result<(), std::io::Error> {
764        for graph in &self.graphs {
765            graph.write_dot(writer, settings)?;
766            writeln!(writer)?;
767        }
768        Ok(())
769    }
770
771    #[instrument(
772        skip_all,
773          fields(
774              amplitude.name = %self.name,
775          )
776      )]
777    pub fn write_dot_fmt<W: fmt::Write>(
778        &self,
779        writer: &mut W,
780        settings: &DotExportSettings,
781    ) -> Result<(), std::fmt::Error> {
782        for graph in &self.graphs {
783            graph.write_dot_fmt(writer, settings)?;
784            writeln!(writer)?;
785        }
786        Ok(())
787    }
788
789    pub fn generate_grouped_derived_data(&mut self) -> Result<()> {
790        // for each group we must collect all inequivalent esurfaces.
791
792        let group_derived_data = self
793            .graph_group_structure
794            .iter()
795            .map(|group| {
796                let mut group_esurface_structure =
797                    BTreeMap::<Atom, TiVec<GraphGroupPosition, Option<RaisedEsurfaceId>>>::default(
798                    );
799
800                for (graph_group_position, graph_id) in group.iter_enumerated() {
801                    let amplitude_graph = &self.graphs[graph_id];
802                    let lmb_reps = amplitude_graph.graph.integrand_replacement(
803                        &amplitude_graph.graph.full_filter(),
804                        &amplitude_graph.graph.loop_momentum_basis,
805                        &[W_.x___],
806                    );
807
808                    let esurfaces = &amplitude_graph.graph.surface_cache.esurface_cache;
809
810                    // Cached surfaces without a surviving pole are not threshold candidates.
811                    for (raised_esurface_id, raised_group) in amplitude_graph
812                        .derived_data
813                        .raised_data
814                        .raised_groups
815                        .iter_enumerated()
816                        .filter(|(_, raised_group)| raised_group.max_occurence > 0)
817                    {
818                        let esurface = &esurfaces[raised_group.esurface_ids[0]];
819                        let esurface_atom = esurface.lmb_atom(&amplitude_graph.graph, &lmb_reps);
820
821                        group_esurface_structure
822                            .entry(esurface_atom)
823                            .or_insert(ti_vec![None; group.len()])[graph_group_position] =
824                            Some(raised_esurface_id);
825                    }
826                }
827
828                let (surface_atoms, esurface_map) = group_esurface_structure.into_iter().unzip();
829
830                GroupDerivedData {
831                    esurface_map,
832                    esurface_atoms: surface_atoms,
833                }
834            })
835            .collect::<TiVec<GroupId, _>>();
836
837        let _: () = self.group_derived_data = group_derived_data;
838        Ok(())
839    }
840}
841
842#[derive(Clone, Encode, Decode)]
843#[trait_decode(trait= GammaLoopContext)]
844pub struct AmplitudeGraph {
845    pub graph: Graph,
846    pub derived_data: AmplitudeDerivedData,
847}
848
849#[derive(Clone)]
850struct ResolvedAmplitudeThresholdCountertermDraft {
851    name: String,
852    group_id: Option<usize>,
853    origin: ThresholdCountertermOrigin,
854    disable: bool,
855    requested_subspace: Option<Vec<EdgeIndex>>,
856    requested_parent_lmb: Option<Vec<EdgeIndex>>,
857    multiplier: Option<ThresholdCountertermMultiplier>,
858    subspace: SubspaceData,
859}
860
861impl ResolvedAmplitudeThresholdCountertermDraft {
862    fn is_compatible_with(
863        &self,
864        other: &Self,
865        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
866    ) -> bool {
867        self.name == other.name
868            && self.group_id == other.group_id
869            && self.disable == other.disable
870            && self.multiplier == other.multiplier
871            && self
872                .subspace
873                .has_equivalent_embedding(&other.subspace, all_lmbs)
874    }
875}
876
877struct ResolvedAmplitudeThresholdAssociationDraft {
878    esurface_id: EsurfaceID,
879    threshold_edges: Vec<EdgeIndex>,
880    origin: ThresholdCountertermOrigin,
881    variant_subspaces: Vec<SubspaceData>,
882}
883
884struct ResolvedAmplitudeThresholdGroupDraft {
885    raised_esurface_group: RaisedEsurfaceGroup,
886    associations: Vec<ResolvedAmplitudeThresholdAssociationDraft>,
887    variants: Vec<ResolvedAmplitudeThresholdCountertermDraft>,
888}
889
890struct AmplitudeThresholdCountertermBuild {
891    legacy_counterterms: TiVec<RaisedEsurfaceId, AmplitudeCountertermAtom>,
892    variants: TiVec<ThresholdCountertermVariantId, AmplitudeThresholdCountertermVariant>,
893    resolved: ResolvedThresholdCounterterms,
894    raised_esurface_ids: TiVec<EsurfaceID, RaisedEsurfaceId>,
895}
896
897pub struct AnalyticalEvaluationConfig<'a> {
898    pub model: &'a Model,
899    pub evaluate_numerically: bool,
900    pub vakint: &'a Vakint,
901    pub true_settings: &'a vakint::VakintSettings,
902    pub settings: &'a VakintSettings,
903    pub run_time_settings: &'a RuntimeSettings,
904    pub include_global_numerator: bool,
905}
906
907impl AmplitudeGraph {
908    pub(crate) fn new(graph: Graph) -> Self {
909        AmplitudeGraph {
910            graph,
911            derived_data: AmplitudeDerivedData {
912                threshold_topology: None,
913                all_mighty_integrand: Atom::Zero,
914                all_mighty_numerators: Vec::new(),
915                cff_expression: None,
916
917                lmbs: None,
918                tropical_sampler: None,
919                multi_channeling_setup: None,
920                threshold_counterterms: TiVec::new(),
921                threshold_counterterm_variants: TiVec::new(),
922                resolved_threshold_counterterms: None,
923                raised_data: RaisedEsurfaceData {
924                    raised_groups: TiVec::new(),
925                    pass_two_evaluator: None,
926                },
927                raised_esurface_ids: TiVec::new(),
928            },
929        }
930    }
931}
932
933impl AmplitudeGraph {
934    pub fn renormalization_part(
935        &mut self,
936        settings: &UVgenerationSettings,
937    ) -> Result<RenormalizationPart> {
938        if self.derived_data.cff_expression.is_none() {
939            // Preserve the source-energy gate formerly run by generate_cff,
940            // without requiring energy integration for this 4D-only operation.
941            self.graph.ensure_energy_convergent_cycles(
942                &self
943                    .graph
944                    .no_dummy()
945                    .subtract(&self.graph.initial_state_cut),
946            )?;
947        }
948        settings.orchestrator.renormalization_part(
949            &mut self.graph,
950            // RenormalizationPart forces a 4D forest: it never builds a CFF
951            // or attaches 3D numerator factors and needs no stored source.
952            OrientationProjection::four_d(&OrientationPattern::default()),
953            settings,
954        )
955    }
956
957    #[allow(dead_code)]
958    pub(crate) fn write_dot<W: std::io::Write>(
959        &self,
960        writer: &mut W,
961        settings: &DotExportSettings,
962    ) -> Result<(), std::io::Error> {
963        if let Some(graph) = self.graph_with_materialized_threshold_counterterms(settings) {
964            graph.dot_serialize_io(writer, settings)
965        } else {
966            self.graph.dot_serialize_io(writer, settings)
967        }
968    }
969
970    pub(crate) fn write_dot_fmt<W: fmt::Write>(
971        &self,
972        writer: &mut W,
973        settings: &DotExportSettings,
974    ) -> Result<(), std::fmt::Error> {
975        if let Some(graph) = self.graph_with_materialized_threshold_counterterms(settings) {
976            graph.dot_serialize_fmt(writer, settings)
977        } else {
978            self.graph.dot_serialize_fmt(writer, settings)
979        }
980    }
981
982    fn graph_with_materialized_threshold_counterterms(
983        &self,
984        settings: &DotExportSettings,
985    ) -> Option<Graph> {
986        // Non-master members retain their supplied metadata; implicit native defaults must
987        // not become explicit master-topology directives when the graph is reimported.
988        if !settings.include_autogenerated_fields
989            || (self.graph.group_id.is_some() && !self.graph.is_group_master)
990        {
991            return None;
992        }
993        let resolved = self.derived_data.resolved_threshold_counterterms.as_ref()?;
994        let all_lmbs = self
995            .derived_data
996            .threshold_topology
997            .as_ref()
998            .map(|(_, lmbs)| lmbs)
999            .or(self.derived_data.lmbs.as_ref())?;
1000        let mut graph = self.graph.clone();
1001        graph.threshold_counterterms = crate::graph::autogen::Autogen::explicit(
1002            resolved.materialized_spec(&self.graph.threshold_counterterms, all_lmbs),
1003        );
1004        Some(graph)
1005    }
1006
1007    #[instrument(skip_all, err)]
1008    pub(crate) fn generate_cff(&mut self, settings: &GenerationSettings) -> Result<()> {
1009        self.graph.set_medium_mode(settings.medium.mode)?;
1010        self.graph.ensure_energy_convergent_cycles(
1011            &self
1012                .graph
1013                .no_dummy()
1014                .subtract(&self.graph.initial_state_cut),
1015        )?;
1016        let _progress_guard = generation_progress::enter_detailed_progress_span("Generating CFF");
1017        let shift_rewrite = self
1018            .graph
1019            .get_esurface_canonization(&self.graph.loop_momentum_basis);
1020
1021        let contract_edges = self
1022            .graph
1023            .iter_edges_of(
1024                &self
1025                    .graph
1026                    .tree_edges
1027                    .subtract(&self.graph.initial_state_cut)
1028                    .subtract(&self.graph.external_filter::<SuBitGraph>()),
1029            )
1030            .map(|x| x.1)
1031            .collect_vec();
1032
1033        // The options provision maps from the complete factorized numerator.
1034        // The numerator itself remains caller-owned and is mapped only when
1035        // the corresponding orientation integrand is assembled.
1036        let options = self.graph.production_cff_3d_expression_options(settings)?;
1037        let generated = self.graph.generate_3d_expression_for_integrand(
1038            &contract_edges,
1039            &shift_rewrite,
1040            &options,
1041            None,
1042        )?;
1043
1044        self.derived_data.cff_expression = Some(generated);
1045
1046        Ok(())
1047    }
1048
1049    #[instrument(skip_all, err)]
1050    pub(crate) fn preprocess(
1051        &mut self,
1052        model: &Model,
1053        settings: &GenerationSettings,
1054        locked_runtime_settings: &LockedRuntimeSettings,
1055    ) -> Result<GraphGenerationStats> {
1056        let _progress_guard = generation_progress::enter_detailed_progress_span("preprocessing");
1057        let preprocess_started = std::time::Instant::now();
1058        let vk = crate::utils::vakint()?;
1059
1060        self.generate_cff(settings)?;
1061
1062        // UV orchestration can extend the graph surface cache, while raised IDs
1063        // belong to the CFF expression generated above.
1064        let raised_data = settings.threshold_subtraction.enable_thresholds.then(|| {
1065            self.graph.determine_raised_esurfaces_from_expression(
1066                &self
1067                    .derived_data
1068                    .cff_expression
1069                    .as_ref()
1070                    .expect("cff_expression should have been created")
1071                    .expression,
1072            )
1073        });
1074
1075        self.build_integrands(settings, vk)?;
1076
1077        if self.graph.is_group_master {
1078            self.build_tropical_sampler(settings)?;
1079        }
1080
1081        self.build_lmbs();
1082
1083        if self.graph.is_group_master {
1084            self.build_multi_channeling_channels(settings.override_lmb_heuristics);
1085        }
1086
1087        if let Some(mut raised_data) = raised_data {
1088            let max_order = raised_data
1089                .raised_groups
1090                .iter()
1091                .map(|raised_group| raised_group.max_occurence)
1092                .max()
1093                .unwrap_or(0);
1094            if max_order > 1 {
1095                self.graph.param_builder.initialize_duals(max_order);
1096            }
1097            raised_data.pass_two_evaluator = Some(
1098                (1..=max_order)
1099                    .map(|order| {
1100                        threshold_counterterm_helper(
1101                            order as u8,
1102                            self.graph.get_loop_number(),
1103                            &settings.evaluator,
1104                        )
1105                    })
1106                    .collect(),
1107            );
1108
1109            let build = self.build_threshold_counterterm_parametric_integrand(
1110                &raised_data,
1111                settings,
1112                vk,
1113                locked_runtime_settings,
1114                model,
1115            )?;
1116            self.derived_data.threshold_counterterms = build.legacy_counterterms;
1117            self.derived_data.threshold_counterterm_variants = build.variants;
1118            self.derived_data.resolved_threshold_counterterms = Some(build.resolved);
1119            self.derived_data.raised_esurface_ids = build.raised_esurface_ids;
1120            self.derived_data.raised_data = raised_data;
1121        } else {
1122            self.derived_data.resolved_threshold_counterterms = None;
1123            self.derived_data.threshold_counterterms.clear();
1124            self.derived_data.threshold_counterterm_variants.clear();
1125            self.derived_data.raised_esurface_ids.clear();
1126            self.derived_data.raised_data.raised_groups.clear();
1127            self.derived_data.raised_data.pass_two_evaluator = None;
1128        }
1129
1130        Ok(GraphGenerationStats {
1131            total_time: preprocess_started.elapsed(),
1132            ..GraphGenerationStats::default()
1133        })
1134    }
1135
1136    #[instrument(skip_all)]
1137    fn build_multi_channeling_channels(&mut self, override_lmb_heuristics: bool) {
1138        let _progress_guard =
1139            generation_progress::enter_detailed_progress_span("Building Multi-Channeling Channels");
1140        let channels = self.graph.build_multi_channeling_channels(
1141            self.derived_data.lmbs.as_ref().unwrap(),
1142            override_lmb_heuristics,
1143        );
1144
1145        self.derived_data.multi_channeling_setup = Some(channels)
1146    }
1147
1148    //  pub fn ct_params(&self, model: &Model) -> ParamBuilder<f64> {
1149    //      let mut param_builder = self.param_builder_core(model);
1150    //      param_builder.uv_damp_atom(vec![
1151    //          Atom::var(GS.uv_damp_plus),
1152    //          Atom::var(GS.uv_damp_minus),
1153    //      ]);
1154    //      param_builder.derivative_at_tstar_atom(Atom::var(GS.deta));
1155    //      param_builder.radius_atom(Atom::var(GS.radius));
1156    //      param_builder.radius_star_atom(Atom::var(GS.radius_star));
1157    //      param_builder.h_function_atom(Atom::var(GS.hfunction));
1158    //      param_builder
1159    //  }
1160
1161    //  pub(crate) fn param_builder_core(&self, model: &Model) -> ParamBuilder<f64> {
1162    //      // the float type does not matter here
1163    //      let mut param_builder = ParamBuilder::<f64>::new_empty();
1164
1165    //      // this is wrong if we allow for vacuum graphs
1166    //      param_builder.external_energies_atom(&self.graph);
1167    //      param_builder.orientation_params(&self.graph);
1168
1169    //      // param_builder.polarizations(&self.graph);
1170    //      // param_builder.polar
1171
1172    //      // spatial components of external momenta
1173    //      param_builder.external_spatial_atom(&self.graph);
1174    //      param_builder.polarization_params(&self.graph);
1175    //      // spatial EMR
1176    //      param_builder.emr_spatial_atom(&self.graph);
1177
1178    //      param_builder.model_parameters_atom(model);
1179
1180    //      param_builder.m_uv_atom(Atom::var(GS.m_uv_vacuum));
1181
1182    //      param_builder.mu_r_sq_atom(Atom::var(GS.mu_r_sq));
1183
1184    //      self.add_function_map(&mut param_builder);
1185
1186    //      param_builder
1187    //  }
1188
1189    // pub fn fill_in_params(
1190    //     &self,
1191    //     param_builder: &mut ParamBuilder,
1192    //     model: &Model,
1193    //     settings: &Settings,
1194    // ) {
1195    //     param_builder.external_energies_value(momentum_sample);
1196    // }
1197
1198    // fn get_eager_const_map(&self)->HashM
1199
1200    pub fn to_numerical(
1201        numerical_result: AtomView,
1202        true_settings: &vakint::VakintSettings,
1203    ) -> Result<NumericalEvaluationResult> {
1204        Ok(NumericalEvaluationResult::from_atom(
1205            numerical_result,
1206            vakint_symbol!(&true_settings.epsilon_symbol),
1207            true_settings,
1208        )?)
1209    }
1210
1211    pub fn analytical_evaluation<S: SubGraphLike<Base = SuBitGraph> + SubSetOps>(
1212        &self,
1213        component: &S,
1214        config: AnalyticalEvaluationConfig<'_>,
1215    ) -> Result<Atom> {
1216        if self.graph.param_builder.medium_mode != MediumMode::Vacuum {
1217            return Err(eyre!(
1218                "Analytical evaluation with Vakint does not support thermal modes (graph '{}', mode {:?}); use inspect or integrate for medium-dependent results",
1219                self.graph.name,
1220                self.graph.param_builder.medium_mode
1221            ));
1222        }
1223        let mut true_settings = config.true_settings.clone();
1224        let pysec_dec_enabled_in_vakint = true_settings.evaluation_order.0.iter().find_map(|o| {
1225            if let EvaluationMethod::PySecDec(opts) = o {
1226                Some(opts)
1227            } else {
1228                None
1229            }
1230        });
1231
1232        let complex_params_vakint =
1233            if config.evaluate_numerically || pysec_dec_enabled_in_vakint.is_some() {
1234                let mut param_builder = self.graph.param_builder.clone(); //ParamBuilder::<f64>::new(&self.graph, model);
1235                param_builder.update_model_values(config.model)?;
1236                param_builder.m_uv_value(Complex::new_re(F(config.run_time_settings.general.m_uv)));
1237                param_builder.renormalization_localization_scale_value(Complex::new_re(F(config
1238                    .run_time_settings
1239                    .general
1240                    .renormalization_localization_scale)));
1241                param_builder.mu_r_sq_value(Complex::new_re(F(config
1242                    .run_time_settings
1243                    .general
1244                    .mu_r_sq())));
1245                param_builder.inverse_temperature_value(Complex::new_re(F(config
1246                    .run_time_settings
1247                    .general
1248                    .inverse_temperature)));
1249
1250                // println!("\nParamBuilder parameters:\n{}", param_builder);
1251
1252                let mut complex_params: HashMap<String, symbolica::domains::float::Complex<f64>> =
1253                    HashMap::default();
1254                for params in param_builder.pairs.into_iter() {
1255                    let ps: crate::integrands::process::ParamValuePairs = params;
1256                    for (p_name, p_value) in
1257                        ps.params
1258                            .iter()
1259                            .zip(ps.value_range)
1260                            .map(|(a, value_index)| {
1261                                (
1262                                    a.to_canonical_string(),
1263                                    param_builder.values[0][value_index],
1264                                )
1265                            })
1266                    {
1267                        complex_params.insert(
1268                            p_name,
1269                            symbolica::domains::float::Complex::new(
1270                                p_value.re.into(),
1271                                p_value.im.into(),
1272                            ),
1273                        );
1274                    }
1275                }
1276                // Make sure to remove entries already supported by vakint, as they may not match required precision
1277                for atom in &[
1278                    Atom::Var(Var::new(Symbol::PI)),
1279                    Atom::Var(Var::new(vakint_symbol!("EulerGamma"))),
1280                    function!(Symbol::LOG, Atom::num(2)),
1281                ] {
1282                    _ = complex_params.remove(&atom.to_string());
1283                }
1284
1285                // Make sure to properly do the upcasting to required precision in vakint settings
1286                config
1287                    .vakint
1288                    .params_from_complex_f64(&true_settings, &complex_params)
1289            } else {
1290                HashMap::default()
1291            };
1292
1293        if let Some(pysec_dec_opts) = pysec_dec_enabled_in_vakint {
1294            true_settings.evaluation_order.adjust(
1295                None,
1296                pysec_dec_opts.relative_precision,
1297                &HashMap::default(),
1298                &complex_params_vakint,
1299                &HashMap::default(),
1300            )?;
1301        }
1302
1303        let mut num = self
1304            .graph
1305            .numerator(component, &self.graph.empty_subgraph());
1306        if config.include_global_numerator {
1307            num.state.expr *= &self.graph.global_prefactor.num;
1308        }
1309
1310        let before_gamma = num.to_d_dim(GS.dim).get_single_atom().unwrap();
1311        let before_gamma_plain = before_gamma.to_plain_string();
1312        let four_dimensional_numerator = before_gamma.simplify_gamma();
1313        let after_gamma_plain = four_dimensional_numerator.to_plain_string();
1314        crate::debug_tags!(#uv, #integrated, #vakint, #profile, #trace;
1315            stage = "amplitude_to_vakint_after_simplify_gamma",
1316            changed = before_gamma_plain != after_gamma_plain,
1317            before_gamma_count = %before_gamma_plain.matches("spenso::gamma").count(),
1318            after_gamma_count = %after_gamma_plain.matches("spenso::gamma").count(),
1319            before_chain_count = %before_gamma_plain.matches("spenso::chain").count(),
1320            after_chain_count = %after_gamma_plain.matches("spenso::chain").count(),
1321            log.before_gamma = before_gamma,
1322            log.after_gamma = four_dimensional_numerator,
1323            "Gamma simplification before Vakint"
1324        );
1325
1326        let mut four_dimensional_integrand = four_dimensional_numerator
1327            / self
1328                .graph
1329                .denominator(component, |e| e.extra_data.vakint_edge_power.unwrap_or(1));
1330
1331        // println!("Four-dimensional integrand: {}", four_dimensional_integrand);
1332
1333        let component_lmb = self.graph.lmb_of(component);
1334        let mom_reps = self.graph.uv_wrapped_replacement(
1335            &self.graph.full_filter(),
1336            &component_lmb,
1337            &[W_.x___],
1338        );
1339
1340        // println!("Reps:");
1341        // for r in &mom_reps {
1342        //     println!("{r}");
1343        // }
1344
1345        // rewrite the inner_t as well
1346        four_dimensional_integrand = four_dimensional_integrand.replace_multiple(&mom_reps);
1347
1348        // println!("LMB: {}", lmb);
1349        // let vk_mom = vakint_symbol!("k");
1350        // for (i, l) in lmb.loop_edges.iter().enumerate() {
1351        //     four_dimensional_integrand = four_dimensional_integrand
1352        //         .replace(function!(GS.emr_mom, usize::from(*l) as i64))
1353        //         .with(function!(vk_mom, i as i64 + 1))
1354        //         .replace(function!(
1355        //             GS.emr_mom,
1356        //             function!(vk_mom, i as i64 + 1),
1357        //             W_.x___
1358        //         ))
1359        //         .with(function!(vk_mom, i as i64 + 1, W_.x___));
1360        // }
1361
1362        let mut vakint_integrand = to_vakint_integrand(
1363            &four_dimensional_integrand,
1364            &self.graph,
1365            &self.graph.full_filter(),
1366            &self.graph.empty_subgraph::<SuBitGraph>(),
1367            &[(
1368                component.included().clone(),
1369                component.included().clone(),
1370                component_lmb,
1371            )],
1372            config.settings,
1373            false,
1374        )?;
1375
1376        vakint_integrand.canonicalize(&true_settings, &config.vakint.topologies, false)?;
1377        // println!("Canonized: {}", vakint_integrand);
1378        vakint_integrand.tensor_reduce(config.vakint, &true_settings)?;
1379        // println!("Tensor Reduced {}", vakint_integrand);
1380        vakint_integrand.evaluate_integral(config.vakint, &true_settings)?;
1381        // println!("Evaluated {}", vakint_integrand);
1382        let analytical_evaluation: Atom = vakint_integrand.into();
1383        // println!(
1384        //     "\nVakint analytical evaluation:\n{:#}",
1385        //     analytical_evaluation
1386        // );
1387        if !config.evaluate_numerically {
1388            Ok(analytical_evaluation)
1389        } else {
1390            let (numerical_evaluation, _error) = config
1391                .vakint
1392                .numerical_evaluation(
1393                    &true_settings,
1394                    analytical_evaluation.as_view(),
1395                    &HashMap::default(),
1396                    &complex_params_vakint,
1397                    None,
1398                )
1399                .unwrap();
1400
1401            // println!("\nVakint numerical evaluation:\n{:#}", numerical_evaluation);
1402
1403            let numerical_evaluation_atom =
1404                numerical_evaluation.to_atom(vakint_symbol!(true_settings.epsilon_symbol.clone()));
1405
1406            Ok(numerical_evaluation_atom)
1407        }
1408    }
1409
1410    #[instrument(skip_all, err)]
1411    pub(crate) fn build_integrands(
1412        &mut self,
1413        settings: &GenerationSettings,
1414        vakint: &Vakint,
1415    ) -> Result<()> {
1416        let _progress_guard =
1417            generation_progress::enter_detailed_progress_span("Building Parametric Integrand");
1418        let started = std::time::Instant::now();
1419        crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1420            stage = "amplitude_graph_build_integrands_start",
1421            graph = %self.graph.name,
1422            subtract_uv = settings.uv.subtract_uv,
1423            generate_integrated = settings.uv.generate_integrated,
1424            only = %settings.uv.final_integrand,
1425            "Generation timing milestone"
1426        );
1427        let cff_options = self.graph.production_cff_3d_expression_options(settings)?;
1428        let production_expression = self
1429            .derived_data
1430            .cff_expression
1431            .as_ref()
1432            .expect("cff_expression should have been created");
1433        let production_orientations = &production_expression.expression.orientations;
1434        crate::debug_tags!(#generation, #profile, #graph, #orientation, #summary;
1435            stage = "amplitude_graph_valid_orientations_done",
1436            graph = %self.graph.name,
1437            orientation_count = production_orientations.len(),
1438            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1439            "Generation timing milestone"
1440        );
1441        let cutstructure = CutStructure::empty(&self.graph);
1442        let orchestration_started = std::time::Instant::now();
1443        let parametric_exprs = settings.uv.orchestrator.parametric_integrands(
1444            &mut self.graph,
1445            cutstructure,
1446            vakint,
1447            OrientationProjection::exact_expression(
1448                production_expression,
1449                &cff_options,
1450                &settings.orientation_pattern,
1451                settings.explicit_orientation_sum_only,
1452            )
1453            .with_vacuum_subtraction(settings.medium.vacuum_subtraction),
1454            settings,
1455        )?;
1456        crate::debug_tags!(#generation, #profile, #uv, #graph, #summary;
1457            stage = "amplitude_graph_parametric_orchestration_done",
1458            graph = %self.graph.name,
1459            parametric_integrand_count = parametric_exprs.len(),
1460            elapsed_ms = orchestration_started.elapsed().as_secs_f64() * 1000.0,
1461            total_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1462            "Generation timing milestone"
1463        );
1464
1465        let assign_started = std::time::Instant::now();
1466        // The uncut amplitude must produce exactly one expression with exactly one residue.
1467        let [expr] = parametric_exprs.as_slice() else {
1468            return Err(eyre!(
1469                "amplitude UV construction must produce exactly one cut integrand, got {}",
1470                parametric_exprs.len(),
1471            ));
1472        };
1473        let mut roots = expr.integrands.iter();
1474        let (index, integrand) = roots
1475            .next()
1476            .ok_or_else(|| eyre!("amplitude UV integrand has no root residue"))?;
1477        if *index != CutCFFIndex::new_all_none() || roots.next().is_some() {
1478            return Err(eyre!(
1479                "amplitude UV integrand must contain exactly one root residue"
1480            ));
1481        }
1482        self.derived_data.all_mighty_integrand = integrand.clone();
1483        self.derived_data.all_mighty_numerators = expr.integrands.numerators().to_vec();
1484        crate::debug_tags!(#generation, #profile, #graph, #summary;
1485            stage = "amplitude_graph_build_integrands_done",
1486            graph = %self.graph.name,
1487            assign_elapsed_ms = assign_started.elapsed().as_secs_f64() * 1000.0,
1488            elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
1489            "Generation timing milestone"
1490        );
1491
1492        Ok(())
1493    }
1494
1495    fn configured_amplitude_threshold_variants(
1496        spec: &ThresholdCountertermSpec,
1497        threshold_edges: &[EdgeIndex],
1498    ) -> Vec<(ThresholdCountertermVariant, ThresholdCountertermOrigin)> {
1499        let configured = spec
1500            .cuts
1501            .iter()
1502            .find(|cut| cut.edges.is_empty())
1503            .and_then(|cut| {
1504                cut.thresholds
1505                    .iter()
1506                    .find(|threshold| threshold.edges == threshold_edges)
1507            })
1508            .filter(|threshold| !threshold.counterterms.is_empty());
1509
1510        match configured {
1511            Some(threshold) => threshold
1512                .counterterms
1513                .iter()
1514                .cloned()
1515                .map(|mut variant| {
1516                    if let Some(multiplier) = &mut variant.multiplier {
1517                        for (name, definition) in &spec.function_map {
1518                            multiplier
1519                                .function_map
1520                                .entry(name.clone())
1521                                .or_insert_with(|| definition.clone());
1522                        }
1523                    }
1524                    (variant, ThresholdCountertermOrigin::Explicit)
1525                })
1526                .collect(),
1527            None => vec![(
1528                ThresholdCountertermVariant {
1529                    name: Some("default".to_string()),
1530                    subspace: None,
1531                    parent_lmb: None,
1532                    disable: false,
1533                    multiplier: None,
1534                    group_id: None,
1535                },
1536                ThresholdCountertermOrigin::Autogenerated,
1537            )],
1538        }
1539    }
1540
1541    fn preferred_amplitude_parent_lmb(&self) -> Result<LmbIndex> {
1542        let (topology, all_lmbs) = self.derived_data.threshold_topology.as_ref().map_or_else(
1543            || {
1544                (
1545                    &self.graph,
1546                    self.derived_data
1547                        .lmbs
1548                        .as_ref()
1549                        .expect("amplitude threshold resolution requires loop-momentum bases"),
1550                )
1551            },
1552            |(graph, lmbs)| (graph, lmbs),
1553        );
1554        if let Some((lmb_index, _)) = all_lmbs
1555            .iter_enumerated()
1556            .find(|(_, lmb)| lmb.loop_edges == topology.loop_momentum_basis.loop_edges)
1557        {
1558            return Ok(lmb_index);
1559        }
1560
1561        all_lmbs
1562            .iter_enumerated()
1563            .next()
1564            .map(|(lmb_index, _)| lmb_index)
1565            .ok_or_else(|| eyre!("Graph '{}' has no generated LMBs", self.graph.name))
1566    }
1567
1568    fn resolve_amplitude_threshold_variant_subspace(
1569        &self,
1570        variant: &ThresholdCountertermVariant,
1571        legacy_subspace: &SubspaceData,
1572        context: &str,
1573    ) -> Result<SubspaceData> {
1574        let (topology, all_lmbs) = self.derived_data.threshold_topology.as_ref().map_or_else(
1575            || {
1576                (
1577                    &self.graph,
1578                    self.derived_data
1579                        .lmbs
1580                        .as_ref()
1581                        .expect("amplitude threshold resolution requires loop-momentum bases"),
1582                )
1583            },
1584            |(graph, lmbs)| (graph, lmbs),
1585        );
1586        let containing_subgraph = topology.no_dummy();
1587        let build_in_parent = |parent_lmb_index| match &variant.subspace {
1588            Some(requested_basis_edges) => SubspaceData::new_from_parent_basis_edges(
1589                requested_basis_edges,
1590                &containing_subgraph,
1591                parent_lmb_index,
1592                topology,
1593                all_lmbs,
1594            ),
1595            None => SubspaceData::new_with_user_selected_lmb(
1596                containing_subgraph.clone(),
1597                parent_lmb_index,
1598                topology,
1599                all_lmbs,
1600            ),
1601        };
1602
1603        if let Some(requested_parent_lmb) = &variant.parent_lmb {
1604            let matching_lmbs = all_lmbs
1605                .iter_enumerated()
1606                .filter_map(|(lmb_index, lmb)| {
1607                    lmb.loop_edges
1608                        .iter()
1609                        .eq(requested_parent_lmb.iter())
1610                        .then_some(lmb_index)
1611                })
1612                .collect_vec();
1613            if matching_lmbs.is_empty() {
1614                return Err(eyre!(
1615                    "{context} requests parent_lmb {:?}, which is not among graph '{}' generated LMBs",
1616                    requested_parent_lmb,
1617                    self.graph.name,
1618                ));
1619            }
1620            let mut built = matching_lmbs
1621                .iter()
1622                .map(|&lmb_index| build_in_parent(lmb_index).map(|subspace| (lmb_index, subspace)))
1623                .collect::<Result<Vec<_>>>()
1624                .with_context(|| {
1625                    format!("{context} is incompatible with its requested parent_lmb")
1626                })?;
1627            let (_, selected) = built.remove(0);
1628            if built
1629                .iter()
1630                .all(|(_, candidate)| selected.has_equivalent_embedding(candidate, all_lmbs))
1631            {
1632                return Ok(selected);
1633            }
1634            return Err(eyre!(
1635                "{context} parent_lmb {:?} has genuinely different generated embeddings",
1636                requested_parent_lmb,
1637            ));
1638        }
1639
1640        if variant.subspace.is_none() {
1641            return Ok(legacy_subspace.clone());
1642        }
1643
1644        let preferred_parent = legacy_subspace.parent_lmb_index();
1645        if let Ok(subspace) = build_in_parent(preferred_parent) {
1646            return Ok(subspace);
1647        }
1648
1649        let mut compatible = Vec::new();
1650        let mut rejections = Vec::new();
1651        for (lmb_index, lmb) in all_lmbs.iter_enumerated() {
1652            if lmb_index == preferred_parent {
1653                continue;
1654            }
1655            match build_in_parent(lmb_index) {
1656                Ok(subspace) => compatible.push((lmb_index, subspace)),
1657                Err(error) => rejections.push(format!("parent {:?}: {error:#}", lmb.loop_edges,)),
1658            }
1659        }
1660
1661        match compatible.len() {
1662            1 => Ok(compatible.pop().unwrap().1),
1663            0 => Err(eyre!(
1664                "{context} cannot resolve subspace {:?} in any generated parent LMB. Rejections:\n{}",
1665                variant.subspace,
1666                rejections.join("\n"),
1667            )),
1668            _ => {
1669                let (_, selected) = compatible.remove(0);
1670                if compatible
1671                    .iter()
1672                    .all(|(_, candidate)| selected.has_equivalent_embedding(candidate, all_lmbs))
1673                {
1674                    Ok(selected)
1675                } else {
1676                    Err(eyre!(
1677                        "{context} subspace {:?} has multiple genuinely different non-preferred parent LMB embeddings {:?}; specify parent_lmb",
1678                        variant.subspace,
1679                        compatible
1680                            .iter()
1681                            .map(|(lmb_index, _)| &all_lmbs[*lmb_index].loop_edges)
1682                            .collect_vec(),
1683                    ))
1684                }
1685            }
1686        }
1687    }
1688
1689    fn explicit_threshold_group_members(&self) -> Result<Vec<(usize, SubspaceData, String)>> {
1690        let (topology, all_lmbs) = self.derived_data.threshold_topology.as_ref().map_or_else(
1691            || {
1692                (
1693                    &self.graph,
1694                    self.derived_data
1695                        .lmbs
1696                        .as_ref()
1697                        .expect("threshold validation requires LMBs"),
1698                )
1699            },
1700            |(graph, lmbs)| (graph, lmbs),
1701        );
1702        let default = SubspaceData::new_with_user_selected_lmb(
1703            topology.no_dummy(),
1704            self.preferred_amplitude_parent_lmb()?,
1705            topology,
1706            all_lmbs,
1707        )?;
1708        self.graph
1709            .threshold_counterterms
1710            .cuts
1711            .iter()
1712            .flat_map(|cut| &cut.thresholds)
1713            .flat_map(|threshold| {
1714                threshold.counterterms.iter().filter_map(move |variant| {
1715                    variant.group_id.map(|label| (threshold, variant, label))
1716                })
1717            })
1718            .map(|(threshold, variant, label)| {
1719                let description = format!(
1720                    "graph '{}' threshold {:?} variant '{}' parent {:?} subspace {:?}",
1721                    self.graph.name,
1722                    threshold.edges,
1723                    variant.name.as_deref().unwrap_or("default"),
1724                    variant.parent_lmb,
1725                    variant.subspace
1726                );
1727                self.resolve_amplitude_threshold_variant_subspace(variant, &default, &description)
1728                    .map(|subspace| (label, subspace, description))
1729            })
1730            .collect()
1731    }
1732
1733    fn resolve_amplitude_threshold_counterterm_directives(
1734        &self,
1735        raised_data: &RaisedEsurfaceData,
1736        settings: &GenerationSettings,
1737        locked_runtime_settings: &LockedRuntimeSettings,
1738        model: &Model,
1739    ) -> Result<(
1740        ResolvedThresholdCounterterms,
1741        TiVec<EsurfaceID, RaisedEsurfaceId>,
1742    )> {
1743        let global_cff = self
1744            .derived_data
1745            .cff_expression
1746            .as_ref()
1747            .expect("cff_expression should have been created");
1748        let (topology, all_lmbs) = self.derived_data.threshold_topology.as_ref().map_or_else(
1749            || {
1750                (
1751                    &self.graph,
1752                    self.derived_data
1753                        .lmbs
1754                        .as_ref()
1755                        .expect("amplitude threshold resolution requires loop-momentum bases"),
1756                )
1757            },
1758            |(graph, lmbs)| (graph, lmbs),
1759        );
1760        let preferred_parent = self.preferred_amplitude_parent_lmb()?;
1761        let legacy_subspace = SubspaceData::new_with_user_selected_lmb(
1762            topology.no_dummy(),
1763            preferred_parent,
1764            topology,
1765            all_lmbs,
1766        )
1767        .with_context(|| {
1768            format!(
1769                "Graph '{}' cannot construct its legacy maximal amplitude threshold subspace",
1770                self.graph.name,
1771            )
1772        })?;
1773
1774        let mut explicit = BTreeMap::new();
1775        for (label, subspace, description) in self.explicit_threshold_group_members()? {
1776            explicit
1777                .entry(label)
1778                .or_insert_with(Vec::new)
1779                .push((subspace.solve_signature(all_lmbs), description));
1780        }
1781        let conflicts = explicit
1782            .into_iter()
1783            .filter_map(|(label, members)| {
1784                (members.iter().map(|(key, _)| key).unique().count() > 1).then(|| {
1785                    format!(
1786                        "group_id={label}: {}",
1787                        members
1788                            .into_iter()
1789                            .map(|(key, description)| format!(
1790                                "{description}, signed cycles {key:?}"
1791                            ))
1792                            .join("; ")
1793                    )
1794                })
1795            })
1796            .collect_vec();
1797        if !conflicts.is_empty() {
1798            return Err(eyre!(
1799                "Amplitude graph '{}' has incompatible threshold solve groups:\n{}",
1800                self.graph.name,
1801                conflicts.join("\n")
1802            ));
1803        }
1804
1805        let mut raised_esurface_ids: TiVec<EsurfaceID, Option<RaisedEsurfaceId>> =
1806            ti_vec![None; global_cff.expression.surfaces.esurface_cache.len()];
1807        for (raised_esurface_id, raised_group) in raised_data.raised_groups.iter_enumerated() {
1808            for &esurface_id in &raised_group.esurface_ids {
1809                raised_esurface_ids[esurface_id] = Some(raised_esurface_id);
1810            }
1811        }
1812        let raised_esurface_ids = raised_esurface_ids
1813            .into_iter()
1814            .enumerate()
1815            .map(|(esurface_id, raised_esurface_id)| {
1816                raised_esurface_id.ok_or_else(|| {
1817                    eyre!(
1818                        "Graph '{}' E-surface {esurface_id} is missing from raised-esurface data",
1819                        self.graph.name,
1820                    )
1821                })
1822            })
1823            .collect::<Result<TiVec<EsurfaceID, RaisedEsurfaceId>>>()?;
1824
1825        let external_filter: SuBitGraph = self.graph.external_filter();
1826        let mut incoming_externals = Vec::new();
1827        let mut outgoing_externals = Vec::new();
1828        for (edge, edge_id, _) in self.graph.iter_edges_of(&external_filter) {
1829            match edge {
1830                HedgePair::Unpaired {
1831                    flow: Flow::Sink, ..
1832                } => incoming_externals.push(edge_id),
1833                HedgePair::Unpaired {
1834                    flow: Flow::Source, ..
1835                } => outgoing_externals.push(edge_id),
1836                _ => unreachable!("the external filter must contain only unpaired edges"),
1837            }
1838        }
1839        let masses = settings
1840            .threshold_subtraction
1841            .check_esurface_at_generation
1842            .then(|| self.graph.get_real_mass_vector(model));
1843        // The complete CFF is retained for 3D UV construction; threshold activity
1844        // follows the requested orientation selection independently.
1845        let selected_esurface_ids = global_cff
1846            .expression
1847            .orientations
1848            .iter()
1849            .filter(|orientation| settings.orientation_pattern.filter(*orientation))
1850            .flat_map(|orientation| {
1851                orientation
1852                    .iter_denominator_nodes()
1853                    .filter_map(|node| match node.data {
1854                        crate::cff::surface::HybridSurfaceID::Esurface(esurface_id) => {
1855                            Some(esurface_id)
1856                        }
1857                        _ => None,
1858                    })
1859            })
1860            .collect::<AHashSet<_>>();
1861
1862        let configured_thresholds = self
1863            .graph
1864            .threshold_counterterms
1865            .cuts
1866            .iter()
1867            .flat_map(|cut| cut.thresholds.iter().map(|threshold| &threshold.edges))
1868            .collect_vec();
1869        if !configured_thresholds.is_empty() {
1870            // A selected orientation need not contain every configured threshold. Validate the
1871            // declarations against topology-wide bond identities so those absent only from the
1872            // selected CFF remain dormant without accepting a valid-edge typo silently. Bonds
1873            // crossing an edge contracted by amplitude CFF generation cannot become E-surfaces.
1874            let contracted_edges = topology
1875                .iter_edges_of(
1876                    &topology
1877                        .tree_edges
1878                        .subtract(&topology.initial_state_cut)
1879                        .subtract(&topology.external_filter::<SuBitGraph>()),
1880                )
1881                .map(|(_, edge_id, _)| edge_id)
1882                .collect::<AHashSet<_>>();
1883            let no_dummy = topology.no_dummy();
1884            let external_count = topology
1885                .iter_edges_of(&no_dummy)
1886                .filter(|(pair, _, _)| matches!(pair, HedgePair::Unpaired { .. }))
1887                .count();
1888            let topology_thresholds = topology
1889                .underlying
1890                .all_bonds_of(&no_dummy, &(1..))
1891                .into_iter()
1892                .filter_map(|bond| {
1893                    let mut edges = Vec::new();
1894                    let mut bond_external_count = 0;
1895                    for (pair, edge_id, _) in topology.iter_edges_of(&bond) {
1896                        match pair {
1897                            HedgePair::Split { .. } => edges.push(edge_id),
1898                            HedgePair::Unpaired { .. } => bond_external_count += 1,
1899                            HedgePair::Paired { .. } => {
1900                                unreachable!("a topology bond contains only boundary half-edges")
1901                            }
1902                        }
1903                    }
1904                    edges.sort_unstable();
1905                    (!edges.is_empty()
1906                        && !edges.iter().any(|edge| contracted_edges.contains(edge))
1907                        && bond_external_count > 0
1908                        && bond_external_count < external_count)
1909                        .then_some(edges)
1910                })
1911                .collect::<AHashSet<_>>();
1912
1913            for threshold_edges in configured_thresholds {
1914                if !topology_thresholds.contains(threshold_edges) {
1915                    return Err(eyre!(
1916                        "Amplitude graph '{}' threshold_counterterms threshold {:?} does not match a topology-discovered E-surface",
1917                        self.graph.name,
1918                        threshold_edges,
1919                    ));
1920                }
1921            }
1922        }
1923
1924        let mut group_drafts = Vec::new();
1925        let mut legacy_equivalent = true;
1926        for (raised_esurface_id, raised_group) in raised_data.raised_groups.iter_enumerated() {
1927            let selected_group_esurface_ids = raised_group
1928                .esurface_ids
1929                .iter()
1930                .copied()
1931                .filter(|esurface_id| selected_esurface_ids.contains(esurface_id))
1932                .collect_vec();
1933            let Some(&representative_esurface_id) = selected_group_esurface_ids.first() else {
1934                debug!(
1935                    "Leaving amplitude graph '{}' raised threshold group {} dormant because none of its E-surfaces occurs in the selected orientations",
1936                    self.graph.name, raised_esurface_id.0,
1937                );
1938                continue;
1939            };
1940            let representative_esurface =
1941                &global_cff.expression.surfaces.esurface_cache[representative_esurface_id];
1942            if representative_esurface.external_shift.is_empty() {
1943                continue;
1944            }
1945            if let Some(masses) = &masses
1946                && !locked_runtime_settings.existence_check(
1947                    representative_esurface,
1948                    masses,
1949                    &self.graph.get_external_signature(),
1950                    &self.graph.loop_momentum_basis,
1951                    settings.threshold_subtraction.esurface_existence_threshold,
1952                )
1953            {
1954                continue;
1955            }
1956            if settings
1957                .threshold_subtraction
1958                .assume_positive_external_energies
1959                && masses.is_none()
1960                && !representative_esurface
1961                    .external_shift_is_strictly_negative_for_positive_energies(
1962                        &incoming_externals,
1963                        &outgoing_externals,
1964                    )
1965            {
1966                continue;
1967            }
1968
1969            let mut associations = Vec::new();
1970            let mut representative_variants =
1971                None::<Vec<ResolvedAmplitudeThresholdCountertermDraft>>;
1972            for &esurface_id in &selected_group_esurface_ids {
1973                let mut threshold_edges = global_cff.expression.surfaces.esurface_cache
1974                    [esurface_id]
1975                    .energies
1976                    .iter()
1977                    .copied()
1978                    .collect_vec();
1979                threshold_edges.sort_unstable();
1980                let configured = Self::configured_amplitude_threshold_variants(
1981                    &self.graph.threshold_counterterms,
1982                    &threshold_edges,
1983                );
1984                let mut variants = Vec::with_capacity(configured.len());
1985                for (variant, origin) in configured {
1986                    if !variant.disable
1987                        && variant
1988                            .multiplier
1989                            .as_ref()
1990                            .is_some_and(|multiplier| multiplier.symmetrize)
1991                    {
1992                        unimplemented!(
1993                            "symmetrized threshold-counterterm multipliers are not implemented (amplitude graph '{}', threshold {:?}, variant '{}')",
1994                            self.graph.name,
1995                            threshold_edges,
1996                            variant.name.as_deref().unwrap_or("default"),
1997                        );
1998                    }
1999                    let name = variant
2000                        .name
2001                        .clone()
2002                        .unwrap_or_else(|| "default".to_string());
2003                    let context = format!(
2004                        "amplitude graph '{}' threshold {:?} variant '{}'",
2005                        self.graph.name, threshold_edges, name,
2006                    );
2007                    let subspace = self.resolve_amplitude_threshold_variant_subspace(
2008                        &variant,
2009                        &legacy_subspace,
2010                        &context,
2011                    )?;
2012                    variants.push(ResolvedAmplitudeThresholdCountertermDraft {
2013                        name,
2014                        group_id: variant.group_id,
2015                        origin,
2016                        disable: variant.disable,
2017                        requested_subspace: variant.subspace,
2018                        requested_parent_lmb: variant.parent_lmb,
2019                        multiplier: variant.multiplier,
2020                        subspace,
2021                    });
2022                }
2023                let association_origin = variants
2024                    .first()
2025                    .map(|variant| variant.origin)
2026                    .unwrap_or(ThresholdCountertermOrigin::Autogenerated);
2027                let variant_subspaces = variants
2028                    .iter()
2029                    .map(|variant| variant.subspace.clone())
2030                    .collect();
2031
2032                if let Some(expected) = &representative_variants {
2033                    if variants.len() != expected.len()
2034                        || variants.iter().zip(expected).any(|(variant, expected)| {
2035                            !variant.is_compatible_with(expected, all_lmbs)
2036                        })
2037                    {
2038                        return Err(eyre!(
2039                            "Amplitude graph '{}' raised threshold {:?} resolves incompatible variants for E-surfaces {} and {}: threshold {:?} [{}]; threshold {:?} [{}]. Constituents of one raised residue must retain the same variant names, group IDs, subspaces and multipliers",
2040                            self.graph.name,
2041                            raised_group.esurface_ids,
2042                            representative_esurface_id.0,
2043                            esurface_id.0,
2044                            representative_esurface.energies,
2045                            expected
2046                                .iter()
2047                                .map(|variant| format!(
2048                                    "'{}' group_id={:?} parent={:?} cycles={:?}",
2049                                    variant.name,
2050                                    variant.group_id,
2051                                    variant.requested_parent_lmb,
2052                                    variant.subspace.solve_signature(all_lmbs)
2053                                ))
2054                                .join("; "),
2055                            threshold_edges,
2056                            variants
2057                                .iter()
2058                                .map(|variant| format!(
2059                                    "'{}' group_id={:?} parent={:?} cycles={:?}",
2060                                    variant.name,
2061                                    variant.group_id,
2062                                    variant.requested_parent_lmb,
2063                                    variant.subspace.solve_signature(all_lmbs)
2064                                ))
2065                                .join("; "),
2066                        ));
2067                    }
2068                } else {
2069                    representative_variants = Some(variants);
2070                }
2071                associations.push(ResolvedAmplitudeThresholdAssociationDraft {
2072                    esurface_id,
2073                    threshold_edges,
2074                    origin: association_origin,
2075                    variant_subspaces,
2076                });
2077            }
2078
2079            let variants = representative_variants.unwrap_or_default();
2080            if variants.len() != 1
2081                || variants[0].disable
2082                || variants[0].group_id.is_some()
2083                || variants[0].multiplier.is_some()
2084                || !variants[0]
2085                    .subspace
2086                    .has_equivalent_embedding(&legacy_subspace, all_lmbs)
2087            {
2088                legacy_equivalent = false;
2089            }
2090            group_drafts.push(ResolvedAmplitudeThresholdGroupDraft {
2091                raised_esurface_group: raised_group.clone(),
2092                associations,
2093                variants,
2094            });
2095        }
2096
2097        let mut variants =
2098            TiVec::<ThresholdCountertermVariantId, ResolvedThresholdCountertermVariant>::new();
2099        for group in group_drafts {
2100            for (variant_index, variant) in group.variants.into_iter().enumerate() {
2101                if variant.disable {
2102                    continue;
2103                }
2104                let parent_lmb_index = variant.subspace.parent_lmb_index();
2105                variants.push(ResolvedThresholdCountertermVariant {
2106                    name: variant.name,
2107                    group_id: variant.group_id,
2108                    cut_group_id: None,
2109                    associations: group
2110                        .associations
2111                        .iter()
2112                        .map(|association| {
2113                            let subspace = association.variant_subspaces[variant_index].clone();
2114                            ResolvedThresholdCountertermAssociation {
2115                                cut_id: None,
2116                                cut_edges: Vec::new(),
2117                                threshold_edges: association.threshold_edges.clone(),
2118                                esurface_id: association.esurface_id,
2119                                requires_explicit_parent_lmb: subspace.parent_lmb_index()
2120                                    != legacy_subspace.parent_lmb_index(),
2121                                subspace,
2122                                eligible: true,
2123                                origin: association.origin,
2124                            }
2125                        })
2126                        .collect(),
2127                    side: ThresholdCountertermSide::Amplitude,
2128                    threshold_esurface_ids: group.raised_esurface_group.esurface_ids.clone(),
2129                    raised_esurface_group: group.raised_esurface_group.clone(),
2130                    requested_subspace: variant.requested_subspace,
2131                    requested_parent_lmb: variant.requested_parent_lmb,
2132                    resolved_parent_lmb: all_lmbs[parent_lmb_index].loop_edges.clone().into(),
2133                    subspace_loop_count: variant.subspace.loopcount(),
2134                    subspace: variant.subspace,
2135                    multiplier: variant.multiplier,
2136                });
2137            }
2138        }
2139
2140        Ok((
2141            ResolvedThresholdCounterterms {
2142                legacy_equivalent,
2143                variants,
2144                cross_section_cut_groups: TiVec::new(),
2145            },
2146            raised_esurface_ids,
2147        ))
2148    }
2149
2150    #[instrument(skip_all, err)]
2151    fn build_threshold_counterterm_parametric_integrand(
2152        &mut self,
2153        raised_data: &RaisedEsurfaceData,
2154        settings: &GenerationSettings,
2155        vakint: &Vakint,
2156        locked_runtime_settings: &LockedRuntimeSettings,
2157        model: &Model,
2158    ) -> Result<AmplitudeThresholdCountertermBuild> {
2159        let _progress_guard =
2160            generation_progress::enter_detailed_progress_span("Building Threshold Counterterms");
2161        let cff_options = self.graph.production_cff_3d_expression_options(settings)?;
2162        let production_expression = self
2163            .derived_data
2164            .cff_expression
2165            .as_ref()
2166            .expect("cff_expression should have been created");
2167        let (resolved, raised_esurface_ids) = self
2168            .resolve_amplitude_threshold_counterterm_directives(
2169                raised_data,
2170                settings,
2171                locked_runtime_settings,
2172                model,
2173            )?;
2174        let mut cuts = Vec::with_capacity(resolved.variants.len());
2175        for variant in &resolved.variants {
2176            let mut cut_union: SuBitGraph = self.graph.empty_subgraph();
2177            let representative_esurface = &self
2178                .derived_data
2179                .cff_expression
2180                .as_ref()
2181                .expect("cff_expression should have been created")
2182                .expression
2183                .surfaces
2184                .esurface_cache[variant.raised_esurface_group.esurface_ids[0]];
2185            for energy in &representative_esurface.energies {
2186                let (_, hedge_pair) = self.graph[energy];
2187                match hedge_pair {
2188                    HedgePair::Paired { source, sink } => {
2189                        cut_union.add(source);
2190                        cut_union.add(sink);
2191                    }
2192                    _ => unreachable!(),
2193                }
2194            }
2195
2196            let cutset = CutSet {
2197                residue_selector: ResidueSelector {
2198                    lu: None,
2199                    left_th_cut: Some(variant.raised_esurface_group.clone()),
2200                    right_th_cut: None,
2201                },
2202                union: cut_union,
2203                canonicalize_external_shifts: false,
2204            };
2205
2206            cuts.push(cutset);
2207        }
2208        let cut_structure = CutStructure { cuts };
2209        let exprs: Vec<_> = settings.uv.orchestrator.parametric_integrands(
2210            &mut self.graph,
2211            cut_structure,
2212            vakint,
2213            OrientationProjection::exact_expression(
2214                production_expression,
2215                &cff_options,
2216                &settings.orientation_pattern,
2217                settings.explicit_orientation_sum_only,
2218            )
2219            .with_vacuum_subtraction(settings.medium.vacuum_subtraction),
2220            settings,
2221        )?;
2222
2223        let mut variants =
2224            TiVec::<ThresholdCountertermVariantId, AmplitudeThresholdCountertermVariant>::new();
2225        for ((variant_id, variant), expr) in
2226            resolved.variants.iter_enumerated().zip(exprs.into_iter())
2227        {
2228            let loop_number = self.graph.n_loops(&self.graph.underlying.full_filter());
2229            let jacobian_factor = Atom::var(GS.radius_star_left).pow(loop_number as i32 * 3 - 1);
2230
2231            let expr = expr.map(|integrand| integrand * &jacobian_factor);
2232            let counterterm_atom = AmplitudeCountertermAtom {
2233                parametric: expr.integrands,
2234            };
2235            let raised_group = expr.cuts.residue_selector.left_th_cut.ok_or_else(|| {
2236                eyre!(
2237                    "Threshold orchestrator amplitude result {} for graph '{}' has no threshold residue selector",
2238                    variant_id.0,
2239                    self.graph.name,
2240                )
2241            })?;
2242            if raised_group != variant.raised_esurface_group {
2243                return Err(eyre!(
2244                    "Threshold orchestrator amplitude result {} for graph '{}' returned raised group {:?}, expected {:?}",
2245                    variant_id.0,
2246                    self.graph.name,
2247                    raised_group.esurface_ids,
2248                    variant.raised_esurface_group.esurface_ids,
2249                ));
2250            }
2251            let raised_esurface_id = raised_esurface_ids[raised_group.esurface_ids[0]];
2252            debug!("raised_esurface_id: {}", raised_esurface_id.0);
2253
2254            if tracing::enabled!(tracing::Level::DEBUG) {
2255                for (_, integrand) in counterterm_atom.parametric.resolved()?.iter() {
2256                    debug!("counterterm integrand: {}", integrand.log_print(Some(100)));
2257                }
2258            }
2259
2260            variants.push(AmplitudeThresholdCountertermVariant {
2261                raised_esurface_id,
2262                atom: counterterm_atom,
2263            });
2264        }
2265        let legacy_counterterms = if resolved.legacy_equivalent {
2266            let mut legacy_counterterms = ti_vec![
2267                AmplitudeCountertermAtom::new();
2268                raised_data.raised_groups.len()
2269            ];
2270            for variant in &variants {
2271                if legacy_counterterms[variant.raised_esurface_id].is_generated() {
2272                    return Err(eyre!(
2273                        "Legacy-equivalent amplitude graph '{}' generated duplicate threshold variants for raised group {}",
2274                        self.graph.name,
2275                        variant.raised_esurface_id.0,
2276                    ));
2277                }
2278                legacy_counterterms[variant.raised_esurface_id] = variant.atom.clone();
2279            }
2280            legacy_counterterms
2281        } else {
2282            // The generalized runtime consumes `variants` directly and deliberately leaves the
2283            // homogeneous raised-surface lane empty so duplicate geometry keeps independent IDs.
2284            TiVec::new()
2285        };
2286
2287        Ok(AmplitudeThresholdCountertermBuild {
2288            legacy_counterterms,
2289            variants,
2290            resolved,
2291            raised_esurface_ids,
2292        })
2293    }
2294
2295    #[instrument(skip_all)]
2296    fn build_lmbs(&mut self) {
2297        let _progress_guard =
2298            generation_progress::enter_detailed_progress_span("Building Loop Momentum Bases");
2299        let lmbs = self
2300            .graph
2301            .generate_loop_momentum_bases_of(&self.graph.no_dummy());
2302
2303        self.derived_data.lmbs = Some(lmbs)
2304    }
2305
2306    #[instrument(skip_all, err)]
2307    fn build_tropical_sampler(&mut self, process_settings: &GenerationSettings) -> Result<()> {
2308        let _progress_guard =
2309            generation_progress::enter_detailed_progress_span("Building Tropical Sampler");
2310        if process_settings
2311            .tropical_subgraph_table
2312            .disable_tropical_generation
2313        {
2314            debug!("Tropical subgraph table generation is disabled.");
2315            return Ok(());
2316        }
2317        let num_virtual_loop_edges = self.graph.iter_loop_edges().count();
2318
2319        if num_virtual_loop_edges == 0 {
2320            debug!("Graph has no loop edges, skipping tropical sampler generation.");
2321            return Ok(());
2322        }
2323
2324        let num_loops = self.graph.loop_momentum_basis.loop_edges.len();
2325        let target_omega = process_settings.tropical_subgraph_table.target_omega;
2326
2327        let weight = (target_omega + (3 * num_loops) as f64 / 2.) / num_virtual_loop_edges as f64;
2328
2329        debug!(
2330            "Building tropical subgraph table with all edge weights set to: {}",
2331            weight
2332        );
2333
2334        let tropical_edges = self
2335            .graph
2336            .iter_loop_edges()
2337            .map(|(pair, _edge_id, edge)| {
2338                let is_massive = edge.data.particle.is_massive();
2339
2340                let vertices = match pair {
2341                    HedgePair::Paired { source, sink } => (
2342                        self.graph.underlying.node_id(source).0 as u8,
2343                        self.graph.underlying.node_id(sink).0 as u8,
2344                    ),
2345                    _ => unreachable!(),
2346                };
2347
2348                momtrop::Edge {
2349                    is_massive,
2350                    weight,
2351                    vertices,
2352                }
2353            })
2354            .collect_vec();
2355
2356        let mut external_vertices_pool = AHashSet::new();
2357
2358        for (pair, _, _) in self.graph.iter_non_loop_edges() {
2359            match pair {
2360                HedgePair::Paired { source, sink } => {
2361                    let source_id = self.graph.underlying.node_id(source).0 as u8;
2362                    let sink_id = self.graph.underlying.node_id(sink).0 as u8;
2363
2364                    external_vertices_pool.insert(source_id);
2365                    external_vertices_pool.insert(sink_id);
2366                }
2367                HedgePair::Unpaired { hedge, .. } => {
2368                    let id = self.graph.underlying.node_id(hedge).0 as u8;
2369                    external_vertices_pool.insert(id);
2370                }
2371                _ => unreachable!(),
2372            }
2373        }
2374
2375        let mut external_vertices = vec![];
2376
2377        for tropical_edge in &tropical_edges {
2378            if external_vertices_pool.contains(&tropical_edge.vertices.0) {
2379                external_vertices.push(tropical_edge.vertices.0);
2380            }
2381
2382            if external_vertices_pool.contains(&tropical_edge.vertices.1) {
2383                external_vertices.push(tropical_edge.vertices.1);
2384            }
2385        }
2386
2387        let tropical_graph = momtrop::Graph {
2388            edges: tropical_edges,
2389            externals: external_vertices,
2390        };
2391
2392        let loop_part = self
2393            .graph
2394            .iter_loop_edges()
2395            .map(|(_, edge_id, _edge)| {
2396                self.graph.loop_momentum_basis.edge_signatures[edge_id]
2397                    .internal
2398                    .clone()
2399                    .to_momtrop_format()
2400            })
2401            .collect_vec();
2402
2403        let sampler = tropical_graph
2404            .build_sampler(loop_part)
2405            .map_err(|e| eyre!(e))?;
2406
2407        let _: () = self.derived_data.tropical_sampler = Some(sampler);
2408        Ok(())
2409    }
2410
2411    // Expects cff_expression, esurface_data,
2412    #[instrument(
2413          name = "generate_term_for_graph",
2414          level = "info",
2415          skip(self, model, global_settings),
2416          fields(
2417              graph.name = %self.graph.name
2418
2419          ),
2420          err
2421      )]
2422    fn generate_term_for_graph(
2423        &self,
2424        model: &Model,
2425        own_group_position: GraphGroupPosition,
2426        esurface_map: TiVec<GroupEsurfaceId, TiVec<GraphGroupPosition, Option<RaisedEsurfaceId>>>,
2427        global_settings: &GlobalSettings,
2428    ) -> Result<(AmplitudeGraphTerm, GraphGenerationStats)> {
2429        let _progress_guard = generation_progress::enter_detailed_progress_span(&format!(
2430            "Generating Evaluators for {}",
2431            self.graph.name
2432        ));
2433        AmplitudeGraphTerm::from_amplitude_graph(
2434            self,
2435            own_group_position,
2436            esurface_map,
2437            model,
2438            global_settings,
2439        )
2440    }
2441}
2442
2443#[derive(Clone, Encode, Decode)]
2444#[trait_decode(trait = GammaLoopContext)]
2445pub struct AmplitudeThresholdCountertermVariant {
2446    pub raised_esurface_id: RaisedEsurfaceId,
2447    pub atom: AmplitudeCountertermAtom,
2448}
2449
2450#[derive(Clone, Encode, Decode)]
2451#[trait_decode(trait = GammaLoopContext)]
2452pub struct AmplitudeDerivedData {
2453    /// Master topology and coordinates used to interpret graph-group threshold metadata.
2454    pub threshold_topology: Option<(Graph, TiVec<LmbIndex, LoopMomentumBasis>)>,
2455    pub all_mighty_integrand: Atom,
2456    pub all_mighty_numerators: Vec<Arc<FnMapEntry>>,
2457    /// Compatibility storage used by the current homogeneous amplitude runtime.
2458    pub threshold_counterterms: TiVec<RaisedEsurfaceId, AmplitudeCountertermAtom>,
2459    /// Canonical variant-indexed symbolic storage. Duplicate geometric thresholds remain distinct.
2460    pub threshold_counterterm_variants:
2461        TiVec<ThresholdCountertermVariantId, AmplitudeThresholdCountertermVariant>,
2462    pub resolved_threshold_counterterms: Option<ResolvedThresholdCounterterms>,
2463    pub raised_data: RaisedEsurfaceData,
2464    pub raised_esurface_ids: TiVec<EsurfaceID, RaisedEsurfaceId>,
2465    pub multi_channeling_setup: Option<LmbMultiChannelingSetup>,
2466    pub lmbs: Option<TiVec<LmbIndex, LoopMomentumBasis>>,
2467    pub tropical_sampler: Option<SampleGenerator<3>>,
2468    pub cff_expression: Option<
2469        GeneratedThreeDExpression<crate::cff::esurface::Esurface, crate::cff::hsurface::Hsurface>,
2470    >,
2471}
2472
2473impl AmplitudeDerivedData {
2474    pub(crate) fn resolved_integrand(&self) -> Result<Atom> {
2475        let integrands = Integrands::from_iter([(
2476            CutCFFIndex::new_all_none(),
2477            self.all_mighty_integrand.clone(),
2478        )])
2479        .with_numerators(self.all_mighty_numerators.iter().cloned())?
2480        .resolved()?;
2481        Ok(integrands
2482            .iter()
2483            .next()
2484            .expect("the amplitude semantic view retains its root residue")
2485            .1
2486            .clone())
2487    }
2488}
2489
2490pub trait AmplitudeState:
2491    Clone + std::fmt::Debug + bincode::Encode + for<'a> bincode::Decode<GammaLoopContextContainer<'a>>
2492{
2493}
2494impl AmplitudeState for () {}
2495
2496#[derive(Clone, Encode, Decode, Debug)]
2497pub struct Processed {}
2498impl AmplitudeState for Processed {}
2499
2500// #[derive(Clone, Encode, Decode, Debug)]
2501// pub struct ReadyForTerm {}
2502// impl AmplitudeState for ReadyForTerm {}
2503
2504impl Amplitude {
2505    pub fn from_dot_string<Str: AsRef<str>>(s: Str, name: String, model: &Model) -> Result<Self> {
2506        let graphs = Graph::from_string(s, model)?;
2507
2508        let mut amp = Amplitude::new(name);
2509        for g in graphs {
2510            amp.add_graph(g)?;
2511        }
2512        Ok(amp)
2513    }
2514
2515    pub fn from_dot_file<P>(p: P, name: String, model: &Model) -> Result<Self>
2516    where
2517        P: AsRef<Path>,
2518    {
2519        let graphs = Graph::from_file(p, model)?;
2520
2521        let mut amp = Amplitude::new(name);
2522        for g in graphs {
2523            amp.add_graph(g)?;
2524        }
2525        Ok(amp)
2526    }
2527
2528    pub fn from_graph_list(name: impl ToString, mut graphs: Vec<Graph>) -> Result<Self> {
2529        let mut amplitude: Amplitude = Amplitude::new(name);
2530        amplitude.graph_group_structure = complete_group_parsing(&mut graphs)?;
2531        for group in &amplitude.graph_group_structure {
2532            ThresholdCountertermSpec::validate_group_ids(
2533                group
2534                    .into_iter()
2535                    .map(|id| &*graphs[id].threshold_counterterms),
2536            )
2537            .with_context(|| {
2538                format!(
2539                    "Invalid threshold group IDs for amplitude group master '{}'",
2540                    graphs[group.master()].name,
2541                )
2542            })?;
2543        }
2544
2545        for amplitude_graph in graphs {
2546            amplitude.add_graph(amplitude_graph)?;
2547        }
2548        Ok(amplitude)
2549    }
2550
2551    pub(crate) fn new(name: impl ToString) -> Self {
2552        Self {
2553            integrand: None,
2554            name: name.to_string(),
2555            graphs: vec![],
2556            graph_group_structure: TiVec::new(),
2557            external_particles: vec![],
2558            external_signature: SignatureLike::from_iter(iter::empty::<i8>()),
2559            group_derived_data: TiVec::new(),
2560        }
2561    }
2562
2563    fn add_graph(&mut self, graph: Graph) -> Result<()> {
2564        let new_external_particels = graph.get_external_partcles();
2565        let new_external_signature = graph.get_external_signature();
2566
2567        if !self.graphs.is_empty() {
2568            if self.external_particles != new_external_particels {
2569                return Err(eyre!("amplitude graph has different externals")).with_context(|| {
2570                    format!(
2571                        "Found {} externals, expected {} for the graph {}",
2572                        new_external_particels.len(),
2573                        self.external_particles.len(),
2574                        DotGraph::from(&graph).debug_dot()
2575                    )
2576                });
2577            }
2578
2579            if self.external_signature != new_external_signature {
2580                return Err(eyre!("wrong external signature"));
2581            }
2582        } else {
2583            self.external_particles = new_external_particels;
2584            self.external_signature = new_external_signature;
2585        }
2586
2587        self.graphs.push(AmplitudeGraph::new(graph));
2588
2589        //  TODO: validate that the graph is compatible
2590        Ok(())
2591    }
2592}
2593
2594fn threshold_counterterm_helper_atoms(
2595    order: u8,
2596    loop_number: usize,
2597) -> (SingleThresholdPieces<Atom>, Atom) {
2598    let loop_3 = loop_number as i64 * 3;
2599
2600    let laurent_coeff_indices = (1..=order).map(|i| -(i as i8));
2601
2602    let mut laurent_coeffs = laurent_coeff_indices.map(|laurent_coeff_index| {
2603        build_derivative_structure_atom(order, laurent_coeff_index)
2604            .replace(GS.rescale_star)
2605            .with(GS.radius_star_left)
2606    });
2607
2608    let i = Atom::i();
2609
2610    let radius = Atom::var(GS.radius_left);
2611    let radius_star = Atom::var(GS.radius_star_left);
2612    let uv_damp_plus = Atom::var(GS.uv_damp_plus_left);
2613    let uv_damp_minus = Atom::var(GS.uv_damp_minus_left);
2614    let hfunction = Atom::var(GS.hfunction_left_th);
2615
2616    let delta_r_plus = &radius - &radius_star;
2617    let delta_r_minus = -&radius - &radius_star;
2618
2619    let jacobian_ratio = (Atom::one() / &radius).pow(loop_3 - 1);
2620
2621    let local_prefactor =
2622        &jacobian_ratio * (uv_damp_plus / &delta_r_plus + uv_damp_minus / &delta_r_minus);
2623
2624    let integrated_prefactor = -i * Atom::var(GS.pi) * &jacobian_ratio * hfunction;
2625
2626    let leading_laurent_coeff = laurent_coeffs.next().unwrap();
2627    let mut raised_local = Atom::Zero;
2628
2629    for pow in 2..=order {
2630        raised_local += laurent_coeffs.next().unwrap()
2631            * &jacobian_ratio
2632            * (Atom::one() / delta_r_plus.pow(pow as i64)
2633                + Atom::one() / delta_r_minus.pow(pow as i64));
2634    }
2635
2636    let local = local_prefactor.clone() * &leading_laurent_coeff + &raised_local;
2637    let integrated = integrated_prefactor.clone() * &leading_laurent_coeff;
2638    // Keep the no-directive helper structurally identical to the historical single-output lane.
2639    let legacy = (local_prefactor + integrated_prefactor) * leading_laurent_coeff + raised_local;
2640
2641    debug!(
2642        "Threshold counterterm helper atoms for order {} and loop number {}: local={}, integrated={}, legacy={}",
2643        order, loop_number, local, integrated, legacy,
2644    );
2645    (SingleThresholdPieces { local, integrated }, legacy)
2646}
2647
2648enum ThresholdCountertermHelperOutputs {
2649    Legacy,
2650    Pieces,
2651    LegacyAndPieces,
2652}
2653
2654fn build_threshold_counterterm_helper(
2655    order: u8,
2656    loop_number: usize,
2657    evaluator_settings: &EvaluatorSettings,
2658    outputs: ThresholdCountertermHelperOutputs,
2659) -> GenericEvaluator {
2660    let (pieces, legacy) = threshold_counterterm_helper_atoms(order, loop_number);
2661    let atoms = match outputs {
2662        ThresholdCountertermHelperOutputs::Legacy => vec![legacy],
2663        ThresholdCountertermHelperOutputs::Pieces => vec![pieces.local, pieces.integrated],
2664        ThresholdCountertermHelperOutputs::LegacyAndPieces => {
2665            vec![legacy, pieces.local, pieces.integrated]
2666        }
2667    };
2668    let fn_map = FunctionMap::default();
2669
2670    let mut params = params_for_derivative_order(order)
2671        .into_iter()
2672        .map(|param| param.replace(GS.rescale_star).with(GS.radius_star_left))
2673        .collect_vec();
2674
2675    let radius = Atom::var(GS.radius_left);
2676    let radius_star = Atom::var(GS.radius_star_left);
2677    let uv_damp_plus = Atom::var(GS.uv_damp_plus_left);
2678    let uv_damp_minus = Atom::var(GS.uv_damp_minus_left);
2679    let hfunction = Atom::var(GS.hfunction_left_th);
2680
2681    params.push(radius);
2682    params.push(radius_star);
2683    params.push(uv_damp_plus);
2684    params.push(uv_damp_minus);
2685    params.push(hfunction);
2686
2687    GenericEvaluator::new_from_raw_params(
2688        atoms,
2689        &params,
2690        &fn_map,
2691        vec![],
2692        evaluator_settings.optimization_settings(),
2693        None,
2694        evaluator_settings,
2695    )
2696    .unwrap()
2697    .into_eager_only()
2698}
2699
2700pub(crate) fn threshold_counterterm_helper(
2701    order: u8,
2702    loop_number: usize,
2703    evaluator_settings: &EvaluatorSettings,
2704) -> GenericEvaluator {
2705    build_threshold_counterterm_helper(
2706        order,
2707        loop_number,
2708        evaluator_settings,
2709        ThresholdCountertermHelperOutputs::Legacy,
2710    )
2711}
2712
2713pub(crate) fn threshold_counterterm_pieces_helper(
2714    order: u8,
2715    loop_number: usize,
2716    evaluator_settings: &EvaluatorSettings,
2717) -> GenericEvaluator {
2718    build_threshold_counterterm_helper(
2719        order,
2720        loop_number,
2721        evaluator_settings,
2722        ThresholdCountertermHelperOutputs::Pieces,
2723    )
2724}
2725
2726pub(crate) fn threshold_counterterm_recording_helper(
2727    order: u8,
2728    loop_number: usize,
2729    evaluator_settings: &EvaluatorSettings,
2730) -> GenericEvaluator {
2731    build_threshold_counterterm_helper(
2732        order,
2733        loop_number,
2734        evaluator_settings,
2735        ThresholdCountertermHelperOutputs::LegacyAndPieces,
2736    )
2737}
2738
2739#[cfg(test)]
2740pub mod test {
2741
2742    use std::{
2743        fs,
2744        io::Cursor,
2745        time::{SystemTime, UNIX_EPOCH},
2746    };
2747
2748    use crate::{
2749        DependentMomentaConstructor, GammaLoopContextContainer,
2750        cff::{CutCFFIndex, esurface::ExistingEsurfaceId, expression::OrientationID},
2751        dot,
2752        graph::{
2753            FeynmanGraph, Graph, GraphGroupPosition,
2754            autogen::Autogen,
2755            parse::IntoGraph,
2756            threshold_counterterms::{
2757                ThresholdCountertermCut, ThresholdCountertermMultiplier, ThresholdCountertermSpec,
2758                ThresholdCountertermThreshold, ThresholdCountertermVariant,
2759            },
2760        },
2761        initialisation::test_initialise,
2762        integrands::process::{
2763            MomentumSpaceEvaluationInput, ProcessIntegrand, amplitude::AmplitudeGraphTerm,
2764        },
2765        momentum::{
2766            Dep, ExternalMomenta, Helicity, ThreeMomentum,
2767            sample::{LoopIndex, MomentumSample},
2768        },
2769        processes::{
2770            AmplitudeGraph, DotExportSettings, ThresholdCountertermComponentKind,
2771            ThresholdCountertermOrigin, ThresholdCountertermSide,
2772        },
2773        settings::{
2774            GlobalSettings, RuntimeSettings,
2775            global::{GenerationSettings, OrientationPattern, ThresholdSubtractionSettings},
2776            runtime::kinematic::{
2777                Externals, KinematicsSettings, improvement::PhaseSpaceImprovementSettings,
2778            },
2779        },
2780        subtraction::amplitude_counterterm::AmplitudeCountertermComponentEvaluation,
2781        utils::{ArbPrec, F, load_generic_model},
2782        uv::{
2783            UVOrchestrator, UVgenerationSettings,
2784            approx::{CutStructure, OrientationProjection},
2785        },
2786    };
2787    use itertools::Itertools;
2788    use spenso::algebra::complex::Complex;
2789    use symbolica::state::State;
2790    use typed_index_collections::TiVec;
2791
2792    #[test]
2793    fn metadata_free_amplitude_group_retains_native_member_geometry() {
2794        std::thread::Builder::new()
2795            .stack_size(32 * 1024 * 1024)
2796            .spawn(|| {
2797                test_initialise().unwrap();
2798                let model = load_generic_model("scalars");
2799                let graphs = Graph::from_string(
2800                    include_str!(concat!(
2801                        env!("CARGO_MANIFEST_DIR"),
2802                        "/../../tests/resources/graphs/grouped_subtraction/group.dot"
2803                    )),
2804                    &model,
2805                )
2806                .unwrap();
2807                let mut amplitude =
2808                    super::Amplitude::from_graph_list("native_group_defaults", graphs).unwrap();
2809                let settings = GenerationSettings {
2810                    threshold_subtraction: ThresholdSubtractionSettings {
2811                        enable_thresholds: true,
2812                        assume_positive_external_energies: false,
2813                        check_esurface_at_generation: false,
2814                        ..Default::default()
2815                    },
2816                    ..Default::default()
2817                };
2818                let pool = rayon::ThreadPoolBuilder::new()
2819                    .num_threads(1)
2820                    .build()
2821                    .unwrap();
2822                amplitude
2823                    .preprocess(&model, &settings, &(&RuntimeSettings::default()).into(), &pool)
2824                    .unwrap();
2825                let master = &amplitude.graphs[amplitude.graph_group_structure[super::GroupId(0)].master()];
2826                assert!(amplitude.graphs.iter().any(|member| member.graph.n_edges() > master.graph.n_edges()));
2827                for member in &amplitude.graphs {
2828                    let resolved = member.derived_data.resolved_threshold_counterterms.as_ref().unwrap();
2829                    assert!(resolved.legacy_equivalent);
2830                    assert!(!resolved.variants.is_empty());
2831                    let lmbs = member.derived_data.lmbs.as_ref().unwrap();
2832                    for variant in &resolved.variants {
2833                        assert_eq!(
2834                            variant.subspace.get_lmb(lmbs).edge_signatures,
2835                            member.graph.loop_momentum_basis.edge_signatures,
2836                            "implicit full-space defaults must retain the member's physical propagator routing",
2837                        );
2838                    }
2839                }
2840                let mut exported = String::new();
2841                for member in &amplitude.graphs {
2842                    member.write_dot_fmt(&mut exported, &DotExportSettings {
2843                        include_autogenerated_fields: true,
2844                        ..Default::default()
2845                    }).unwrap();
2846                }
2847                let reimported = super::Amplitude::from_graph_list(
2848                    "native_group_defaults_roundtrip", Graph::from_string(&exported, &model).unwrap()
2849                ).unwrap();
2850                assert!(reimported.graphs[1].graph.threshold_counterterms.autogenerated,
2851                    "export must not reinterpret a native default as explicit master metadata");
2852            })
2853            .unwrap()
2854            .join()
2855            .unwrap();
2856    }
2857
2858    #[test]
2859    fn amplitude_group_ids_are_contiguous_across_members() {
2860        test_initialise().unwrap();
2861        let graph: Graph = dot!(
2862            digraph shared_group_id_namespace {
2863                graph [group_id=0]
2864                edge [particle=scalar_0]
2865                node [num=1]
2866                e [style=invis]
2867                e -> A:0 [id=3]
2868                B:1 -> e [id=2]
2869                A -> B [id=1]
2870                A -> B [id=0 lmb_id=0]
2871            },
2872            "scalars"
2873        )
2874        .unwrap();
2875        let members = (0..2)
2876            .map(|group_id| {
2877                let mut member = graph.clone();
2878                member.name = format!("namespace_member_{group_id}");
2879                member.is_group_master = group_id == 0;
2880                member.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
2881                    schema_version: 1,
2882                    function_map: Default::default(),
2883                    cuts: vec![ThresholdCountertermCut {
2884                        edges: Vec::new(),
2885                        thresholds: vec![ThresholdCountertermThreshold {
2886                            edges: vec![super::EdgeIndex(0), super::EdgeIndex(1)],
2887                            counterterms: vec![ThresholdCountertermVariant {
2888                                name: Some(format!("group_{group_id}")),
2889                                group_id: Some(group_id),
2890                                subspace: Some(vec![super::EdgeIndex(0)]),
2891                                parent_lmb: Some(vec![super::EdgeIndex(0)]),
2892                                disable: true,
2893                                multiplier: None,
2894                            }],
2895                        }],
2896                    }],
2897                });
2898                // The second member carries only label 1; label 0 belongs to its master.
2899                member
2900                    .threshold_counterterms
2901                    .validate_for_graph(&member)
2902                    .unwrap();
2903                member
2904            })
2905            .collect();
2906        let amplitude = super::Amplitude::from_graph_list("shared_namespace", members).unwrap();
2907        assert_eq!(amplitude.graph_group_structure.len(), 1);
2908        assert_eq!(amplitude.graphs.len(), 2);
2909    }
2910
2911    #[test]
2912    fn amplitude_directive_defaults_and_explicit_variants_use_the_empty_cut() {
2913        let threshold_edges = vec![super::EdgeIndex::from(0), super::EdgeIndex::from(1)];
2914        let mut spec = ThresholdCountertermSpec {
2915            schema_version: 1,
2916            function_map: Default::default(),
2917            cuts: vec![ThresholdCountertermCut {
2918                edges: Vec::new(),
2919                thresholds: vec![ThresholdCountertermThreshold {
2920                    edges: threshold_edges.clone(),
2921                    counterterms: Vec::new(),
2922                }],
2923            }],
2924        };
2925
2926        let default =
2927            super::AmplitudeGraph::configured_amplitude_threshold_variants(&spec, &threshold_edges);
2928        assert_eq!(default.len(), 1);
2929        assert_eq!(default[0].0.name.as_deref(), Some("default"));
2930        assert_eq!(default[0].1, ThresholdCountertermOrigin::Autogenerated);
2931
2932        spec.cuts[0].thresholds[0].counterterms = vec![
2933            ThresholdCountertermVariant {
2934                group_id: None,
2935                name: Some("disabled".to_string()),
2936                subspace: None,
2937                parent_lmb: Some(vec![super::EdgeIndex::from(0)]),
2938                disable: true,
2939                multiplier: None,
2940            },
2941            ThresholdCountertermVariant {
2942                group_id: None,
2943                name: Some("duplicate".to_string()),
2944                subspace: None,
2945                parent_lmb: Some(vec![super::EdgeIndex::from(0)]),
2946                disable: false,
2947                multiplier: None,
2948            },
2949        ];
2950        spec.function_map = [
2951            ("shared".to_string(), "7".to_string()),
2952            ("scale".to_string(), "2".to_string()),
2953        ]
2954        .into();
2955        spec.cuts[0].thresholds[0].counterterms[1].multiplier =
2956            Some(ThresholdCountertermMultiplier {
2957                expression: "shared*scale".to_string(),
2958                function_map: [("scale".to_string(), "3".to_string())].into(),
2959                symmetrize: false,
2960                opaque_derivatives: true,
2961            });
2962        let explicit =
2963            super::AmplitudeGraph::configured_amplitude_threshold_variants(&spec, &threshold_edges);
2964        assert_eq!(explicit.len(), 2);
2965        assert!(explicit[0].0.disable);
2966        let definitions = &explicit[1].0.multiplier.as_ref().unwrap().function_map;
2967        assert_eq!(definitions["shared"], "7");
2968        assert_eq!(definitions["scale"], "3");
2969        assert_eq!(spec.function_map["scale"], "2");
2970        assert!(
2971            explicit
2972                .iter()
2973                .all(|(_, origin)| *origin == ThresholdCountertermOrigin::Explicit)
2974        );
2975    }
2976
2977    #[test]
2978    fn member_threshold_subspace_is_interpreted_on_master_topology() {
2979        test_initialise().unwrap();
2980        let master: Graph = dot!(digraph metadata_master {
2981            ext [style=invis]
2982            node [num=1]
2983            edge [num=1 mass=0]
2984            ext -> a [id=0]
2985            ext -> b [id=1]
2986            ext -> c [id=2]
2987            a -> b [id=3]
2988            b -> c [id=4]
2989            c -> a [id=5 lmb_id=0]
2990        })
2991        .unwrap();
2992        // Deliberately change a member-local edge orientation. Metadata still follows
2993        // the supplied master's convention; aligning physical meaning is the user's task.
2994        let member: Graph = dot!(digraph metadata_member {
2995            ext [style=invis]
2996            node [num=1]
2997            edge [num=1 mass=0]
2998            ext -> a [id=0]
2999            ext -> b [id=1]
3000            ext -> c [id=2]
3001            b -> a [id=3]
3002            b -> c [id=4]
3003            c -> a [id=5 lmb_id=0]
3004        })
3005        .unwrap();
3006        let edges = vec![super::EdgeIndex::from(5)];
3007        let master_lmbs = typed_index_collections::ti_vec![master.loop_momentum_basis.clone()];
3008        let member_lmbs = typed_index_collections::ti_vec![member.loop_momentum_basis.clone()];
3009        let parent = super::LmbIndex::from(0);
3010        let expected = super::SubspaceData::new_from_parent_basis_edges(
3011            &edges,
3012            &master.full_filter(),
3013            parent,
3014            &master,
3015            &master_lmbs,
3016        )
3017        .unwrap();
3018        let native = super::SubspaceData::new_from_parent_basis_edges(
3019            &edges,
3020            &member.full_filter(),
3021            parent,
3022            &member,
3023            &member_lmbs,
3024        )
3025        .unwrap();
3026        assert_ne!(
3027            expected.solve_signature(&master_lmbs),
3028            native.solve_signature(&member_lmbs)
3029        );
3030        let mut member = AmplitudeGraph::new(member);
3031        member.derived_data.lmbs = Some(member_lmbs);
3032        member.derived_data.threshold_topology = Some((master, master_lmbs.clone()));
3033        let resolved = member
3034            .resolve_amplitude_threshold_variant_subspace(
3035                &ThresholdCountertermVariant {
3036                    name: Some("master_coordinates".to_owned()),
3037                    group_id: Some(0),
3038                    subspace: Some(edges.clone()),
3039                    parent_lmb: Some(edges),
3040                    disable: false,
3041                    multiplier: None,
3042                },
3043                &expected,
3044                "member metadata in master topology",
3045            )
3046            .unwrap();
3047        assert_eq!(
3048            resolved.solve_signature(&master_lmbs),
3049            expected.solve_signature(&master_lmbs)
3050        );
3051    }
3052
3053    #[test]
3054    fn amplitude_legacy_parent_falls_back_when_the_generation_lmb_is_not_generated() {
3055        test_initialise().unwrap();
3056        let model = load_generic_model("scalars");
3057        let mut graph: AmplitudeGraph =
3058            include_str!("../../../../tests/resources/graphs/uv_tests/dotted_sunrise.dot")
3059                .into_graph(&model)
3060                .unwrap();
3061        graph.build_lmbs();
3062
3063        let all_lmbs = graph.derived_data.lmbs.as_ref().unwrap();
3064        let generation_lmb = graph.graph.loop_momentum_basis.loop_edges.clone();
3065        assert!(
3066            all_lmbs.iter().all(|lmb| lmb.loop_edges != generation_lmb),
3067            "the dotted-sunrise topology must exercise the legacy LMB fallback",
3068        );
3069        let fallback_parent = all_lmbs.iter_enumerated().next().unwrap().0;
3070        assert_eq!(
3071            graph.preferred_amplitude_parent_lmb().unwrap(),
3072            fallback_parent,
3073        );
3074
3075        let legacy_subspace = super::SubspaceData::new_with_user_selected_lmb(
3076            graph.graph.no_dummy(),
3077            fallback_parent,
3078            &graph.graph,
3079            all_lmbs,
3080        )
3081        .unwrap();
3082        let error = graph
3083            .resolve_amplitude_threshold_variant_subspace(
3084                &ThresholdCountertermVariant {
3085                    group_id: None,
3086                    name: Some("explicit_missing_parent".to_string()),
3087                    subspace: None,
3088                    parent_lmb: Some(generation_lmb.iter().copied().collect()),
3089                    disable: false,
3090                    multiplier: None,
3091                },
3092                &legacy_subspace,
3093                "dotted-sunrise explicit-parent test",
3094            )
3095            .unwrap_err();
3096        assert!(
3097            error
3098                .to_string()
3099                .contains("is not among graph 'sunrise' generated LMBs")
3100        );
3101    }
3102
3103    #[test]
3104    fn amplitude_rejects_a_valid_edge_set_that_is_not_a_topological_threshold() {
3105        test_initialise().unwrap();
3106        let mut graph: AmplitudeGraph = dot!(
3107            digraph unmatched_amplitude_threshold {
3108                edge [particle=scalar_1]
3109                node [num=1]
3110                e [style=invis]
3111                e -> A:0 [id=3]
3112                B:1 -> e [id=2]
3113                A -> B [id=1]
3114                A -> B [id=0]
3115            },
3116            "scalars"
3117        )
3118        .unwrap();
3119        graph.graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
3120            schema_version: 1,
3121            function_map: Default::default(),
3122            cuts: vec![ThresholdCountertermCut {
3123                edges: Vec::new(),
3124                thresholds: vec![ThresholdCountertermThreshold {
3125                    // Edge 0 is internal and valid, but the parallel edge 1 means it is not a
3126                    // complete connected cut boundary and therefore cannot be an E-surface.
3127                    edges: vec![super::EdgeIndex(0)],
3128                    counterterms: Vec::new(),
3129                }],
3130            }],
3131        });
3132
3133        let settings = GenerationSettings {
3134            threshold_subtraction: ThresholdSubtractionSettings {
3135                enable_thresholds: true,
3136                assume_positive_external_energies: false,
3137                check_esurface_at_generation: false,
3138                ..Default::default()
3139            },
3140            ..Default::default()
3141        };
3142        graph.generate_cff(&settings).unwrap();
3143        let raised_data = graph.graph.determine_raised_esurfaces_from_expression(
3144            &graph
3145                .derived_data
3146                .cff_expression
3147                .as_ref()
3148                .unwrap()
3149                .expression,
3150        );
3151        graph.build_lmbs();
3152
3153        let error = graph
3154            .resolve_amplitude_threshold_counterterm_directives(
3155                &raised_data,
3156                &settings,
3157                &(&RuntimeSettings::default()).into(),
3158                &load_generic_model("scalars"),
3159            )
3160            .unwrap_err();
3161        assert!(
3162            error
3163                .to_string()
3164                .contains("does not match a topology-discovered E-surface")
3165        );
3166    }
3167
3168    #[test]
3169    fn amplitude_orientation_excluded_topological_threshold_remains_dormant() {
3170        test_initialise().unwrap();
3171        let mut graph: AmplitudeGraph = dot!(
3172            digraph dormant_amplitude_threshold {
3173                edge [particle=scalar_1]
3174                node [num=1]
3175                e [style=invis]
3176                e -> A:0 [id=4]
3177                C:1 -> e [id=3]
3178                A -> B [id=0]
3179                B -> C [id=1]
3180                A -> C [id=2]
3181            },
3182            "scalars"
3183        )
3184        .unwrap();
3185        let selected_graph = graph.graph.clone();
3186        graph.generate_cff(&GenerationSettings::default()).unwrap();
3187        let full_cff = graph.derived_data.cff_expression.as_ref().unwrap();
3188        let physical_thresholds = full_cff
3189            .expression
3190            .surfaces
3191            .esurface_cache
3192            .iter_enumerated()
3193            .filter(|(_, esurface)| !esurface.external_shift.is_empty())
3194            .map(|(esurface_id, esurface)| {
3195                (
3196                    esurface_id,
3197                    esurface.energies.iter().copied().sorted().collect_vec(),
3198                )
3199            })
3200            .collect_vec();
3201        let (orientation_pattern, dormant_threshold_edges) = full_cff
3202            .expression
3203            .orientations
3204            .iter()
3205            .find_map(|orientation| {
3206                let present = orientation
3207                    .iter_denominator_nodes()
3208                    .filter_map(|node| match node.data {
3209                        crate::cff::surface::HybridSurfaceID::Esurface(esurface_id) => Some(
3210                            full_cff.expression.surfaces.esurface_cache[esurface_id]
3211                                .energies
3212                                .iter()
3213                                .copied()
3214                                .sorted()
3215                                .collect_vec(),
3216                        ),
3217                        _ => None,
3218                    })
3219                    .collect::<std::collections::BTreeSet<_>>();
3220                physical_thresholds
3221                    .iter()
3222                    .find(|(_, edges)| !present.contains(edges))
3223                    .map(|(_, edges)| {
3224                        (
3225                            OrientationPattern::from_orientation(orientation),
3226                            edges.clone(),
3227                        )
3228                    })
3229            })
3230            .expect("the triangle must have a physical threshold absent from one orientation");
3231
3232        let mut graph = AmplitudeGraph::new(selected_graph);
3233        graph.graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
3234            schema_version: 1,
3235            function_map: Default::default(),
3236            cuts: vec![ThresholdCountertermCut {
3237                edges: Vec::new(),
3238                thresholds: vec![ThresholdCountertermThreshold {
3239                    edges: dormant_threshold_edges.clone(),
3240                    counterterms: vec![ThresholdCountertermVariant {
3241                        group_id: None,
3242                        name: Some("dormant".to_string()),
3243                        subspace: None,
3244                        parent_lmb: Some(
3245                            graph
3246                                .graph
3247                                .loop_momentum_basis
3248                                .loop_edges
3249                                .iter()
3250                                .copied()
3251                                .collect(),
3252                        ),
3253                        disable: false,
3254                        multiplier: None,
3255                    }],
3256                }],
3257            }],
3258        });
3259        let settings = GenerationSettings {
3260            orientation_pattern,
3261            threshold_subtraction: ThresholdSubtractionSettings {
3262                enable_thresholds: true,
3263                assume_positive_external_energies: false,
3264                check_esurface_at_generation: false,
3265                ..Default::default()
3266            },
3267            ..Default::default()
3268        };
3269        graph.generate_cff(&settings).unwrap();
3270        // CFF generation retains the full catalogue; only the selected subset
3271        // contributes evaluators and active threshold-counterterm instances.
3272        assert_eq!(
3273            graph
3274                .derived_data
3275                .cff_expression
3276                .as_ref()
3277                .unwrap()
3278                .expression
3279                .orientations
3280                .iter()
3281                .filter(|orientation| settings.orientation_pattern.filter(*orientation))
3282                .count(),
3283            1,
3284        );
3285        let raised_data = graph.graph.determine_raised_esurfaces_from_expression(
3286            &graph
3287                .derived_data
3288                .cff_expression
3289                .as_ref()
3290                .unwrap()
3291                .expression,
3292        );
3293        graph.build_lmbs();
3294        let (resolved, _) = graph
3295            .resolve_amplitude_threshold_counterterm_directives(
3296                &raised_data,
3297                &settings,
3298                &(&RuntimeSettings::default()).into(),
3299                &load_generic_model("scalars"),
3300            )
3301            .unwrap();
3302        assert!(resolved.variants.iter().all(|variant| {
3303            variant.name != "dormant"
3304                && variant
3305                    .associations
3306                    .iter()
3307                    .all(|association| association.threshold_edges != dormant_threshold_edges)
3308        }));
3309    }
3310
3311    #[test]
3312    fn amplitude_variant_subspace_resolves_in_the_requested_parent() {
3313        test_initialise().unwrap();
3314        let mut graph: AmplitudeGraph = dot!(
3315            digraph amplitude_variant_subspace {
3316                edge [particle=scalar_1]
3317                node [num=1]
3318                e [style=invis]
3319                e -> A:0 [id=3]
3320                B:1 -> e [id=4]
3321                A -> B [id=0]
3322                A -> B [id=1]
3323                A -> B [id=2]
3324            },
3325            "scalars"
3326        )
3327        .unwrap();
3328        graph.build_lmbs();
3329        let preferred_parent = graph.preferred_amplitude_parent_lmb().unwrap();
3330        let all_lmbs = graph.derived_data.lmbs.as_ref().unwrap();
3331        let parent_edges = all_lmbs[preferred_parent]
3332            .loop_edges
3333            .iter()
3334            .copied()
3335            .collect::<Vec<_>>();
3336        let requested_subspace = vec![parent_edges[0]];
3337        let legacy_subspace = super::SubspaceData::new_with_user_selected_lmb(
3338            graph.graph.no_dummy(),
3339            preferred_parent,
3340            &graph.graph,
3341            all_lmbs,
3342        )
3343        .unwrap();
3344        let resolved = graph
3345            .resolve_amplitude_threshold_variant_subspace(
3346                &ThresholdCountertermVariant {
3347                    group_id: None,
3348                    name: Some("one_loop".to_string()),
3349                    subspace: Some(requested_subspace.clone()),
3350                    parent_lmb: Some(parent_edges),
3351                    disable: false,
3352                    multiplier: None,
3353                },
3354                &legacy_subspace,
3355                "amplitude subspace test",
3356            )
3357            .unwrap();
3358
3359        assert_eq!(resolved.parent_lmb_index(), preferred_parent);
3360        assert_eq!(resolved.loopcount(), 1);
3361        assert_eq!(
3362            resolved.iter_basis_edges(all_lmbs).collect::<Vec<_>>(),
3363            requested_subspace,
3364        );
3365    }
3366
3367    #[test]
3368    fn amplitude_preprocessing_keeps_duplicate_geometric_variants_independent() {
3369        std::thread::Builder::new()
3370            .name("amplitude-threshold-variant-test".to_string())
3371            .stack_size(32 * 1024 * 1024)
3372            .spawn(|| {
3373                test_initialise().unwrap();
3374                let mut graph: AmplitudeGraph = dot!(
3375                    digraph duplicate_threshold_variants {
3376                        edge [particle=scalar_1]
3377                        node [num=1]
3378                        e [style=invis]
3379                        e -> A:0 [id=3]
3380                        B:1 -> e [id=2]
3381                        A -> B [id=1]
3382                        A -> B [id=0]
3383                    },
3384                    "scalars"
3385                )
3386                .unwrap();
3387                graph.generate_cff(&GenerationSettings::default()).unwrap();
3388                let threshold_edges = graph
3389                    .derived_data
3390                    .cff_expression
3391                    .as_ref()
3392                    .unwrap()
3393                    .expression
3394                    .surfaces
3395                    .esurface_cache
3396                    .iter()
3397                    .find(|esurface| !esurface.external_shift.is_empty())
3398                    .expect("the scalar bubble must contain a physical threshold")
3399                    .energies
3400                    .iter()
3401                    .copied()
3402                    .sorted()
3403                    .collect::<Vec<_>>();
3404                graph.graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
3405                    schema_version: 1,
3406                    function_map: Default::default(),
3407                    cuts: vec![ThresholdCountertermCut {
3408                        edges: Vec::new(),
3409                        thresholds: vec![ThresholdCountertermThreshold {
3410                            edges: threshold_edges.clone(),
3411                            counterterms: vec![
3412                                ThresholdCountertermVariant {
3413                                    group_id: None,
3414                                    name: Some("first".to_string()),
3415                                    subspace: None,
3416                                    parent_lmb: Some(
3417                                        graph
3418                                            .graph
3419                                            .loop_momentum_basis
3420                                            .loop_edges
3421                                            .iter()
3422                                            .copied()
3423                                            .collect(),
3424                                    ),
3425                                    disable: false,
3426                                    multiplier: None,
3427                                },
3428                                ThresholdCountertermVariant {
3429                                    group_id: None,
3430                                    name: Some("disabled".to_string()),
3431                                    subspace: None,
3432                                    parent_lmb: Some(
3433                                        graph
3434                                            .graph
3435                                            .loop_momentum_basis
3436                                            .loop_edges
3437                                            .iter()
3438                                            .copied()
3439                                            .collect(),
3440                                    ),
3441                                    disable: true,
3442                                    multiplier: None,
3443                                },
3444                                ThresholdCountertermVariant {
3445                                    group_id: None,
3446                                    name: Some("second".to_string()),
3447                                    subspace: None,
3448                                    parent_lmb: Some(
3449                                        graph
3450                                            .graph
3451                                            .loop_momentum_basis
3452                                            .loop_edges
3453                                            .iter()
3454                                            .copied()
3455                                            .collect(),
3456                                    ),
3457                                    disable: false,
3458                                    multiplier: None,
3459                                },
3460                            ],
3461                        }],
3462                    }],
3463                });
3464
3465                let model = load_generic_model("scalars");
3466                let mut generation_settings = GenerationSettings {
3467                    threshold_subtraction: ThresholdSubtractionSettings {
3468                        enable_thresholds: true,
3469                        assume_positive_external_energies: false,
3470                        check_esurface_at_generation: false,
3471                        ..Default::default()
3472                    },
3473                    ..Default::default()
3474                };
3475                graph
3476                    .preprocess(
3477                        &model,
3478                        &generation_settings,
3479                        &(&RuntimeSettings::default()).into(),
3480                    )
3481                    .unwrap();
3482
3483                let resolved = graph
3484                    .derived_data
3485                    .resolved_threshold_counterterms
3486                    .as_ref()
3487                    .unwrap();
3488                assert!(!resolved.legacy_equivalent);
3489                let mut names_by_group = std::collections::BTreeMap::<Vec<usize>, Vec<&str>>::new();
3490                for variant in resolved.variants.iter().filter(|variant| {
3491                    variant
3492                        .associations
3493                        .iter()
3494                        .any(|association| association.threshold_edges == threshold_edges)
3495                }) {
3496                    names_by_group
3497                        .entry(
3498                            variant
3499                                .threshold_esurface_ids
3500                                .iter()
3501                                .map(|esurface_id| esurface_id.0)
3502                                .collect(),
3503                        )
3504                        .or_default()
3505                        .push(&variant.name);
3506                }
3507                assert!(!names_by_group.is_empty());
3508                assert!(
3509                    names_by_group
3510                        .values()
3511                        .all(|names| names == &["first", "second"])
3512                );
3513                assert!(
3514                    resolved
3515                        .variants
3516                        .iter()
3517                        .all(|variant| variant.side == ThresholdCountertermSide::Amplitude)
3518                );
3519                assert_eq!(
3520                    graph.derived_data.threshold_counterterm_variants.len(),
3521                    resolved.variants.len(),
3522                );
3523                assert!(
3524                    graph
3525                        .derived_data
3526                        .threshold_counterterm_variants
3527                        .iter()
3528                        .all(|variant| variant.atom.is_generated())
3529                );
3530                assert!(graph.derived_data.threshold_counterterms.is_empty());
3531
3532                let (term, _) = AmplitudeGraphTerm::from_amplitude_graph(
3533                    &graph,
3534                    GraphGroupPosition(0),
3535                    TiVec::new(),
3536                    &model,
3537                    &GlobalSettings {
3538                        generation: generation_settings.clone(),
3539                        ..Default::default()
3540                    },
3541                )
3542                .unwrap();
3543                let registry = term
3544                    .threshold_counterterm
3545                    .metadata_registry
3546                    .as_ref()
3547                    .expect("explicit duplicate variants must create amplitude metadata");
3548                assert_eq!(registry.variants.len(), resolved.variants.len());
3549                assert_eq!(registry.components.len(), resolved.variants.len() * 2);
3550                assert!(registry.evaluators.is_empty());
3551                assert!(term.threshold_counterterm.threshold_multipliers.is_none());
3552
3553                let decomposition =
3554                    crate::integrands::process::amplitude::amplitude_threshold_event_info(
3555                        registry,
3556                        Complex::new_re(F(100.0)),
3557                        vec![
3558                            AmplitudeCountertermComponentEvaluation {
3559                                variant_id: super::ThresholdCountertermVariantId(0),
3560                                kind: ThresholdCountertermComponentKind::Local,
3561                                esurface_id: super::RaisedEsurfaceId(4),
3562                                overlap_group: 2,
3563                                multiplier_value: F(3.0),
3564                                bare: Some(Complex::new_re(F(-2.0))),
3565                                weighted: Complex::new_re(F(-6.0)),
3566                                evaluation_skipped: false,
3567                            },
3568                            AmplitudeCountertermComponentEvaluation {
3569                                variant_id: super::ThresholdCountertermVariantId(0),
3570                                kind: ThresholdCountertermComponentKind::Integrated,
3571                                esurface_id: super::RaisedEsurfaceId(4),
3572                                overlap_group: 2,
3573                                multiplier_value: F(0.0),
3574                                bare: None,
3575                                weighted: Complex::new_re(F(0.0)),
3576                                evaluation_skipped: true,
3577                            },
3578                        ],
3579                    )
3580                    .unwrap();
3581                assert_eq!(decomposition.total(), Complex::new_re(F(94.0)));
3582                assert_eq!(decomposition.components[0].component_id, 0);
3583                assert_eq!(decomposition.components[1].component_id, 1);
3584                assert!(decomposition.components[1].evaluation_skipped);
3585
3586                graph.graph.threshold_counterterms =
3587                    Autogen::generated(ThresholdCountertermSpec::default());
3588                graph
3589                    .preprocess(
3590                        &model,
3591                        &generation_settings,
3592                        &(&RuntimeSettings::default()).into(),
3593                    )
3594                    .unwrap();
3595                let resolved = graph
3596                    .derived_data
3597                    .resolved_threshold_counterterms
3598                    .as_ref()
3599                    .unwrap();
3600                assert!(resolved.legacy_equivalent);
3601                assert_eq!(
3602                    graph.derived_data.threshold_counterterms.len(),
3603                    graph.derived_data.raised_data.raised_groups.len(),
3604                );
3605                assert_eq!(
3606                    graph
3607                        .derived_data
3608                        .threshold_counterterms
3609                        .iter()
3610                        .filter(|counterterm| counterterm.is_generated())
3611                        .count(),
3612                    graph.derived_data.threshold_counterterm_variants.len(),
3613                );
3614
3615                generation_settings.threshold_subtraction.enable_thresholds = false;
3616                graph
3617                    .preprocess(
3618                        &model,
3619                        &generation_settings,
3620                        &(&RuntimeSettings::default()).into(),
3621                    )
3622                    .unwrap();
3623                assert!(graph.derived_data.resolved_threshold_counterterms.is_none());
3624                assert!(graph.derived_data.threshold_counterterms.is_empty());
3625                assert!(graph.derived_data.threshold_counterterm_variants.is_empty());
3626                assert!(graph.derived_data.raised_data.raised_groups.is_empty());
3627            })
3628            .expect("amplitude threshold-variant test thread must start")
3629            .join()
3630            .expect("amplitude threshold-variant test thread must finish successfully");
3631    }
3632
3633    #[test]
3634    fn generalized_amplitude_approach_keeps_duplicate_variant_subspaces_and_complements() {
3635        std::thread::Builder::new()
3636            .name("generalized-amplitude-threshold-approach-test".to_string())
3637            .stack_size(32 * 1024 * 1024)
3638            .spawn(|| {
3639                test_initialise().unwrap();
3640                let model = load_generic_model("scalars");
3641                let mut graph: Graph = dot!(
3642                    digraph generalized_threshold_approach {
3643                        edge [particle=scalar_1]
3644                        node [num=1]
3645                        e [style=invis]
3646                        e -> A:0 [id=4]
3647                        B:1 -> e [id=3]
3648                        A -> B [id=2]
3649                        A -> B [id=1]
3650                        A -> B [id=0]
3651                    },
3652                    "scalars"
3653                )
3654                .unwrap();
3655                let mut parent_probe = AmplitudeGraph::new(graph.clone());
3656                parent_probe.build_lmbs();
3657                let preferred_parent = parent_probe.preferred_amplitude_parent_lmb().unwrap();
3658                let parent_edges = parent_probe.derived_data.lmbs.as_ref().unwrap()
3659                    [preferred_parent]
3660                    .loop_edges
3661                    .iter()
3662                    .copied()
3663                    .collect::<Vec<_>>();
3664                assert_eq!(parent_edges.len(), 2);
3665                graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
3666                    schema_version: 1,
3667                    function_map: Default::default(),
3668                    cuts: vec![ThresholdCountertermCut {
3669                        edges: Vec::new(),
3670                        thresholds: vec![ThresholdCountertermThreshold {
3671                            edges: vec![
3672                                super::EdgeIndex(0),
3673                                super::EdgeIndex(1),
3674                                super::EdgeIndex(2),
3675                            ],
3676                            counterterms: vec![
3677                                ThresholdCountertermVariant {
3678                                    group_id: None,
3679                                    name: Some("one_loop".to_string()),
3680                                    subspace: Some(vec![parent_edges[0]]),
3681                                    parent_lmb: Some(parent_edges.clone()),
3682                                    disable: false,
3683                                    multiplier: None,
3684                                },
3685                                ThresholdCountertermVariant {
3686                                    group_id: None,
3687                                    name: Some("two_loop".to_string()),
3688                                    subspace: Some(parent_edges.clone()),
3689                                    parent_lmb: Some(parent_edges),
3690                                    disable: false,
3691                                    multiplier: None,
3692                                },
3693                            ],
3694                        }],
3695                    }],
3696                });
3697
3698                let generation_settings = GenerationSettings {
3699                    threshold_subtraction: ThresholdSubtractionSettings {
3700                        enable_thresholds: true,
3701                        assume_positive_external_energies: false,
3702                        check_esurface_at_generation: false,
3703                        ..Default::default()
3704                    },
3705                    ..Default::default()
3706                };
3707                let runtime = RuntimeSettings {
3708                    kinematics: KinematicsSettings {
3709                        e_cm: 6.0,
3710                        externals: Externals::Constant {
3711                            momenta: vec![
3712                                ExternalMomenta::Independent([F(6.0), F(0.0), F(0.0), F(0.0)]),
3713                                ExternalMomenta::Dependent(Dep::Dep),
3714                            ],
3715                            helicities: vec![Helicity::ZERO; 2],
3716                            improvement_settings: PhaseSpaceImprovementSettings::default(),
3717                            f_64_cache: None,
3718                            f_128_cache: None,
3719                            arb_cache: Default::default(),
3720                        },
3721                    },
3722                    ..RuntimeSettings::default()
3723                };
3724                let pool = rayon::ThreadPoolBuilder::new()
3725                    .num_threads(1)
3726                    .build()
3727                    .unwrap();
3728                let mut amplitude = super::Amplitude::from_graph_list(
3729                    "generalized_threshold_approach",
3730                    vec![graph],
3731                )
3732                .unwrap();
3733                amplitude
3734                    .preprocess(&model, &generation_settings, &(&runtime).into(), &pool)
3735                    .unwrap();
3736                amplitude
3737                    .build_integrand(
3738                        &model,
3739                        "generalized_threshold_approach",
3740                        &GlobalSettings {
3741                            generation: generation_settings,
3742                            ..Default::default()
3743                        },
3744                        (&runtime).into(),
3745                        &pool,
3746                    )
3747                    .unwrap();
3748                let ProcessIntegrand::Amplitude(integrand) = amplitude.integrand.as_mut().unwrap()
3749                else {
3750                    panic!("approach fixture built a non-amplitude integrand")
3751                };
3752                let external_signature =
3753                    integrand.data.graph_terms[0].graph.get_external_signature();
3754                let sample = MomentumSample::new(
3755                    vec![
3756                        ThreeMomentum::new(
3757                            F::<ArbPrec>::from_f64(0.4),
3758                            F::<ArbPrec>::from_f64(0.1),
3759                            F::<ArbPrec>::from_f64(-0.2),
3760                        ),
3761                        ThreeMomentum::new(
3762                            F::<ArbPrec>::from_f64(-0.3),
3763                            F::<ArbPrec>::from_f64(0.2),
3764                            F::<ArbPrec>::from_f64(0.15),
3765                        ),
3766                    ]
3767                    .into_iter()
3768                    .collect(),
3769                    0,
3770                    &runtime.kinematics.externals,
3771                    0,
3772                    F::<ArbPrec>::from_f64(1.0),
3773                    DependentMomentaConstructor::Amplitude(&external_signature),
3774                    None,
3775                )
3776                .unwrap();
3777
3778                let term = &mut integrand.data.graph_terms[0];
3779                let approach = term
3780                    .kinematics_for_threshold_approach(&runtime, &model, &sample)
3781                    .unwrap();
3782                let variant_ids = approach
3783                    .variant_ids
3784                    .as_ref()
3785                    .expect("generalized approach must expose stable variant IDs");
3786                assert_eq!(variant_ids.len(), approach.existing_esurfaces.len());
3787                assert!(variant_ids.len() >= 2);
3788                assert_eq!(
3789                    variant_ids
3790                        .iter()
3791                        .copied()
3792                        .collect::<std::collections::BTreeSet<_>>()
3793                        .len(),
3794                    variant_ids.len(),
3795                );
3796
3797                let counterterm = &term.threshold_counterterm;
3798                let mut loop_counts_by_raised =
3799                    std::collections::BTreeMap::<_, std::collections::BTreeSet<_>>::new();
3800                for &variant_id in variant_ids {
3801                    loop_counts_by_raised
3802                        .entry(counterterm.variant_raised_esurfaces[variant_id])
3803                        .or_default()
3804                        .insert(counterterm.variant_subspaces[variant_id].loopcount());
3805                }
3806                assert!(loop_counts_by_raised.values().any(|counts| {
3807                    counts
3808                        == &[1, 2]
3809                            .into_iter()
3810                            .collect::<std::collections::BTreeSet<_>>()
3811                }));
3812
3813                let mut referenced_variants = std::collections::BTreeSet::new();
3814                for group in &approach.overlap_groups_with_kinematics {
3815                    let centers = group
3816                        .approach_centers_at_esurface
3817                        .as_ref()
3818                        .expect("generalized overlap groups need per-variant centers");
3819                    assert_eq!(centers.len(), group.overlap_group.existing_esurfaces.len());
3820                    assert_eq!(
3821                        group.loop_momenta_at_esurface.len(),
3822                        group.overlap_group.existing_esurfaces.len()
3823                    );
3824                    for (position, &existing_esurface_id) in
3825                        group.overlap_group.existing_esurfaces.iter().enumerate()
3826                    {
3827                        let variant_id = variant_ids[existing_esurface_id];
3828                        referenced_variants.insert(variant_id);
3829                        let local_id = ExistingEsurfaceId::from(position);
3830                        let root = group.loop_momenta_at_esurface[local_id]
3831                            .as_ref()
3832                            .expect("each generalized threshold needs an r_star sample");
3833                        let center = centers[local_id]
3834                            .as_ref()
3835                            .expect("each generalized threshold needs a full center");
3836                        for momentum in root.loop_moms().iter().chain(center.iter()) {
3837                            for component in [&momentum.px, &momentum.py, &momentum.pz] {
3838                                assert!(component.into_f64().is_finite());
3839                            }
3840                        }
3841
3842                        let subspace = &counterterm.variant_subspaces[variant_id];
3843                        let parent_lmb = subspace.get_lmb(&counterterm.lmbs);
3844                        let base_in_parent =
3845                            sample.lmb_transform(&term.graph.loop_momentum_basis, parent_lmb);
3846                        let root_in_parent =
3847                            root.lmb_transform(&term.graph.loop_momentum_basis, parent_lmb);
3848                        let mut center_sample = root.clone();
3849                        center_sample.sample.loop_moms = center.clone();
3850                        let center_in_parent = center_sample
3851                            .lmb_transform(&term.graph.loop_momentum_basis, parent_lmb);
3852                        for loop_index in (0..term.graph.get_loop_number()).map(LoopIndex::from) {
3853                            if subspace.contains_loop_index(loop_index) {
3854                                continue;
3855                            }
3856                            for (actual, expected) in [
3857                                (
3858                                    &root_in_parent.loop_moms()[loop_index],
3859                                    &base_in_parent.loop_moms()[loop_index],
3860                                ),
3861                                (
3862                                    &center_in_parent.loop_moms()[loop_index],
3863                                    &base_in_parent.loop_moms()[loop_index],
3864                                ),
3865                            ] {
3866                                for (actual, expected) in [
3867                                    (&actual.px, &expected.px),
3868                                    (&actual.py, &expected.py),
3869                                    (&actual.pz, &expected.pz),
3870                                ] {
3871                                    assert!((actual - expected).abs().into_f64() < 1.0e-20);
3872                                }
3873                            }
3874                        }
3875                    }
3876                }
3877                assert_eq!(
3878                    referenced_variants,
3879                    variant_ids
3880                        .iter()
3881                        .copied()
3882                        .collect::<std::collections::BTreeSet<_>>(),
3883                    "overlap groups must retain every semantic duplicate independently",
3884                );
3885            })
3886            .expect("generalized amplitude approach test thread must start")
3887            .join()
3888            .expect("generalized amplitude approach test thread must finish successfully");
3889    }
3890
3891    #[test]
3892    fn amplitude_dot_export_materializes_resolved_defaults_and_round_trips() {
3893        std::thread::Builder::new()
3894            .name("amplitude-threshold-dot-export-test".to_string())
3895            .stack_size(32 * 1024 * 1024)
3896            .spawn(|| {
3897                test_initialise().unwrap();
3898                let mut graph: AmplitudeGraph = dot!(
3899                    digraph amplitude_threshold_dot_export {
3900                        // A massive external scalar above the two-massless-particle threshold
3901                        // guarantees that the runtime part of this regression records L/I pieces.
3902                        edge [particle=scalar_0]
3903                        node [num=1]
3904                        e [style=invis]
3905                        e -> A:0 [id=3 particle=scalar_2]
3906                        B:1 -> e [id=2 particle=scalar_2]
3907                        A -> B [id=1]
3908                        A -> B [id=0]
3909                    },
3910                    "scalars"
3911                )
3912                .unwrap();
3913                let model = load_generic_model("scalars");
3914                let generation_settings = GenerationSettings {
3915                    threshold_subtraction: ThresholdSubtractionSettings {
3916                        enable_thresholds: true,
3917                        assume_positive_external_energies: false,
3918                        check_esurface_at_generation: false,
3919                        ..Default::default()
3920                    },
3921                    ..Default::default()
3922                };
3923                graph
3924                    .preprocess(
3925                        &model,
3926                        &generation_settings,
3927                        &(&RuntimeSettings::default()).into(),
3928                    )
3929                    .unwrap();
3930
3931                assert!(graph.graph.threshold_counterterms.autogenerated);
3932                assert!(graph.graph.threshold_counterterms.cuts.is_empty());
3933                let resolved = graph
3934                    .derived_data
3935                    .resolved_threshold_counterterms
3936                    .as_ref()
3937                    .unwrap();
3938                assert!(resolved.legacy_equivalent);
3939                let expected_materialized = resolved.materialized_spec(
3940                    &graph.graph.threshold_counterterms,
3941                    graph.derived_data.lmbs.as_ref().unwrap(),
3942                );
3943                assert!(!expected_materialized.cuts.is_empty());
3944                assert!(
3945                    expected_materialized
3946                        .cuts
3947                        .iter()
3948                        .all(|cut| cut.edges.is_empty())
3949                );
3950
3951                let mut ordinary = String::new();
3952                graph
3953                    .write_dot_fmt(&mut ordinary, &DotExportSettings::default())
3954                    .unwrap();
3955                assert!(!ordinary.contains("threshold_counterterms"));
3956                let ordinary_import = Graph::from_string(&ordinary, &model).unwrap().remove(0);
3957                assert!(ordinary_import.threshold_counterterms.autogenerated);
3958                assert!(ordinary_import.threshold_counterterms.cuts.is_empty());
3959
3960                let mut normalized = String::new();
3961                graph
3962                    .write_dot_fmt(
3963                        &mut normalized,
3964                        &DotExportSettings {
3965                            include_autogenerated_fields: true,
3966                            ..DotExportSettings::default()
3967                        },
3968                    )
3969                    .unwrap();
3970                let normalized_import = Graph::from_string(&normalized, &model).unwrap().remove(0);
3971                assert!(!normalized_import.threshold_counterterms.autogenerated);
3972                assert_eq!(
3973                    *normalized_import.threshold_counterterms,
3974                    expected_materialized
3975                );
3976                assert!(graph.graph.threshold_counterterms.autogenerated);
3977                assert!(graph.graph.threshold_counterterms.cuts.is_empty());
3978
3979                let mut round_tripped = AmplitudeGraph::new(normalized_import);
3980                round_tripped
3981                    .preprocess(
3982                        &model,
3983                        &generation_settings,
3984                        &(&RuntimeSettings::default()).into(),
3985                    )
3986                    .unwrap();
3987                assert!(
3988                    round_tripped
3989                        .derived_data
3990                        .resolved_threshold_counterterms
3991                        .as_ref()
3992                        .unwrap()
3993                        .legacy_equivalent
3994                );
3995                let (term, _) = AmplitudeGraphTerm::from_amplitude_graph(
3996                    &round_tripped,
3997                    GraphGroupPosition(0),
3998                    TiVec::new(),
3999                    &model,
4000                    &GlobalSettings {
4001                        generation: generation_settings.clone(),
4002                        ..Default::default()
4003                    },
4004                )
4005                .unwrap();
4006                assert!(term.threshold_counterterm.legacy_equivalent);
4007                assert!(term.threshold_counterterm.variant_evaluators.is_empty());
4008                assert!(term.threshold_counterterm.variant_subspaces.is_empty());
4009                assert!(term.threshold_counterterm.metadata_registry.is_some());
4010                assert_eq!(
4011                    term.threshold_counterterm.helper_evaluators.len(),
4012                    round_tripped
4013                        .derived_data
4014                        .raised_data
4015                        .pass_two_evaluator
4016                        .as_ref()
4017                        .unwrap()
4018                        .len(),
4019                );
4020
4021                let normalized_graph = Graph::from_string(&normalized, &model).unwrap().remove(0);
4022                let loop_count = normalized_graph.get_loop_number();
4023                // A deterministic timelike external momentum is required here: the generic
4024                // random 1 -> 1 sample is lightlike and leaves this massless bubble below its
4025                // threshold, so it cannot exercise the detailed L/I event decomposition.
4026                let mut runtime = RuntimeSettings {
4027                    kinematics: KinematicsSettings {
4028                        e_cm: 4.0,
4029                        externals: Externals::Constant {
4030                            momenta: vec![
4031                                ExternalMomenta::Independent([F(4.0), F(0.0), F(0.0), F(0.0)]),
4032                                ExternalMomenta::Dependent(Dep::Dep),
4033                            ],
4034                            helicities: vec![Helicity::ZERO; 2],
4035                            improvement_settings: PhaseSpaceImprovementSettings::default(),
4036                            f_64_cache: None,
4037                            f_128_cache: None,
4038                            arb_cache: Default::default(),
4039                        },
4040                    },
4041                    ..RuntimeSettings::default()
4042                };
4043                runtime.general.generate_events = true;
4044                runtime.general.store_additional_weights_in_event = true;
4045                let mut amplitude = super::Amplitude::from_graph_list(
4046                    "amplitude_threshold_event_roundtrip",
4047                    vec![normalized_graph],
4048                )
4049                .unwrap();
4050                let pool = rayon::ThreadPoolBuilder::new()
4051                    .num_threads(1)
4052                    .build()
4053                    .unwrap();
4054                amplitude
4055                    .preprocess(&model, &generation_settings, &(&runtime).into(), &pool)
4056                    .unwrap();
4057                amplitude
4058                    .build_integrand(
4059                        &model,
4060                        "amplitude_threshold_event_roundtrip",
4061                        &GlobalSettings {
4062                            generation: generation_settings.clone(),
4063                            ..Default::default()
4064                        },
4065                        (&runtime).into(),
4066                        &pool,
4067                    )
4068                    .unwrap();
4069                let integrand = amplitude.integrand.as_mut().unwrap();
4070                integrand.warm_up(&model).unwrap();
4071                let result = integrand
4072                    .evaluate_momentum_configuration(
4073                        &model,
4074                        &crate::integrands::process::MomentumSpaceEvaluationInput {
4075                            loop_momenta: (0..loop_count)
4076                                .map(|index| {
4077                                    crate::momentum::ThreeMomentum::new(
4078                                        F(0.35 + index as f64 * 0.1),
4079                                        F(-0.2),
4080                                        F(0.45),
4081                                    )
4082                                })
4083                                .collect(),
4084                            integrator_weight: F(1.0),
4085                            graph_id: Some(0),
4086                            group_id: None,
4087                            orientation: None,
4088                            channel_id: None,
4089                        },
4090                        false,
4091                    )
4092                    .unwrap();
4093                let events = result
4094                    .event_groups
4095                    .iter()
4096                    .flat_map(|group| group.iter())
4097                    .collect::<Vec<_>>();
4098                assert_eq!(events.len(), 1);
4099                let event = events[0];
4100                let decomposition = event
4101                    .additional_weights
4102                    .threshold_counterterms
4103                    .as_ref()
4104                    .expect("explicit normalized amplitude must record threshold components");
4105                assert!(!decomposition.components.is_empty());
4106                assert_eq!(decomposition.total(), event.weight);
4107                assert!(decomposition.components.iter().all(|component| {
4108                    component.multiplier_values.as_slice() == [F(1.0)]
4109                        && component.bare.is_some()
4110                        && component.bare == Some(component.weighted)
4111                        && !component.evaluation_skipped
4112                }));
4113                assert!(event.additional_weights.weights.keys().any(|key| matches!(
4114                    key,
4115                    crate::observables::AdditionalWeightKey::AmplitudeThresholdCounterterm { .. }
4116                )));
4117            })
4118            .expect("amplitude threshold DOT-export test thread must start")
4119            .join()
4120            .expect("amplitude threshold DOT-export test thread must finish successfully");
4121    }
4122
4123    #[test]
4124    fn grouped_amplitude_threshold_directives_remain_member_local_through_runtime_and_save_load() {
4125        std::thread::Builder::new()
4126            .name("grouped-amplitude-threshold-ownership-test".to_string())
4127            .stack_size(32 * 1024 * 1024)
4128            .spawn(|| {
4129                test_initialise().unwrap();
4130                let model = load_generic_model("scalars");
4131                let mut member_a: Graph = dot!(
4132                    digraph grouped_threshold_member_a {
4133                        graph [group_id=0 is_group_master=true]
4134                        edge [particle=scalar_0]
4135                        node [num=1]
4136                        e [style=invis]
4137                        e -> A:0 [id=3 particle=scalar_2]
4138                        B:1 -> e [id=2 particle=scalar_2]
4139                        A -> B [id=1]
4140                        A -> B [id=0 lmb_id=0]
4141                    },
4142                    "scalars"
4143                )
4144                .unwrap();
4145                let mut member_b: Graph = dot!(
4146                    digraph grouped_threshold_member_b {
4147                        graph [group_id=0]
4148                        edge [particle=scalar_0]
4149                        node [num=1]
4150                        e [style=invis]
4151                        e -> A:0 [id=3 particle=scalar_2]
4152                        B:1 -> e [id=2 particle=scalar_2]
4153                        A -> B [id=1 lmb_id=0]
4154                        A -> B [id=0]
4155                    },
4156                    "scalars"
4157                )
4158                .unwrap();
4159                for (graph, name, subspace_edge, expression) in [
4160                    (&mut member_a, "member_a_one_loop", 0, "1"),
4161                    (&mut member_b, "member_b_one_loop", 1, "2"),
4162                ] {
4163                    graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
4164                        schema_version: 1,
4165                        function_map: Default::default(),
4166                        cuts: vec![ThresholdCountertermCut {
4167                            edges: Vec::new(),
4168                            thresholds: vec![ThresholdCountertermThreshold {
4169                                edges: vec![super::EdgeIndex(0), super::EdgeIndex(1)],
4170                                counterterms: vec![ThresholdCountertermVariant {
4171                                    group_id: None,
4172                                    name: Some(name.to_string()),
4173                                    subspace: Some(vec![super::EdgeIndex(subspace_edge)]),
4174                                    parent_lmb: Some(vec![super::EdgeIndex(subspace_edge)]),
4175                                    disable: false,
4176                                    multiplier: Some(ThresholdCountertermMultiplier {
4177                                        expression: expression.to_string(),
4178                                        function_map: Default::default(),
4179                                        symmetrize: false,
4180                                        opaque_derivatives: true,
4181                                    }),
4182                                }],
4183                            }],
4184                        }],
4185                    });
4186                }
4187
4188                let generation_settings = GenerationSettings {
4189                    threshold_subtraction: ThresholdSubtractionSettings {
4190                        enable_thresholds: true,
4191                        assume_positive_external_energies: false,
4192                        check_esurface_at_generation: false,
4193                        ..Default::default()
4194                    },
4195                    ..Default::default()
4196                };
4197                // Keep the two-member bubble above threshold so both member-local registries
4198                // are referenced by actual detailed L/I event components.
4199                let mut runtime = RuntimeSettings {
4200                    kinematics: KinematicsSettings {
4201                        e_cm: 4.0,
4202                        externals: Externals::Constant {
4203                            momenta: vec![
4204                                ExternalMomenta::Independent([F(4.0), F(0.0), F(0.0), F(0.0)]),
4205                                ExternalMomenta::Dependent(Dep::Dep),
4206                            ],
4207                            helicities: vec![Helicity::ZERO; 2],
4208                            improvement_settings: PhaseSpaceImprovementSettings::default(),
4209                            f_64_cache: None,
4210                            f_128_cache: None,
4211                            arb_cache: Default::default(),
4212                        },
4213                    },
4214                    ..RuntimeSettings::default()
4215                };
4216                runtime.general.generate_events = true;
4217                runtime.general.store_additional_weights_in_event = true;
4218                let pool = rayon::ThreadPoolBuilder::new()
4219                    .num_threads(1)
4220                    .build()
4221                    .unwrap();
4222                let mut amplitude = super::Amplitude::from_graph_list(
4223                    "grouped_threshold_ownership",
4224                    vec![member_a, member_b],
4225                )
4226                .unwrap();
4227                assert_eq!(amplitude.graph_group_structure.len(), 1);
4228                assert_eq!(
4229                    (&amplitude.graph_group_structure[super::GroupId(0)])
4230                        .into_iter()
4231                        .count(),
4232                    2
4233                );
4234                amplitude
4235                    .preprocess(&model, &generation_settings, &(&runtime).into(), &pool)
4236                    .unwrap();
4237
4238                let expected = [
4239                    ("grouped_threshold_member_a", "member_a_one_loop", 0, "1"),
4240                    ("grouped_threshold_member_b", "member_b_one_loop", 1, "2"),
4241                ];
4242                for (graph, (_, name, subspace_edge, expression)) in
4243                    amplitude.graphs.iter().zip(expected)
4244                {
4245                    let resolved = graph
4246                        .derived_data
4247                        .resolved_threshold_counterterms
4248                        .as_ref()
4249                        .unwrap();
4250                    assert!(!resolved.legacy_equivalent);
4251                    // The bubble has two raised E-surface groups for this energy-edge set. The
4252                    // compact declaration resolves once per group, with IDs scoped to this graph
4253                    // member rather than allocated across the owning amplitude group.
4254                    assert_eq!(resolved.variants.len(), 2);
4255                    assert_eq!(
4256                        resolved
4257                            .variants
4258                            .keys()
4259                            .map(|variant_id| variant_id.0)
4260                            .collect::<Vec<_>>(),
4261                        [0, 1]
4262                    );
4263                    for variant in &resolved.variants {
4264                        assert_eq!(variant.name, name);
4265                        assert_eq!(
4266                            variant.requested_subspace,
4267                            Some(vec![super::EdgeIndex(subspace_edge)])
4268                        );
4269                        assert_eq!(
4270                            variant
4271                                .subspace
4272                                .iter_basis_edges(graph.derived_data.lmbs.as_ref().unwrap())
4273                                .collect::<Vec<_>>(),
4274                            vec![super::EdgeIndex(subspace_edge)]
4275                        );
4276                        assert_eq!(variant.multiplier.as_ref().unwrap().expression, expression);
4277                    }
4278                }
4279
4280                for include_autogenerated_fields in [false, true] {
4281                    let mut exported = String::new();
4282                    amplitude
4283                        .write_dot_fmt(
4284                            &mut exported,
4285                            &DotExportSettings {
4286                                include_autogenerated_fields,
4287                                ..DotExportSettings::default()
4288                            },
4289                        )
4290                        .unwrap();
4291                    let exported_graphs = Graph::from_string(&exported, &model).unwrap();
4292                    assert_eq!(exported_graphs.len(), 2);
4293                    for (graph, (graph_name, name, subspace_edge, expression)) in
4294                        exported_graphs.iter().zip(expected)
4295                    {
4296                        assert_eq!(graph.name, graph_name);
4297                        let spec = &graph.threshold_counterterms;
4298                        assert!(!spec.autogenerated);
4299                        assert_eq!(spec.cuts.len(), 1);
4300                        assert!(spec.cuts[0].edges.is_empty());
4301                        let variants = &spec.cuts[0].thresholds[0].counterterms;
4302                        assert_eq!(variants.len(), 1);
4303                        assert_eq!(variants[0].name.as_deref(), Some(name));
4304                        assert_eq!(
4305                            variants[0].subspace,
4306                            Some(vec![super::EdgeIndex(subspace_edge)])
4307                        );
4308                        assert_eq!(
4309                            variants[0].multiplier.as_ref().unwrap().expression,
4310                            expression
4311                        );
4312                    }
4313                }
4314
4315                amplitude
4316                    .build_integrand(
4317                        &model,
4318                        "grouped_threshold_ownership",
4319                        &GlobalSettings {
4320                            generation: generation_settings.clone(),
4321                            ..Default::default()
4322                        },
4323                        (&runtime).into(),
4324                        &pool,
4325                    )
4326                    .unwrap();
4327                let integrand = amplitude.integrand.as_mut().unwrap();
4328                integrand.warm_up(&model).unwrap();
4329
4330                let inspect_ownership = |integrand: &ProcessIntegrand| {
4331                    let ProcessIntegrand::Amplitude(integrand) = integrand else {
4332                        panic!("grouped amplitude fixture built a non-amplitude integrand")
4333                    };
4334                    assert_eq!(integrand.data.graph_group_structure.len(), 1);
4335                    assert_eq!(
4336                        (&integrand.data.graph_group_structure[super::GroupId(0)])
4337                            .into_iter()
4338                            .count(),
4339                        2
4340                    );
4341                    for (term, (graph_name, name, subspace_edge, expression)) in
4342                        integrand.data.graph_terms.iter().zip(expected)
4343                    {
4344                        assert_eq!(term.graph.name, graph_name);
4345                        let counterterm = &term.threshold_counterterm;
4346                        assert!(!counterterm.legacy_equivalent);
4347                        assert_eq!(counterterm.variant_metadata.len(), 2);
4348                        for (variant_id, variant) in counterterm.variant_metadata.iter_enumerated()
4349                        {
4350                            assert!(variant_id.0 < 2);
4351                            assert_eq!(variant.name, name);
4352                            assert_eq!(
4353                                variant.requested_subspace,
4354                                Some(vec![super::EdgeIndex(subspace_edge)])
4355                            );
4356                        }
4357                        let multipliers = counterterm.threshold_multipliers.as_ref().unwrap();
4358                        assert_eq!(multipliers.left_variants().len(), 2);
4359                        for (variant_id, reference) in
4360                            multipliers.left_variants().iter().enumerate()
4361                        {
4362                            assert_eq!(reference.variant_id.0, variant_id);
4363                            assert_eq!(reference.evaluator_id.unwrap().0, 0);
4364                        }
4365                        let registry = counterterm.metadata_registry.as_ref().unwrap();
4366                        assert_eq!(registry.graph_name, graph_name);
4367                        assert_eq!(registry.variants.len(), 2);
4368                        for (variant_id, variant) in registry.variants.iter().enumerate() {
4369                            assert_eq!(variant.variant_id, variant_id);
4370                            assert_eq!(variant.name, name);
4371                            assert_eq!(variant.resolved_subspace, [subspace_edge]);
4372                        }
4373                        assert_eq!(registry.evaluators.len(), 1);
4374                        assert_eq!(registry.evaluators[0].evaluator_id, 0);
4375                        assert_eq!(registry.evaluators[0].variant_ids, [0, 1]);
4376                        assert_eq!(registry.evaluators[0].expression, expression);
4377                        assert!(registry.components.iter().all(|component| {
4378                            component.variant_ids.len() == 1
4379                                && component.variant_ids[0] < 2
4380                                && component.evaluator_ids == [Some(0)]
4381                        }));
4382                    }
4383                };
4384                inspect_ownership(integrand);
4385
4386                let evaluate_members = |integrand: &mut ProcessIntegrand| {
4387                    (0..2)
4388                        .map(|graph_id| {
4389                            let registry = match integrand {
4390                                ProcessIntegrand::Amplitude(amplitude_integrand) => {
4391                                    amplitude_integrand.data.graph_terms[graph_id]
4392                                        .threshold_counterterm
4393                                        .metadata_registry
4394                                        .clone()
4395                                        .unwrap()
4396                                }
4397                                ProcessIntegrand::CrossSection(_) => unreachable!(),
4398                            };
4399                            let result = integrand
4400                                .evaluate_momentum_configuration(
4401                                    &model,
4402                                    &MomentumSpaceEvaluationInput {
4403                                        loop_momenta: vec![crate::momentum::ThreeMomentum::new(
4404                                            F(0.35),
4405                                            F(-0.2),
4406                                            F(0.45),
4407                                        )],
4408                                        integrator_weight: F(1.0),
4409                                        graph_id: Some(graph_id),
4410                                        group_id: None,
4411                                        orientation: None,
4412                                        channel_id: None,
4413                                    },
4414                                    false,
4415                                )
4416                                .unwrap();
4417                            let events = result
4418                                .event_groups
4419                                .iter()
4420                                .flat_map(|group| group.iter())
4421                                .collect::<Vec<_>>();
4422                            assert_eq!(events.len(), 1);
4423                            let event = events[0];
4424                            let decomposition = event
4425                                .additional_weights
4426                                .threshold_counterterms
4427                                .as_ref()
4428                                .expect("each grouped member must record its own decomposition");
4429                            assert_eq!(decomposition.total(), event.weight);
4430                            assert!(!decomposition.components.is_empty());
4431                            let expected_multiplier = F(if graph_id == 0 { 1.0 } else { 2.0 });
4432                            for component in &decomposition.components {
4433                                let metadata = &registry.components[component.component_id];
4434                                assert_eq!(metadata.variant_ids.len(), 1);
4435                                assert!(metadata.variant_ids[0] < 2);
4436                                assert_eq!(metadata.evaluator_ids, [Some(0)]);
4437                                assert_eq!(
4438                                    component.multiplier_values.as_slice(),
4439                                    [expected_multiplier]
4440                                );
4441                                assert_eq!(component.effective_multiplier, expected_multiplier);
4442                            }
4443                            (
4444                                event.weight,
4445                                decomposition
4446                                    .components
4447                                    .iter()
4448                                    .map(|component| {
4449                                        (
4450                                            component.component_id,
4451                                            component.multiplier_values.clone(),
4452                                            component.effective_multiplier,
4453                                            component.bare,
4454                                            component.weighted,
4455                                            component.evaluation_skipped,
4456                                        )
4457                                    })
4458                                    .collect::<Vec<_>>(),
4459                            )
4460                        })
4461                        .collect::<Vec<_>>()
4462                };
4463                let before_save = evaluate_members(integrand);
4464
4465                let unique = SystemTime::now()
4466                    .duration_since(UNIX_EPOCH)
4467                    .unwrap()
4468                    .as_nanos();
4469                let save_root = std::env::temp_dir().join(format!(
4470                    "gammalooprs-grouped-amplitude-threshold-{}-{unique}",
4471                    std::process::id(),
4472                ));
4473                amplitude.save(&save_root, true).unwrap();
4474                let mut state_bytes = Vec::new();
4475                State::export(&mut state_bytes).unwrap();
4476                let state_map = State::import(&mut Cursor::new(state_bytes), None).unwrap();
4477                let context = GammaLoopContextContainer {
4478                    model: &model,
4479                    state_map: &state_map,
4480                };
4481                let mut loaded =
4482                    super::Amplitude::load(save_root.join("grouped_threshold_ownership"), context)
4483                        .unwrap();
4484                let loaded_integrand = loaded.integrand.as_mut().unwrap();
4485                loaded_integrand.warm_up(&model).unwrap();
4486                inspect_ownership(loaded_integrand);
4487                let after_load = evaluate_members(loaded_integrand);
4488                assert_eq!(after_load, before_save);
4489                fs::remove_dir_all(&save_root).unwrap();
4490            })
4491            .expect("grouped-amplitude threshold ownership test thread must start")
4492            .join()
4493            .expect("grouped-amplitude threshold ownership test must finish successfully");
4494    }
4495
4496    #[test]
4497    fn raised_amplitude_constituents_reject_different_group_ids() {
4498        test_initialise().unwrap();
4499        let model = crate::model::Model::from_file(
4500            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4501                .join("../../assets/models/json/scalars/scalars_2p_3p.json"),
4502        )
4503        .unwrap();
4504        let mut graph = AmplitudeGraph::new(
4505            Graph::from_string(
4506                include_str!("../../../../tests/resources/graphs/dotted_bubble_amp.dot"),
4507                &model,
4508            )
4509            .unwrap()
4510            .remove(0),
4511        );
4512        // These two topological thresholds are constituents of the same double pole.
4513        // Splitting the shared semantic variant between solve groups would give the
4514        // single raised residue two inconsistent prescriptions.
4515        graph.graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
4516            schema_version: 1,
4517            function_map: Default::default(),
4518            cuts: vec![ThresholdCountertermCut {
4519                edges: Vec::new(),
4520                thresholds: [1, 2]
4521                    .into_iter()
4522                    .enumerate()
4523                    .map(|(group_id, edge)| ThresholdCountertermThreshold {
4524                        edges: vec![super::EdgeIndex(edge), super::EdgeIndex(3)],
4525                        counterterms: vec![ThresholdCountertermVariant {
4526                            name: Some("shared_raised_variant".to_string()),
4527                            group_id: Some(group_id),
4528                            subspace: Some(vec![super::EdgeIndex(3)]),
4529                            parent_lmb: Some(vec![super::EdgeIndex(3)]),
4530                            disable: false,
4531                            multiplier: None,
4532                        }],
4533                    })
4534                    .collect(),
4535            }],
4536        });
4537        let settings = GenerationSettings {
4538            threshold_subtraction: ThresholdSubtractionSettings {
4539                enable_thresholds: true,
4540                assume_positive_external_energies: false,
4541                check_esurface_at_generation: false,
4542                ..Default::default()
4543            },
4544            ..Default::default()
4545        };
4546        graph.generate_cff(&settings).unwrap();
4547        graph.build_lmbs();
4548        let raised_data = graph.graph.determine_raised_esurfaces_from_expression(
4549            &graph
4550                .derived_data
4551                .cff_expression
4552                .as_ref()
4553                .unwrap()
4554                .expression,
4555        );
4556        let error = graph
4557            .resolve_amplitude_threshold_counterterm_directives(
4558                &raised_data,
4559                &settings,
4560                &(&RuntimeSettings::default()).into(),
4561                &model,
4562            )
4563            .unwrap_err()
4564            .to_string();
4565        for detail in [
4566            "Constituents of one raised residue",
4567            "[EdgeIndex(1), EdgeIndex(3)]",
4568            "[EdgeIndex(2), EdgeIndex(3)]",
4569            "group_id=Some(0)",
4570            "group_id=Some(1)",
4571            "shared_raised_variant",
4572        ] {
4573            assert!(error.contains(detail), "missing {detail}: {error}");
4574        }
4575    }
4576
4577    #[test]
4578    fn generalized_amplitude_directive_preserves_raised_threshold_order_and_components() {
4579        std::thread::Builder::new()
4580            .name("generalized-raised-amplitude-threshold-test".to_string())
4581            .stack_size(32 * 1024 * 1024)
4582            .spawn(|| {
4583                test_initialise().unwrap();
4584                let model = crate::model::Model::from_file(
4585                    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4586                        .join("../../assets/models/json/scalars/scalars_2p_3p.json"),
4587                )
4588                .unwrap();
4589                let mut graph = Graph::from_string(
4590                    include_str!("../../../../tests/resources/graphs/dotted_bubble_amp.dot"),
4591                    &model,
4592                )
4593                .unwrap()
4594                .remove(0);
4595                let mut generation_settings = GenerationSettings {
4596                    threshold_subtraction: ThresholdSubtractionSettings {
4597                        enable_thresholds: true,
4598                        assume_positive_external_energies: false,
4599                        check_esurface_at_generation: false,
4600                        ..Default::default()
4601                    },
4602                    ..Default::default()
4603                };
4604                generation_settings
4605                    .tropical_subgraph_table
4606                    .disable_tropical_generation = true;
4607
4608                let mut legacy_graph = AmplitudeGraph::new(graph.clone());
4609                legacy_graph
4610                    .preprocess(
4611                        &model,
4612                        &generation_settings,
4613                        &(&RuntimeSettings::default()).into(),
4614                    )
4615                    .unwrap();
4616                let expected_thresholds = [
4617                    vec![super::EdgeIndex(1), super::EdgeIndex(3)],
4618                    vec![super::EdgeIndex(2), super::EdgeIndex(3)],
4619                ];
4620                let legacy_resolved = legacy_graph
4621                    .derived_data
4622                    .resolved_threshold_counterterms
4623                    .as_ref()
4624                    .unwrap();
4625                assert_eq!(legacy_resolved.variants.len(), 2);
4626                for variant in &legacy_resolved.variants {
4627                    assert_eq!(variant.raised_esurface_group.max_occurence, 2);
4628                    assert_eq!(
4629                        variant
4630                            .associations
4631                            .iter()
4632                            .map(|association| association.threshold_edges.clone())
4633                            .collect::<Vec<_>>(),
4634                        expected_thresholds
4635                    );
4636                    assert_eq!(variant.subspace_loop_count, 1);
4637                    assert_eq!(
4638                        variant
4639                            .subspace
4640                            .iter_basis_edges(legacy_graph.derived_data.lmbs.as_ref().unwrap())
4641                            .collect::<Vec<_>>(),
4642                        [super::EdgeIndex(3)]
4643                    );
4644                }
4645
4646                let explicit_variant = ThresholdCountertermVariant {
4647                    group_id: None,
4648                    name: Some("raised_one_loop".to_string()),
4649                    subspace: Some(vec![super::EdgeIndex(3)]),
4650                    parent_lmb: Some(vec![super::EdgeIndex(3)]),
4651                    disable: false,
4652                    multiplier: Some(ThresholdCountertermMultiplier {
4653                        expression: "2".to_string(),
4654                        function_map: Default::default(),
4655                        symmetrize: false,
4656                        opaque_derivatives: true,
4657                    }),
4658                };
4659                graph.threshold_counterterms = Autogen::explicit(ThresholdCountertermSpec {
4660                    schema_version: 1,
4661                    function_map: Default::default(),
4662                    cuts: vec![ThresholdCountertermCut {
4663                        edges: Vec::new(),
4664                        thresholds: expected_thresholds
4665                            .iter()
4666                            .map(|edges| ThresholdCountertermThreshold {
4667                                edges: edges.clone(),
4668                                counterterms: vec![explicit_variant.clone()],
4669                            })
4670                            .collect(),
4671                    }],
4672                });
4673                let mut runtime = RuntimeSettings {
4674                    kinematics: KinematicsSettings {
4675                        e_cm: 4.0,
4676                        externals: Externals::Constant {
4677                            momenta: vec![
4678                                ExternalMomenta::Independent([
4679                                    F(4.0),
4680                                    F(0.0),
4681                                    F(0.0),
4682                                    F(0.0),
4683                                ]),
4684                                ExternalMomenta::Dependent(Dep::Dep),
4685                            ],
4686                            helicities: vec![Helicity::ZERO; 2],
4687                            improvement_settings: PhaseSpaceImprovementSettings::default(),
4688                            f_64_cache: None,
4689                            f_128_cache: None,
4690                            arb_cache: Default::default(),
4691                        },
4692                    },
4693                    ..RuntimeSettings::default()
4694                };
4695                runtime.general.generate_events = true;
4696                runtime.general.store_additional_weights_in_event = true;
4697                let pool = rayon::ThreadPoolBuilder::new()
4698                    .num_threads(1)
4699                    .build()
4700                    .unwrap();
4701                let mut amplitude = super::Amplitude::from_graph_list(
4702                    "generalized_raised_amplitude_threshold",
4703                    vec![graph],
4704                )
4705                .unwrap();
4706                amplitude
4707                    .preprocess(&model, &generation_settings, &(&runtime).into(), &pool)
4708                    .unwrap();
4709                let resolved = amplitude.graphs[0]
4710                    .derived_data
4711                    .resolved_threshold_counterterms
4712                    .as_ref()
4713                    .unwrap();
4714                assert!(!resolved.legacy_equivalent);
4715                assert_eq!(resolved.variants.len(), 2);
4716                for variant in &resolved.variants {
4717                    assert_eq!(variant.name, "raised_one_loop");
4718                    assert_eq!(variant.raised_esurface_group.max_occurence, 2);
4719                    assert_eq!(variant.subspace_loop_count, 1);
4720                    assert_eq!(variant.multiplier.as_ref().unwrap().expression, "2");
4721                }
4722                for symbolic in &amplitude.graphs[0]
4723                    .derived_data
4724                    .threshold_counterterm_variants
4725                {
4726                    let orders = symbolic
4727                        .atom
4728                        .parametric
4729                        .iter()
4730                        .filter_map(|(index, _)| index.left_threshold_order)
4731                        .collect::<std::collections::BTreeSet<_>>();
4732                    assert_eq!(orders, [1, 2].into_iter().collect::<std::collections::BTreeSet<_>>());
4733                }
4734
4735                amplitude
4736                    .build_integrand(
4737                        &model,
4738                        "generalized_raised_amplitude_threshold",
4739                        &GlobalSettings {
4740                            generation: generation_settings.clone(),
4741                            ..Default::default()
4742                        },
4743                        (&runtime).into(),
4744                        &pool,
4745                    )
4746                    .unwrap();
4747                let inspect_raised_runtime = |integrand: &ProcessIntegrand| {
4748                    let ProcessIntegrand::Amplitude(integrand) = integrand else {
4749                        panic!("raised amplitude fixture built a non-amplitude integrand")
4750                    };
4751                    let counterterm = &integrand.data.graph_terms[0].threshold_counterterm;
4752                    assert!(!counterterm.legacy_equivalent);
4753                    assert_eq!(counterterm.variant_evaluators.len(), 2);
4754                    for (variant_id, evaluator) in
4755                        counterterm.variant_evaluators.iter_enumerated()
4756                    {
4757                        assert_eq!(
4758                            evaluator
4759                                .evaluator_stacks
4760                                .keys()
4761                                .filter_map(|index| index.left_threshold_order)
4762                                .collect::<std::collections::BTreeSet<_>>(),
4763                            [1, 2].into_iter().collect::<std::collections::BTreeSet<_>>()
4764                        );
4765                        let helpers = &counterterm.variant_helper_evaluators[variant_id];
4766                        assert_eq!(helpers.len(), 2);
4767                        assert!(helpers.iter().all(|helper| helper.exprs_len == 2));
4768                    }
4769
4770                    let registry = counterterm.metadata_registry.as_ref().unwrap();
4771                    assert_eq!(registry.variants.len(), 2);
4772                    assert!(registry.variants.iter().all(|variant| {
4773                        variant.name == "raised_one_loop"
4774                            && variant.subspace_loop_count == 1
4775                            && variant.resolved_subspace == [3]
4776                    }));
4777                    assert_eq!(registry.evaluators.len(), 1);
4778                    assert_eq!(registry.evaluators[0].variant_ids, [0, 1]);
4779                    assert_eq!(registry.evaluators[0].expression, "2");
4780                    assert_eq!(registry.components.len(), 4);
4781                    for variant_id in 0..2 {
4782                        assert_eq!(
4783                            registry
4784                                .components
4785                                .iter()
4786                                .filter(|component| component.variant_ids == [variant_id])
4787                                .map(|component| component.kind)
4788                                .collect::<std::collections::BTreeSet<_>>(),
4789                            [
4790                                ThresholdCountertermComponentKind::Local,
4791                                ThresholdCountertermComponentKind::Integrated,
4792                            ]
4793                            .into_iter()
4794                            .collect::<std::collections::BTreeSet<_>>()
4795                        );
4796                    }
4797                    registry.clone()
4798                };
4799                let registry_before_save =
4800                    inspect_raised_runtime(amplitude.integrand.as_ref().unwrap());
4801
4802                let evaluate_raised = |integrand: &mut ProcessIntegrand| {
4803                    let result = integrand
4804                        .evaluate_momentum_configuration(
4805                            &model,
4806                            &MomentumSpaceEvaluationInput {
4807                                loop_momenta: vec![crate::momentum::ThreeMomentum::new(
4808                                    F(0.35),
4809                                    F(-0.2),
4810                                    F(0.45),
4811                                )],
4812                                integrator_weight: F(1.0),
4813                                graph_id: Some(0),
4814                                group_id: None,
4815                                orientation: None,
4816                                channel_id: None,
4817                            },
4818                            false,
4819                        )
4820                        .unwrap();
4821                    let events = result
4822                        .event_groups
4823                        .iter()
4824                        .flat_map(|group| group.iter())
4825                        .collect::<Vec<_>>();
4826                    assert_eq!(events.len(), 1);
4827                    let event = events[0];
4828                    let decomposition = event
4829                        .additional_weights
4830                        .threshold_counterterms
4831                        .as_ref()
4832                        .expect("raised generalized threshold must record completed L/I pieces");
4833                    assert_eq!(decomposition.total(), event.weight);
4834                    assert_eq!(decomposition.components.len(), 2);
4835                    assert_eq!(
4836                        decomposition
4837                            .components
4838                            .iter()
4839                            .map(|component| {
4840                                registry_before_save.components[component.component_id].kind
4841                            })
4842                            .collect::<std::collections::BTreeSet<_>>(),
4843                        [
4844                            ThresholdCountertermComponentKind::Local,
4845                            ThresholdCountertermComponentKind::Integrated,
4846                        ]
4847                        .into_iter()
4848                        .collect::<std::collections::BTreeSet<_>>()
4849                    );
4850                    for component in &decomposition.components {
4851                        let metadata = &registry_before_save.components[component.component_id];
4852                        assert_eq!(metadata.variant_ids.len(), 1);
4853                        assert!(metadata.variant_ids[0] < 2);
4854                        assert_eq!(metadata.evaluator_ids, [Some(0)]);
4855                        assert_eq!(component.multiplier_values.as_slice(), [F(2.0)]);
4856                        assert_eq!(component.effective_multiplier, F(2.0));
4857                        assert!(component.bare.is_some());
4858                        assert!(!component.evaluation_skipped);
4859                        let crate::observables::ThresholdCountertermComponentOccurrence::Amplitude {
4860                            raised_esurface_id,
4861                            overlap_group,
4862                        } = &component.occurrence
4863                        else {
4864                            panic!("amplitude fixture recorded a non-amplitude occurrence")
4865                        };
4866                        assert!(event.additional_weights.weights.contains_key(
4867                            &crate::observables::AdditionalWeightKey::AmplitudeThresholdCountertermVariant {
4868                                variant_id: metadata.variant_ids[0],
4869                                esurface_id: *raised_esurface_id,
4870                                overlap_group: *overlap_group,
4871                            },
4872                        ));
4873                    }
4874                    (
4875                        event.weight,
4876                        decomposition.original,
4877                        decomposition
4878                            .components
4879                            .iter()
4880                            .map(|component| {
4881                                (
4882                                    component.component_id,
4883                                    component.occurrence.clone(),
4884                                    component.multiplier_values.clone(),
4885                                    component.effective_multiplier,
4886                                    component.bare,
4887                                    component.weighted,
4888                                    component.evaluation_skipped,
4889                                )
4890                            })
4891                            .collect::<Vec<_>>(),
4892                    )
4893                };
4894                let integrand = amplitude.integrand.as_mut().unwrap();
4895                integrand.warm_up(&model).unwrap();
4896                let before_save = evaluate_raised(integrand);
4897
4898                let unique = SystemTime::now()
4899                    .duration_since(UNIX_EPOCH)
4900                    .unwrap()
4901                    .as_nanos();
4902                let save_root = std::env::temp_dir().join(format!(
4903                    "gammalooprs-raised-amplitude-threshold-{}-{unique}",
4904                    std::process::id(),
4905                ));
4906                amplitude.save(&save_root, true).unwrap();
4907                let mut state_bytes = Vec::new();
4908                State::export(&mut state_bytes).unwrap();
4909                let state_map = State::import(&mut Cursor::new(state_bytes), None).unwrap();
4910                let context = GammaLoopContextContainer {
4911                    model: &model,
4912                    state_map: &state_map,
4913                };
4914                let mut loaded = super::Amplitude::load(
4915                    save_root.join("generalized_raised_amplitude_threshold"),
4916                    context,
4917                )
4918                .unwrap();
4919                let loaded_integrand = loaded.integrand.as_mut().unwrap();
4920                assert_eq!(
4921                    inspect_raised_runtime(loaded_integrand),
4922                    registry_before_save
4923                );
4924                loaded_integrand.warm_up(&model).unwrap();
4925                assert_eq!(evaluate_raised(loaded_integrand), before_save);
4926                fs::remove_dir_all(&save_root).unwrap();
4927            })
4928            .unwrap()
4929            .join()
4930            .unwrap();
4931    }
4932
4933    #[test]
4934    fn amplitude_tree() {
4935        test_initialise().unwrap();
4936        let mut graph: AmplitudeGraph = dot!(digraph qqx_aaa_tree_1 {
4937                num="spenso::g(spenso::dind(spenso::cof(3, hedge(1))), spenso::cof(3, hedge(2)))/3"
4938                ext    [style=invis]
4939                ext -> v1:1 [particle="d" id=1];
4940                ext -> v3:2 [particle="d~" id=2];
4941                v1:3 -> ext [particle="a" id=3];
4942                v2:4 -> ext [particle="a" id=4];
4943                v3:0 -> ext [particle="a" id=0];
4944                v1 -> v2 [particle="d" id=5];
4945                v2 -> v3 [particle="d" id=6];
4946    })
4947    .unwrap();
4948
4949        let _model = load_generic_model("sm");
4950
4951        graph.generate_cff(&GenerationSettings::default()).unwrap();
4952        // graph.build_parametric_integrand(&GenerationSettings::default());
4953
4954        let param_builder = &graph.graph.param_builder;
4955        println!("{param_builder}");
4956
4957        // GenericEvaluator::new_from_builder(
4958        //     [GS.orientation_delta(&EdgeVec::from_iter(vec![Orientation::Default; 7]))],
4959        //     param_builder,
4960        //     None,
4961        //     OptimizationSettings::default(),
4962        //     true,
4963        // )
4964        // .unwrap();
4965        // println!(" {}", a);
4966    }
4967
4968    #[test]
4969    fn uncut_amplitude_generation_has_one_residue_for_both_uv_backends() {
4970        test_initialise().unwrap();
4971        let mut amplitude_graph: AmplitudeGraph = dot!(
4972            digraph bub {
4973                edge [particle=scalar_1]
4974                node [num=1]
4975                e [style=invis]
4976                e -> A:0 [id=3]
4977                B:1 -> e [id=2]
4978                A -> B [id=1]
4979                A -> B [id=0]
4980            },
4981            "scalars"
4982        )
4983        .unwrap();
4984        let generation_settings = GenerationSettings::default();
4985        amplitude_graph.generate_cff(&generation_settings).unwrap();
4986        let production = amplitude_graph
4987            .derived_data
4988            .cff_expression
4989            .as_ref()
4990            .unwrap();
4991        let options = amplitude_graph
4992            .graph
4993            .production_cff_3d_expression_options(&generation_settings)
4994            .unwrap();
4995
4996        for orchestrator in [UVOrchestrator::LegacyDagForest, UVOrchestrator::HedgePoset] {
4997            let mut graph = amplitude_graph.graph.clone();
4998            let cut_structure = CutStructure::empty(&graph);
4999            assert_eq!(cut_structure.cuts.len(), 1);
5000            let expected_cut = cut_structure.cuts[0].clone();
5001            let settings = GenerationSettings {
5002                uv: UVgenerationSettings {
5003                    generate_integrated: false,
5004                    softct: false,
5005                    orchestrator,
5006                    ..Default::default()
5007                },
5008                ..Default::default()
5009            };
5010            let generated = orchestrator
5011                .parametric_integrands(
5012                    &mut graph,
5013                    cut_structure,
5014                    crate::utils::vakint().unwrap(),
5015                    OrientationProjection::exact_expression(
5016                        production,
5017                        &options,
5018                        &generation_settings.orientation_pattern,
5019                        generation_settings.explicit_orientation_sum_only,
5020                    ),
5021                    &settings,
5022                )
5023                .unwrap();
5024
5025            assert_eq!(generated.len(), 1, "{orchestrator}");
5026            assert_eq!(generated[0].cuts, expected_cut, "{orchestrator}");
5027            let residues = generated[0].integrands.iter().collect::<Vec<_>>();
5028            assert_eq!(residues.len(), 1, "{orchestrator}");
5029            assert_eq!(*residues[0].0, CutCFFIndex::new_all_none());
5030        }
5031    }
5032
5033    #[test]
5034    fn generation_orientation_pattern_filters_evaluator_orientations() {
5035        test_initialise().unwrap();
5036        let mut graph: AmplitudeGraph = dot!(
5037            digraph bub {
5038                edge [particle=scalar_1]
5039                node [num=1]
5040                e [style=invis]
5041                e -> A:0 [id=3]
5042                B:1 -> e [id=2]
5043                A -> B [id=1]
5044                A -> B [id=0]
5045            },
5046            "scalars"
5047        )
5048        .unwrap();
5049
5050        let model = load_generic_model("scalars");
5051        let generation_settings = GenerationSettings {
5052            threshold_subtraction: ThresholdSubtractionSettings {
5053                enable_thresholds: false,
5054                ..Default::default()
5055            },
5056            ..Default::default()
5057        };
5058        let runtime_settings = RuntimeSettings::default();
5059        graph
5060            .preprocess(&model, &generation_settings, &(&runtime_settings).into())
5061            .unwrap();
5062
5063        assert!(
5064            graph
5065                .derived_data
5066                .cff_expression
5067                .as_ref()
5068                .unwrap()
5069                .expression
5070                .orientations
5071                .len()
5072                > 1
5073        );
5074
5075        let global_settings = GlobalSettings {
5076            generation: GenerationSettings {
5077                orientation_pattern: OrientationPattern::from_orientation(
5078                    &graph
5079                        .derived_data
5080                        .cff_expression
5081                        .as_ref()
5082                        .unwrap()
5083                        .expression
5084                        .orientations[OrientationID(0)],
5085                ),
5086                threshold_subtraction: ThresholdSubtractionSettings {
5087                    enable_thresholds: false,
5088                    ..Default::default()
5089                },
5090                ..Default::default()
5091            },
5092            ..Default::default()
5093        };
5094
5095        let (term, _stats) = AmplitudeGraphTerm::from_amplitude_graph(
5096            &graph,
5097            GraphGroupPosition(0),
5098            TiVec::new(),
5099            &model,
5100            &global_settings,
5101        )
5102        .unwrap();
5103
5104        assert_eq!(term.orientations.len(), 1);
5105        assert_eq!(
5106            term.orientations[OrientationID(0)],
5107            graph
5108                .derived_data
5109                .cff_expression
5110                .as_ref()
5111                .unwrap()
5112                .expression
5113                .orientations[OrientationID(0)]
5114            .data
5115            .orientation
5116        );
5117    }
5118}