Skip to main content

gammalooprs/uv/
export.rs

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/// The provenance representation must match the numerator exported beside it.
42/// A direct 3D export therefore records its CFF projection paths rather than
43/// forcing construction of an unrelated 4D counterterm atom.
44#[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    /// Whether to evaluate forest nodes. Computed exports consume the
65    /// orientations already selected while generating the graph term;
66    /// structure-only exports do not evaluate orientation-dependent atoms.
67    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    /// Return the atom-free provenance embedded in this computed Hedge DOT.
96    ///
97    /// Parsing remains encapsulated here so callers do not need a direct DOT
98    /// parser dependency. Legacy and structure-only exports return `None`.
99    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            // Generation and persistent selection preserve graph order;
161            // reject stale runtime metadata instead of pairing a different
162            // graph with this stored production residue map.
163            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                // Finalized UV nodes already include the CFF spatial measure.
242                // Reuse the physical cut-group conversion, as production does.
243                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            // A computed 3D export carries provenance from the direct
418            // orientation-local CFF projections themselves. Do not construct
419            // unrelated 4D atoms merely to annotate this diagnostic output.
420            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            // One amplitude contour and two Born cut multiplicities expose both
992            // a lost cut i and any second insertion of the spatial measure.
993            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 = &amplitudes["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                // Compare the stored trees first. If their factorizations differ,
1085                // evaluate those trees directly at exact nonsingular points.
1086                // These signed checks certify the sampled values, not a symbolic
1087                // identity; neither numerator is expanded or polynomialized.
1088                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                    // The runtime tree-denominator function is the identity;
1118                    // these contact Born graphs have an empty product inside it.
1119                    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                    // With all cut energies set to one, |A|²=1 and no graph
1134                    // symmetry factor, dPhi_n has residue coefficient
1135                    // 2pi / [(2pi)^(3(n-1)) * 2^n]. The LU derivative is separate.
1136                    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                // Require the empty-forest root and at least one actual UV node;
1201                // merely exporting the uncomputed forest DOT misses this failure.
1202                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}