1use std::sync::Arc;
2
3use color_eyre::Result;
4use eyre::{Context, eyre};
5use linnet::half_edge::subgraph::SubSetOps;
6use linnet::parser::DotGraph;
7use serde::{Deserialize, Serialize};
8use spenso::shadowing::symbolica_utils::SpensoPrintSettings;
9use symbolica::atom::{Atom, AtomCore};
10
11use crate::{
12 cff::CutCFFIndex,
13 graph::{
14 Graph,
15 cuts::{CutSet, ResidueSelector},
16 },
17 integrands::process::ProcessIntegrand,
18 processes::{DotExportSettings, ProcessCollection, process::Process},
19 settings::global::GenerationSettings,
20 uv::{
21 UVOrchestrator,
22 approx::{
23 CutStructure, OrientationProjection,
24 local_3d::Local3DProjectionPath,
25 local_4d::{Local4dBranchProvenance, Local4dComponentProvenance},
26 },
27 forest::CutForests,
28 hedge_poset::Wood as HedgePosetWood,
29 orchestrator::validate_local_counterterm_schemes,
30 settings::FinalIntegrandDimension,
31 wood::CutWoods,
32 },
33};
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub(crate) struct UVForestNodeProvenance {
37 pub parent_keys: Vec<String>,
38 pub local: UVForestLocalProvenance,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "representation")]
46pub(crate) enum UVForestLocalProvenance {
47 #[serde(rename = "4d")]
48 FourD {
49 components: Vec<Local4dComponentProvenance>,
50 branches: Vec<Local4dBranchProvenance>,
51 },
52 #[serde(rename = "3d")]
53 ThreeD {
54 projection_paths: Vec<Local3DProjectionPath>,
55 },
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub(crate) struct UVForestProvenance {
60 pub node: UVForestNodeProvenance,
61}
62
63pub struct UVForestExportSettings {
64 pub computed: bool,
68}
69
70pub struct UVForestExport {
71 pub graph_name: String,
72 pub forest_dot: String,
73 pub node_terms: Vec<UVForestNodeTerm>,
74}
75
76pub struct UVForestNodeTerm {
77 pub forest_index: usize,
78 pub node_index: usize,
79 pub node_key: String,
80 pub term_index: usize,
81 pub residue_index: CutCFFIndex,
82 pub dot: String,
83}
84
85impl UVForestNodeTerm {
86 pub fn file_name(&self) -> String {
87 let key = sanitize_file_component(&self.node_key);
88 let residue = residue_suffix(self.residue_index);
89 format!(
90 "node_{:03}_{}_term_{:03}_{}.dot",
91 self.node_index, key, self.term_index, residue
92 )
93 }
94
95 pub fn forest_provenance(&self) -> Result<Option<serde_json::Value>> {
100 let dot: DotGraph = DotGraph::from_string(&self.dot)
101 .map_err(|error| eyre!("failed to parse UV forest node DOT: {error}"))?;
102 let Some(json) = dot.global_data.statements.get("forest_provenance") else {
103 return Ok(None);
104 };
105 Ok(Some(
106 serde_json::from_str(json).context("while parsing UV forest provenance JSON")?,
107 ))
108 }
109}
110
111pub(crate) struct UVForestNodeExpression {
112 pub forest_index: usize,
113 pub node_index: usize,
114 pub node_key: String,
115 pub term_index: usize,
116 pub residue_index: CutCFFIndex,
117 pub numerator: Atom,
118 pub forest_provenance: Option<Arc<UVForestProvenance>>,
119}
120
121impl Process {
122 pub fn export_uv_forest_graph(
123 &self,
124 integrand_name: &str,
125 graph_id: usize,
126 settings: &UVForestExportSettings,
127 ) -> Result<UVForestExport> {
128 let generation_settings = &self
129 .settings_history
130 .as_ref()
131 .ok_or_else(|| {
132 eyre!(
133 "Cannot export UV forests for process {} without generation settings history",
134 self.definition.folder_name
135 )
136 })?
137 .generation;
138 let resolved = self.get_integrand(integrand_name)?;
139 let integrand = resolved.require_generated()?;
140 let source = if settings.computed {
141 let (graph, expression) = match &self.collection {
142 ProcessCollection::Amplitudes(amplitudes) => {
143 let source = amplitudes[&resolved.canonical_name]
144 .graphs
145 .get(graph_id)
146 .ok_or_else(|| eyre!("Missing source amplitude graph {graph_id}"))?;
147 (&source.graph, source.derived_data.cff_expression.as_ref())
148 }
149 ProcessCollection::CrossSections(cross_sections) => {
150 let source = cross_sections[&resolved.canonical_name]
151 .supergraphs
152 .get(graph_id)
153 .ok_or_else(|| eyre!("Missing source cross-section graph {graph_id}"))?;
154 (
155 &source.graph,
156 source.derived_data.global_cff_expression.as_ref(),
157 )
158 }
159 };
160 if integrand.graph_name_by_id(graph_id) != Some(graph.name.as_str()) {
164 return Err(eyre!(
165 "Source/runtime graph mismatch for computed UV forest export at id {graph_id}"
166 ));
167 }
168 Some((
169 graph,
170 expression.ok_or_else(|| {
171 eyre!(
172 "Graph {} has no stored production CFF for computed UV forest export",
173 graph.name
174 )
175 })?,
176 ))
177 } else {
178 None
179 };
180 let cff_options = source
181 .map(|(graph, _)| graph.production_cff_3d_expression_options(generation_settings))
182 .transpose()?;
183 let orientation = source
184 .zip(cff_options.as_ref())
185 .map(|((_, expression), options)| {
186 OrientationProjection::exact_expression(
187 expression,
188 options,
189 &generation_settings.orientation_pattern,
190 generation_settings.explicit_orientation_sum_only,
191 )
192 .with_vacuum_subtraction(generation_settings.medium.vacuum_subtraction)
193 });
194 match integrand {
195 ProcessIntegrand::Amplitude(integrand) => {
196 let term = integrand.data.graph_terms.get(graph_id).ok_or_else(|| {
197 eyre!(
198 "Graph id {} is out of range for amplitude integrand {}",
199 graph_id,
200 integrand.data.name
201 )
202 })?;
203 let cut_structure = CutStructure::empty(&term.graph);
204 export_graph(
205 &term.graph,
206 cut_structure,
207 orientation,
208 generation_settings,
209 settings,
210 |_, atom| atom,
211 )
212 }
213 ProcessIntegrand::CrossSection(integrand) => {
214 let term = integrand.data.graph_terms.get(graph_id).ok_or_else(|| {
215 eyre!(
216 "Graph id {} is out of range for cross-section integrand {}",
217 graph_id,
218 integrand.data.name
219 )
220 })?;
221 let cuts = term
222 .cut_group_data
223 .cut_groups
224 .iter()
225 .map(|cuts| CutSet {
226 residue_selector: ResidueSelector {
227 lu: Some(cuts.lu_cut_selection(&term.graph, &term.cuts)),
228 left_th_cut: None,
229 right_th_cut: None,
230 },
231 union: cuts
232 .cuts
233 .iter()
234 .map(|cut_id| term.cuts[*cut_id].cut.as_subgraph())
235 .reduce(|cut_1, cut_2| cut_1.union(&cut_2))
236 .unwrap_or_else(|| term.graph.empty_subgraph()),
237 canonicalize_external_shifts: false,
238 })
239 .collect();
240 let cut_structure = CutStructure { cuts };
241 let lu_prefactors = term
244 .cut_group_data
245 .cut_groups
246 .iter()
247 .map(|group| group.lu_prefactor(&term.graph, &term.cuts))
248 .collect::<Result<Vec<_>>>()?;
249
250 export_graph(
251 &term.graph,
252 cut_structure,
253 orientation,
254 generation_settings,
255 settings,
256 |forest_index, atom| atom * &lu_prefactors[forest_index],
257 )
258 }
259 }
260 }
261}
262
263fn export_graph(
264 graph: &Graph,
265 cut_structure: CutStructure,
266 orientation: Option<OrientationProjection<'_>>,
267 generation_settings: &GenerationSettings,
268 export_settings: &UVForestExportSettings,
269 mut post_process: impl FnMut(usize, Atom) -> Atom,
270) -> Result<UVForestExport> {
271 if generation_settings.uv.orchestrator == UVOrchestrator::Compare {
272 return Err(eyre!(
273 "UV forest export does not support uv.orchestrator = compare"
274 ));
275 }
276 validate_local_counterterm_schemes(graph, &cut_structure, &generation_settings.uv)?;
277
278 let mut forest_dot = String::new();
279 let mut node_terms = Vec::new();
280 for (forest_index, cut) in cut_structure.cuts.into_iter().enumerate() {
281 let single_cut = CutStructure { cuts: vec![cut] };
282 let forest_name = format!(
283 "uv_{}_forest_{forest_index:03}",
284 sanitize_file_component(&graph.name)
285 );
286 let mut graph = graph.clone();
287 match generation_settings.uv.orchestrator {
288 UVOrchestrator::LegacyDagForest => export_legacy_forest(
289 forest_index,
290 &forest_name,
291 &mut graph,
292 single_cut,
293 orientation,
294 generation_settings,
295 export_settings,
296 &mut |atom| post_process(forest_index, atom),
297 &mut forest_dot,
298 &mut node_terms,
299 )?,
300 UVOrchestrator::HedgePoset => export_hedge_poset_forest(
301 forest_index,
302 &forest_name,
303 &mut graph,
304 single_cut,
305 orientation,
306 generation_settings,
307 export_settings,
308 &mut |atom| post_process(forest_index, atom),
309 &mut forest_dot,
310 &mut node_terms,
311 )?,
312 UVOrchestrator::Compare => unreachable!("compare is rejected before export"),
313 }
314 }
315
316 Ok(UVForestExport {
317 graph_name: graph.name.clone(),
318 forest_dot,
319 node_terms,
320 })
321}
322
323#[allow(clippy::too_many_arguments)]
324fn export_legacy_forest(
325 forest_index: usize,
326 forest_name: &str,
327 graph: &mut Graph,
328 cut_structure: CutStructure,
329 orientation: Option<OrientationProjection<'_>>,
330 generation_settings: &GenerationSettings,
331 export_settings: &UVForestExportSettings,
332 post_process: &mut impl FnMut(Atom) -> Atom,
333 forest_dot: &mut String,
334 node_terms: &mut Vec<UVForestNodeTerm>,
335) -> Result<()> {
336 let cut_woods = CutWoods::new(cut_structure, graph, generation_settings)?;
337 let mut cut_forests = cut_woods.unfold(graph);
338 let Some(forest) = cut_forests.forests.first_mut() else {
339 return Err(eyre!("Legacy UV exporter produced no forest"));
340 };
341 forest_dot.push_str(&forest.dot_serialize_for_export(forest_name));
342 forest_dot.push('\n');
343
344 if !export_settings.computed {
345 return Ok(());
346 }
347
348 if matches!(
349 generation_settings.uv.final_integrand,
350 FinalIntegrandDimension::FourD
351 ) {
352 return Err(eyre!(
353 "computed 4D UV forest export is not supported by the legacy DAG forest for graph '{}' (forest {forest_index}); use the HedgePoset orchestrator",
354 graph.name,
355 ));
356 }
357 let orientation = orientation.ok_or_else(|| {
358 eyre!("Computed UV forest export requires its stored production CFF expression")
359 })?;
360 compute_legacy_forest(graph, &mut cut_forests, orientation, generation_settings)?;
361 let forest = cut_forests
362 .forests
363 .first()
364 .expect("legacy forest exists after compute");
365 node_terms.extend(
366 forest
367 .export_node_expressions(graph, forest_index, post_process)?
368 .into_iter()
369 .map(|term| node_expression_to_dot(graph, forest_name, term))
370 .collect::<Result<Vec<_>>>()?,
371 );
372 Ok(())
373}
374
375fn compute_legacy_forest(
376 graph: &mut Graph,
377 cut_forests: &mut CutForests,
378 orientation: OrientationProjection<'_>,
379 generation_settings: &GenerationSettings,
380) -> Result<()> {
381 cut_forests.compute(
382 graph,
383 crate::utils::vakint()?,
384 orientation,
385 &generation_settings.uv,
386 )
387}
388
389#[allow(clippy::too_many_arguments)]
390fn export_hedge_poset_forest(
391 forest_index: usize,
392 forest_name: &str,
393 graph: &mut Graph,
394 cut_structure: CutStructure,
395 orientation: Option<OrientationProjection<'_>>,
396 generation_settings: &GenerationSettings,
397 export_settings: &UVForestExportSettings,
398 post_process: &mut impl FnMut(Atom) -> Atom,
399 forest_dot: &mut String,
400 node_terms: &mut Vec<UVForestNodeTerm>,
401) -> Result<()> {
402 let wood = HedgePosetWood::new(cut_structure, graph, generation_settings)?;
403 let mut forests = wood.unfold();
404 forest_dot.push_str(&name_dot_graph(forests.dot_serialize(), forest_name));
405 forest_dot.push('\n');
406
407 if !export_settings.computed {
408 return Ok(());
409 }
410
411 let expressions = match &generation_settings.uv.final_integrand {
412 FinalIntegrandDimension::FourD => {
413 forests.integrate(graph, crate::utils::vakint()?, &generation_settings.uv)?;
414 forests.export_4d_node_expressions(forest_index)?
415 }
416 FinalIntegrandDimension::ThreeD => {
417 forests.compute(
421 graph,
422 crate::utils::vakint()?,
423 orientation.ok_or_else(|| {
424 eyre!("Computed UV forest export requires its stored production CFF expression")
425 })?,
426 &generation_settings.uv,
427 )?;
428 forests.export_node_expressions(forest_index, post_process)?
429 }
430 };
431 node_terms.extend(
432 expressions
433 .into_iter()
434 .map(|term| node_expression_to_dot(graph, forest_name, term))
435 .collect::<Result<Vec<_>>>()?,
436 );
437 Ok(())
438}
439
440fn node_expression_to_dot(
441 graph: &Graph,
442 forest_name: &str,
443 term: UVForestNodeExpression,
444) -> Result<UVForestNodeTerm> {
445 let graph_name = format!(
446 "{}_node_{:03}_term_{:03}",
447 forest_name, term.node_index, term.term_index
448 );
449 let mut dot_graph = graph
450 .with_global_numerator_only(graph_name, term.numerator.clone())
451 .to_dot_graph_with_settings(&DotExportSettings {
452 split_xs_by_initial_states: true,
453 output_full_numerator: false,
454 ..DotExportSettings::default()
455 });
456 dot_graph
457 .global_data
458 .statements
459 .insert("forest_name".into(), forest_name.to_owned());
460 dot_graph
461 .global_data
462 .statements
463 .insert("forest_index".into(), term.forest_index.to_string());
464 dot_graph
465 .global_data
466 .statements
467 .insert("forest_node_index".into(), term.node_index.to_string());
468 dot_graph
469 .global_data
470 .statements
471 .insert("forest_node_key".into(), term.node_key.clone());
472 dot_graph
473 .global_data
474 .statements
475 .insert("forest_term_index".into(), term.term_index.to_string());
476 dot_graph.global_data.statements.insert(
477 "forest_residue_index".into(),
478 residue_suffix(term.residue_index),
479 );
480 if let Some(provenance) = term.forest_provenance.as_deref() {
481 let provenance = serde_json::to_string(provenance)
482 .context("while serializing UV forest provenance as JSON")?;
483 dot_graph
484 .global_data
485 .statements
486 .insert("forest_provenance".into(), provenance);
487 }
488 let full_num = term
489 .numerator
490 .printer(SpensoPrintSettings::typst_options())
491 .to_string();
492 dot_graph
493 .global_data
494 .statements
495 .insert("full_num".into(), full_num);
496
497 let mut dot = Vec::new();
498 dot_graph
499 .write_io(&mut dot)
500 .context("while serializing UV forest node DOT")?;
501 Ok(UVForestNodeTerm {
502 forest_index: term.forest_index,
503 node_index: term.node_index,
504 node_key: term.node_key,
505 term_index: term.term_index,
506 residue_index: term.residue_index,
507 dot: String::from_utf8(dot).context("DOT graph serialization was not UTF-8")?,
508 })
509}
510
511fn name_dot_graph(dot: String, name: &str) -> String {
512 dot.replacen("digraph {", &format!("digraph {name} {{"), 1)
513 .replacen("digraph Poset {", &format!("digraph {name} {{"), 1)
514}
515
516pub(crate) fn sanitize_file_component(value: &str) -> String {
517 let mut sanitized = String::new();
518 for c in value.chars() {
519 let next = if c.is_ascii_alphanumeric() || c == '-' {
520 c
521 } else {
522 '_'
523 };
524 if next == '_' {
525 if !sanitized.ends_with('_') {
526 sanitized.push(next);
527 }
528 } else {
529 sanitized.push(next);
530 }
531 }
532 sanitized.truncate(48);
533 sanitized = sanitized.trim_matches('_').to_string();
534 if sanitized.is_empty() {
535 "key".to_string()
536 } else {
537 sanitized
538 }
539}
540
541fn residue_suffix(index: CutCFFIndex) -> String {
542 let suffix = index.to_string();
543 if suffix.is_empty() {
544 "all_none".to_string()
545 } else {
546 sanitize_file_component(&suffix)
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use std::sync::{Arc, Once, OnceLock};
553
554 use linnet::parser::DotGraph;
555 use symbolica::{
556 atom::{Atom, AtomCore},
557 function, symbol,
558 };
559
560 use super::{
561 UVForestExportSettings, UVForestLocalProvenance, UVForestNodeExpression,
562 UVForestNodeProvenance, UVForestNodeTerm, UVForestProvenance, export_graph,
563 node_expression_to_dot, sanitize_file_component,
564 };
565 use crate::{
566 cff::CutCFFIndex,
567 dot,
568 graph::{FeynmanGraph, Graph, parse::IntoGraph},
569 initialisation::test_initialise,
570 settings::global::GenerationSettings,
571 utils::GS,
572 uv::{
573 ApproximationType, UVOrchestrator, UVgenerationSettings,
574 approx::{
575 CutStructure, OrientationProjection,
576 local_4d::{
577 Local4dBranch, Local4dBranchProvenance, Local4dComponentProvenance,
578 Local4dRouteSignature,
579 },
580 },
581 settings::FinalIntegrandDimension,
582 },
583 };
584
585 static EXPORT_TEST_INIT: Once = Once::new();
586 static SM_MODEL: OnceLock<crate::model::Model> = OnceLock::new();
587 static SCALAR_MODEL: OnceLock<crate::model::Model> = OnceLock::new();
588
589 #[test]
590 fn node_term_file_name_contains_stable_indices_key_and_residue() {
591 let term = UVForestNodeTerm {
592 forest_index: 2,
593 node_index: 4,
594 node_key: "{1,2}(3)".to_string(),
595 term_index: 7,
596 residue_index: CutCFFIndex {
597 left_threshold_order: None,
598 right_threshold_order: None,
599 lu_cut_order: Some(1),
600 },
601 dot: String::new(),
602 };
603
604 assert_eq!(term.file_name(), "node_004_1_2_3_term_007_lu_cut_1.dot");
605 }
606
607 #[test]
608 fn node_term_file_name_uses_all_none_residue_suffix() {
609 let term = UVForestNodeTerm {
610 forest_index: 0,
611 node_index: 0,
612 node_key: "!!!".to_string(),
613 term_index: 0,
614 residue_index: CutCFFIndex::new_all_none(),
615 dot: String::new(),
616 };
617
618 assert_eq!(term.file_name(), "node_000_key_term_000_all_none.dot");
619 }
620
621 #[test]
622 fn file_components_cannot_escape_the_export_directory() {
623 assert_eq!(sanitize_file_component("../result"), "result");
624 assert_eq!(sanitize_file_component("/tmp/result"), "tmp_result");
625 assert_eq!(sanitize_file_component("a/b"), "a_b");
626 assert_eq!(sanitize_file_component(""), "key");
627 }
628
629 #[test]
630 fn computed_node_full_numerator_is_a_spenso_aware_typst_fragment() {
631 EXPORT_TEST_INIT.call_once(|| test_initialise().unwrap());
632 let graph: Graph = dot!(digraph G {
633 ext [style=invis]
634 node [num=1]
635 ext -> A
636 C -> A
637 A -> D
638 D -> B
639 B -> C
640 C -> D
641 B -> ext
642 }, SM_MODEL.get_or_init(|| crate::utils::load_generic_model("sm")))
643 .unwrap();
644 let term = UVForestNodeExpression {
645 forest_index: 0,
646 node_index: 1,
647 node_key: "{1}".to_string(),
648 term_index: 2,
649 residue_index: CutCFFIndex::new_all_none(),
650 numerator: function!(GS.uv_truncate, Atom::num(1)),
651 forest_provenance: None,
652 };
653
654 let exported = node_expression_to_dot(&graph, "uv_typst", term).unwrap();
655
656 assert!(exported.dot.contains(r#"full_num = "op(\"Tr\")(1)";"#));
657 assert!(!exported.dot.contains("gammalooprs::Truncate"));
658 assert!(!exported.dot.contains("forest_provenance"));
659 }
660
661 #[test]
662 fn computed_node_full_numerator_distinguishes_physical_and_uv_mass_denominators() {
663 EXPORT_TEST_INIT.call_once(|| test_initialise().unwrap());
664 let graph: Graph = dot!(digraph G {
665 ext [style=invis]
666 node [num=1]
667 ext -> A
668 A -> A
669 A -> ext
670 }, SM_MODEL.get_or_init(|| crate::utils::load_generic_model("sm")))
671 .unwrap();
672 let momentum_squared = Atom::var(symbol!("inspect_momentum_squared"));
673 let physical_mass = Atom::var(symbol!("inspect_physical_mass"));
674 let uv_expansion_mass = Atom::var(GS.m_uv_expansion);
675 let uv_vacuum_mass = Atom::var(GS.m_uv_vacuum);
676 let physical_denominator = function!(
677 GS.den,
678 1,
679 momentum_squared.clone(),
680 physical_mass.clone().pow(2),
681 &momentum_squared - physical_mass.clone().pow(2)
682 );
683 let terminal_uv_denominator = function!(
684 GS.den,
685 2,
686 momentum_squared.clone(),
687 uv_expansion_mass.clone().pow(2),
688 momentum_squared - uv_vacuum_mass.clone().pow(2)
689 );
690 let term = UVForestNodeExpression {
691 forest_index: 0,
692 node_index: 1,
693 node_key: "{1}".to_string(),
694 term_index: 0,
695 residue_index: CutCFFIndex::new_all_none(),
696 numerator: physical_denominator * terminal_uv_denominator,
697 forest_provenance: None,
698 };
699
700 let exported = node_expression_to_dot(&graph, "uv_mass_provenance", term).unwrap();
701 let parsed: DotGraph = DotGraph::from_string(&exported.dot).unwrap();
702 let full_num = parsed
703 .global_data
704 .statements
705 .get("full_num")
706 .expect("computed inspect DOT must contain its full numerator");
707
708 assert_eq!(
709 full_num.matches("denom").count(),
710 2,
711 "inspect output must retain the physical and terminal-UV denominators separately: {full_num}"
712 );
713 assert!(
714 full_num.contains("inspect_physical_mass"),
715 "inspect output lost the physical-mass denominator: {full_num}"
716 );
717 assert!(
718 full_num.contains("UVexp") && full_num.contains("UV"),
719 "inspect output lost the distinct UV expansion and vacuum-mass roles: {full_num}"
720 );
721 }
722
723 #[test]
724 fn computed_hedge_4d_export_has_one_all_none_term_per_node() {
725 EXPORT_TEST_INIT.call_once(|| test_initialise().unwrap());
726 let graph: Graph = include_str!("../../../../tests/resources/graphs/scalar_bubble.dot")
727 .into_graph(SCALAR_MODEL.get_or_init(|| crate::utils::load_generic_model("scalars")))
728 .unwrap();
729 let generation_settings = GenerationSettings {
730 uv: UVgenerationSettings {
731 generate_integrated: false,
732 final_integrand: FinalIntegrandDimension::FourD,
733 orchestrator: UVOrchestrator::HedgePoset,
734 ..Default::default()
735 },
736 ..Default::default()
737 };
738 let export = export_graph(
739 &graph,
740 CutStructure::empty(&graph),
741 None,
742 &generation_settings,
743 &UVForestExportSettings { computed: true },
744 |_, _| panic!("a computed 4D export must not apply 3D post-processing"),
745 )
746 .unwrap();
747
748 assert!(!export.node_terms.is_empty());
749 let mut nodes = std::collections::BTreeSet::new();
750 for term in &export.node_terms {
751 assert!(nodes.insert((term.forest_index, term.node_index, &term.node_key)));
752 assert_eq!(term.term_index, 0);
753 assert_eq!(term.residue_index, CutCFFIndex::new_all_none());
754 let provenance = term
755 .forest_provenance()
756 .unwrap()
757 .expect("computed Hedge export must retain 4D provenance");
758 assert!(provenance.get("local_3d").is_none());
759 assert_eq!(provenance.as_object().unwrap().len(), 1);
760 assert!(provenance["node"].get("components").is_none());
761 assert!(provenance["node"].get("local_4d_branches").is_none());
762 assert_eq!(provenance["node"]["local"]["representation"], "4d");
763 assert!(provenance["node"]["local"]["components"].is_array());
764 assert!(provenance["node"]["local"]["branches"].is_array());
765 }
766 }
767
768 #[test]
769 fn computed_hedge_3d_export_retains_direct_projection_provenance() {
770 EXPORT_TEST_INIT.call_once(|| test_initialise().unwrap());
771 let mut graph: Graph = include_str!("../../../../tests/resources/graphs/scalar_bubble.dot")
772 .into_graph(SCALAR_MODEL.get_or_init(|| crate::utils::load_generic_model("scalars")))
773 .unwrap();
774 let generation_settings = GenerationSettings {
775 uv: UVgenerationSettings {
776 generate_integrated: false,
777 final_integrand: FinalIntegrandDimension::ThreeD,
778 orchestrator: UVOrchestrator::HedgePoset,
779 ..Default::default()
780 },
781 ..Default::default()
782 };
783 let options = graph
784 .production_cff_3d_expression_options(&generation_settings)
785 .unwrap();
786 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
787 let numerator = graph.production_numerator_atom_for_full_3d_expression();
788 let production = graph
789 .generate_3d_expression_for_integrand(&[], &canonization, &options, Some(&numerator))
790 .unwrap();
791 let orientation = OrientationProjection::exact_expression(
792 &production,
793 &options,
794 &generation_settings.orientation_pattern,
795 generation_settings.explicit_orientation_sum_only,
796 );
797 let export = export_graph(
798 &graph,
799 CutStructure::empty(&graph),
800 Some(orientation),
801 &generation_settings,
802 &UVForestExportSettings { computed: true },
803 |_, atom| atom,
804 )
805 .unwrap();
806
807 assert!(!export.node_terms.is_empty());
808 let provenances = export
809 .node_terms
810 .iter()
811 .map(|term| {
812 term.forest_provenance()
813 .unwrap()
814 .expect("computed 3D Hedge export must retain direct projection provenance")
815 })
816 .collect::<Vec<_>>();
817 assert!(provenances.iter().all(|provenance| {
818 provenance["node"].get("components").is_none()
819 && provenance["node"].get("local_4d_branches").is_none()
820 && provenance["node"]["local"]["representation"] == "3d"
821 && provenance["node"]["local"]["projection_paths"].is_array()
822 }));
823 assert!(
824 provenances
825 .iter()
826 .any(|provenance| provenance["node"]["local"]["projection_paths"]
827 .as_array()
828 .is_some_and(|paths| paths.iter().any(|path| path["steps"]
829 .as_array()
830 .is_some_and(|steps| !steps.is_empty())))),
831 "a non-root computed 3D term must report its actual CFF projection path"
832 );
833 for step in provenances
834 .iter()
835 .flat_map(|provenance| {
836 provenance["node"]["local"]["projection_paths"]
837 .as_array()
838 .expect("projection paths were validated above")
839 })
840 .flat_map(|path| {
841 path["steps"]
842 .as_array()
843 .expect("a direct projection path must contain a steps array")
844 })
845 {
846 assert!(step["current_component"].is_string());
847 assert!(step["given_component"].is_string());
848 assert!(step["active_subgraph"].is_string());
849 assert!(step["rescaled_subgraph"].is_string());
850 assert!(step["canonical_route_loop_edges"].is_array());
851 assert!(step["route_loop_edges"].is_array());
852 assert_eq!(step["conceptual_branches"][0]["branch"], "U");
853 assert_eq!(
854 step["conceptual_branches"][0]["coefficient"].as_i64(),
855 Some(1)
856 );
857 assert_eq!(step["materialization"], "direct_u");
858 }
859 }
860
861 #[test]
862 fn computed_legacy_4d_export_is_rejected_contextually() {
863 EXPORT_TEST_INIT.call_once(|| test_initialise().unwrap());
864 let graph: Graph = include_str!("../../../../tests/resources/graphs/scalar_bubble.dot")
865 .into_graph(SCALAR_MODEL.get_or_init(|| crate::utils::load_generic_model("scalars")))
866 .unwrap();
867 let generation_settings = GenerationSettings {
868 uv: UVgenerationSettings {
869 final_integrand: FinalIntegrandDimension::FourD,
870 orchestrator: UVOrchestrator::LegacyDagForest,
871 ..Default::default()
872 },
873 ..Default::default()
874 };
875 let error = export_graph(
876 &graph,
877 CutStructure::empty(&graph),
878 None,
879 &generation_settings,
880 &UVForestExportSettings { computed: true },
881 |_, atom| atom,
882 )
883 .err()
884 .expect("legacy computed 4D export must fail before expression generation");
885
886 let message = error.to_string();
887 assert!(message.contains("computed 4D UV forest export"));
888 assert!(message.contains("legacy DAG forest"));
889 assert!(message.contains("graph 'bubble'"));
890 assert!(message.contains("forest 0"));
891 assert!(message.contains("HedgePoset"));
892 }
893
894 #[test]
895 fn computed_hedge_provenance_survives_json_and_dot_round_trip() {
896 EXPORT_TEST_INIT.call_once(|| test_initialise().unwrap());
897 let graph: Graph = dot!(digraph G {
898 ext [style=invis]
899 node [num=1]
900 ext -> A
901 A -> A
902 A -> ext
903 }, SM_MODEL.get_or_init(|| crate::utils::load_generic_model("sm")))
904 .unwrap();
905 let provenance = Arc::new(UVForestProvenance {
906 node: UVForestNodeProvenance {
907 parent_keys: vec!["parent:{1}".to_string()],
908 local: UVForestLocalProvenance::FourD {
909 components: vec![Local4dComponentProvenance {
910 component: "12".to_string(),
911 scheme: ApproximationType::IR,
912 dod: 2,
913 route_loop_edges: vec![1],
914 route_external_edges: vec![0, 2],
915 route_signatures: vec![Local4dRouteSignature {
916 edge: 1,
917 loop_signature: "+".to_string(),
918 external_signature: "00".to_string(),
919 }],
920 }],
921 branches: vec![
922 Local4dBranchProvenance {
923 branch: Local4dBranch::U,
924 byte_size: 11,
925 },
926 Local4dBranchProvenance {
927 branch: Local4dBranch::S,
928 byte_size: 7,
929 },
930 Local4dBranchProvenance {
931 branch: Local4dBranch::US,
932 byte_size: 5,
933 },
934 Local4dBranchProvenance {
935 branch: Local4dBranch::Combined,
936 byte_size: 13,
937 },
938 ],
939 },
940 },
941 });
942 let term = UVForestNodeExpression {
943 forest_index: 3,
944 node_index: 4,
945 node_key: "{1};{2}".to_string(),
946 term_index: 5,
947 residue_index: CutCFFIndex::new_all_none(),
948 numerator: Atom::one(),
949 forest_provenance: Some(provenance.clone()),
950 };
951 let exported = node_expression_to_dot(&graph, "uv_provenance", term).unwrap();
952 assert_eq!(
953 exported.forest_provenance().unwrap(),
954 Some(serde_json::to_value(provenance.as_ref()).unwrap())
955 );
956 let reparsed: DotGraph = DotGraph::from_string(&exported.dot).unwrap();
957 let json = &reparsed.global_data.statements["forest_provenance"];
958 let round_trip: UVForestProvenance = serde_json::from_str(json).unwrap();
959
960 assert_eq!(round_trip, *provenance);
961 assert_eq!(reparsed.global_data.statements["forest_index"], "3");
962 assert_eq!(reparsed.global_data.statements["forest_node_index"], "4");
963 assert_eq!(
964 reparsed.global_data.statements["forest_node_key"],
965 "{1};{2}"
966 );
967 assert_eq!(reparsed.global_data.statements["forest_term_index"], "5");
968 assert_eq!(
969 reparsed.global_data.statements["forest_residue_index"],
970 "all_none"
971 );
972 }
973
974 #[test]
975 fn computed_exports_match_physical_production_normalization() -> color_eyre::Result<()> {
976 use crate::{
977 feyngen::GenerationType,
978 processes::{Process, ProcessCollection, ProcessDefinition},
979 settings::{GlobalSettings, RuntimeSettings},
980 utils::{W_, load_generic_model},
981 uv::marker::UvMarker,
982 };
983 use linnet::half_edge::{involution::EdgeIndex, subgraph::SubSetOps};
984 use symbolica::atom::{AtomCore, AtomView};
985
986 test_initialise()?;
987 let model = load_generic_model("scalars");
988 let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build()?;
989 let runtime = RuntimeSettings::default();
990 for orchestrator in [UVOrchestrator::LegacyDagForest, UVOrchestrator::HedgePoset] {
991 for multiplicity in [0, 2, 3] {
994 let (kind, source) = if multiplicity == 0 {
995 (
996 GenerationType::Amplitude,
997 include_str!(concat!(
998 env!("CARGO_MANIFEST_DIR"),
999 "/../../tests/resources/graphs/scalar_bubble.dot"
1000 ))
1001 .to_string(),
1002 )
1003 } else {
1004 let mut source = String::from(
1005 "digraph scalar_born { ext [style=invis]; \
1006 ext -> left [particle=scalar_2,is_cut=0]; \
1007 right -> ext [particle=scalar_2,is_cut=0];",
1008 );
1009 for _ in 0..multiplicity {
1010 source.push_str("left -> right [particle=scalar_0];");
1011 }
1012 source.push('}');
1013 (GenerationType::CrossSection, source)
1014 };
1015 let graphs = Graph::from_string(&source, &model)?;
1016 let definition = ProcessDefinition::from_graph_list(&graphs, kind, &model)?;
1017 let mut process = Process::from_graph_list(
1018 "export_phase".into(),
1019 "default".into(),
1020 graphs,
1021 kind,
1022 Some(definition),
1023 None,
1024 &model,
1025 )?;
1026 let mut settings = GlobalSettings::default();
1027 settings.generation.uv.orchestrator = orchestrator;
1028 settings.generation.uv.subtract_uv = false;
1029 settings.generation.uv.generate_integrated = false;
1030 settings.generation.threshold_subtraction.enable_thresholds = false;
1031 settings.generation.explicit_orientation_sum_only = true;
1032 settings.generation.evaluator.compile = false;
1033 process.preprocess(&model, &settings, &(&runtime).into(), &pool)?;
1034 process.generate_integrands(&model, &settings, (&runtime).into(), &pool)?;
1035 let (graph, expected) = match &process.collection {
1036 ProcessCollection::Amplitudes(amplitudes) => {
1037 let graph = &litudes["default"].graphs[0];
1038 (&graph.graph, graph.derived_data.resolved_integrand()?)
1039 }
1040 ProcessCollection::CrossSections(cross_sections) => {
1041 let graph = &cross_sections["default"].supergraphs[0];
1042 (
1043 &graph.graph,
1044 graph
1045 .derived_data
1046 .cut_paramatric_integrand
1047 .iter()
1048 .map(|integrand| integrand.integrands.resolved())
1049 .collect::<color_eyre::Result<Vec<_>>>()?
1050 .iter()
1051 .flat_map(|integrands| integrands.iter())
1052 .map(|(_, atom)| atom.clone())
1053 .sum::<Atom>(),
1054 )
1055 }
1056 };
1057 let exported = process.export_uv_forest_graph(
1058 "default",
1059 0,
1060 &UVForestExportSettings { computed: true },
1061 )?;
1062 let split = graph
1063 .iter_edges_of(
1064 &graph
1065 .full_filter()
1066 .subtract(&graph.initial_state_cut)
1067 .subtract(&graph.tree_edges),
1068 )
1069 .map(|(_, edge, _)| GS.split_mom_pattern_simple(edge))
1070 .collect::<Vec<_>>();
1071 let mut actual = Atom::Zero;
1072 for term in &exported.node_terms {
1073 for parsed in Graph::from_string(&term.dot, &model)? {
1074 actual += UvMarker::new(&settings.generation.uv).finish(
1075 &parsed
1076 .global_prefactor
1077 .num
1078 .replace_multiple(&split)
1079 .replace(GS.den(W_.a_, W_.b_, W_.c_, W_.d_))
1080 .with(W_.d_),
1081 );
1082 }
1083 }
1084 if actual != expected {
1089 let mut inputs = actual
1090 .get_all_indeterminates(false)
1091 .into_iter()
1092 .chain(expected.get_all_indeterminates(false))
1093 .map(|input| input.to_owned())
1094 .collect::<Vec<_>>();
1095 inputs.sort();
1096 inputs.dedup();
1097 for base in [3, 5, 7] {
1098 let mut actual_value = actual.clone();
1099 let mut expected_value = expected.clone();
1100 for (index, input) in inputs.iter().enumerate() {
1101 let value = Atom::num(base).pow(index as i64 + 1);
1102 actual_value = actual_value.replace(input.clone()).with(value.clone());
1103 expected_value = expected_value.replace(input.clone()).with(value);
1104 }
1105 assert!(matches!(actual_value.as_view(), AtomView::Num(_)));
1106 assert!(matches!(expected_value.as_view(), AtomView::Num(_)));
1107 assert!(!expected_value.is_zero());
1108 assert_eq!(
1109 actual_value, expected_value,
1110 "{kind}/{orchestrator}: computed export differs from production at base {base}"
1111 );
1112 }
1113 }
1114 if multiplicity != 0 {
1115 let [coupling, expression] = model.get_coupling("SCALAR_COUPLING").rep_rule();
1116 let lambda: Atom = model.get_parameter("lam").name.into();
1117 let mut physical = actual
1120 .replace(GS.wrap_tree_denoms(Atom::one()))
1121 .with(Atom::one())
1122 .replace(coupling)
1123 .with(expression)
1124 .replace(lambda)
1125 .with(Atom::one())
1126 .replace(GS.rescale_star)
1127 .with(Atom::one())
1128 .replace(GS.hfunction_lu_cut)
1129 .with(Atom::one());
1130 for edge in 0..graph.n_edges() {
1131 physical = physical.replace(GS.ose(EdgeIndex(edge))).with(Atom::one());
1132 }
1133 let two_pi = Atom::num(2) * Atom::var(GS.pi);
1137 let born = &two_pi
1138 / (two_pi.pow(3 * (multiplicity - 1)) * Atom::num(2).pow(multiplicity));
1139 assert_eq!(
1140 physical, born,
1141 "{orchestrator}: scalar Born export has the wrong phase or normalization"
1142 );
1143 }
1144 }
1145 }
1146 Ok(())
1147 }
1148
1149 #[test]
1150 fn computed_export_retains_nonempty_forests_in_all_routes_and_orchestrators()
1151 -> color_eyre::Result<()> {
1152 test_initialise()?;
1153 let mut graph: Graph = dot!(digraph computed_direct_forest {
1154 edge [num=1 mass=1]
1155 node [num=1]
1156 a -> b [id=0 lmb_id=0]
1157 a -> b [id=1]
1158 })?;
1159 let mut generation = GenerationSettings::default();
1160 assert!(generation.uv.subtract_uv);
1161 assert!(generation.uv.generate_integrated);
1162 assert_eq!(
1163 generation.uv.final_integrand,
1164 crate::uv::settings::FinalIntegrandDimension::ThreeD
1165 );
1166 let options = graph.production_cff_3d_expression_options(&generation)?;
1167 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
1168 let production = graph.generate_3d_expression_for_integrand(
1169 &[],
1170 &canonization,
1171 &options,
1172 Some(&Atom::one()),
1173 )?;
1174 assert!(!production.expression.orientations.is_empty());
1175
1176 for orchestrator in [UVOrchestrator::LegacyDagForest, UVOrchestrator::HedgePoset] {
1177 generation.uv.orchestrator = orchestrator;
1178 for (explicit_sum, projected) in [(false, false), (true, false), (true, true)] {
1179 generation.explicit_orientation_sum_only = explicit_sum;
1180 generation.uv.local_uv_cts_from_expanded_4d_integrands = projected;
1181 let projection = OrientationProjection::exact_expression(
1182 &production,
1183 &options,
1184 &generation.orientation_pattern,
1185 generation.explicit_orientation_sum_only,
1186 );
1187 let exported = export_graph(
1188 &graph,
1189 CutStructure::empty(&graph),
1190 Some(projection),
1191 &generation,
1192 &UVForestExportSettings { computed: true },
1193 |_, atom| atom,
1194 )?;
1195 let node_indices = exported
1196 .node_terms
1197 .iter()
1198 .map(|term| term.node_index)
1199 .collect::<std::collections::BTreeSet<_>>();
1200 assert!(
1203 node_indices.len() > 1,
1204 "{} (summed={explicit_sum}, projected={projected}): no nonempty computed forest",
1205 generation.uv.orchestrator
1206 );
1207 assert!(exported.node_terms.iter().all(|term| {
1208 term.forest_index == 0
1209 && term.residue_index == CutCFFIndex::new_all_none()
1210 && term.dot.contains("forest_residue_index")
1211 }));
1212
1213 let topology = export_graph(
1214 &graph,
1215 CutStructure::empty(&graph),
1216 None,
1217 &generation,
1218 &UVForestExportSettings { computed: false },
1219 |_, atom| atom,
1220 )?;
1221 assert!(!topology.forest_dot.is_empty());
1222 assert!(topology.node_terms.is_empty());
1223 }
1224 }
1225 Ok(())
1226 }
1227}