Skip to main content

gammalooprs/processes/
process.rs

1use ahash::HashMap;
2use ahash::HashSet;
3use linnet::half_edge::involution::Flow;
4use linnet::half_edge::involution::HedgePair;
5use linnet::half_edge::involution::Orientation;
6use rayon::ThreadPool;
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    fs::{self, File},
10    io::Write,
11    path::{Path, PathBuf},
12};
13use tracing::warn;
14// use bincode::{Decode, Encode};
15use bincode_trait_derive::{Decode, Encode};
16use color_eyre::{Help, Result};
17use itertools::Itertools;
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use std::fmt;
21use tracing::debug;
22
23use crate::graph::FeynmanGraph;
24use crate::graph::edge::PossibleParticle;
25use crate::processes::DotExportSettings;
26
27use crate::processes::StandaloneExportSettings;
28use crate::{
29    GammaLoopContext, GammaLoopContextContainer,
30    feyngen::NumeratorAwareGraphGroupingOption,
31    integrands::process::ProcessIntegrand,
32    numerator::GlobalPrefactor,
33    settings::{GlobalSettings, RuntimeSettings, runtime::LockedRuntimeSettings},
34    uv::export::{UVForestExportSettings, sanitize_file_component},
35};
36use eyre::{Context, eyre};
37
38use crate::{
39    feyngen::{FeynGenFilters, GenerationType},
40    graph::Graph,
41    model::Model,
42    settings::global::GenerationSettings,
43};
44
45use super::{
46    Amplitude, CrossSection, GeneratedGraphReport, GenerationProcessKind, GenerationProgressPhase,
47    NamedGraphGenerationReport, generation_progress,
48};
49
50const SETTINGS_HISTORY_TOML: &str = "settings_history.toml";
51const SETTINGS_HISTORY_YAML: &str = "settings_history.yaml";
52
53pub struct ResolvedIntegrandRef<'a> {
54    pub canonical_name: String,
55    pub integrand: Option<&'a ProcessIntegrand>,
56}
57
58impl<'a> ResolvedIntegrandRef<'a> {
59    pub fn get_settings(&self) -> Option<&'a RuntimeSettings> {
60        self.integrand.map(ProcessIntegrand::get_settings)
61    }
62
63    pub fn require_generated(&self) -> Result<&'a ProcessIntegrand> {
64        self.integrand.ok_or_else(|| {
65            eyre!(
66                "Integrand {} has not yet been generated, but exists",
67                self.canonical_name
68            )
69        })
70    }
71}
72
73fn create_overwriting_file(path: &Path, file_kind: &str) -> Result<File> {
74    if path.exists() {
75        if path.is_dir() {
76            fs::remove_dir_all(path).with_context(|| {
77                format!(
78                    "Trying to remove existing directory before exporting {file_kind} {}",
79                    path.display()
80                )
81            })?;
82        } else {
83            fs::remove_file(path).with_context(|| {
84                format!(
85                    "Trying to remove existing file before exporting {file_kind} {}",
86                    path.display()
87                )
88            })?;
89        }
90    }
91
92    File::create(path).with_context(|| {
93        format!(
94            "Trying to create file to export {file_kind} {}",
95            path.display()
96        )
97    })
98}
99
100fn load_settings_history(path: &Path) -> Result<Option<GlobalSettings>> {
101    let settings_history_toml = path.join(SETTINGS_HISTORY_TOML);
102    if settings_history_toml.exists() {
103        let settings_history_raw =
104            fs::read_to_string(&settings_history_toml).with_context(|| {
105                format!(
106                    "Error reading process settings history file {}",
107                    settings_history_toml.display()
108                )
109            })?;
110        let settings_history = toml::from_str(&settings_history_raw).with_context(|| {
111            format!(
112                "Error parsing process settings history file {}",
113                settings_history_toml.display()
114            )
115        })?;
116        return Ok(Some(settings_history));
117    }
118
119    let settings_history_yaml = path.join(SETTINGS_HISTORY_YAML);
120    if settings_history_yaml.exists() {
121        warn!(
122            "Using legacy process settings history file {}. Re-save state to migrate to {}.",
123            settings_history_yaml.display(),
124            SETTINGS_HISTORY_TOML
125        );
126        let settings_history = serde_yaml::from_reader(File::open(&settings_history_yaml)?)
127            .with_context(|| {
128                format!(
129                    "Error parsing legacy process settings history file {}",
130                    settings_history_yaml.display()
131                )
132            })?;
133        return Ok(Some(settings_history));
134    }
135
136    Ok(None)
137}
138
139pub(crate) fn saved_child_dirs(
140    root: &Path,
141    expected_binary: &str,
142    kind: &str,
143) -> Result<Vec<PathBuf>> {
144    let mut saved_dirs = Vec::new();
145
146    for entry in fs::read_dir(root).with_context(|| format!("Error reading {}", root.display()))? {
147        let Ok(entry) = entry else {
148            debug!("Error reading entry");
149            continue;
150        };
151        if !entry.file_type()?.is_dir() {
152            continue;
153        }
154
155        let path = entry.path();
156        if !path.join(expected_binary).is_file() {
157            debug!(
158                "Skipping helper directory {} while loading {}s because '{}' is missing",
159                path.display(),
160                kind,
161                expected_binary
162            );
163            continue;
164        }
165
166        saved_dirs.push(path);
167    }
168
169    saved_dirs.sort();
170    Ok(saved_dirs)
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Encode, Decode)]
174#[trait_decode(trait = GammaLoopContext)]
175pub struct ProcessDefinition {
176    pub generation_type: GenerationType,
177    pub initial_pdgs: Vec<i64>,
178    pub final_pdgs_lists: Vec<Vec<i64>>,
179    pub loop_count_range: (usize, usize),
180    pub symmetrize_initial_states: bool,
181    pub symmetrize_final_states: bool,
182    pub symmetrize_left_right_states: bool,
183    pub allow_symmetrization_of_external_fermions_in_amplitudes: bool,
184    pub max_multiplicity_for_fast_cut_filter: usize,
185    pub amplitude_filters: FeynGenFilters,
186    pub cross_section_filters: FeynGenFilters,
187    pub folder_name: String,
188    pub process_id: usize,
189    pub numerator_grouping: NumeratorAwareGraphGroupingOption,
190    pub filter_self_loop: bool,
191    pub filter_zero_flow_edges: bool,
192    pub graph_prefix: String,
193    pub selected_graphs: Option<Vec<String>>,
194    pub vetoed_graphs: Option<Vec<String>>,
195    pub loop_momentum_bases: Option<HashMap<String, Vec<usize>>>,
196    pub prefactor: GlobalPrefactor,
197}
198
199impl fmt::Display for ProcessDefinition {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        write!(
202            f,
203            "Process #{}: '{}'\nGeneration type: {}{}{}\nInitial PDGs: {:?}{}\nFinal PDGs: {}{}\nLoop count: {}\nAmplitude filters:{}{}\nCross-section filters:{}{}",
204            self.process_id,
205            self.folder_name,
206            self.generation_type,
207            if self.symmetrize_left_right_states {
208                " (left-right symmetrized)"
209            } else {
210                ""
211            },
212            if self.allow_symmetrization_of_external_fermions_in_amplitudes
213                && self.generation_type == GenerationType::Amplitude
214                && (self.symmetrize_initial_states
215                    || self.symmetrize_final_states
216                    || self.symmetrize_left_right_states)
217            {
218                " (allowing fermion symmetrization)"
219            } else {
220                ""
221            },
222            self.initial_pdgs,
223            if self.symmetrize_initial_states {
224                " (symmetrized)"
225            } else {
226                ""
227            },
228            if self.final_pdgs_lists.len() == 1 {
229                format!("{:?}", self.final_pdgs_lists[0])
230            } else {
231                format!(
232                    "[ {} ]",
233                    self.final_pdgs_lists
234                        .iter()
235                        .map(|pdgs| format!("{:?}", pdgs))
236                        .join(" | ")
237                )
238            },
239            if self.symmetrize_final_states {
240                " (symmetrized)"
241            } else {
242                ""
243            },
244            if self.loop_count_range.0 == self.loop_count_range.1 {
245                format!("{}", self.loop_count_range.0)
246            } else {
247                format!("{:?}", self.loop_count_range)
248            },
249            if self.amplitude_filters.0.is_empty() {
250                " None"
251            } else {
252                "\n"
253            },
254            if self.amplitude_filters.0.is_empty() {
255                "".into()
256            } else {
257                self.amplitude_filters
258                    .0
259                    .iter()
260                    .map(|f| format!(" > {}", f))
261                    .collect::<Vec<String>>()
262                    .join("\n")
263            },
264            if self.cross_section_filters.0.is_empty() {
265                " None"
266            } else {
267                "\n"
268            },
269            if self.cross_section_filters.0.is_empty() {
270                "".into()
271            } else {
272                self.cross_section_filters
273                    .0
274                    .iter()
275                    .map(|f| format!(" > {}", f))
276                    .collect::<Vec<String>>()
277                    .join("\n")
278            }
279        )
280    }
281}
282
283impl Default for ProcessDefinition {
284    fn default() -> Self {
285        Self {
286            generation_type: GenerationType::Amplitude,
287            initial_pdgs: vec![],
288            final_pdgs_lists: vec![],
289            loop_count_range: (1, 1),
290            symmetrize_initial_states: false,
291            symmetrize_final_states: false,
292            symmetrize_left_right_states: false,
293            allow_symmetrization_of_external_fermions_in_amplitudes: false,
294            max_multiplicity_for_fast_cut_filter: 6,
295            amplitude_filters: FeynGenFilters(vec![]),
296            cross_section_filters: FeynGenFilters(vec![]),
297            folder_name: "undefined_process".to_string(),
298            process_id: 0,
299            numerator_grouping: NumeratorAwareGraphGroupingOption::NoGrouping,
300            filter_self_loop: true,
301            graph_prefix: "GL".to_string(),
302            selected_graphs: None,
303            vetoed_graphs: None,
304            loop_momentum_bases: None,
305            prefactor: GlobalPrefactor::default(),
306            filter_zero_flow_edges: true,
307        }
308    }
309}
310
311impl ProcessDefinition {
312    pub(crate) fn covariant_cut_states(&self, model: &Model) -> Result<Vec<Vec<i64>>> {
313        if self.generation_type != GenerationType::CrossSection {
314            return Ok(self.final_pdgs_lists.clone());
315        }
316        let states = model.covariant_cut_states(&self.final_pdgs_lists)?;
317        let representatives = self.covariant_cut_representatives(model);
318        for &member in self.final_pdgs_lists.iter().flatten() {
319            if let Some(&physical) = representatives.get(&(member as isize))
320                && member != physical as i64
321            {
322                return Err(eyre!(
323                    "Final-state PDG {member} overlaps the covariant cut multiplet of requested physical PDG {physical}; separate physical-vector and unphysical diagnostic final-state requests so their observable labels remain unambiguous. For imported covariant partner graphs, supply the physical channel with --process-spec; raw graph states do not establish that intent"
324                ));
325            }
326        }
327        for filter in [&self.amplitude_filters, &self.cross_section_filters] {
328            if let Some(vetoes) = filter.get_particle_vetos() {
329                for (&member, &physical) in &representatives {
330                    let anti = model
331                        .get_particle_from_pdg(member)
332                        .get_anti_particle(model)
333                        .pdg_code;
334                    if vetoes.contains(&(member as i64)) || vetoes.contains(&(anti as i64)) {
335                        return Err(eyre!(
336                            "Particle veto removes PDG {member} from the covariant cut multiplet of physical PDG {physical}; retain the complete vector/Goldstone/ghost sector for a physical cross section"
337                        ));
338                    }
339                }
340            }
341        }
342        Ok(states)
343    }
344
345    pub(crate) fn covariant_cut_representatives(&self, model: &Model) -> BTreeMap<isize, isize> {
346        if self.generation_type != GenerationType::CrossSection {
347            return BTreeMap::new();
348        }
349        let (_, unresolved) = self.unresolved_cut_content(model);
350        model
351            .covariant_cut_multiplets
352            .iter()
353            .filter(|(physical, _)| {
354                self.final_pdgs_lists
355                    .iter()
356                    .any(|state| state.contains(physical))
357                    || unresolved
358                        .iter()
359                        .any(|particle| particle.pdg_code as i64 == **physical)
360            })
361            .flat_map(|(&physical, members)| {
362                members
363                    .iter()
364                    .map(move |&member| (member as isize, physical as isize))
365            })
366            .collect()
367    }
368
369    // Best attempt at creating what process definition matches the given graphs
370    pub fn from_graph_list(
371        graphs: &[Graph],
372        generation_type: GenerationType,
373        model: &Model,
374    ) -> Result<Self> {
375        let mut initial_pdgs = HashSet::default();
376
377        for g in graphs {
378            let mut initial_pdgs_of_graph = vec![];
379            match generation_type {
380                GenerationType::Amplitude => {
381                    for (pair, _, edge) in g.iter_edges() {
382                        if matches!(
383                            pair,
384                            HedgePair::Unpaired {
385                                hedge: _,
386                                flow: Flow::Sink
387                            }
388                        ) {
389                            if let PossibleParticle::Particle(particle) = &edge.data.particle {
390                                initial_pdgs_of_graph.push(particle.0.pdg_code as i64);
391                            } else {
392                                debug!("Edge without particle data in initial state");
393                            }
394                        }
395                    }
396                }
397                GenerationType::CrossSection => {
398                    for (_, _, edge) in g.iter_edges_of(&g.initial_state_cut) {
399                        if let PossibleParticle::Particle(particle) = &edge.data.particle {
400                            initial_pdgs_of_graph.push(particle.0.pdg_code as i64);
401                        } else {
402                            debug!("Edge without particle data in initial state");
403                        }
404                    }
405                }
406            }
407
408            initial_pdgs_of_graph.sort();
409            initial_pdgs.insert(initial_pdgs_of_graph);
410        }
411
412        let initial_pdgs = if initial_pdgs.len() == 1 {
413            initial_pdgs.into_iter().next().unwrap()
414        } else {
415            warn!("Multiple initial states found in graphs, setting initial state to empty");
416            vec![]
417        };
418
419        let mut final_states = HashSet::default();
420        match generation_type {
421            GenerationType::Amplitude => {
422                for g in graphs {
423                    let mut final_pdgs_of_graph = vec![];
424                    for (pair, _, edge) in g.iter_edges() {
425                        if matches!(
426                            pair,
427                            HedgePair::Unpaired {
428                                hedge: _,
429                                flow: Flow::Source
430                            }
431                        ) {
432                            if let PossibleParticle::Particle(particle) = &edge.data.particle {
433                                final_pdgs_of_graph.push(particle.0.pdg_code as i64);
434                            } else {
435                                debug!("Edge without particle data in final state");
436                            }
437                        }
438                    }
439                    final_pdgs_of_graph.sort();
440                    final_states.insert(final_pdgs_of_graph);
441                }
442            }
443            GenerationType::CrossSection => {
444                for g in graphs {
445                    let (source_nodes, target_nodes) = g.get_source_and_target();
446                    let st_cuts = g.all_st_cuts_for_cs(
447                        source_nodes,
448                        target_nodes,
449                        &g.get_initial_state_tree(),
450                    );
451                    for (_, cut, _) in st_cuts {
452                        let mut final_pdgs_of_cut = vec![];
453                        for (orientaion, edge) in cut.iter_edges(&g.underlying) {
454                            if let PossibleParticle::Particle(particle) = &edge.data.particle {
455                                if orientaion == Orientation::Reversed {
456                                    final_pdgs_of_cut
457                                        .push(particle.0.get_anti_particle(model).pdg_code as i64);
458                                } else {
459                                    final_pdgs_of_cut.push(particle.0.pdg_code as i64);
460                                }
461                            } else {
462                                debug!("Edge without particle data in final state");
463                            }
464                        }
465                        final_pdgs_of_cut.sort();
466                        final_states.insert(final_pdgs_of_cut);
467                    }
468                }
469            }
470        }
471
472        let final_pdgs_lists = final_states.into_iter().sorted().collect_vec();
473        let mut min_loop_count = usize::MAX;
474        let mut max_loop_count = 0usize;
475
476        for g in graphs {
477            // don't know how the looop count is really intended, for now it doesn't matter I think
478            let lc = g.underlying.cyclotomatic_number(&g.full_filter());
479            if lc < min_loop_count {
480                min_loop_count = lc;
481            }
482            if lc > max_loop_count {
483                max_loop_count = lc;
484            }
485        }
486
487        let loop_count_range = (min_loop_count, max_loop_count);
488
489        Ok(Self {
490            generation_type,
491            initial_pdgs,
492            final_pdgs_lists,
493            loop_count_range,
494            ..Self::default()
495        })
496    }
497}
498
499#[derive(Clone, Encode, Decode)]
500#[trait_decode(trait = GammaLoopContext)]
501pub struct Process {
502    pub definition: ProcessDefinition,
503    pub settings_history: Option<GlobalSettings>,
504    pub collection: ProcessCollection,
505}
506
507impl Process {
508    pub fn warm_up(&mut self, model: &Model) -> Result<()> {
509        self.collection.warm_up(model)
510    }
511    pub fn preprocess(
512        &mut self,
513        model: &Model,
514        settings: &GlobalSettings,
515        locked_runtime_settings: &LockedRuntimeSettings,
516        thread_pool: &ThreadPool,
517    ) -> Result<Vec<GeneratedGraphReport>> {
518        let reports = self.collection.preprocess(
519            model,
520            &self.definition,
521            &settings.generation,
522            locked_runtime_settings,
523            thread_pool,
524        )?;
525        self.settings_history = Some(settings.clone());
526        Ok(self.attach_process_id(reports))
527    }
528
529    fn attach_process_id(
530        &self,
531        reports: Vec<NamedGraphGenerationReport>,
532    ) -> Vec<GeneratedGraphReport> {
533        reports
534            .into_iter()
535            .map(|report| GeneratedGraphReport {
536                process_id: self.definition.process_id,
537                integrand_name: report.integrand_name,
538                graph_name: report.graph_name,
539                stats: report.stats,
540            })
541            .collect()
542    }
543}
544
545impl Process {
546    pub(crate) fn load_amplitude(
547        path: impl AsRef<Path>,
548        context: GammaLoopContextContainer,
549        selected_integrands: Option<&BTreeSet<String>>,
550    ) -> Result<Self> {
551        let binary = fs::read(path.as_ref().join("def.bin")).context(format!(
552            "Error reading def.bin in {}",
553            path.as_ref().display()
554        ))?;
555
556        let settings_history = load_settings_history(path.as_ref())?;
557
558        let (definition, _) =
559            bincode::decode_from_slice_with_context(&binary, bincode::config::standard(), context)
560                .context("Error decoding process definition")?;
561
562        let mut collection = ProcessCollection::new_amplitude();
563        for path in saved_child_dirs(path.as_ref(), "amp.bin", "amplitude")? {
564            if selected_integrands.is_some_and(|selected| {
565                path.file_name()
566                    .and_then(|name| name.to_str())
567                    .is_none_or(|name| !selected.contains(name))
568            }) {
569                continue;
570            }
571            debug!("loading amplitude at {}", path.display());
572            let amp = Amplitude::load(path, context).context("Error loading amplitude")?;
573
574            collection.add_amplitude(amp);
575        }
576
577        Ok(Self {
578            definition,
579            collection,
580            settings_history,
581        })
582    }
583
584    pub(crate) fn load_cross_section(
585        path: impl AsRef<Path>,
586        context: GammaLoopContextContainer,
587        selected_integrands: Option<&BTreeSet<String>>,
588    ) -> Result<Self> {
589        let binary = fs::read(path.as_ref().join("def.bin"))?;
590        let (definition, _) =
591            bincode::decode_from_slice_with_context(&binary, bincode::config::standard(), context)?;
592
593        let mut collection = ProcessCollection::new_cross_section();
594        let settings_history = load_settings_history(path.as_ref())?;
595        for path in saved_child_dirs(path.as_ref(), "cs.bin", "cross section")? {
596            if selected_integrands.is_some_and(|selected| {
597                path.file_name()
598                    .and_then(|name| name.to_str())
599                    .is_none_or(|name| !selected.contains(name))
600            }) {
601                continue;
602            }
603            debug!("loading cross section at {}", path.display());
604            let cs = CrossSection::load(path, context).context("Error loading cross section")?;
605
606            collection.add_cross_section(cs);
607        }
608
609        Ok(Self {
610            definition,
611            collection,
612            settings_history,
613        })
614    }
615
616    pub fn save(&mut self, path: impl AsRef<Path>, override_existing: bool) -> Result<()> {
617        match &mut self.collection {
618            ProcessCollection::Amplitudes(a) => {
619                let p = path.as_ref().join("amplitudes");
620                fs::create_dir_all(&p)?;
621                let p = p.join(PathBuf::from(self.definition.folder_name.clone()));
622
623                let r = fs::create_dir_all(&p).with_context(|| {
624                    format!(
625                        "Trying to create directory to export amplitude dot {}",
626                        p.display()
627                    )
628                });
629                if override_existing {
630                    r?;
631                }
632
633                let binary = bincode::encode_to_vec(&self.definition, bincode::config::standard())?;
634                fs::write(p.join("def.bin"), binary)?;
635
636                if let Some(a) = &self.settings_history {
637                    File::create(p.join(SETTINGS_HISTORY_TOML))?
638                        .write_all(toml::to_string_pretty(a)?.as_bytes())?;
639                }
640
641                for amp in a.values_mut() {
642                    amp.save(&p, override_existing)?;
643                }
644            }
645            ProcessCollection::CrossSections(cs) => {
646                let p = path.as_ref().join("cross_sections");
647                fs::create_dir_all(&p)?;
648                let p = p.join(PathBuf::from(self.definition.folder_name.clone()));
649
650                let r = fs::create_dir_all(&p).with_context(|| {
651                    format!(
652                        "Trying to create directory to save cross section dot {}",
653                        p.display()
654                    )
655                });
656
657                if override_existing {
658                    r?;
659                }
660
661                let binary = bincode::encode_to_vec(&self.definition, bincode::config::standard())?;
662                fs::write(p.join("def.bin"), binary)?;
663
664                if let Some(a) = &self.settings_history {
665                    File::create(p.join(SETTINGS_HISTORY_TOML))?
666                        .write_all(toml::to_string_pretty(a)?.as_bytes())?;
667                }
668
669                for cs in cs.values_mut() {
670                    cs.save(&p, override_existing)?;
671                }
672            }
673        }
674
675        Ok(())
676    }
677
678    pub fn compile(
679        &mut self,
680        path: impl AsRef<Path>,
681        override_existing: bool,
682        integrand_name: Option<String>,
683        thread_pool: &ThreadPool,
684    ) -> Result<Vec<GeneratedGraphReport>> {
685        match &mut self.collection {
686            ProcessCollection::Amplitudes(a) => {
687                let p = path.as_ref().join("amplitudes");
688                fs::create_dir_all(&p)?;
689                let p = p.join(PathBuf::from(self.definition.folder_name.clone()));
690
691                let r = fs::create_dir_all(&p).with_context(|| {
692                    format!(
693                        "Trying to create directory to export amplitude dot {}",
694                        p.display()
695                    )
696                });
697                if override_existing {
698                    r?;
699                }
700
701                let mut reports = Vec::new();
702                for amp in a.values_mut() {
703                    if let Some(int_name) = integrand_name.clone()
704                        && amp.name != int_name
705                    {
706                        continue;
707                    }
708
709                    reports.extend(amp.compile(&p, override_existing, thread_pool)?);
710                }
711                Ok(self.attach_process_id(reports))
712            }
713            ProcessCollection::CrossSections(cs) => {
714                let p = path.as_ref().join("cross_sections");
715                fs::create_dir_all(&p)?;
716                let p = p.join(PathBuf::from(self.definition.folder_name.clone()));
717
718                let r = fs::create_dir_all(&p).with_context(|| {
719                    format!(
720                        "Trying to create directory to export cross section dot {}",
721                        p.display()
722                    )
723                });
724                if override_existing {
725                    r?;
726                }
727
728                let mut reports = Vec::new();
729                for cs in cs.values_mut() {
730                    if let Some(int_name) = integrand_name.clone()
731                        && cs.name != int_name
732                    {
733                        continue;
734                    }
735
736                    reports.extend(cs.compile(&p, override_existing, thread_pool)?);
737                }
738                Ok(self.attach_process_id(reports))
739            }
740        }
741    }
742
743    pub fn activate_loaded_integrand_backends(
744        &mut self,
745        allow_symjit_fallback: bool,
746    ) -> Result<()> {
747        match &mut self.collection {
748            ProcessCollection::Amplitudes(amplitudes) => {
749                for (integrand_name, amplitude) in amplitudes.iter_mut() {
750                    if let Some(integrand) = amplitude.integrand.as_mut()
751                        && let Some(reason) =
752                            integrand.activate_runtime_backends_after_load(allow_symjit_fallback)?
753                    {
754                        warn!(
755                            "Falling back to symjit for integrand '{}' in process #{} ({}) after external compiled evaluator loading failed: {}",
756                            integrand_name,
757                            self.definition.process_id,
758                            self.definition.folder_name,
759                            reason
760                        );
761                    }
762                }
763            }
764            ProcessCollection::CrossSections(cross_sections) => {
765                for (integrand_name, cross_section) in cross_sections.iter_mut() {
766                    if let Some(integrand) = cross_section.integrand.as_mut()
767                        && let Some(reason) =
768                            integrand.activate_runtime_backends_after_load(allow_symjit_fallback)?
769                    {
770                        warn!(
771                            "Falling back to symjit for integrand '{}' in process #{} ({}) after external compiled evaluator loading failed: {}",
772                            integrand_name,
773                            self.definition.process_id,
774                            self.definition.folder_name,
775                            reason
776                        );
777                    }
778                }
779            }
780        }
781
782        Ok(())
783    }
784
785    pub fn get_integrand(
786        &self,
787        integrand_name: impl AsRef<str>,
788    ) -> Result<ResolvedIntegrandRef<'_>> {
789        self.collection.get_integrand(integrand_name)
790    }
791
792    pub fn get_integrand_names(&self) -> Vec<&str> {
793        self.collection.get_integrand_names()
794    }
795
796    pub fn get_integrand_mut(
797        &mut self,
798        integrand_name: impl AsRef<str>,
799    ) -> Result<&mut ProcessIntegrand> {
800        self.collection.get_integrand_mut(integrand_name)
801    }
802
803    pub(crate) fn export_standalone(
804        &self,
805        path: impl AsRef<Path>,
806        settings: &StandaloneExportSettings,
807    ) -> Result<()> {
808        match &self.collection {
809            ProcessCollection::Amplitudes(a) => {
810                let p = path.as_ref().join("amplitudes");
811                let path = p.join(PathBuf::from(self.definition.folder_name.clone()));
812                fs::create_dir_all(&path)?;
813                for amp in a.values() {
814                    // Create a folder for each amplitude
815                    let amp_path = path.join(&amp.name);
816                    fs::create_dir_all(&amp_path).with_context(|| {
817                        format!(
818                            "Trying to create directory for amplitude {}",
819                            amp_path.display()
820                        )
821                    })?;
822
823                    amp.export_standalone(&amp_path, settings)?;
824                }
825            }
826            ProcessCollection::CrossSections(cs) => {
827                let p = path.as_ref().join("cross_sections");
828                let path = p.join(PathBuf::from(self.definition.folder_name.clone()));
829                fs::create_dir_all(&path)?;
830                for cs in cs.values() {
831                    // Create a folder for each cross section
832                    let cs_path = path.join(&cs.name);
833                    fs::create_dir_all(&cs_path).with_context(|| {
834                        format!(
835                            "Trying to create directory for cross section {}",
836                            cs_path.display()
837                        )
838                    })?;
839
840                    cs.export_standalone(&cs_path, settings)?;
841                }
842            }
843        }
844        Ok(())
845    }
846
847    pub(crate) fn export_dot(
848        &self,
849        path: impl AsRef<Path>,
850        settings: &DotExportSettings,
851    ) -> Result<()> {
852        match &self.collection {
853            ProcessCollection::Amplitudes(a) => {
854                let p = path.as_ref().join("amplitudes");
855                let path = p.join(PathBuf::from(self.definition.folder_name.clone()));
856                fs::create_dir_all(&path)?;
857                for (amp_name, amp) in a {
858                    // Create a folder for each amplitude
859                    let amp_path = path.join(&amp.name);
860                    fs::create_dir_all(&amp_path).with_context(|| {
861                        format!(
862                            "Trying to create directory for amplitude {}",
863                            amp_path.display()
864                        )
865                    })?;
866
867                    if settings.combine_diagrams {
868                        // Save all graphs combined in one file
869                        let output_path = amp_path.join(format!("{}_graphs.dot", amp_name.clone()));
870                        let mut dot = create_overwriting_file(&output_path, "amplitude graph")?;
871                        for graph in amp.graphs.iter() {
872                            graph.graph.dot_serialize_io(&mut dot, settings)?;
873                        }
874                    } else {
875                        // Save each graph in its own file
876                        for graph in amp.graphs.iter() {
877                            let output_path = amp_path.join(format!("{}.dot", graph.graph.name));
878                            let mut dot = create_overwriting_file(&output_path, "amplitude graph")?;
879                            graph.graph.dot_serialize_io(&mut dot, settings)?;
880                        }
881                    }
882                }
883            }
884            ProcessCollection::CrossSections(cs) => {
885                let p = path.as_ref().join("cross_sections");
886                let path = p.join(PathBuf::from(self.definition.folder_name.clone()));
887                fs::create_dir_all(&path)?;
888                for (xs_name, cs) in cs {
889                    // Create a folder for each cross section
890                    let cs_path = path.join(&cs.name);
891                    fs::create_dir_all(&cs_path).with_context(|| {
892                        format!(
893                            "Trying to create directory for cross section {}",
894                            cs_path.display()
895                        )
896                    })?;
897
898                    if settings.combine_diagrams {
899                        // Save all graphs combined in one file
900                        let output_path = cs_path.join(format!("{}_graphs.dot", xs_name.clone()));
901                        let mut dot = create_overwriting_file(&output_path, "cross section graph")?;
902                        for graph in cs.supergraphs.iter() {
903                            graph.graph.dot_serialize_io(&mut dot, settings)?;
904                        }
905                    } else {
906                        // Save each supergraph in its own file
907                        for graph in cs.supergraphs.iter() {
908                            let output_path = cs_path.join(format!("{}.dot", graph.graph.name));
909                            let mut dot =
910                                create_overwriting_file(&output_path, "cross section graph")?;
911                            graph.graph.dot_serialize_io(&mut dot, settings)?;
912                        }
913                    }
914                }
915            }
916        }
917        Ok(())
918    }
919
920    pub(crate) fn export_uv_forests(
921        &self,
922        path: impl AsRef<Path>,
923        integrand_name: &str,
924        graph_ids: &[usize],
925        settings: &UVForestExportSettings,
926    ) -> Result<()> {
927        let resolved = self.get_integrand(integrand_name)?;
928        resolved.require_generated()?;
929        let integrand_path = match &self.collection {
930            ProcessCollection::Amplitudes(_) => path
931                .as_ref()
932                .join("amplitudes")
933                .join(PathBuf::from(self.definition.folder_name.clone()))
934                .join(&resolved.canonical_name),
935            ProcessCollection::CrossSections(_) => path
936                .as_ref()
937                .join("cross_sections")
938                .join(PathBuf::from(self.definition.folder_name.clone()))
939                .join(&resolved.canonical_name),
940        };
941        fs::create_dir_all(&integrand_path).with_context(|| {
942            format!(
943                "Trying to create directory for UV forest export {}",
944                integrand_path.display()
945            )
946        })?;
947
948        for &graph_id in graph_ids {
949            let export = self.export_uv_forest_graph(integrand_name, graph_id, settings)?;
950            let graph_name = sanitize_file_component(&export.graph_name);
951            let forest_path = integrand_path.join(format!("{graph_name}.forest.dot"));
952            let mut forest_file = create_overwriting_file(&forest_path, "UV forest")?;
953            forest_file.write_all(export.forest_dot.as_bytes())?;
954
955            for term in export.node_terms {
956                let node_dir = integrand_path
957                    .join(format!("{graph_name}_nodes"))
958                    .join(format!("forest_{:03}", term.forest_index));
959                fs::create_dir_all(&node_dir).with_context(|| {
960                    format!(
961                        "Trying to create directory for UV forest node graph {}",
962                        node_dir.display()
963                    )
964                })?;
965                let mut dot = create_overwriting_file(
966                    &node_dir.join(term.file_name()),
967                    "UV forest node graph",
968                )?;
969                dot.write_all(term.dot.as_bytes())?;
970            }
971        }
972
973        Ok(())
974    }
975
976    pub fn from_graph_list(
977        process_name: String,
978        integrand_name: String,
979        graphs: Vec<Graph>,
980        generation_type: GenerationType,
981        definition: Option<ProcessDefinition>,
982        sub_classes: Option<Vec<Vec<String>>>,
983        model: &Model,
984    ) -> Result<Self> {
985        let mut proc_definition = definition.unwrap_or_default();
986        proc_definition.folder_name = process_name;
987        match generation_type {
988            GenerationType::Amplitude => {
989                let mut collection: ProcessCollection = ProcessCollection::new_amplitude();
990
991                if let Some(_sub_classes) = sub_classes {
992                    todo!("implement seperation of processes into user defined sub classes");
993                } else {
994                    collection.add_amplitude(Amplitude::from_graph_list(integrand_name, graphs)?);
995
996                    // TODO: construct a better default definition from graph (i.e. at least the external IDs)
997                    Ok(Self {
998                        settings_history: None,
999                        definition: proc_definition,
1000                        collection,
1001                    })
1002                }
1003            }
1004            GenerationType::CrossSection => {
1005                let mut collection: ProcessCollection = ProcessCollection::new_cross_section();
1006
1007                if let Some(_sub_classes) = sub_classes {
1008                    todo!("implement seperation of processes into user defined sub classes");
1009                } else {
1010                    collection.add_cross_section(CrossSection::from_graph_list(
1011                        integrand_name,
1012                        graphs,
1013                        model,
1014                    )?);
1015                    // TODO: construct a better default definition from graph (i.e. at least the external IDs)
1016                    Ok(Self {
1017                        settings_history: None,
1018                        definition: proc_definition,
1019                        collection,
1020                    })
1021                }
1022            }
1023        }
1024    }
1025
1026    pub fn generate_integrands(
1027        &mut self,
1028        model: &Model,
1029        global_settings: &GlobalSettings,
1030        runtime_default: LockedRuntimeSettings,
1031        thread_pool: &ThreadPool,
1032    ) -> Result<Vec<GeneratedGraphReport>> {
1033        let reports = self.collection.generate_integrands(
1034            model,
1035            &self.definition,
1036            global_settings,
1037            runtime_default,
1038            thread_pool,
1039        )?;
1040        Ok(self.attach_process_id(reports))
1041    }
1042}
1043
1044#[derive(Clone, Encode, Decode)]
1045#[trait_decode(trait = GammaLoopContext)]
1046pub enum ProcessCollection {
1047    Amplitudes(BTreeMap<String, Amplitude>),
1048    CrossSections(BTreeMap<String, CrossSection>),
1049}
1050
1051impl ProcessCollection {
1052    fn new_amplitude() -> Self {
1053        Self::Amplitudes(BTreeMap::new())
1054    }
1055
1056    pub fn get_integrand_names(&self) -> Vec<&str> {
1057        match self {
1058            Self::Amplitudes(amplitudes) => amplitudes.keys().map(|a| a.as_str()).collect(),
1059            Self::CrossSections(cross_sections) => {
1060                cross_sections.keys().map(|a| a.as_str()).collect()
1061            }
1062        }
1063    }
1064
1065    fn get_integrand(&self, name: impl AsRef<str>) -> Result<ResolvedIntegrandRef<'_>> {
1066        let canonical_name = self.find_integrand(Some(name.as_ref().to_string()))?;
1067        let integrand = match self {
1068            Self::Amplitudes(amplitudes) => amplitudes
1069                .get(&canonical_name)
1070                .expect("resolved amplitude name must exist")
1071                .integrand
1072                .as_ref(),
1073            Self::CrossSections(cross_sections) => cross_sections
1074                .get(&canonical_name)
1075                .expect("resolved cross section name must exist")
1076                .integrand
1077                .as_ref(),
1078        };
1079
1080        Ok(ResolvedIntegrandRef {
1081            canonical_name,
1082            integrand,
1083        })
1084    }
1085
1086    pub fn find_integrand(&self, name: Option<String>) -> Result<String> {
1087        let all_integrand_names = self.get_integrand_names();
1088
1089        let integrand_name = if let Some(name) = name {
1090            if !all_integrand_names.contains(&name.as_str()) {
1091                return Err(color_eyre::eyre::eyre!(
1092                    "No integrand named '{}' in process, Available integrands: {:?}",
1093                    name,
1094                    all_integrand_names
1095                ));
1096            }
1097            name
1098        } else {
1099            if all_integrand_names.len() != 1 {
1100                return Err(color_eyre::eyre::eyre!(
1101                    "Multiple integrands in process,Please specify one of: {:?}",
1102                    all_integrand_names
1103                ));
1104            }
1105            all_integrand_names[0].to_string()
1106        };
1107
1108        Ok(integrand_name)
1109    }
1110
1111    fn get_integrand_mut(&mut self, name: impl AsRef<str>) -> Result<&mut ProcessIntegrand> {
1112        let res = match self {
1113            Self::Amplitudes(amplitudes) => {
1114                if amplitudes.contains_key(name.as_ref()) {
1115                    Ok(amplitudes
1116                        .get_mut(name.as_ref())
1117                        .unwrap()
1118                        .integrand
1119                        .as_mut())
1120                } else {
1121                    let names = amplitudes.keys().map(|a| a.as_str()).collect::<Vec<_>>();
1122
1123                    Err(eyre!("Integrand {} does not exist", name.as_ref()))
1124                        .suggestion(format!("Available amplitude names: {}", names.join(", ")))
1125                }
1126            }
1127            Self::CrossSections(cross_sections) => {
1128                if cross_sections.contains_key(name.as_ref()) {
1129                    Ok(cross_sections
1130                        .get_mut(name.as_ref())
1131                        .unwrap()
1132                        .integrand
1133                        .as_mut())
1134                } else {
1135                    let names = cross_sections
1136                        .keys()
1137                        .map(|a| a.as_str())
1138                        .collect::<Vec<_>>();
1139
1140                    Err(eyre!("Integrand {} does not exist", name.as_ref())).suggestion(format!(
1141                        "Available cross section names: {}",
1142                        names.join(", ")
1143                    ))
1144                }
1145            }
1146        }?;
1147
1148        match res {
1149            Some(integrand) => Ok(integrand),
1150            None => Err(eyre!(
1151                "Integrand {} has not yet been generated, but exists",
1152                name.as_ref()
1153            )),
1154        }
1155    }
1156
1157    fn new_cross_section() -> Self {
1158        Self::CrossSections(BTreeMap::new())
1159    }
1160    pub fn remove_integrand(&mut self, integrand_name: &str) -> Result<()> {
1161        match self {
1162            Self::Amplitudes(amplitudes) => {
1163                amplitudes
1164                    .remove(integrand_name)
1165                    .ok_or(eyre!("No amplitude named {}", integrand_name))?;
1166            }
1167            Self::CrossSections(cross_sections) => {
1168                cross_sections
1169                    .remove(integrand_name)
1170                    .ok_or(eyre!("No cross section named {}", integrand_name))?;
1171            }
1172        }
1173        Ok(())
1174    }
1175
1176    pub fn add_amplitude(&mut self, amplitude: Amplitude) {
1177        match self {
1178            Self::Amplitudes(amplitudes) => amplitudes.insert(amplitude.name.clone(), amplitude),
1179            _ => panic!("Cannot add amplitude to a cross section collection"),
1180        };
1181    }
1182
1183    pub fn add_cross_section(&mut self, cross_section: CrossSection) {
1184        match self {
1185            Self::CrossSections(cross_sections) => {
1186                cross_sections.insert(cross_section.name.clone(), cross_section);
1187            }
1188            _ => panic!("Cannot add cross section to an amplitude collection"),
1189        }
1190    }
1191
1192    fn preprocess(
1193        &mut self,
1194        model: &Model,
1195        process_definition: &ProcessDefinition,
1196        settings: &GenerationSettings,
1197        locked_runtime_settings: &LockedRuntimeSettings,
1198        thread_pool: &ThreadPool,
1199    ) -> Result<Vec<NamedGraphGenerationReport>> {
1200        match self {
1201            Self::Amplitudes(amplitudes) => {
1202                let mut reports = Vec::new();
1203                for amplitude in amplitudes.values_mut() {
1204                    generation_progress::begin_phase(
1205                        GenerationProgressPhase::GraphPreprocessing,
1206                        GenerationProcessKind::Amplitude,
1207                        &process_definition.folder_name,
1208                        &amplitude.name,
1209                        amplitude.graphs.len(),
1210                        None,
1211                    );
1212                    reports.extend(amplitude.preprocess(
1213                        model,
1214                        settings,
1215                        locked_runtime_settings,
1216                        thread_pool,
1217                    )?);
1218                }
1219                Ok(reports)
1220            }
1221            Self::CrossSections(cross_sections) => {
1222                let mut reports = Vec::new();
1223                for cross_section in cross_sections.values_mut() {
1224                    reports.extend(cross_section.preprocess(
1225                        model,
1226                        process_definition,
1227                        settings,
1228                        *locked_runtime_settings,
1229                        thread_pool,
1230                    )?);
1231                }
1232                Ok(reports)
1233            }
1234        }
1235    }
1236
1237    pub fn warm_up(&mut self, model: &Model) -> Result<()> {
1238        match self {
1239            Self::Amplitudes(amplitudes) => {
1240                for amplitude in amplitudes.values_mut() {
1241                    amplitude.warm_up(model)?;
1242                }
1243            }
1244            Self::CrossSections(cross_sections) => {
1245                for cross_section in cross_sections.values_mut() {
1246                    cross_section.warm_up(model)?;
1247                }
1248            }
1249        }
1250        Ok(())
1251    }
1252
1253    fn generate_integrands(
1254        &mut self,
1255        model: &Model,
1256        process_definition: &ProcessDefinition,
1257        global_settings: &GlobalSettings,
1258        runtime_default: LockedRuntimeSettings,
1259        thread_pool: &ThreadPool,
1260    ) -> Result<Vec<NamedGraphGenerationReport>> {
1261        match self {
1262            Self::Amplitudes(amplitudes) => {
1263                let mut reports = Vec::new();
1264                for amplitude in amplitudes.values_mut() {
1265                    reports.extend(amplitude.build_integrand(
1266                        model,
1267                        &process_definition.folder_name,
1268                        global_settings,
1269                        runtime_default,
1270                        thread_pool,
1271                    )?);
1272                }
1273                Ok(reports)
1274            }
1275            Self::CrossSections(cross_sections) => {
1276                let mut reports = Vec::new();
1277                for cross_section in cross_sections.values_mut() {
1278                    reports.extend(cross_section.build_integrand(
1279                        model,
1280                        process_definition,
1281                        global_settings,
1282                        runtime_default,
1283                        thread_pool,
1284                    )?);
1285                }
1286                Ok(reports)
1287            }
1288        }
1289    }
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294    use std::{
1295        fs,
1296        path::PathBuf,
1297        time::{SystemTime, UNIX_EPOCH},
1298    };
1299
1300    use crate::{GammaLoopContextContainer, utils::load_generic_model};
1301    use symbolica::atom::{Atom, AtomCore};
1302
1303    fn fresh_temp_dir(name: &str) -> PathBuf {
1304        let unique = SystemTime::now()
1305            .duration_since(UNIX_EPOCH)
1306            .unwrap()
1307            .as_nanos();
1308        let path = std::env::temp_dir().join(format!(
1309            "gammalooprs-{name}-{}-{unique}",
1310            std::process::id()
1311        ));
1312        fs::create_dir_all(&path).unwrap();
1313        path
1314    }
1315
1316    #[test]
1317    fn saved_child_dirs_skip_cross_section_compile_artifact_folders() {
1318        let temp = fresh_temp_dir("saved-child-dirs");
1319        let saved_dir = temp.join("NLO");
1320        let compiled_dir = temp.join("cs_NLO");
1321
1322        fs::create_dir_all(&saved_dir).unwrap();
1323        fs::write(saved_dir.join("cs.bin"), []).unwrap();
1324        fs::create_dir_all(compiled_dir.join("integrand").join("GL08")).unwrap();
1325
1326        let dirs = super::saved_child_dirs(&temp, "cs.bin", "cross section").unwrap();
1327
1328        assert_eq!(dirs, vec![saved_dir]);
1329        fs::remove_dir_all(temp).unwrap();
1330    }
1331
1332    #[test]
1333    fn computed_uv_forest_process_export_uses_stored_sources_in_all_routes()
1334    -> color_eyre::Result<()> {
1335        use crate::{
1336            feyngen::GenerationType,
1337            graph::{Graph, GroupId},
1338            initialisation::test_initialise,
1339            processes::{
1340                GraphGroupSelectionSpec, Process, ProcessCollection, ProcessDefinition, ProcessList,
1341            },
1342            settings::{GlobalSettings, RuntimeSettings},
1343            uv::{
1344                UVOrchestrator, export::UVForestExportSettings, settings::FinalIntegrandDimension,
1345            },
1346        };
1347        use std::collections::{BTreeMap, BTreeSet};
1348
1349        test_initialise()?;
1350        let model = load_generic_model("scalars");
1351        let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build()?;
1352        let runtime = RuntimeSettings::default();
1353        let directory = fresh_temp_dir("computed-uv-process-export");
1354        for (kind, folder, source) in [
1355            (
1356                GenerationType::Amplitude,
1357                "amplitudes",
1358                include_str!(concat!(
1359                    env!("CARGO_MANIFEST_DIR"),
1360                    "/../../tests/resources/graphs/scalar_bubble.dot"
1361                )),
1362            ),
1363            (
1364                GenerationType::CrossSection,
1365                "cross_sections",
1366                include_str!(concat!(
1367                    env!("CARGO_MANIFEST_DIR"),
1368                    "/../../tests/resources/graphs/mass_approach_scalar_self_energy.dot"
1369                )),
1370            ),
1371        ] {
1372            for orchestrator in [UVOrchestrator::LegacyDagForest, UVOrchestrator::HedgePoset] {
1373                for (mode, explicit_sum, projected) in [
1374                    ("direct_keyed", false, false),
1375                    ("direct_summed", true, false),
1376                    ("projected_summed", true, true),
1377                ] {
1378                    // Keep the fixture's physical graph and all default subtraction
1379                    // terms; only the three requested CFF/UV routes differ.
1380                    let mut graphs = Graph::from_string(source, &model)?;
1381                    let exercise_selection = kind == GenerationType::CrossSection
1382                        && orchestrator == UVOrchestrator::HedgePoset
1383                        && mode == "direct_keyed";
1384                    if !exercise_selection {
1385                        graphs.truncate(1);
1386                    }
1387                    let mut graph_name = graphs[0].name.clone();
1388                    let definition = ProcessDefinition::from_graph_list(&graphs, kind, &model)?;
1389                    let process = Process::from_graph_list(
1390                        "export_fixture".into(),
1391                        "default".into(),
1392                        graphs,
1393                        kind,
1394                        Some(definition),
1395                        None,
1396                        &model,
1397                    )?;
1398                    let mut processes = ProcessList {
1399                        processes: vec![process],
1400                    };
1401                    let mut settings = GlobalSettings::default();
1402                    settings.generation.uv.orchestrator = orchestrator;
1403                    settings.generation.explicit_orientation_sum_only = explicit_sum;
1404                    settings
1405                        .generation
1406                        .uv
1407                        .local_uv_cts_from_expanded_4d_integrands = projected;
1408                    assert_eq!(
1409                        settings.generation.uv.final_integrand,
1410                        FinalIntegrandDimension::ThreeD
1411                    );
1412                    assert!(settings.generation.uv.subtract_uv);
1413                    assert!(settings.generation.uv.generate_integrated);
1414                    assert!(settings.generation.uv.softct);
1415                    assert!(settings.generation.threshold_subtraction.enable_thresholds);
1416                    assert!(!settings.generation.evaluator.store_atom);
1417                    processes.preprocess(&model, &settings, &(&runtime).into(), &pool)?;
1418                    processes.generate_integrands(&model, &settings, (&runtime).into(), &pool)?;
1419                    // Record both physical cut inventories before selection, so
1420                    // removal/reindexing cannot silently redefine the expected output.
1421                    let expected_by_graph: Vec<Vec<BTreeSet<String>>> = match processes.processes[0]
1422                        .get_integrand("default")?
1423                        .require_generated()?
1424                    {
1425                        crate::integrands::process::ProcessIntegrand::Amplitude(_) => {
1426                            vec![vec![["all_none".to_string()].into_iter().collect()]]
1427                        }
1428                        crate::integrands::process::ProcessIntegrand::CrossSection(integrand) => {
1429                            integrand
1430                                .data
1431                                .graph_terms
1432                                .iter()
1433                                .map(|term| {
1434                                    term.cut_group_data
1435                                        .cut_groups
1436                                        .iter()
1437                                        .map(|cuts| {
1438                                            crate::graph::cuts::ResidueSelector {
1439                                                lu: Some(
1440                                                    cuts.lu_cut_selection(&term.graph, &term.cuts),
1441                                                ),
1442                                                left_th_cut: None,
1443                                                right_th_cut: None,
1444                                            }
1445                                            .generate_allowed_keys()
1446                                            .into_iter()
1447                                            .map(|key| {
1448                                                format!("lu_cut_{}", key.lu_cut_order.unwrap())
1449                                            })
1450                                            .collect()
1451                                        })
1452                                        .collect()
1453                                })
1454                                .collect()
1455                        }
1456                    };
1457                    assert!(!expected_by_graph.is_empty());
1458                    let case_dir = directory.join(format!("{folder}-{orchestrator}-{mode}"));
1459                    let source_expression =
1460                        |graph: &Graph,
1461                         source: &three_dimensional_reps::GeneratedThreeDExpression<
1462                            crate::cff::esurface::Esurface,
1463                            crate::cff::hsurface::Hsurface,
1464                        >|
1465                         -> eyre::Result<Atom> {
1466                            let expressions = graph
1467                                .cff_from_production_expression(
1468                                    source,
1469                                    &crate::graph::cuts::CutSet::empty(graph.n_hedges()),
1470                                    &crate::settings::global::OrientationPattern::default(),
1471                                )?
1472                                .expression_with_selectors();
1473                            Ok(expressions
1474                                .iter()
1475                                .fold(Atom::Zero, |sum, (_, atom)| sum + atom))
1476                        };
1477                    let mut production_expression: Option<Atom> = None;
1478                    let mut expected_exported_expressions: Option<BTreeMap<(usize, String), Atom>> =
1479                        None;
1480                    for phase in ["generated", "loaded", "selected"] {
1481                        if phase == "selected" {
1482                            if !exercise_selection {
1483                                continue;
1484                            }
1485                            // Exercise the same persistent selection owner as the CLI,
1486                            // after save/load, removing graph 0 and compacting graph 1.
1487                            let ProcessCollection::CrossSections(cross_sections) =
1488                                &mut processes.processes[0].collection
1489                            else {
1490                                unreachable!();
1491                            };
1492                            let cross_section = cross_sections.get_mut("default").unwrap();
1493                            let retained = &cross_section.supergraphs[1];
1494                            let removed_name = graph_name.clone();
1495                            graph_name = retained.graph.name.clone();
1496                            assert_ne!(graph_name, removed_name);
1497                            let expected_cuts = retained
1498                                .cuts
1499                                .iter()
1500                                .map(|cut| cut.cut.clone())
1501                                .collect::<BTreeSet<_>>();
1502                            let expected_numerator = retained
1503                                .graph
1504                                .production_numerator_atom_for_full_3d_expression();
1505                            expected_exported_expressions = None;
1506                            production_expression = Some(source_expression(
1507                                &retained.graph,
1508                                retained
1509                                    .derived_data
1510                                    .global_cff_expression
1511                                    .as_ref()
1512                                    .unwrap(),
1513                            )?);
1514                            let plan = cross_section.plan_graph_group_selection(
1515                                &GraphGroupSelectionSpec::from_master_graph_names(vec![
1516                                    graph_name.clone(),
1517                                ]),
1518                            )?;
1519                            assert_eq!(plan.retained_group_ids(), &[GroupId(1)]);
1520                            assert_eq!(plan.new_group_id_for_old(GroupId(1)), Some(GroupId(0)));
1521                            assert_eq!(plan.report().removed_graphs, vec![removed_name.clone()]);
1522                            cross_section.apply_graph_group_selection(&plan)?;
1523                            let retained = &cross_section.supergraphs[0];
1524                            assert!(
1525                                (retained
1526                                    .graph
1527                                    .production_numerator_atom_for_full_3d_expression()
1528                                    - expected_numerator)
1529                                    .collect_factors()
1530                                    .is_zero(),
1531                                "persistent selection changed the retained physical numerator"
1532                            );
1533                            assert!(
1534                                processes.processes[0]
1535                                    .get_integrand("default")?
1536                                    .require_generated()
1537                                    .is_err()
1538                            );
1539                            // Match normal regeneration: preprocess the retained source
1540                            // and then rebuild the runtime integrand with identical settings.
1541                            processes.preprocess(&model, &settings, &(&runtime).into(), &pool)?;
1542                            processes.generate_integrands(
1543                                &model,
1544                                &settings,
1545                                (&runtime).into(),
1546                                &pool,
1547                            )?;
1548                            assert_eq!(
1549                                processes.processes[0]
1550                                    .get_integrand("default")?
1551                                    .require_generated()?
1552                                    .graph_name_by_id(1),
1553                                None,
1554                            );
1555                            let crate::integrands::process::ProcessIntegrand::CrossSection(
1556                                integrand,
1557                            ) = processes.processes[0]
1558                                .get_integrand("default")?
1559                                .require_generated()?
1560                            else {
1561                                unreachable!();
1562                            };
1563                            let term = &integrand.data.graph_terms[0];
1564                            assert_eq!(
1565                                term.cuts
1566                                    .iter()
1567                                    .map(|cut| cut.cut.clone())
1568                                    .collect::<BTreeSet<_>>(),
1569                                expected_cuts,
1570                                "selection/regeneration changed the retained physical cuts"
1571                            );
1572                        }
1573                        let expected_residues =
1574                            &expected_by_graph[usize::from(phase == "selected")];
1575                        assert!(!expected_residues.is_empty());
1576                        let process = &processes.processes[0];
1577                        let (source_graph, production) = match &process.collection {
1578                            ProcessCollection::Amplitudes(amplitudes) => {
1579                                let graph = &amplitudes["default"].graphs[0];
1580                                (
1581                                    &graph.graph,
1582                                    graph.derived_data.cff_expression.as_ref().unwrap(),
1583                                )
1584                            }
1585                            ProcessCollection::CrossSections(cross_sections) => {
1586                                let graph = &cross_sections["default"].supergraphs[0];
1587                                (
1588                                    &graph.graph,
1589                                    graph.derived_data.global_cff_expression.as_ref().unwrap(),
1590                                )
1591                            }
1592                        };
1593                        assert_eq!(source_graph.name, graph_name);
1594                        assert_eq!(
1595                            process
1596                                .get_integrand("default")?
1597                                .require_generated()?
1598                                .graph_name_by_id(0),
1599                            Some(graph_name.as_str())
1600                        );
1601                        assert!(!production.expression.orientations.is_empty());
1602                        // Persistence retains the complete consumed expression, including
1603                        // surface references, energy factors, and convention prefactors;
1604                        // transient degree reports and internal cache/tree layout do not
1605                        // affect this contract.
1606                        // Selection/regeneration must also retain the old graph 1 source.
1607                        let expression = source_expression(source_graph, production)?;
1608                        if let Some(expected) = &production_expression {
1609                            assert!(
1610                                (&expression - expected).expand().together().is_zero(),
1611                                "stored production CFF changed after {phase}"
1612                            );
1613                        } else {
1614                            production_expression = Some(expression);
1615                        }
1616                        let export_dir = case_dir.join(phase);
1617                        processes.export_uv_forests(
1618                            &export_dir,
1619                            0,
1620                            "default",
1621                            &[0],
1622                            &UVForestExportSettings { computed: true },
1623                        )?;
1624                        let graph_dir = export_dir
1625                            .join("processes")
1626                            .join(folder)
1627                            .join("export_fixture/default");
1628                        assert!(graph_dir.join(format!("{graph_name}.forest.dot")).is_file());
1629                        assert_eq!(
1630                            fs::read_dir(&graph_dir)?
1631                                .map(|entry| entry
1632                                    .map(|entry| entry.file_name().to_string_lossy().into_owned()))
1633                                .collect::<std::io::Result<Vec<_>>>()?
1634                                .into_iter()
1635                                .filter(|name| name.ends_with(".forest.dot"))
1636                                .collect::<BTreeSet<_>>(),
1637                            BTreeSet::from([format!("{graph_name}.forest.dot")]),
1638                            "computed export retained a removed graph",
1639                        );
1640                        let mut exported_expressions = BTreeMap::<(usize, String), Atom>::new();
1641                        let mut forest_indices = BTreeSet::new();
1642                        for forest in fs::read_dir(graph_dir.join(format!("{graph_name}_nodes")))? {
1643                            let forest = forest?;
1644                            let forest_index = forest
1645                                .file_name()
1646                                .to_string_lossy()
1647                                .strip_prefix("forest_")
1648                                .unwrap()
1649                                .parse::<usize>()?;
1650                            assert!(forest_indices.insert(forest_index));
1651                            let expected = &expected_residues[forest_index];
1652                            let mut actual = BTreeSet::new();
1653                            for node in fs::read_dir(forest.path())? {
1654                                let node = node?;
1655                                let file = node.file_name();
1656                                let file = file.to_string_lossy();
1657                                let residue = expected
1658                                    .iter()
1659                                    .find(|residue| file.ends_with(&format!("_{residue}.dot")))
1660                                    .expect(
1661                                        "exported residue must belong to this complete physical cut",
1662                                    );
1663                                actual.insert(residue.clone());
1664                                let node_dot = fs::read_to_string(node.path())?;
1665                                assert!(node_dot.contains("forest_residue_index"));
1666                                for exported in Graph::from_string(&node_dot, &model)? {
1667                                    *exported_expressions
1668                                        .entry((forest_index, residue.clone()))
1669                                        .or_insert(Atom::Zero) += exported.global_prefactor.num;
1670                                }
1671                            }
1672                            assert_eq!(&actual, expected, "a physical cut lost a residue order");
1673                        }
1674                        assert_eq!(
1675                            forest_indices,
1676                            (0..expected_residues.len()).collect::<BTreeSet<_>>()
1677                        );
1678                        assert!(
1679                            exported_expressions
1680                                .values()
1681                                .any(|expression| !expression.is_zero()),
1682                            "{folder}/{orchestrator}/{mode}: the computed export has no nonzero value"
1683                        );
1684                        // Persistence preserves complete exported values for every
1685                        // physical cut and residue, independently of how terms are
1686                        // grouped into nodes or numbered in the forest traversal.
1687                        if let Some(expected) = &expected_exported_expressions {
1688                            assert_eq!(
1689                                exported_expressions.keys().collect::<BTreeSet<_>>(),
1690                                expected.keys().collect::<BTreeSet<_>>()
1691                            );
1692                            for (key, expression) in &exported_expressions {
1693                                assert!(
1694                                    (expression.collect_factors()
1695                                        - expected[key].collect_factors())
1696                                    .collect_factors()
1697                                    .is_zero(),
1698                                    "{folder}/{orchestrator}/{mode}: exported residue {key:?} changed after {phase}"
1699                                );
1700                            }
1701                        } else {
1702                            expected_exported_expressions = Some(exported_expressions);
1703                        }
1704                        if phase == "generated" {
1705                            let saved = case_dir.join("saved");
1706                            processes.processes[0].save(&saved, true)?;
1707                            let mut symbols = Vec::new();
1708                            symbolica::state::State::export(&mut symbols)?;
1709                            let state_map = symbolica::state::State::import(
1710                                &mut std::io::Cursor::new(symbols),
1711                                None,
1712                            )?;
1713                            let context = GammaLoopContextContainer {
1714                                model: &model,
1715                                state_map: &state_map,
1716                            };
1717                            let path = saved.join(folder).join("export_fixture");
1718                            processes.processes[0] = match kind {
1719                                GenerationType::Amplitude => {
1720                                    Process::load_amplitude(path, context, None)?
1721                                }
1722                                GenerationType::CrossSection => {
1723                                    Process::load_cross_section(path, context, None)?
1724                                }
1725                            };
1726                        }
1727                    }
1728                    // The public lookup must reject an ID/source mismatch rather
1729                    // than pairing a runtime graph with a different stored CFF.
1730                    let source_graph = match &mut processes.processes[0].collection {
1731                        ProcessCollection::Amplitudes(amplitudes) => {
1732                            &mut amplitudes.get_mut("default").unwrap().graphs[0].graph
1733                        }
1734                        ProcessCollection::CrossSections(cross_sections) => {
1735                            &mut cross_sections.get_mut("default").unwrap().supergraphs[0].graph
1736                        }
1737                    };
1738                    source_graph.name.push_str("_mismatch");
1739                    let error = processes
1740                        .export_uv_forests(
1741                            case_dir.join("mismatch"),
1742                            0,
1743                            "default",
1744                            &[0],
1745                            &UVForestExportSettings { computed: true },
1746                        )
1747                        .unwrap_err();
1748                    assert!(format!("{error:#}").contains("Source/runtime graph mismatch"));
1749                    let source = match &mut processes.processes[0].collection {
1750                        ProcessCollection::Amplitudes(amplitudes) => {
1751                            let graph = &mut amplitudes.get_mut("default").unwrap().graphs[0];
1752                            graph.graph.name = graph_name.clone();
1753                            &mut graph.derived_data.cff_expression
1754                        }
1755                        ProcessCollection::CrossSections(cross_sections) => {
1756                            let graph =
1757                                &mut cross_sections.get_mut("default").unwrap().supergraphs[0];
1758                            graph.graph.name = graph_name.clone();
1759                            &mut graph.derived_data.global_cff_expression
1760                        }
1761                    };
1762                    *source = None;
1763                    let topology_dir = case_dir.join("topology_without_source");
1764                    processes.export_uv_forests(
1765                        &topology_dir,
1766                        0,
1767                        "default",
1768                        &[0],
1769                        &UVForestExportSettings { computed: false },
1770                    )?;
1771                    let topology_graph_dir = topology_dir
1772                        .join("processes")
1773                        .join(folder)
1774                        .join("export_fixture/default");
1775                    assert!(
1776                        topology_graph_dir
1777                            .join(format!("{graph_name}.forest.dot"))
1778                            .is_file()
1779                    );
1780                    assert!(
1781                        !topology_graph_dir
1782                            .join(format!("{graph_name}_nodes"))
1783                            .exists()
1784                    );
1785                    let error = processes
1786                        .export_uv_forests(
1787                            case_dir.join("missing_source"),
1788                            0,
1789                            "default",
1790                            &[0],
1791                            &UVForestExportSettings { computed: true },
1792                        )
1793                        .unwrap_err();
1794                    assert!(format!("{error:#}").contains("has no stored production CFF"));
1795                }
1796            }
1797        }
1798        fs::remove_dir_all(directory)?;
1799        Ok(())
1800    }
1801
1802    #[test]
1803    fn test_proc_definition_encode() {
1804        let def = crate::processes::ProcessDefinition::default();
1805        let encoded = bincode::encode_to_vec(&def, bincode::config::standard()).unwrap();
1806        let model_sm = load_generic_model("sm");
1807
1808        let mut symbols = Vec::new();
1809        symbolica::state::State::export(&mut symbols).unwrap();
1810        let state_map =
1811            symbolica::state::State::import(&mut std::io::Cursor::new(symbols), None).unwrap();
1812
1813        let context = GammaLoopContextContainer {
1814            model: &model_sm,
1815            state_map: &state_map,
1816        };
1817
1818        let (decoded, _): (crate::processes::ProcessDefinition, _) =
1819            bincode::decode_from_slice_with_context(&encoded, bincode::config::standard(), context)
1820                .unwrap();
1821        assert_eq!(def, decoded);
1822    }
1823}