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;
14use 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 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 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 let amp_path = path.join(&.name);
816 fs::create_dir_all(&_path).with_context(|| {
817 format!(
818 "Trying to create directory for amplitude {}",
819 amp_path.display()
820 )
821 })?;
822
823 amp.export_standalone(&_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 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 let amp_path = path.join(&.name);
860 fs::create_dir_all(&_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 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 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 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 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 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 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 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 &litude.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 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 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 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 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 = &litudes["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 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 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 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}