1use crate::{
2 cff::{
3 CffEnergyDegreeBoundReport,
4 esurface::Esurface,
5 expression::{
6 GammaLoopOrientationExpression, OrientationExpression, OrientationID,
7 OrientationSelector, energy_map_replacements_gs,
8 },
9 hsurface::Hsurface,
10 surface::{GammaLoopLinearEnergyExpr, LinearEnergyExpr},
11 },
12 debug_tags,
13 graph::{Graph, LoopMomentumBasis, cuts::CutSet},
14 momentum::Sign,
15 settings::global::OrientationPattern,
16 utils::GS,
17 uv::{
18 ApproximationType, Spinney, UVgenerationSettings,
19 approx::{
20 direct_3d::{Direct3dApproximation, Direct3dCts},
21 final_integrand::{FinalIntegrandBuilder, FinalIntegrands},
22 integrated::{Integrated, IntegratedCts},
23 local_3d::{Local3DCts, Localizer},
24 local_4d::{Full4dCts, Local4dCts},
25 projected_4d::{Local4dProjectionContext, Projected4dApproximation},
26 },
27 marker::UvMarker,
28 settings::FinalIntegrandDimension,
29 },
30};
31use color_eyre::Result;
32use eyre::eyre;
33use gammaloop_tracing_filter::{LogMessage, debug_instrument};
34
35use std::sync::Mutex;
36
37use symbolica::{
38 atom::{Atom, AtomCore},
39 function,
40};
41
42use linnet::half_edge::involution::EdgeIndex;
43#[cfg(test)]
44use linnet::half_edge::involution::{EdgeVec, Orientation};
45use linnet::half_edge::subgraph::{InternalSubGraph, SuBitGraph, SubSetLike, SubSetOps};
46#[cfg(test)]
47use three_dimensional_reps::CffGenerationContext;
48use three_dimensional_reps::{Generate3DExpressionOptions, GeneratedThreeDExpression};
49use typed_index_collections::TiVec;
50
51use vakint::Vakint;
52
53#[cfg(test)]
54use self::direct_3d::DirectResidueBranches;
55
56pub mod direct_3d;
57pub mod final_integrand;
58pub mod integrated;
59pub mod local_3d;
60pub mod local_4d;
61pub(crate) mod projected_4d;
62
63pub trait Rooted {
64 fn root() -> Self;
65}
66
67pub trait ForestNodeLike: LogMessage {
68 fn subgraph(&self) -> &SuBitGraph;
69 fn lmb(&self) -> &LoopMomentumBasis;
70 fn lmb_id(&self) -> EdgeIndex {
71 *self.lmb().loop_edges.first().unwrap()
72 }
73 fn dod(&self) -> i32;
75 fn renormalization_scheme(&self) -> ApproximationType;
76 fn topo_order(&self) -> usize;
77 fn reduced_subgraph(&self, given: &Self) -> SuBitGraph;
78}
79
80pub struct UVCtx<'a> {
81 pub graph: &'a Graph,
82 pub settings: &'a UVgenerationSettings,
83}
84
85impl<'a> UVCtx<'a> {
86 pub fn new(graph: &'a Graph, settings: &'a UVgenerationSettings) -> Self {
87 Self { graph, settings }
88 }
89}
90
91#[derive(Clone)]
92pub struct SimpleApprox {
93 t_args: Vec<Atom>,
94 pub sign: Sign,
95 graph: InternalSubGraph,
96}
97
98impl SimpleApprox {
99 pub(crate) fn expr(&self, bigger_graph: &SuBitGraph) -> Atom {
100 let reduced = UvMarker::subgraph(bigger_graph, &self.graph.filter);
101 let mut mul = Atom::num(1);
102 for i in &self.t_args {
103 mul *= i;
104 }
105 reduced * mul
106 }
107
108 pub(crate) fn t_op(&self, bigger_graph: &SuBitGraph) -> Atom {
109 function!(GS.uv_approx, self.expr(bigger_graph))
110 }
111
112 pub(crate) fn root(subgraph: InternalSubGraph) -> Self {
113 if !subgraph.is_empty() {
114 panic!(
115 "Root approximation must be empty {} {:?}",
116 subgraph.string_label(),
117 subgraph
118 )
119 }
120 SimpleApprox {
121 t_args: vec![],
122 sign: Sign::Positive,
123 graph: subgraph,
124 }
125 }
126
127 pub(crate) fn dependent(&self, bigger_graph: InternalSubGraph) -> Self {
128 Self {
129 t_args: vec![self.t_op(&bigger_graph.filter)],
130 sign: -self.sign,
131 graph: bigger_graph,
132 }
133 }
134}
135
136#[derive(Clone)]
137pub struct Approximation {
138 pub spinney: Spinney,
139 local_3d: Option<Local3DCts>,
140 local: Option<Local4dCts>,
141 integrated: Option<IntegratedCts>,
142 final_integrand: Option<FinalIntegrands>,
143 pub topo_order: usize,
144 pub simple_approx: Option<SimpleApprox>,
145}
146
147impl Approximation {
148 pub(crate) fn integrated(&self, graph: &Graph) -> Result<&IntegratedCts> {
149 self.integrated
150 .as_ref()
151 .ok_or_else(|| eyre!("No integrated CT for {}", self.simple_display(graph)))
152 }
153
154 pub(crate) fn local(&self, graph: &Graph) -> Result<&Local4dCts> {
155 self.local
156 .as_ref()
157 .ok_or_else(|| eyre!("No local CT for {}", self.simple_display(graph)))
158 }
159
160 pub(crate) fn recursion_input_4d(&self, graph: &Graph) -> Result<Full4dCts> {
161 Full4dCts::recursion_input(
162 self.local(graph)?,
163 self.integrated(graph)?,
164 self.renormalization_scheme(),
165 self.spinney.subgraph.is_empty(),
166 self.lmb(),
167 )
168 }
169
170 pub(crate) fn local_3d(&self, graph: &Graph) -> Result<&Local3DCts> {
171 self.local_3d
172 .as_ref()
173 .ok_or_else(|| eyre!("No local 3D CT for {}", self.simple_display(graph)))
174 }
175
176 pub(crate) fn final_integrand(&self, graph: &Graph) -> Result<&FinalIntegrands> {
177 self.final_integrand
178 .as_ref()
179 .ok_or_else(|| eyre!("No final integrand for {}", self.simple_display(graph)))
180 }
181
182 pub fn simple_display(&self, graph: &Graph) -> String {
183 format!(
184 "{} of {}",
185 self.simple_approx
186 .as_ref()
187 .unwrap()
188 .expr(&graph.full_filter()),
189 graph.name
190 )
191 }
192}
193
194impl LogMessage for Approximation {
195 fn log_display(&self) -> String {
196 format!(
197 "subgraph={}, topo_order={}, dod={}",
198 self.spinney.filter().string_label(),
199 self.topo_order,
200 self.spinney.dod
201 )
202 }
203}
204
205impl ForestNodeLike for Approximation {
206 fn dod(&self) -> i32 {
207 self.spinney.dod
208 }
209
210 fn renormalization_scheme(&self) -> ApproximationType {
211 self.spinney.renormalization_scheme
212 }
213
214 fn lmb(&self) -> &LoopMomentumBasis {
215 &self.spinney.lmb
216 }
217
218 fn reduced_subgraph(&self, given: &Self) -> SuBitGraph {
219 self.spinney
220 .subgraph
221 .subtract(&given.spinney.subgraph)
222 .filter
223 }
224
225 fn subgraph(&self) -> &SuBitGraph {
226 self.spinney.filter()
227 }
228
229 fn topo_order(&self) -> usize {
230 self.topo_order
231 }
232}
233
234#[derive(Clone)]
235pub struct CutStructure {
236 pub cuts: Vec<CutSet>,
237}
238
239#[derive(Clone, Copy, Debug)]
240enum OrientationProjectionSource<'a> {
241 Exact {
242 orientations: &'a TiVec<OrientationID, OrientationExpression>,
243 root_expression: Option<&'a GeneratedThreeDExpression<Esurface, Hsurface>>,
244 options: &'a Generate3DExpressionOptions,
245 },
246 FourDOnly,
247}
248
249#[derive(Clone, Copy, Debug)]
250pub(crate) struct OrientationProjection<'a> {
251 source: OrientationProjectionSource<'a>,
252 energy_degree_bound_reports: Option<&'a Mutex<Vec<CffEnergyDegreeBoundReport>>>,
253 pub(crate) orientation_pattern: &'a OrientationPattern,
254 pub(crate) explicit_orientation_sum_only: bool,
255 pub(crate) vacuum_subtraction: bool,
256}
257
258impl<'a> OrientationProjection<'a> {
259 pub(crate) fn four_d(orientation_pattern: &'a OrientationPattern) -> Self {
263 Self {
264 source: OrientationProjectionSource::FourDOnly,
265 energy_degree_bound_reports: None,
266 orientation_pattern,
267 explicit_orientation_sum_only: false,
268 vacuum_subtraction: false,
269 }
270 }
271
272 #[cfg(test)]
273 pub(crate) fn exact(
274 orientations: &'a TiVec<OrientationID, OrientationExpression>,
275 options: &'a Generate3DExpressionOptions,
276 orientation_pattern: &'a OrientationPattern,
277 explicit_orientation_sum_only: bool,
278 ) -> Self {
279 Self {
280 source: OrientationProjectionSource::Exact {
281 orientations,
282 root_expression: None,
283 options,
284 },
285 energy_degree_bound_reports: None,
286 orientation_pattern,
287 explicit_orientation_sum_only,
288 vacuum_subtraction: false,
289 }
290 }
291
292 pub(crate) fn exact_expression(
293 expression: &'a GeneratedThreeDExpression<Esurface, Hsurface>,
294 options: &'a Generate3DExpressionOptions,
295 orientation_pattern: &'a OrientationPattern,
296 explicit_orientation_sum_only: bool,
297 ) -> Self {
298 Self {
299 source: OrientationProjectionSource::Exact {
300 orientations: &expression.expression.orientations,
301 root_expression: Some(expression),
302 options,
303 },
304 energy_degree_bound_reports: None,
305 orientation_pattern,
306 explicit_orientation_sum_only,
307 vacuum_subtraction: false,
308 }
309 }
310
311 pub(crate) fn with_vacuum_subtraction(mut self, enabled: bool) -> Self {
312 self.vacuum_subtraction = enabled;
313 self
314 }
315
316 pub(crate) fn with_energy_degree_bound_reports(
317 mut self,
318 reports: &'a Mutex<Vec<CffEnergyDegreeBoundReport>>,
319 ) -> Self {
320 self.energy_degree_bound_reports = Some(reports);
321 self
322 }
323
324 pub(crate) fn record_energy_degree_bound_report(self, report: &CffEnergyDegreeBoundReport) {
325 let Some(reports) = self.energy_degree_bound_reports else {
326 return;
327 };
328 let mut report = report.clone();
329 report.physical_parent_bounds.sort_unstable();
330 report.assigned_cff_source_bounds.sort_unstable();
331 let mut reports = reports
332 .lock()
333 .unwrap_or_else(|poisoned| poisoned.into_inner());
334 if !reports.contains(&report) {
335 reports.push(report);
336 }
337 }
338
339 pub(crate) fn cff_options(self) -> Result<&'a Generate3DExpressionOptions> {
340 match self.source {
341 OrientationProjectionSource::Exact { options, .. } => Ok(options),
342 OrientationProjectionSource::FourDOnly => Err(eyre!(
343 "four-dimensional-only renormalization has no 3D projection options"
344 )),
345 }
346 }
347
348 pub(crate) fn orientation_ids(self) -> Result<Vec<OrientationID>> {
349 Ok(self
350 .exact_orientations()?
351 .iter_enumerated()
352 .filter_map(|(id, orientation)| {
353 self.orientation_pattern
354 .filter_orientation(&orientation.data.orientation)
355 .then_some(id)
356 })
357 .collect())
358 }
359
360 pub(crate) fn exact_orientations(
361 self,
362 ) -> Result<&'a TiVec<OrientationID, OrientationExpression>> {
363 match self.source {
364 OrientationProjectionSource::Exact { orientations, .. } => Ok(orientations),
365 OrientationProjectionSource::FourDOnly => Err(eyre!(
366 "four-dimensional-only renormalization has no 3D residue maps"
367 )),
368 }
369 }
370
371 pub(crate) fn root_expression(
372 self,
373 ) -> Option<&'a GeneratedThreeDExpression<Esurface, Hsurface>> {
374 match self.source {
375 OrientationProjectionSource::Exact {
376 root_expression, ..
377 } => root_expression,
378 OrientationProjectionSource::FourDOnly => None,
379 }
380 }
381
382 pub(crate) fn map_numerator(
386 self,
387 graph: &Graph,
388 selector_id: OrientationID,
389 source_edge_energy_map: Option<&[LinearEnergyExpr]>,
390 numerator: &Atom,
391 ) -> Result<Atom> {
392 let orientation = self.exact_orientations()?.get(selector_id).ok_or_else(|| {
393 eyre!(
394 "missing production energy map for orientation {}",
395 selector_id.0
396 )
397 })?;
398 let replacements = source_edge_energy_map.map_or_else(
399 || orientation.energy_replacements_gs(graph),
400 |edge_energy_map| {
401 energy_map_replacements_gs(
402 edge_energy_map.iter().map(|energy| energy.to_atom_gs(&[])),
403 graph,
404 )
405 },
406 );
407 Ok(numerator.replace_multiple(replacements))
408 }
409}
410
411impl CutStructure {
412 pub(crate) fn empty(graph: &Graph) -> Self {
413 Self {
414 cuts: vec![CutSet::empty(graph.n_hedges())],
415 }
416 }
417}
418
419impl Approximation {
420 pub(crate) fn root(
421 &mut self,
422 graph: &mut Graph,
423 localizer: Localizer<'_>,
424 settings: &UVgenerationSettings,
425 ) -> Result<()> {
426 self.simple_approx = Some(SimpleApprox::root(self.spinney.subgraph.clone()));
427 self.local = Some(Local4dCts::root());
428 let integrated = IntegratedCts::root();
429 if let FinalIntegrandDimension::ThreeD = settings.final_integrand {
430 let direct = Direct3dCts::root(graph, localizer)?;
434 self.final_integrand = Some(
435 FinalIntegrandBuilder::new(localizer, settings).build_direct(
436 graph,
437 self,
438 &direct,
439 &integrated,
440 )?,
441 );
442 self.local_3d = Some(Local3DCts::Direct(direct));
443 }
444 self.integrated = Some(integrated);
445
446 Ok(())
447 }
448
449 pub(crate) fn new(spinney: Spinney) -> Approximation {
450 Approximation {
451 spinney,
452 topo_order: 0,
453 final_integrand: None,
454 simple_approx: None,
455 local: None,
456 local_3d: None,
457 integrated: None,
458 }
459 }
460
461 #[debug_instrument(
462 graph = %graph.log_display(),
463 current = %self.log_display(),
464 given = %dependent.log_display(),
465 reduced = ?self.reduced_subgraph(dependent),
466 )]
467 pub(crate) fn compute_4d(
468 &mut self,
469 graph: &Graph,
470 vakint: (&Vakint, &vakint::VakintSettings),
471 dependent: &Self,
472 settings: &UVgenerationSettings,
473 ) -> Result<()> {
474 let ctx = UVCtx { graph, settings };
475 debug_tags!(#generation,#uv,#fourd;
476 simple = %self.simple_display(graph),
477 "Computing 4D",
478 );
479
480 let old_full = dependent.recursion_input_4d(graph)?;
481 let local = local_4d::uv_limit(&old_full, &ctx, self, dependent, self, dependent)?;
482 let integrated = if settings.generate_integrated {
483 Integrated::new(vakint.0, vakint.1)
484 .run(&local, &ctx, self, dependent, self, dependent)?
485 } else {
486 IntegratedCts::root()
487 };
488
489 self.local = Some(local);
490 self.integrated = Some(integrated);
491
492 Ok(())
493 }
494
495 #[allow(clippy::too_many_arguments)]
497 #[debug_instrument(
498 graph = %graph.log_display(),
499 current = %self.log_display(),
500 given = %dependent.log_display(),
501 )]
502 pub(crate) fn compute_3d(
503 &mut self,
504 dependent: &Self,
505 graph: &mut Graph,
506 localizer: Localizer<'_>,
507 settings: &UVgenerationSettings,
508 projection_context: &mut Local4dProjectionContext,
509 ) -> Result<()> {
510 let (local_3d, final_integrand) =
511 if settings.local_uv_cts_from_expanded_4d_integrands {
512 let local_4d = self.local(graph)?;
513 let projected = Projected4dApproximation::new(localizer, graph, settings)
514 .project_local_4d(local_4d, projection_context)?;
515 let final_integrand = FinalIntegrandBuilder::new(localizer, settings)
516 .build_projected(graph, self, &projected, self.integrated(graph)?)?;
517 (Local3DCts::Projected4d(projected), final_integrand)
518 } else {
519 let parent_local = dependent.local_3d(graph)?;
520 let parent_integrated = dependent.integrated(graph)?;
521 let direct = Direct3dApproximation::new(localizer, graph, settings).run(
522 parent_local.direct()?,
523 parent_integrated,
524 self,
525 dependent,
526 self,
527 dependent,
528 )?;
529 let final_integrand = FinalIntegrandBuilder::new(localizer, settings)
530 .build_direct(graph, self, &direct, self.integrated(graph)?)?;
531 (Local3DCts::Direct(direct), final_integrand)
532 };
533
534 self.final_integrand = Some(final_integrand);
535 self.local_3d = Some(local_3d);
536 Ok(())
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use std::collections::{BTreeMap, BTreeSet};
543
544 use super::*;
545 use crate::{
546 cff::CutCFFIndex,
547 dot,
548 graph::{FeynmanGraph, cuts::LuCutSelection, parse::IntoGraph},
549 initialisation::test_initialise,
550 integrands::{
551 evaluation::EvaluationMetaData,
552 process::{
553 GenericEvaluatorFloat,
554 evaluators::{EvaluatorStack, SingleOrAllOrientations},
555 },
556 },
557 momentum::{
558 ThreeMomentum,
559 sample::{BareMomentumSample, ExternalFourMomenta, LoopMomenta, MomentumSample},
560 },
561 numerator::symbolica_ext::NumeratorAtomExt,
562 processes::{EvaluatorSettings, cross_section::build_derivative_structure},
563 settings::RuntimeSettings,
564 utils::{
565 ArbPrec, F, FUN_LIB, FloatLike, TENSORLIB, W_, cut_energy,
566 hyperdual_utils::{DualOrNot, extract_t_derivatives_complex, simple_n_deriv_shape},
567 },
568 uv::UltravioletGraph,
569 };
570 use idenso::shorthands::{metric::MetricSimplifier, schoonschip::Schoonschip};
571 use linnet::half_edge::subgraph::subset::SubSet;
572 use spenso::{
573 algebra::{algebraic_traits::IsZero, complex::Complex},
574 network::{ExecutionResult, Sequential, SmallestDegree},
575 };
576 use symbolica::domains::dual::HyperDual;
577 use symbolica::{
578 domains::{
579 float::{Complex as SymComplex, Real},
580 integer::IntegerRing,
581 rational::{Fraction, Rational},
582 },
583 evaluate::ExpressionEvaluator,
584 parse_lit,
585 };
586
587 #[test]
588 fn expanded_4d_setting_does_not_change_the_empty_forest_root() -> Result<()> {
589 test_initialise()?;
590 let mut graph: Graph = dot!(digraph root_identity {
591 edge [num=1 mass=1]
592 node [num=1]
593
594 a -> b [id=0 lmb_id=0]
595 a -> b [id=1]
596 })?;
597 let options = graph.denominator_only_cff_3d_expression_options();
598 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
599 let production = graph.generate_3d_expression_for_integrand(
600 &[],
601 &canonization,
602 &options,
603 Some(&Atom::one()),
604 )?;
605 let mut direct_graph = graph.clone();
606 let mut projected_graph = graph;
607 let cutset = CutSet::empty(direct_graph.n_hedges());
608 let orientation_pattern = OrientationPattern::default();
609 let localizer = Localizer::new(
610 &cutset,
611 OrientationProjection::exact_expression(
612 &production,
613 &options,
614 &orientation_pattern,
615 false,
616 ),
617 );
618 let mut direct = Approximation::new(Spinney::empty(&direct_graph));
619 let mut projected = Approximation::new(Spinney::empty(&projected_graph));
620 let direct_settings = UVgenerationSettings::default();
621 let projected_settings = UVgenerationSettings {
622 local_uv_cts_from_expanded_4d_integrands: true,
623 ..direct_settings.clone()
624 };
625
626 direct.root(&mut direct_graph, localizer, &direct_settings)?;
627 projected.root(&mut projected_graph, localizer, &projected_settings)?;
628
629 assert_eq!(
630 projected
631 .final_integrand(&projected_graph)?
632 .iter()
633 .collect::<BTreeMap<_, _>>(),
634 direct
635 .final_integrand(&direct_graph)?
636 .iter()
637 .collect::<BTreeMap<_, _>>(),
638 "the empty forest must preserve every finalized residue value and selector"
639 );
640 Ok(())
641 }
642
643 #[test]
644 fn selected_raised_lu_projection_preserves_quadratic_contact_family() -> Result<()> {
645 test_initialise()?;
646 let mut graph: Graph = dot!(digraph raised_lu_contact_family {
647 num = 1
648 edge [particle="scalar_1" num=1]
649 node [num=1]
650
651 ext0 [style=invis is_cut=0]
652 v2 -> ext0 [id=0]
653 ext0 -> v3
654 v0 -> v1 [id=1 lmb_id=0]
655 v0 -> v1 [id=2]
656 v3 -> v0 [id=3 lmb_id=1]
657 v1 -> v2 [id=4]
658 v2 -> v3 [id=5]
659 }, "scalars")?;
660 let options = graph.denominator_only_cff_3d_expression_options();
661 let production_numerator =
662 GS.emr_mom(EdgeIndex(1), GS.cind(0)) * GS.emr_mom(EdgeIndex(3), GS.cind(0));
663 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
664 let production = graph.generate_3d_expression_for_integrand(
665 &[],
666 &canonization,
667 &options,
668 Some(&production_numerator),
669 )?;
670 let lu_cut = graph
671 .determine_raised_esurfaces_from_expression(&production.expression)
672 .raised_groups
673 .into_iter()
674 .find(|group| {
675 group.max_occurence == 2
676 && group.esurface_ids.iter().any(|surface_id| {
677 !production.expression.surfaces.esurface_cache[*surface_id]
678 .external_shift
679 .is_empty()
680 })
681 })
682 .expect("the repeated outer channel supplies a raised LU surface");
683 let mut cutset = CutSet::empty(graph.n_hedges());
684 cutset.residue_selector.lu = Some(LuCutSelection {
685 cut_edge_alternatives: lu_cut
686 .esurface_ids
687 .iter()
688 .map(|surface_id| {
689 production.expression.surfaces.esurface_cache[*surface_id]
690 .energies
691 .clone()
692 })
693 .collect(),
694 raised_group: lu_cut,
695 });
696 let contracted = graph
697 .get_edge_subgraph(EdgeIndex(1))
698 .union(&graph.get_edge_subgraph(EdgeIndex(2)));
699 assert_eq!(
703 graph.loop_momentum_basis.edge_signatures[EdgeIndex(3)],
704 graph.loop_momentum_basis.edge_signatures[EdgeIndex(4)],
705 );
706 let multilinear_numerator = GS.emr_mom(EdgeIndex(3), GS.cind(0))
707 * GS.emr_mom(EdgeIndex(4), GS.cind(0))
708 + Atom::num(Rational::from((7, 11)));
709 let analysis_numerator =
710 GS.emr_mom(EdgeIndex(3), GS.cind(0)).pow(2) + Atom::num(Rational::from((7, 11)));
711 let contract_subgraph = contracted
712 .union(&graph.tree_edges)
713 .subtract(&graph.initial_state_cut);
714 let mut raw_graph = graph.clone();
715 let raw = raw_graph.cff(
716 &contract_subgraph,
717 &cutset,
718 &OrientationPattern::default(),
719 &options,
720 Some(&analysis_numerator),
721 )?;
722 let orientation_pattern = OrientationPattern::default();
723 let localizer = Localizer::new(
724 &cutset,
725 OrientationProjection::exact_expression(
726 &production,
727 &options,
728 &orientation_pattern,
729 true,
730 ),
731 );
732 let projected = localizer.projected_cff(
733 &mut graph,
734 &contracted,
735 [&analysis_numerator],
736 CffGenerationContext::Standalone,
737 )?;
738 let raw_expressions = raw
739 .terms
740 .iter()
741 .map(|(index, term)| {
742 (
743 *index,
744 term.orientations
745 .iter()
746 .map(|orientation| {
747 &orientation.expression
748 * analysis_numerator.replace_multiple(
749 orientation.orientation.energy_replacements_gs(&raw_graph),
750 )
751 })
752 .fold(Atom::Zero, |sum, term| sum + term)
753 * Atom::num(raw.production_prefactor_factor()),
754 )
755 })
756 .collect::<BTreeMap<_, _>>();
757 let mut denominator_only_graph = graph.clone();
758 let denominator_only = denominator_only_graph.cff(
759 &contract_subgraph,
760 &cutset,
761 &OrientationPattern::default(),
762 &options,
763 Some(&Atom::one()),
764 )?;
765 let denominator_only_expressions = denominator_only
766 .terms
767 .iter()
768 .map(|(index, term)| {
769 (
770 *index,
771 term.orientations
772 .iter()
773 .map(|orientation| {
774 &orientation.expression
775 * multilinear_numerator.replace_multiple(
776 orientation
777 .orientation
778 .energy_replacements_gs(&denominator_only_graph),
779 )
780 })
781 .fold(Atom::Zero, |sum, term| sum + term)
782 * Atom::num(denominator_only.production_prefactor_factor()),
783 )
784 })
785 .collect::<BTreeMap<_, _>>();
786 let mut projected_expressions = BTreeMap::<CutCFFIndex, Atom>::new();
787 for (orientation_id, source_edge_energy_map, integrands) in projected.iter_orientations() {
788 let mapped_numerator = localizer.map_numerator(
789 &graph,
790 orientation_id,
791 source_edge_energy_map,
792 &analysis_numerator,
793 )?;
794 for (index, expression) in integrands.iter() {
795 *projected_expressions.entry(*index).or_default() += expression * &mapped_numerator;
796 }
797 }
798 let fixed_point = |mut expression: Atom| -> Result<Atom> {
799 expression = expression
800 .replace(GS.dim)
801 .with(4)
802 .replace(GS.dim_epsilon)
803 .with(0)
804 .replace(GS.numerator_sampling_scale)
805 .with(Atom::num(Rational::from((13, 10))))
806 .replace(function!(GS.tree_denom_wrapper, W_.x_))
807 .with(W_.x_)
808 .replace(GS.den(W_.a_, W_.b_, W_.c_, W_.d_))
809 .with(W_.d_);
810 for edge in 0..graph.underlying.n_edges() {
811 let edge = EdgeIndex(edge);
812 expression = expression
813 .replace(graph.underlying[edge].particle.mass_atom())
814 .with(Atom::one())
815 .replace(GS.ose(edge))
816 .with(Atom::num(Rational::from((5, 4))))
817 .replace(cut_energy(edge))
818 .with(Atom::num(Rational::from((5, 4))))
819 .replace(GS.emr_mom(edge, GS.cind(0)))
820 .with(Atom::num(Rational::from((17, 7))));
821 }
822 Ok(expression)
823 };
824 let evaluate_arb = |expression: Atom| -> Result<Complex<F<ArbPrec>>> {
825 let parameters = [Atom::var(GS.pi)];
826 let rational: ExpressionEvaluator<SymComplex<Fraction<IntegerRing>>> = expression
827 .evaluator(¶meters)
828 .build()
829 .map_err(|error| eyre!("failed to build raised-contact evaluator: {error}"))?;
830 let mut arb: ExpressionEvaluator<Complex<F<ArbPrec>>> =
831 rational.map_coeff(&|coefficient| {
832 Complex::new(F::from(&coefficient.re), F::from(&coefficient.im))
833 });
834 let zero = F(ArbPrec::default());
835 Ok(arb.evaluate_single(&[Complex::new(zero.clone().pi(), zero)]))
836 };
837 let tolerance = F(ArbPrec::default().epsilon()).sqrt().sqrt().sqrt();
838 for order in [1, 2] {
839 let index = raw_expressions
840 .keys()
841 .find(|index| index.lu_cut_order == Some(order))
842 .copied()
843 .expect("both raised orders are present");
844 let raw_value = evaluate_arb(fixed_point(raw_expressions[&index].clone())?)?;
845 let denominator_only_value =
846 evaluate_arb(fixed_point(denominator_only_expressions[&index].clone())?)?;
847 let projected_value =
848 evaluate_arb(fixed_point(projected_expressions[&index].clone())?)?;
849 let scale = raw_value
850 .clone()
851 .norm()
852 .re
853 .max(denominator_only_value.clone().norm().re)
854 .max(projected_value.clone().norm().re);
855 assert!(
856 (raw_value.clone() - &denominator_only_value).norm().re <= &tolerance * &scale,
857 "the generalized and denominator-only selected LU{order} residues differ: generalized={raw_value}, denominator-only={denominator_only_value}"
858 );
859 assert!(
860 (raw_value - projected_value).norm().re <= &tolerance * scale,
861 "production hosting changed the complete selected LU{order} residue"
862 );
863 }
864 Ok(())
865 }
866
867 #[test]
868 fn gl24_direct_3d_modes_preserve_orientation_selector_contracts() -> Result<()> {
869 test_initialise()?;
870 let mut graph: Graph = dot!(digraph gl24_selector_contract {
871 edge [num=1 mass=1]
872 node [num=1]
873
874 v0 -> v1 [id=0 is_cut=0]
875 v0 -> v3 [id=1 lmb_id=0 num="(Q(1,spenso::cind(0))^2-Q(1,spenso::cind(1))^2-Q(1,spenso::cind(2))^2-Q(1,spenso::cind(3))^2)^2"]
876 v0 -> v5 [id=2]
877 v1 -> v2 [id=3 lmb_id=1]
878 v1 -> v3 [id=4]
879 v2 -> v4 [id=5 lmb_id=2]
880 v2 -> v4 [id=6]
881 v3 -> v5 [id=7]
882 v4 -> v5 [id=8 mass=2]
883 })?;
884 let options = graph.denominator_only_cff_3d_expression_options();
885 let numerator = graph.production_numerator_atom_for_full_3d_expression();
886 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
887 let production = graph.generate_3d_expression_for_integrand(
888 &[],
889 &canonization,
890 &options,
891 Some(&numerator),
892 )?;
893 let uv_filter = graph
894 .get_edge_subgraph(EdgeIndex(5))
895 .union(&graph.get_edge_subgraph(EdgeIndex(6)));
896 let uv_subgraph = InternalSubGraph::cleaned_filter_optimist(uv_filter, graph.as_ref());
897 let child_spinney = Spinney::new(uv_subgraph, &graph, &graph.loop_momentum_basis)?
898 .expect("the GL24 e5/e6 bubble has a compatible nonempty UV sub-LMB");
899 assert!(!child_spinney.subgraph.is_empty());
900
901 let target_support = [EdgeIndex(3), EdgeIndex(4)]
902 .into_iter()
903 .collect::<BTreeSet<_>>();
904 let lu_surface = graph
905 .determine_raised_esurfaces_from_expression(&production.expression)
906 .raised_groups
907 .into_iter()
908 .find(|group| {
909 group.esurface_ids.iter().any(|surface_id| {
910 production.expression.surfaces.esurface_cache[*surface_id]
911 .energies
912 .iter()
913 .copied()
914 .collect::<BTreeSet<_>>()
915 == target_support
916 })
917 })
918 .ok_or_else(|| eyre!("GL24 q3/q4 LU surface was not generated"))?;
919 let mut cutset = CutSet::empty(graph.n_hedges());
920 cutset.residue_selector.lu = Some(LuCutSelection {
921 raised_group: lu_surface,
922 cut_edge_alternatives: vec![vec![EdgeIndex(3), EdgeIndex(4)]],
923 });
924 let expected_keys = cutset
925 .residue_selector
926 .generate_allowed_keys()
927 .into_iter()
928 .collect::<BTreeSet<_>>();
929 assert_eq!(
930 expected_keys
931 .iter()
932 .filter_map(|index| index.lu_cut_order)
933 .collect::<BTreeSet<_>>(),
934 BTreeSet::from([1]),
935 );
936
937 let orientation_pattern = OrientationPattern::default();
938 let settings = UVgenerationSettings {
939 generate_integrated: false,
940 local_uv_cts_from_expanded_4d_integrands: false,
941 add_marker: false,
942 ..Default::default()
943 };
944 let check = |explicit_orientation_sum_only| -> Result<Atom> {
945 let mut route_graph = graph.clone();
946 let localizer = Localizer::new(
947 &cutset,
948 OrientationProjection::exact_expression(
949 &production,
950 &options,
951 &orientation_pattern,
952 explicit_orientation_sum_only,
953 ),
954 );
955 let mut root = Approximation::new(Spinney::empty(&route_graph));
956 root.root(&mut route_graph, localizer, &settings)?;
957 let mut child = Approximation::new(child_spinney.clone());
958 child.simple_approx = Some(
959 root.simple_approx
960 .as_ref()
961 .expect("the root approximation is initialized")
962 .dependent(child.spinney.subgraph.clone()),
963 );
964 let vakint_settings = vakint::VakintSettings::default();
965 child.compute_4d(
966 &route_graph,
967 (crate::utils::vakint()?, &vakint_settings),
968 &root,
969 &settings,
970 )?;
971 child.compute_3d(
972 &root,
973 &mut route_graph,
974 localizer,
975 &settings,
976 &mut Local4dProjectionContext::default(),
977 )?;
978 let final_integrand = child
982 .final_integrand(&route_graph)?
983 .iter()
984 .find(|(index, _)| expected_keys.contains(index))
985 .ok_or_else(|| eyre!("the selected GL24 residue has no final integrand"))?
986 .1;
987 Ok(final_integrand)
988 };
989 let ordinary_integrand = check(false)?;
990 let explicit_integrand = check(true)?;
991 assert!(!ordinary_integrand.is_zero());
992 assert!(!explicit_integrand.is_zero());
993
994 let selector_atoms = production
995 .expression
996 .orientations
997 .iter_enumerated()
998 .map(|(id, _)| id.atom())
999 .collect::<Vec<_>>();
1000 for (key, coefficient) in ordinary_integrand.coefficient_list::<u8>(&selector_atoms) {
1004 if !coefficient.is_zero() {
1005 assert!(
1006 selector_atoms.contains(&key),
1007 "the final integrand must be homogeneous of degree one in its production selectors"
1008 );
1009 assert!(
1010 !coefficient.contains_symbol(OrientationID::symbol()),
1011 "selector coefficients must not contain uncatalogued production keys"
1012 );
1013 }
1014 }
1015 let ordinary_sum = ordinary_integrand
1016 .replace(function!(
1017 OrientationID::symbol(),
1018 symbolica::symbol!("gl24_selector_")
1019 ))
1020 .with(Atom::one());
1021 assert!(
1022 (ordinary_sum.collect_factors() - explicit_integrand.collect_factors()).is_zero(),
1023 "summing the orientation-local final integrand must recover the explicit final integrand"
1024 );
1025
1026 Ok(())
1027 }
1028
1029 #[test]
1030 fn nested_scalar_bubble_direct_3d_modes_match_without_a_cut() -> Result<()> {
1031 test_initialise()?;
1032 let mut graph: Graph = dot!(
1033 digraph nested_scalar_bubble_selector_contract {
1034 edge [particle="scalar_1"];
1035 node [num=1];
1036
1037 v1 -> v2 [id=0 lmb_id=0];
1038 v1 -> v2 [id=1];
1039 v2 -> v3 [id=2 lmb_id=1];
1040 v3 -> v1 [id=3];
1041 },
1042 "scalars"
1043 )?;
1044 let options = graph.denominator_only_cff_3d_expression_options();
1045 let numerator = graph.production_numerator_atom_for_full_3d_expression();
1046 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
1047 let production = graph.generate_3d_expression_for_integrand(
1048 &[],
1049 &canonization,
1050 &options,
1051 Some(&numerator),
1052 )?;
1053 let uv_filter = graph
1054 .get_edge_subgraph(EdgeIndex(0))
1055 .union(&graph.get_edge_subgraph(EdgeIndex(1)));
1056 let uv_subgraph = InternalSubGraph::cleaned_filter_optimist(uv_filter, graph.as_ref());
1057 let child_spinney = Spinney::new(uv_subgraph, &graph, &graph.loop_momentum_basis)?
1058 .expect("the doubled edge is a logarithmic one-loop UV subgraph");
1059 let orientation_pattern = OrientationPattern::default();
1060 let settings = UVgenerationSettings {
1061 generate_integrated: false,
1062 add_marker: false,
1063 ..Default::default()
1064 };
1065 let build = |mut route_graph: Graph, explicit_orientation_sum_only| -> Result<_> {
1066 let cutset = CutSet::empty(route_graph.n_hedges());
1067 let localizer = Localizer::new(
1068 &cutset,
1069 OrientationProjection::exact_expression(
1070 &production,
1071 &options,
1072 &orientation_pattern,
1073 explicit_orientation_sum_only,
1074 ),
1075 );
1076 let mut root = Approximation::new(Spinney::empty(&route_graph));
1077 root.root(&mut route_graph, localizer, &settings)?;
1078 let mut child = Approximation::new(child_spinney.clone());
1079 child.simple_approx = Some(
1080 root.simple_approx
1081 .as_ref()
1082 .expect("the root approximation is initialized")
1083 .dependent(child.spinney.subgraph.clone()),
1084 );
1085 child.compute_4d(
1086 &route_graph,
1087 (crate::utils::vakint()?, &vakint::VakintSettings::default()),
1088 &root,
1089 &settings,
1090 )?;
1091 child.compute_3d(
1092 &root,
1093 &mut route_graph,
1094 localizer,
1095 &settings,
1096 &mut Local4dProjectionContext::default(),
1097 )?;
1098 let sectors = child.local_3d(&route_graph)?.direct()?.sectors()?;
1099 let mut hosts = sectors
1100 .iter()
1101 .flat_map(|sector| sector.active.iter_keys())
1102 .filter(|(_, integrands)| {
1103 integrands
1104 .resolved()
1105 .expect("diagnostic branch numerator definitions must resolve")
1106 .iter()
1107 .any(|(_, atom)| !atom.is_zero())
1108 })
1109 .map(|(key, _)| key.selector_host)
1110 .collect::<Vec<_>>();
1111 hosts.sort_unstable();
1112 hosts.dedup();
1113 let summarize_direct = |integrands: &DirectResidueBranches| {
1114 let mut by_host = BTreeMap::<OrientationID, Atom>::new();
1115 for (key, branch) in integrands.iter_keys() {
1116 *by_host.entry(key.selector_host).or_default() += branch
1117 .resolved()
1118 .expect("diagnostic branch numerator definitions must resolve")
1119 .iter()
1120 .fold(Atom::Zero, |sum, (_, atom)| sum + atom);
1121 }
1122 by_host.into_iter().collect::<Vec<_>>()
1123 };
1124 let summarize_projected = |integrands: &local_3d::OrientationIntegrands| {
1125 let mut by_host = BTreeMap::<OrientationID, Atom>::new();
1126 for (host, _, branch) in integrands.iter_orientations() {
1127 *by_host.entry(host).or_default() += branch
1128 .resolved()
1129 .expect("diagnostic branch numerator definitions must resolve")
1130 .iter()
1131 .fold(Atom::Zero, |sum, (_, atom)| sum + atom);
1132 }
1133 by_host.into_iter().collect::<Vec<_>>()
1134 };
1135 let child_branches = sectors
1136 .iter()
1137 .flat_map(|sector| summarize_direct(§or.active))
1138 .collect::<Vec<_>>();
1139 let mut outer_branches = Vec::new();
1140 if !explicit_orientation_sum_only {
1141 for sector in sectors {
1142 let reduced = route_graph
1143 .full_filter()
1144 .subtract(child.subgraph())
1145 .subtract(&route_graph.initial_state_cut);
1146 let resnum = route_graph
1147 .numerator(&reduced, child.subgraph())
1148 .get_single_atom()
1149 .expect("the scalar outer numerator is available")
1150 * route_graph.global_atom();
1151 let analysis_branches = sector
1152 .active
1153 .iter_keys()
1154 .map(|(_, integrands)| integrands.resolved())
1155 .collect::<Result<Vec<_>>>()?;
1156 let analysis_numerators = analysis_branches
1157 .iter()
1158 .flat_map(|integrands| integrands.iter())
1159 .map(|(_, atom)| atom * &resnum);
1160 let outer = localizer.projected_cff(
1161 &mut route_graph,
1162 child.subgraph(),
1163 analysis_numerators,
1164 CffGenerationContext::Standalone,
1165 )?;
1166 outer_branches.extend(summarize_projected(&outer));
1167 }
1168 }
1169 let final_integrand = child
1170 .final_integrand(&route_graph)?
1171 .iter()
1172 .find(|(index, _)| *index == CutCFFIndex::new_all_none())
1173 .ok_or_else(|| eyre!("the scalar-bubble child has no root residue"))?
1174 .1
1175 .clone();
1176 Ok((hosts, child_branches, outer_branches, final_integrand))
1177 };
1178 let build_full_cff_oracle = |mut route_graph: Graph| -> Result<Atom> {
1179 let cutset = CutSet::empty(route_graph.n_hedges());
1180 let localizer = Localizer::new(
1181 &cutset,
1182 OrientationProjection::exact_expression(
1183 &production,
1184 &options,
1185 &orientation_pattern,
1186 false,
1187 ),
1188 );
1189 let mut root = Approximation::new(Spinney::empty(&route_graph));
1190 root.root(&mut route_graph, localizer, &settings)?;
1191 let child = Approximation::new(child_spinney.clone());
1192 let root_local = root.local_3d(&route_graph)?.direct()?.clone();
1193 let local = Direct3dApproximation::new(localizer, &mut route_graph, &settings).run(
1194 &root_local,
1195 &IntegratedCts::root(),
1196 &child,
1197 &root,
1198 &child,
1199 &root,
1200 )?;
1201 Ok(FinalIntegrandBuilder::new(localizer, &settings)
1202 .build_direct(&mut route_graph, &child, &local, &IntegratedCts::root())?
1203 .iter()
1204 .find(|(index, _)| *index == CutCFFIndex::new_all_none())
1205 .ok_or_else(|| eyre!("the full-CFF selector oracle has no root residue"))?
1206 .1)
1207 };
1208 let (ordinary_hosts, child_branches, outer_branches, ordinary) =
1209 build(graph.clone(), false)?;
1210 let (_, _, _, explicit) = build(graph.clone(), true)?;
1211 let full_cff_oracle = build_full_cff_oracle(graph.clone())?;
1212
1213 let production_map_ids = production
1214 .expression
1215 .orientations
1216 .iter_enumerated()
1217 .map(|(id, _)| id)
1218 .collect::<Vec<_>>();
1219 let ordinary_sum = production_map_ids
1220 .iter()
1221 .map(|id| id.select(ordinary.as_view()))
1222 .fold(Atom::Zero, |sum, atom| sum + atom);
1223 let full_cff_sum = production_map_ids
1224 .iter()
1225 .map(|id| id.select(full_cff_oracle.as_view()))
1226 .fold(Atom::Zero, |sum, atom| sum + atom);
1227 assert!(
1228 (&full_cff_sum - &explicit).expand().is_zero(),
1229 "the established full-CFF selector oracle must recover the explicit child sum"
1230 );
1231 let mismatched_map_keys = production_map_ids
1232 .iter()
1233 .filter_map(|id| {
1234 let ordinary = id.select(ordinary.as_view());
1235 let oracle = id.select(full_cff_oracle.as_view());
1236 let difference = (&ordinary - &oracle).expand();
1237 (!difference.is_zero()).then_some((
1238 *id,
1239 production.expression.orientations[*id]
1240 .data
1241 .orientation
1242 .clone(),
1243 ordinary,
1244 oracle,
1245 difference,
1246 ))
1247 })
1248 .collect::<Vec<_>>();
1249 assert!(
1250 (ordinary_sum - explicit).expand().is_zero(),
1251 "the isolated scalar-bubble child differs between direct local-3D modes; ordinary hosts {ordinary_hosts:?}, child branches {child_branches:?}, outer branches {outer_branches:?}, mismatched production map keys {mismatched_map_keys:?}"
1252 );
1253
1254 Ok(())
1255 }
1256
1257 #[test]
1258 fn factorized_owned_dot_child_cff_matches_direct_3d_for_uncut_self_energy() -> Result<()> {
1259 test_initialise()?;
1260 let mut graph: Graph = dot!(digraph factorized_child_cff {
1261 edge [num=1 mass=1]
1262 node [num=1]
1263 incoming [style=invis]
1264 outgoing [style=invis]
1265
1266 incoming -> v0 [id=0]
1267 v0 -> v1 [id=1 lmb_id=0]
1268 v0 -> v1 [id=2 num="Q(2,spenso::cind(0))^2-Q(2,spenso::cind(1))^2-Q(2,spenso::cind(2))^2-Q(2,spenso::cind(3))^2-1"]
1269 v1 -> outgoing [id=3]
1270 })?;
1271 let options = graph.denominator_only_cff_3d_expression_options();
1272 let numerator = graph.production_numerator_atom_for_full_3d_expression();
1273 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
1274 let production = graph.generate_3d_expression_for_integrand(
1275 &[],
1276 &canonization,
1277 &options,
1278 Some(&numerator),
1279 )?;
1280 let uv_filter = graph
1281 .get_edge_subgraph(EdgeIndex(1))
1282 .union(&graph.get_edge_subgraph(EdgeIndex(2)));
1283 let uv_subgraph = InternalSubGraph::cleaned_filter_optimist(uv_filter, graph.as_ref());
1284 let child_spinney = Spinney::with_scheme(
1285 uv_subgraph,
1286 &graph,
1287 &graph.loop_momentum_basis,
1288 ApproximationType::MUV,
1289 2,
1290 )?
1291 .expect("the self-energy bubble has a compatible sub-LMB");
1292 assert_eq!(child_spinney.dod, 2);
1293 let cutset = CutSet::empty(graph.n_hedges());
1294 let orientation_pattern = OrientationPattern::default();
1295 let build = |mut route_graph: Graph, from_expanded_4d| -> Result<_> {
1296 let settings = UVgenerationSettings {
1297 generate_integrated: false,
1298 local_uv_cts_from_expanded_4d_integrands: from_expanded_4d,
1299 ..Default::default()
1300 };
1301 let bound_reports = Mutex::new(Vec::new());
1302 let localizer = Localizer::new(
1303 &cutset,
1304 OrientationProjection::exact_expression(
1305 &production,
1306 &options,
1307 &orientation_pattern,
1308 true,
1309 )
1310 .with_energy_degree_bound_reports(&bound_reports),
1311 );
1312 let mut root = Approximation::new(Spinney::empty(&route_graph));
1313 root.root(&mut route_graph, localizer, &settings)?;
1314 let root_integrand = root
1315 .final_integrand(&route_graph)?
1316 .iter()
1317 .find(|(index, _)| *index == CutCFFIndex::new_all_none())
1318 .expect("the uncut root has one residue sector")
1319 .1;
1320 let mut child = Approximation::new(child_spinney.clone());
1321 child.simple_approx = Some(
1322 root.simple_approx
1323 .as_ref()
1324 .expect("the root approximation is initialized")
1325 .dependent(child.spinney.subgraph.clone()),
1326 );
1327 let vakint_settings = vakint::VakintSettings::default();
1328 child.compute_4d(
1329 &route_graph,
1330 (crate::utils::vakint()?, &vakint_settings),
1331 &root,
1332 &settings,
1333 )?;
1334 let projected_denominator_owners: Vec<Vec<usize>> = if from_expanded_4d {
1335 child
1336 .local(&route_graph)?
1337 .active_sectors()
1338 .iter()
1339 .map(|sector| sector.physical_terms())
1340 .collect::<Result<Vec<_>>>()?
1341 .into_iter()
1342 .flatten()
1343 .map(|term| {
1344 term.denominators
1345 .into_iter()
1346 .map(|denominator| usize::from(denominator.source_edge))
1347 .collect()
1348 })
1349 .collect()
1350 } else {
1351 Vec::new()
1352 };
1353 child.compute_3d(
1354 &root,
1355 &mut route_graph,
1356 localizer,
1357 &settings,
1358 &mut Local4dProjectionContext::default(),
1359 )?;
1360 let integrand = child
1361 .final_integrand(&route_graph)?
1362 .iter()
1363 .find(|(index, _)| *index == CutCFFIndex::new_all_none())
1364 .expect("the uncut self-energy has one residue sector")
1365 .1;
1366 let reports = bound_reports
1367 .lock()
1368 .unwrap_or_else(|poisoned| poisoned.into_inner())
1369 .clone();
1370 Ok((
1371 integrand,
1372 root_integrand,
1373 reports,
1374 projected_denominator_owners,
1375 ))
1376 };
1377 let (direct_expression, direct_root, direct_reports, _) = build(graph.clone(), false)?;
1378 let (
1379 projected_expression,
1380 projected_root,
1381 projected_reports,
1382 mut projected_denominator_owners,
1383 ) = build(graph.clone(), true)?;
1384 for owners in &mut projected_denominator_owners {
1387 owners.sort_unstable();
1388 }
1389 projected_denominator_owners.sort_unstable();
1390 projected_denominator_owners.dedup();
1391 assert_eq!(
1392 projected_denominator_owners,
1393 vec![vec![1, 1, 2], vec![1, 2], vec![1, 2, 2], vec![1, 2, 2, 2]],
1394 );
1395
1396 let direct_bound = direct_reports
1397 .iter()
1398 .find(|report| {
1399 report.source_kind == crate::cff::CffEnergyBoundSourceKind::PhysicalGraph
1400 && report.physical_parent_bounds == vec![(2, 2)]
1401 })
1402 .expect("the direct child CFF must retain its owned-dot energy bounds");
1403 assert_eq!(direct_bound.assigned_cff_source_bounds, vec![(2, 2)]);
1404 let projected_bound = projected_reports
1405 .iter()
1406 .find(|report| {
1407 report.source_kind == crate::cff::CffEnergyBoundSourceKind::ExactFourD
1408 && report.physical_parent_bounds == vec![(2, 4)]
1409 })
1410 .expect("the quadratic numerator and two denominator derivatives retain e2 rank four");
1411 assert_eq!(
1412 projected_bound
1413 .assigned_cff_source_bounds
1414 .iter()
1415 .map(|(_, degree)| degree)
1416 .sum::<usize>(),
1417 4,
1418 );
1419
1420 let normalize = |expression: Atom| {
1423 expression
1424 .replace(GS.dim)
1425 .with(4)
1426 .replace(GS.dim_epsilon)
1427 .with(0)
1428 .replace(GS.m_uv_expansion)
1429 .with(Atom::num(Rational::from((7, 5))))
1430 .replace(GS.m_uv_vacuum)
1431 .with(Atom::num(Rational::from((7, 5))))
1432 .replace(function!(GS.tree_denom_wrapper, W_.x_))
1433 .with(W_.x_)
1434 .replace(GS.den(W_.a_, W_.b_, W_.c_, W_.d_))
1435 .with(W_.d_)
1436 .replace(function!(GS.ose, W_.mass_, W_.prop_))
1437 .with(W_.prop_)
1438 .replace(function!(GS.on_shell_energy, W_.a_, W_.prop_))
1439 .with(Atom::var(W_.prop_).pow((1, 2)))
1440 .expand_dots()
1441 .expect("test-only component expansion must succeed")
1442 };
1443 let expressions = vec![
1444 normalize(direct_expression),
1445 normalize(projected_expression),
1446 normalize(direct_root),
1447 normalize(projected_root),
1448 ];
1449 assert_eq!(graph.loop_momentum_basis.loop_edges.len(), 1);
1450 assert_eq!(graph.loop_momentum_basis.ext_edges.len(), 2);
1451 let rational =
1452 |numerator, denominator| F::<ArbPrec>::from(&Rational::from((numerator, denominator)));
1453 let loop_moms: LoopMomenta<F<ArbPrec>> = [ThreeMomentum::new(
1454 rational(31, 100),
1455 rational(-47, 100),
1456 rational(83, 100),
1457 )]
1458 .into_iter()
1459 .collect();
1460 let external_moms: ExternalFourMomenta<F<ArbPrec>> = [
1461 [
1462 rational(12, 5),
1463 rational(0, 1),
1464 rational(0, 1),
1465 rational(0, 1),
1466 ]
1467 .into(),
1468 [
1469 rational(-12, 5),
1470 rational(0, 1),
1471 rational(0, 1),
1472 rational(0, 1),
1473 ]
1474 .into(),
1475 ]
1476 .into_iter()
1477 .collect();
1478 let sample = MomentumSample {
1479 sample: BareMomentumSample {
1480 loop_moms: loop_moms.clone(),
1481 dual_loop_moms: None,
1482 loop_mom_cache_id: 0,
1483 loop_mom_base_cache_id: 0,
1484 external_moms,
1485 external_mom_cache_id: 0,
1486 external_mom_base_cache_id: 0,
1487 jacobian: rational(1, 1),
1488 orientation: None,
1489 parameterization_branch: None,
1490 },
1491 };
1492 let orientations = TiVec::<OrientationID, EdgeVec<Orientation>>::new();
1493 let orientation_filter = SubSet::full(orientations.len());
1494 let mut param_builder = graph.param_builder.clone();
1495 let (mut evaluator, _) = EvaluatorStack::new_explicit_sum_with_timings(
1496 &expressions,
1497 ¶m_builder,
1498 &[],
1499 None,
1500 &EvaluatorSettings::default(),
1501 )?;
1502 let input = <ArbPrec as GenericEvaluatorFloat>::get_parameters(
1503 &mut param_builder,
1504 (false, false),
1505 &graph,
1506 &sample,
1507 &[],
1508 &[],
1509 None,
1510 None,
1511 None,
1512 );
1513 let values = evaluator
1514 .evaluate(
1515 input,
1516 SingleOrAllOrientations::All {
1517 all: &orientations,
1518 filter: &orientation_filter,
1519 },
1520 &RuntimeSettings::default(),
1521 &mut EvaluationMetaData::new_empty(),
1522 )?
1523 .into_iter()
1524 .map(|value| value.unwrap_real())
1525 .collect::<Vec<_>>();
1526 let distance = (values[0].clone() - values[1].clone()).norm().re;
1527 let direct_norm = values[0].clone().norm().re;
1528 let projected_norm = values[1].clone().norm().re;
1529 let scale = if direct_norm > projected_norm {
1530 direct_norm
1531 } else {
1532 projected_norm
1533 };
1534 let relative_distance = if scale.is_zero() {
1535 distance
1536 } else {
1537 distance / scale
1538 };
1539 let tolerance = F(ArbPrec::default().epsilon()).sqrt().sqrt().sqrt();
1540 assert!(
1541 relative_distance <= tolerance,
1542 "kinematically consistent owned-dot self-energy mismatch: direct={:e}, projected={:e}, relative delta={relative_distance:e}, tolerance={tolerance:e}",
1543 values[0],
1544 values[1],
1545 );
1546 Ok(())
1547 }
1548
1549 #[test]
1550 fn direct_nested_scalar_banana_replays_complete_cff_at_depth_three() -> Result<()> {
1551 test_initialise()?;
1552 for depth in [2, 3] {
1553 let mut graph: Graph = if depth == 2 {
1554 dot!(digraph nested_scalar_banana {
1555 edge [num=1 mass=1]
1556 node [num=1]
1557 incoming [style=invis]
1558 outgoing [style=invis]
1559
1560 incoming -> a [id=0]
1561 a -> b [id=1 lmb_id=0]
1562 a -> b [id=2 lmb_id=1]
1563 a -> b [id=3]
1564 b -> outgoing [id=4]
1565 })?
1566 } else {
1567 dot!(digraph depth_three_scalar_banana {
1568 edge [num=1 mass=1]
1569 node [num=1]
1570 incoming [style=invis]
1571 outgoing [style=invis]
1572
1573 incoming -> a [id=0]
1574 a -> b [id=1 lmb_id=0]
1575 a -> b [id=2 lmb_id=1]
1576 a -> b [id=3 lmb_id=2]
1577 a -> b [id=4]
1578 b -> outgoing [id=5]
1579 })?
1580 };
1581 let global_marker = symbolica::symbol!("nested_scalar_banana::global");
1582 graph.global_prefactor.num = Atom::var(global_marker);
1583
1584 let mut filter = graph.get_edge_subgraph(EdgeIndex(1));
1585 let mut spinneys = Vec::new();
1586 for edge in 2..=depth + 1 {
1587 filter = filter.union(&graph.get_edge_subgraph(EdgeIndex(edge)));
1588 let subgraph =
1589 InternalSubGraph::cleaned_filter_optimist(filter.clone(), graph.as_ref());
1590 spinneys.push(
1591 Spinney::new(subgraph, &graph, &graph.loop_momentum_basis)?
1592 .expect("each nested banana has a compatible sub-LMB"),
1593 );
1594 }
1595
1596 let options = graph.denominator_only_cff_3d_expression_options();
1597 let numerator = graph.production_numerator_atom_for_full_3d_expression();
1598 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
1599 let production = graph.generate_3d_expression_for_integrand(
1600 &[],
1601 &canonization,
1602 &options,
1603 Some(&numerator),
1604 )?;
1605 let cutset = CutSet::empty(graph.n_hedges());
1606 let orientation_pattern = OrientationPattern::default();
1607
1608 let build = |mut route_graph: Graph, from_expanded_4d| -> Result<Vec<Atom>> {
1609 let settings = UVgenerationSettings {
1610 generate_integrated: false,
1611 local_uv_cts_from_expanded_4d_integrands: from_expanded_4d,
1612 ..Default::default()
1613 };
1614 let localizer = Localizer::new(
1615 &cutset,
1616 OrientationProjection::exact_expression(
1617 &production,
1618 &options,
1619 &orientation_pattern,
1620 true,
1621 ),
1622 );
1623 let vakint_settings = vakint::VakintSettings::default();
1624 let mut root = Approximation::new(Spinney::empty(&route_graph));
1625 root.root(&mut route_graph, localizer, &settings)?;
1626
1627 let mut parent = root;
1628 let mut finals = Vec::new();
1629 for spinney in &spinneys {
1630 let mut current = Approximation::new(spinney.clone());
1631 current.simple_approx = Some(
1632 parent
1633 .simple_approx
1634 .as_ref()
1635 .expect("the parent approximation is initialized")
1636 .dependent(current.spinney.subgraph.clone()),
1637 );
1638 current.compute_4d(
1639 &route_graph,
1640 (crate::utils::vakint()?, &vakint_settings),
1641 &parent,
1642 &settings,
1643 )?;
1644 current.compute_3d(
1645 &parent,
1646 &mut route_graph,
1647 localizer,
1648 &settings,
1649 &mut Local4dProjectionContext::default(),
1650 )?;
1651 let local = current.local_3d(&route_graph)?;
1652 if let Local3DCts::Direct(direct) = local {
1653 let value = direct.branches()?.factorized_sum();
1654 assert!(
1655 !value.contains_symbol(global_marker),
1656 "the untouched global numerator stays outside every local Taylor operation"
1657 );
1658 let negated = (-local.clone()).map(|atom| Ok(atom.clone()))?;
1659 assert!(
1660 (negated.direct()?.branches()?.factorized_sum() + value)
1661 .expand()
1662 .is_zero(),
1663 "mapping a negated forest must negate its complete materialized value"
1664 );
1665 }
1666 let final_integrand = current
1667 .final_integrand(&route_graph)?
1668 .iter()
1669 .find(|(index, _)| *index == CutCFFIndex::new_all_none())
1670 .ok_or_else(|| eyre!("the nested banana has no uncut final integrand"))?
1671 .1
1672 .clone();
1673 assert!(
1674 !final_integrand.contains_symbol(OrientationID::symbol()),
1675 "explicit final assembly must have summed all selectors"
1676 );
1677 let without_global = final_integrand.replace(global_marker).with(1);
1678 assert!(
1679 !without_global.is_zero(),
1680 "the subtraction fixture must be nonzero"
1681 );
1682 assert!(
1683 (&final_integrand - Atom::var(global_marker) * without_global)
1684 .expand()
1685 .is_zero(),
1686 "final assembly restores the outside global numerator exactly once"
1687 );
1688 finals.push(final_integrand);
1689 parent = current;
1690 }
1691 Ok(finals)
1692 };
1693
1694 let direct = build(graph.clone(), false)?;
1695 let projected = build(graph.clone(), true)?;
1696 let normalize = |expression: Atom| {
1700 expression
1701 .replace(global_marker)
1702 .with(1)
1703 .replace(GS.dim)
1704 .with(4)
1705 .replace(GS.dim_epsilon)
1706 .with(0)
1707 .replace(GS.m_uv_expansion)
1708 .with(Atom::num(Rational::from((7, 5))))
1709 .replace(GS.m_uv_vacuum)
1710 .with(Atom::num(Rational::from((7, 5))))
1711 .replace(function!(GS.tree_denom_wrapper, W_.x_))
1712 .with(W_.x_)
1713 .replace(GS.den(W_.a_, W_.b_, W_.c_, W_.d_))
1714 .with(W_.d_)
1715 .replace(function!(GS.ose, W_.mass_, W_.prop_))
1716 .with(W_.prop_)
1717 .replace(function!(GS.on_shell_energy, W_.a_, W_.prop_))
1718 .with(Atom::var(W_.prop_).pow((1, 2)))
1719 .expand_dots()
1720 .expect("test-only component expansion must succeed")
1721 };
1722 let expressions = direct
1723 .into_iter()
1724 .zip(projected)
1725 .flat_map(|(direct, projected)| [normalize(direct), normalize(projected)])
1726 .collect::<Vec<_>>();
1727
1728 let rational = |numerator, denominator| {
1729 F::<ArbPrec>::from(&Rational::from((numerator, denominator)))
1730 };
1731 let loop_moms: LoopMomenta<F<ArbPrec>> = [
1732 ThreeMomentum::new(rational(31, 100), rational(-47, 100), rational(83, 100)),
1733 ThreeMomentum::new(rational(-19, 100), rational(37, 100), rational(61, 100)),
1734 ThreeMomentum::new(rational(43, 100), rational(29, 100), rational(-53, 100)),
1735 ]
1736 .into_iter()
1737 .take(depth)
1738 .collect();
1739 let external_moms: ExternalFourMomenta<F<ArbPrec>> = [
1740 [
1741 rational(12, 5),
1742 rational(11, 100),
1743 rational(-8, 100),
1744 rational(5, 100),
1745 ]
1746 .into(),
1747 [
1748 rational(-12, 5),
1749 rational(-11, 100),
1750 rational(8, 100),
1751 rational(-5, 100),
1752 ]
1753 .into(),
1754 ]
1755 .into_iter()
1756 .collect();
1757 let sample = MomentumSample {
1758 sample: BareMomentumSample {
1759 loop_moms,
1760 dual_loop_moms: None,
1761 loop_mom_cache_id: 0,
1762 loop_mom_base_cache_id: 0,
1763 external_moms,
1764 external_mom_cache_id: 0,
1765 external_mom_base_cache_id: 0,
1766 jacobian: rational(1, 1),
1767 orientation: None,
1768 parameterization_branch: None,
1769 },
1770 };
1771 let orientations = TiVec::<OrientationID, EdgeVec<Orientation>>::new();
1772 let orientation_filter = SubSet::full(orientations.len());
1773 let mut param_builder = graph.param_builder.clone();
1774 let (mut evaluator, _) = EvaluatorStack::new_explicit_sum_with_timings(
1775 &expressions,
1776 ¶m_builder,
1777 &[],
1778 None,
1779 &EvaluatorSettings::default(),
1780 )?;
1781 let input = <ArbPrec as GenericEvaluatorFloat>::get_parameters(
1782 &mut param_builder,
1783 (false, false),
1784 &graph,
1785 &sample,
1786 &[],
1787 &[],
1788 None,
1789 None,
1790 None,
1791 );
1792 let values = evaluator
1793 .evaluate(
1794 input,
1795 SingleOrAllOrientations::All {
1796 all: &orientations,
1797 filter: &orientation_filter,
1798 },
1799 &RuntimeSettings::default(),
1800 &mut EvaluationMetaData::new_empty(),
1801 )?
1802 .into_iter()
1803 .map(|value| value.unwrap_real())
1804 .collect::<Vec<_>>();
1805 let tolerance = F(ArbPrec::default().epsilon()).sqrt().sqrt().sqrt();
1806 for (stage, [direct, projected]) in values.as_chunks::<2>().0.iter().enumerate() {
1807 let label = format!("depth {depth}, stage {}", stage + 1);
1808 assert!(
1809 [&direct.re, &direct.im, &projected.re, &projected.im]
1810 .into_iter()
1811 .all(symbolica::domains::float::SingleFloat::is_finite),
1812 "{label} must produce finite values: direct={direct:e}, projected={projected:e}"
1813 );
1814 let distance = (direct.clone() - projected.clone()).norm().re;
1815 let direct_norm = direct.clone().norm().re;
1816 let projected_norm = projected.clone().norm().re;
1817 let scale = if direct_norm > projected_norm {
1818 direct_norm
1819 } else {
1820 projected_norm
1821 };
1822 let relative_distance = if scale.is_zero() {
1823 distance
1824 } else {
1825 distance / scale
1826 };
1827 assert!(
1828 relative_distance <= tolerance,
1829 "{label} direct complete-CFF vs projected local-4D mismatch: direct={direct:e}, projected={projected:e}, relative delta={relative_distance:e}, tolerance={tolerance:e}"
1830 );
1831 }
1832 }
1833 Ok(())
1834 }
1835
1836 #[test]
1837 fn complete_self_energy_taylor_sum_matches_direct_3d_for_raised_lu_jets() -> Result<()> {
1838 test_initialise()?;
1839 let base_graph: Graph = dot!(
1840 digraph complete_self_energy_taylor_sum {
1841 num = 1
1842 edge [particle="scalar_1" num=1]
1843 node [num=1]
1844
1845 ext0 [style=invis is_cut=0]
1846 v2 -> ext0 [id=0]
1847 ext0 -> v3
1848 v0 -> v1 [id=1 lmb_id=0 num="Q(1,spenso::mink(4,1))"]
1849 v0 -> v1 [id=2]
1850 v3 -> v0 [id=3 lmb_id=1 num="Q(3,spenso::mink(4,1))"]
1851 v1 -> v2 [id=4]
1852 v2 -> v3 [id=5]
1853 },
1854 "scalars"
1855 )?;
1856 assert_eq!(
1857 base_graph.loop_momentum_basis.edge_signatures[EdgeIndex(3)],
1858 base_graph.loop_momentum_basis.edge_signatures[EdgeIndex(4)],
1859 "the GL0-like cograph must expose a genuinely repeated outer channel"
1860 );
1861
1862 let outer_factors = [
1863 "unit outer numerator",
1864 "factorized cubic outer numerator",
1865 "factorized affine cubic outer numerator",
1866 ];
1867 for outer_label in outer_factors {
1868 let mut graph = base_graph.clone();
1869 graph.underlying = graph.underlying.map_data_ref(
1870 |_, _, vertex| {
1871 let mut vertex = vertex.clone();
1872 if outer_label == "factorized affine cubic outer numerator"
1873 && vertex.name.value == "v2"
1874 {
1875 vertex.num.value *= GS.emr_mom(EdgeIndex(5), GS.cind(0))
1876 + GS.emr_mom(EdgeIndex(0), GS.cind(0));
1877 }
1878 vertex
1879 },
1880 |_, edge, _, data| {
1881 data.map(|data| {
1882 let mut data = data.clone();
1883 let factor = match (outer_label, edge) {
1884 ("factorized cubic outer numerator", EdgeIndex(3..=5)) => {
1885 GS.emr_mom(edge, GS.cind(0))
1886 }
1887 ("factorized affine cubic outer numerator", EdgeIndex(3 | 5)) => {
1888 GS.emr_mom(edge, GS.cind(0))
1889 }
1890 _ => Atom::one(),
1891 };
1892 data.num.value *= factor;
1893 data
1894 })
1895 },
1896 |_, data| data.clone(),
1897 );
1898 let options = graph.denominator_only_cff_3d_expression_options();
1899 let numerator = graph.production_numerator_atom_for_full_3d_expression();
1900 let canonization = graph.get_esurface_canonization(&graph.loop_momentum_basis);
1901 let production = graph.generate_3d_expression_for_integrand(
1902 &[],
1903 &canonization,
1904 &options,
1905 Some(&numerator),
1906 )?;
1907 let lu_cut = graph
1908 .determine_raised_esurfaces_from_expression(&production.expression)
1909 .raised_groups
1910 .into_iter()
1911 .find(|group| {
1912 group.max_occurence > 1
1913 && group.esurface_ids.iter().any(|surface_id| {
1914 !production.expression.surfaces.esurface_cache[*surface_id]
1915 .external_shift
1916 .is_empty()
1917 })
1918 })
1919 .expect("the repeated outer channel must supply a physical raised LU surface");
1920 assert_eq!(
1921 lu_cut.max_occurence, 2,
1922 "the GL0-like cograph must expose LU orders one and two"
1923 );
1924 let mut cutset = CutSet::empty(graph.n_hedges());
1925 cutset.residue_selector.lu = Some(LuCutSelection {
1926 raised_group: lu_cut.clone(),
1927 cut_edge_alternatives: lu_cut
1928 .esurface_ids
1929 .iter()
1930 .map(|surface_id| {
1931 production.expression.surfaces.esurface_cache[*surface_id]
1932 .energies
1933 .clone()
1934 })
1935 .collect(),
1936 });
1937 let uv_filter = graph
1938 .get_edge_subgraph(EdgeIndex(1))
1939 .union(&graph.get_edge_subgraph(EdgeIndex(2)));
1940 let uv_subgraph = InternalSubGraph::cleaned_filter_optimist(uv_filter, graph.as_ref());
1941 let child_spinney = Spinney::with_scheme(
1942 uv_subgraph,
1943 &graph,
1944 &graph.loop_momentum_basis,
1945 ApproximationType::MUV,
1946 1,
1947 )?
1948 .expect("the self-energy bubble has a compatible sub-LMB");
1949 let orientation_pattern = OrientationPattern::default();
1950
1951 let build_child = |mut route_graph: Graph,
1952 route_cutset: &CutSet,
1953 local_uv_cts_from_expanded_4d_integrands|
1954 -> Result<BTreeMap<CutCFFIndex, Atom>> {
1955 let settings = UVgenerationSettings {
1956 generate_integrated: false,
1957 local_uv_cts_from_expanded_4d_integrands,
1958 ..Default::default()
1959 };
1960 let localizer = Localizer::new(
1961 route_cutset,
1962 OrientationProjection::exact_expression(
1963 &production,
1964 &options,
1965 &orientation_pattern,
1966 true,
1967 ),
1968 );
1969 let mut root = Approximation::new(Spinney::empty(&route_graph));
1970 root.root(&mut route_graph, localizer, &settings)?;
1971 let mut child = Approximation::new(child_spinney.clone());
1972 child.simple_approx = Some(
1973 root.simple_approx
1974 .as_ref()
1975 .expect("the root approximation is initialized")
1976 .dependent(child.spinney.subgraph.clone()),
1977 );
1978 let vakint_settings = vakint::VakintSettings::default();
1979 child.compute_4d(
1980 &route_graph,
1981 (crate::utils::vakint()?, &vakint_settings),
1982 &root,
1983 &settings,
1984 )?;
1985 let reduced = child.reduced_subgraph(&root);
1988 let t_arg = route_graph
1989 .numerator(&reduced, root.subgraph())
1990 .to_d_dim(GS.dim)
1991 .get_single_atom()
1992 .expect("the scalar child numerator is available")
1993 / route_graph.denominator(&reduced, |_| 1);
1994 let explicit_taylor_sum = route_graph
1995 .uv_rescaled(
1996 &reduced,
1997 route_graph.n_loops(child.subgraph()),
1998 child.lmb(),
1999 child.lmb(),
2000 &t_arg,
2001 )
2002 .series(GS.rescale, Atom::Zero, 0)
2003 .expect("the scalar child Taylor series exists")
2004 .to_atom()
2005 .replace(GS.rescale)
2006 .with(Atom::one())
2007 .simplify_metrics()
2008 .to_dots()
2009 .normalize_dots();
2010 let local_atom = child.local(&route_graph)?.atom();
2011 assert!(
2012 (local_atom.collect_factors() + explicit_taylor_sum.collect_factors())
2013 .is_zero(),
2014 "the local counterterm must equal the negative independently reconstructed Taylor sum"
2015 );
2016 child.compute_3d(
2017 &root,
2018 &mut route_graph,
2019 localizer,
2020 &settings,
2021 &mut Local4dProjectionContext::default(),
2022 )?;
2023 Ok(child.final_integrand(&route_graph)?.iter().collect())
2024 };
2025 let direct = build_child(graph.clone(), &cutset, false)?;
2026 let expanded = build_child(graph.clone(), &cutset, true)?;
2027 let expected_orders = BTreeSet::from([1, 2]);
2028 assert_eq!(
2029 direct
2030 .keys()
2031 .filter_map(|index| index.lu_cut_order)
2032 .collect::<BTreeSet<_>>(),
2033 expected_orders,
2034 "the direct child must retain both raised LU orders"
2035 );
2036 assert_eq!(
2037 direct.keys().collect::<Vec<_>>(),
2038 expanded.keys().collect::<Vec<_>>(),
2039 "the two routes must finalize the same residue sectors"
2040 );
2041
2042 let mass_squared = Atom::num(Rational::from((4, 9)));
2043 let m_uv = Atom::num(Rational::from((7, 5)));
2044 let base_spatial_momentum = |edge: EdgeIndex| match usize::from(edge) {
2045 1 => Atom::num(Rational::from((4, 3))),
2046 2 => Atom::num(Rational::from((-7, 12))),
2047 3..=5 => Atom::num(Rational::from((3, 4))),
2048 _ => Atom::Zero,
2049 };
2050 let scaled_on_shell_energy = |edge: EdgeIndex, rescale: Atom| {
2051 (mass_squared.clone() + (base_spatial_momentum(edge) * rescale).pow(2)).sqrt()
2052 };
2053 let representative =
2054 &production.expression.surfaces.esurface_cache[lu_cut.esurface_ids[0]];
2055 let root_energy_sum = representative
2056 .energies
2057 .iter()
2058 .map(|edge| scaled_on_shell_energy(*edge, Atom::one()))
2059 .fold(Atom::Zero, |sum, energy| sum + energy);
2060 let external_shift_coefficient = representative
2061 .external_shift
2062 .iter()
2063 .map(|(_, coefficient)| *coefficient)
2064 .sum::<i64>();
2065 assert_ne!(
2066 external_shift_coefficient, 0,
2067 "the selected raised LU surface must have a nonzero external shift"
2068 );
2069 let external_energy = -root_energy_sum / Atom::num(external_shift_coefficient);
2070 let external_edges = graph
2071 .external_momentum_edge_order()
2072 .into_iter()
2073 .collect::<BTreeSet<_>>();
2074 let scalarize = |expression: Atom| -> Result<Atom> {
2075 let mut network = expression.parse_into_net()?;
2080 network.execute::<Sequential, SmallestDegree, _, _, _>(
2081 &*TENSORLIB.read().unwrap(),
2082 &*FUN_LIB,
2083 )?;
2084 Ok(match network.result_scalar()? {
2085 ExecutionResult::One => Atom::one(),
2086 ExecutionResult::Zero => Atom::Zero,
2087 ExecutionResult::Val(value) => value.into_owned(),
2088 })
2089 };
2090 let rescale_expression = |mut expression: Atom| -> Result<Atom> {
2091 expression = expression
2092 .replace(GS.dim)
2093 .with(4)
2094 .simplify_metrics()
2095 .to_dots()
2096 .normalize_dots()
2097 .expand_dots()?;
2098 let rescale = Atom::var(GS.rescale);
2099 let compact_minkowski = parse_lit!(spenso::mink(4));
2100 let time_direction =
2101 function!(GS.delta_vec, GS.cind(0), compact_minkowski.as_view());
2102 expression = expression
2103 .replace(function!(
2104 GS.dot,
2105 time_direction.as_view(),
2106 time_direction.as_view()
2107 ))
2108 .with(Atom::one());
2109 for left in 0..graph.underlying.n_edges() {
2110 let left = EdgeIndex(left);
2111 let left_spatial = if !external_edges.contains(&left) {
2112 base_spatial_momentum(left) * &rescale
2113 } else {
2114 Atom::Zero
2115 };
2116 for right in left.0..graph.underlying.n_edges() {
2117 let right = EdgeIndex(right);
2118 let right_spatial = if !external_edges.contains(&right) {
2119 base_spatial_momentum(right) * &rescale
2120 } else {
2121 Atom::Zero
2122 };
2123 expression = expression
2124 .replace(function!(
2125 GS.dot,
2126 GS.emr_vec(left, compact_minkowski.as_view()),
2127 GS.emr_vec(right, compact_minkowski.as_view())
2128 ))
2129 .with(-&left_spatial * right_spatial);
2130 }
2131 expression = expression
2132 .replace(function!(
2133 GS.dot,
2134 time_direction.as_view(),
2135 GS.emr_vec(left, compact_minkowski.as_view())
2136 ))
2137 .with(Atom::Zero);
2138 }
2139 expression = expression
2140 .replace(Atom::var(GS.m_uv_expansion))
2141 .with(m_uv.clone())
2142 .replace(Atom::var(GS.m_uv_vacuum))
2143 .with(m_uv.clone())
2144 .replace(Atom::var(GS.integrated_loop_scale))
2145 .with(Atom::one())
2146 .replace(function!(GS.tree_denom_wrapper, W_.x_))
2147 .with(W_.x_)
2148 .replace(GS.den(W_.a_, W_.b_, W_.c_, W_.d_))
2149 .with(W_.d_);
2150 expression = scalarize(expression)?;
2151 for edge in 0..graph.underlying.n_edges() {
2152 let edge = EdgeIndex(edge);
2153 expression = expression
2154 .replace(graph.underlying[edge].particle.mass_atom())
2155 .with(mass_squared.clone().sqrt());
2156 if external_edges.contains(&edge) {
2157 expression = expression
2158 .replace(GS.emr_mom(edge, GS.cind(0)))
2159 .with(external_energy.clone());
2160 for spatial_index in 1..=3 {
2161 expression = expression
2162 .replace(GS.emr_mom(edge, GS.cind(spatial_index)))
2163 .with(Atom::Zero)
2164 .replace(GS.emr_vec(edge, GS.cind(spatial_index)))
2165 .with(Atom::Zero);
2166 }
2167 } else {
2168 let on_shell_energy = scaled_on_shell_energy(edge, rescale.clone());
2169 expression = expression
2170 .replace(GS.emr_mom(edge, GS.cind(1)))
2171 .with(base_spatial_momentum(edge) * &rescale)
2172 .replace(GS.emr_vec(edge, GS.cind(1)))
2173 .with(base_spatial_momentum(edge) * &rescale)
2174 .replace(GS.ose(edge))
2175 .with(on_shell_energy.clone())
2176 .replace(cut_energy(edge))
2177 .with(on_shell_energy);
2178 for spatial_index in 2..=3 {
2179 expression = expression
2180 .replace(GS.emr_mom(edge, GS.cind(spatial_index)))
2181 .with(Atom::Zero)
2182 .replace(GS.emr_vec(edge, GS.cind(spatial_index)))
2183 .with(Atom::Zero);
2184 }
2185 }
2186 }
2187 Ok(expression)
2188 };
2189 let eta = rescale_expression(representative.to_atom(&[]))?;
2190 assert!(
2191 eta.replace(GS.rescale).with(Atom::one()).expand().is_zero(),
2192 "the comparison point must lie on the selected raised LU surface"
2193 );
2194 let value_and_t_derivative = |expression: Atom| -> Result<[Atom; 2]> {
2195 let series = rescale_expression(expression)?
2196 .series(GS.rescale, Atom::one(), 1)
2197 .map_err(|error| eyre!("failed to build raised-LU child t jet: {error}"))?;
2198 Ok([
2199 series
2200 .coefficient(Rational::from(0))
2201 .expect("requested coefficient is within series precision"),
2202 series
2203 .coefficient(Rational::from(1))
2204 .expect("requested coefficient is within series precision"),
2205 ])
2206 };
2207 let evaluate_arb = |expression: Atom| -> Result<Complex<F<ArbPrec>>> {
2208 let expression = expression
2209 .replace(function!(GS.on_shell_energy, W_.a_, W_.prop_))
2210 .with(Atom::var(W_.prop_).pow((1, 2)));
2211 let parameters = [Atom::var(GS.pi)];
2212 let rational: ExpressionEvaluator<SymComplex<Fraction<IntegerRing>>> = expression
2213 .evaluator(¶meters)
2214 .build()
2215 .map_err(|error| eyre!("failed to build complete-child evaluator: {error}"))?;
2216 let mut arb: ExpressionEvaluator<Complex<F<ArbPrec>>> =
2217 rational.map_coeff(&|coefficient| {
2218 Complex::new(F::from(&coefficient.re), F::from(&coefficient.im))
2219 });
2220 Ok(arb.evaluate_single(&[Complex::new(
2221 F::<ArbPrec>::from_f64(0.0).pi(),
2222 F::<ArbPrec>::from_f64(0.0),
2223 )]))
2224 };
2225 let tolerance = F(ArbPrec::default().epsilon()).sqrt().sqrt().sqrt();
2228 let eta_series = eta
2229 .series(GS.rescale, Atom::one(), 2)
2230 .map_err(|error| eyre!("failed to build raised-LU eta jet: {error}"))?;
2231 let eta_prime = evaluate_arb(
2232 eta_series
2233 .coefficient(Rational::from(1))
2234 .expect("requested coefficient is within series precision"),
2235 )?;
2236 let eta_second = evaluate_arb(
2237 eta_series
2238 .coefficient(Rational::from(2))
2239 .expect("requested coefficient is within series precision")
2240 * Atom::num(2),
2241 )?;
2242 let combined_raised_residue =
2243 |route: &BTreeMap<CutCFFIndex, Atom>| -> Result<Complex<F<ArbPrec>>> {
2244 let sum_order = |order| {
2245 route
2246 .iter()
2247 .filter(|(index, _)| index.lu_cut_order == Some(order))
2248 .map(|(_, expression)| expression)
2249 .fold(Atom::Zero, |sum, expression| sum + expression)
2250 };
2251 let order_one = sum_order(1);
2252 let order_two = sum_order(2);
2253 assert!(
2254 !order_one.is_zero() && !order_two.is_zero(),
2255 "the fixed raised cut must retain nonzero order-one and order-two pieces"
2256 );
2257 let f_one = value_and_t_derivative(order_one)?;
2258 let f_two = value_and_t_derivative(order_two)?;
2259 Ok(evaluate_arb(f_one[0].clone())? / eta_prime.clone()
2260 + evaluate_arb(f_two[1].clone())? / eta_prime.clone().pow(2)
2261 - evaluate_arb(f_two[0].clone())? * eta_second.clone()
2262 / eta_prime.clone().pow(3))
2263 };
2264
2265 let direct_residue = combined_raised_residue(&direct)?;
2270 let expanded_residue = combined_raised_residue(&expanded)?;
2271 let distance = (direct_residue.clone() - expanded_residue.clone())
2272 .norm()
2273 .re;
2274 let scale = direct_residue
2275 .clone()
2276 .norm()
2277 .re
2278 .max(expanded_residue.clone().norm().re);
2279 let relative_distance = if scale.is_zero() {
2280 distance
2281 } else {
2282 distance / scale
2283 };
2284 assert!(
2285 relative_distance <= tolerance,
2286 "{outer_label} complete raised-LU residue mismatch: direct={direct_residue:e}, expanded={expanded_residue:e}, relative delta={relative_distance:e}, tolerance={tolerance:e}"
2287 );
2288
2289 let production_rational =
2295 |numerator, denominator| Atom::num(Rational::from((numerator, denominator)));
2296 let base_loop_atoms = [
2297 [
2298 production_rational(31, 100),
2299 production_rational(-47, 100),
2300 production_rational(83, 100),
2301 ],
2302 [
2303 production_rational(-19, 100),
2304 production_rational(37, 100),
2305 production_rational(61, 100),
2306 ],
2307 ];
2308 let external_atoms = [[
2309 production_rational(12, 5),
2310 production_rational(11, 100),
2311 production_rational(-8, 100),
2312 production_rational(5, 100),
2313 ]];
2314 assert_eq!(
2315 graph.loop_momentum_basis.loop_edges.len(),
2316 base_loop_atoms.len()
2317 );
2318 assert_eq!(
2319 graph.loop_momentum_basis.ext_edges.len(),
2320 external_atoms.len()
2321 );
2322
2323 let production_rescale = |mut expression: Atom| -> Result<Atom> {
2324 expression = expression
2325 .replace(GS.dim)
2326 .with(4)
2327 .simplify_metrics()
2330 .to_dots()
2331 .normalize_dots()
2332 .replace(GS.dim_epsilon)
2333 .with(0)
2334 .replace(GS.m_uv_expansion)
2335 .with(production_rational(7, 5))
2336 .replace(GS.m_uv_vacuum)
2337 .with(production_rational(7, 5))
2338 .replace(function!(GS.tree_denom_wrapper, W_.x_))
2339 .with(W_.x_)
2340 .replace(GS.den(W_.a_, W_.b_, W_.c_, W_.d_))
2341 .with(W_.d_)
2342 .replace(function!(GS.ose, W_.mass_, W_.prop_))
2343 .with(W_.prop_)
2344 .replace(function!(GS.on_shell_energy, W_.a_, W_.prop_))
2345 .with(Atom::var(W_.prop_).pow((1, 2)))
2346 .expand_dots()?;
2347 expression = scalarize(expression)?;
2348 let t = Atom::var(GS.rescale);
2349 let loop_components = (0..3)
2350 .map(|component| {
2351 base_loop_atoms
2352 .iter()
2353 .map(|momentum| &momentum[component] * &t)
2354 .collect::<Vec<_>>()
2355 })
2356 .collect::<Vec<_>>();
2357 let external_components = (0..3)
2358 .map(|component| {
2359 external_atoms
2360 .iter()
2361 .map(|momentum| momentum[component + 1].clone())
2362 .collect::<Vec<_>>()
2363 })
2364 .collect::<Vec<_>>();
2365 let external_temporal = external_atoms
2366 .iter()
2367 .map(|momentum| momentum[0].clone())
2368 .collect::<Vec<_>>();
2369 for edge in 0..graph.underlying.n_edges() {
2370 let edge = EdgeIndex(edge);
2371 let signature = &graph.loop_momentum_basis.edge_signatures[edge];
2372 let spatial = (0..3)
2373 .map(|component| {
2374 signature
2375 .try_compute_momentum(
2376 &loop_components[component],
2377 &external_components[component],
2378 )
2379 .unwrap_or(Atom::Zero)
2380 })
2381 .collect::<Vec<_>>();
2382 let on_shell_energy = spatial
2383 .iter()
2384 .fold(Atom::one(), |norm, component| norm + component.pow(2))
2385 .sqrt();
2386 let temporal = if signature.internal.iter().any(|sign| sign.is_sign()) {
2387 on_shell_energy.clone()
2388 } else {
2389 signature
2390 .external
2391 .try_apply(&external_temporal)
2392 .unwrap_or(Atom::Zero)
2393 };
2394 expression = expression
2395 .replace(GS.ose(edge))
2396 .with(on_shell_energy.clone())
2397 .replace(cut_energy(edge))
2398 .with(on_shell_energy)
2399 .replace(GS.emr_mom(edge, GS.cind(0)))
2400 .with(temporal);
2401 for (component, spatial_component) in spatial.iter().enumerate() {
2402 expression = expression
2403 .replace(GS.emr_mom(edge, GS.cind(component + 1)))
2404 .with(spatial_component.clone())
2405 .replace(GS.emr_vec(edge, GS.cind(component + 1)))
2406 .with(spatial_component.clone());
2407 }
2408 }
2409 Ok(expression)
2410 };
2411 let production_jet = |expression: Atom| -> Result<[Complex<F<ArbPrec>>; 2]> {
2412 let series = production_rescale(expression)?
2413 .series(GS.rescale, Atom::one(), 1)
2414 .map_err(|error| {
2415 eyre!("failed to build production-boundary Arb jet: {error}")
2416 })?;
2417 Ok([
2418 evaluate_arb(
2419 series
2420 .coefficient(Rational::from(0))
2421 .expect("requested coefficient is within series precision"),
2422 )?,
2423 evaluate_arb(
2424 series
2425 .coefficient(Rational::from(1))
2426 .expect("requested coefficient is within series precision"),
2427 )?,
2428 ])
2429 };
2430 let production_expressions = [&direct, &expanded]
2431 .into_iter()
2432 .map(|route| {
2433 route
2434 .iter()
2435 .find(|(index, _)| index.lu_cut_order == Some(2))
2436 .expect("the production-boundary route has an order-two LU sector")
2437 .1
2438 .clone()
2439 .replace(GS.dim)
2440 .with(4)
2441 .replace(GS.dim_epsilon)
2442 .with(0)
2443 .replace(GS.m_uv_expansion)
2444 .with(production_rational(7, 5))
2445 .replace(GS.m_uv_vacuum)
2446 .with(production_rational(7, 5))
2447 .replace(function!(GS.tree_denom_wrapper, W_.x_))
2448 .with(W_.x_)
2449 .replace(GS.den(W_.a_, W_.b_, W_.c_, W_.d_))
2450 .with(W_.d_)
2451 .replace(function!(GS.ose, W_.mass_, W_.prop_))
2452 .with(W_.prop_)
2453 .replace(function!(GS.on_shell_energy, W_.a_, W_.prop_))
2454 .with(Atom::var(W_.prop_).pow((1, 2)))
2455 })
2456 .collect::<Vec<_>>();
2457 let expected_production_jets = production_expressions
2458 .iter()
2459 .cloned()
2460 .map(production_jet)
2461 .collect::<Result<Vec<_>>>()?;
2462 let arb_rational = |numerator, denominator| {
2463 F::<ArbPrec>::from(&Rational::from((numerator, denominator)))
2464 };
2465 let loop_moms: LoopMomenta<F<ArbPrec>> = [
2466 ThreeMomentum::new(
2467 arb_rational(31, 100),
2468 arb_rational(-47, 100),
2469 arb_rational(83, 100),
2470 ),
2471 ThreeMomentum::new(
2472 arb_rational(-19, 100),
2473 arb_rational(37, 100),
2474 arb_rational(61, 100),
2475 ),
2476 ]
2477 .into_iter()
2478 .collect();
2479 let external_moms: ExternalFourMomenta<F<ArbPrec>> = [[
2480 arb_rational(12, 5),
2481 arb_rational(11, 100),
2482 arb_rational(-8, 100),
2483 arb_rational(5, 100),
2484 ]
2485 .into()]
2486 .into_iter()
2487 .collect();
2488 let dual_t =
2489 HyperDual::new(simple_n_deriv_shape(1)).variable(0, F::<ArbPrec>::from_f64(1.0));
2490 let dual_loop_moms = loop_moms.rescale_with_hyper_dual(&dual_t, None);
2491 let sample = MomentumSample {
2492 sample: BareMomentumSample {
2493 loop_moms,
2494 dual_loop_moms: Some(dual_loop_moms),
2495 loop_mom_cache_id: 0,
2496 loop_mom_base_cache_id: 0,
2497 external_moms,
2498 external_mom_cache_id: 0,
2499 external_mom_base_cache_id: 0,
2500 jacobian: arb_rational(1, 1),
2501 orientation: None,
2502 parameterization_branch: None,
2503 },
2504 };
2505 let actual_production_jets = std::thread::scope(|scope| -> Result<_> {
2509 let worker = std::thread::Builder::new()
2510 .name("raised-lu-production-boundary".to_owned())
2511 .stack_size(32 * 1024 * 1024)
2512 .spawn_scoped(scope, || -> Result<_> {
2513 let orientations = TiVec::<OrientationID, EdgeVec<Orientation>>::new();
2514 let orientation_filter = SubSet::full(orientations.len());
2515 let mut param_builder = graph.param_builder.clone();
2516 param_builder.initialize_duals(2);
2517 let (mut production_evaluator, _) =
2518 EvaluatorStack::new_explicit_sum_with_timings(
2519 &production_expressions,
2520 ¶m_builder,
2521 &[],
2522 Some(simple_n_deriv_shape(1)),
2523 &EvaluatorSettings::default(),
2524 )?;
2525 let input = <ArbPrec as GenericEvaluatorFloat>::get_parameters(
2526 &mut param_builder,
2527 (false, false),
2528 &graph,
2529 &sample,
2530 &[],
2531 &[],
2532 None,
2533 None,
2534 None,
2535 );
2536 production_evaluator.evaluate(
2537 input,
2538 SingleOrAllOrientations::All {
2539 all: &orientations,
2540 filter: &orientation_filter,
2541 },
2542 &RuntimeSettings::default(),
2543 &mut EvaluationMetaData::new_empty(),
2544 )
2545 })?;
2546 worker
2547 .join()
2548 .map_err(|_| eyre!("raised-LU production-boundary worker panicked"))?
2549 })?;
2550 for (route, actual, expected) in ["direct 3D", "projected local 4D"]
2551 .into_iter()
2552 .zip(actual_production_jets)
2553 .zip(&expected_production_jets)
2554 .map(|((route, actual), expected)| (route, actual, expected))
2555 {
2556 let DualOrNot::Dual(actual) = actual else {
2557 panic!("{route} order-two production evaluator did not return a HyperDual jet")
2558 };
2559 let actual = extract_t_derivatives_complex(actual);
2560 for (component, actual, expected) in ["f2", "f2 prime"]
2561 .into_iter()
2562 .zip(actual)
2563 .zip(expected)
2564 .map(|((component, actual), expected)| (component, actual, expected))
2565 {
2566 let distance = (actual.clone() - expected.clone()).norm().re;
2567 let scale = actual.clone().norm().re.max(expected.clone().norm().re);
2568 let relative_distance = if scale.is_zero() {
2569 distance
2570 } else {
2571 distance / scale
2572 };
2573 assert!(
2574 relative_distance <= tolerance,
2575 "{route} production-boundary {component} mismatch: actual={actual:e}, expected={expected:e}, relative delta={relative_distance:e}, tolerance={tolerance:e}"
2576 );
2577 }
2578 }
2579
2580 let consistent_eta = production_rescale(representative.to_atom(&[]))?;
2581 let consistent_eta_series = consistent_eta
2582 .series(GS.rescale, Atom::one(), 2)
2583 .map_err(|error| eyre!("failed to build production-boundary eta jet: {error}"))?;
2584 let consistent_eta_prime = evaluate_arb(
2585 consistent_eta_series
2586 .coefficient(Rational::from(1))
2587 .expect("requested coefficient is within series precision"),
2588 )?;
2589 let consistent_eta_second = evaluate_arb(
2590 consistent_eta_series
2591 .coefficient(Rational::from(2))
2592 .expect("requested coefficient is within series precision")
2593 * Atom::num(2),
2594 )?;
2595 let mut pass_two_evaluator =
2596 build_derivative_structure(2, -1, &EvaluatorSettings::default());
2597 for (route, jet) in ["direct 3D", "projected local 4D"]
2598 .into_iter()
2599 .zip(&expected_production_jets)
2600 {
2601 let actual = <ArbPrec as GenericEvaluatorFloat>::get_evaluator_single(
2602 &mut pass_two_evaluator,
2603 )(&[
2604 jet[0].clone(),
2605 jet[1].clone(),
2606 consistent_eta_prime.clone(),
2607 consistent_eta_second.clone(),
2608 ]);
2609 let expected = jet[1].clone() / consistent_eta_prime.clone().pow(2)
2610 - jet[0].clone() * &consistent_eta_second / consistent_eta_prime.clone().pow(3);
2611 let distance = (actual.clone() - expected.clone()).norm().re;
2612 let scale = actual.clone().norm().re.max(expected.clone().norm().re);
2613 let relative_distance = if scale.is_zero() {
2614 distance
2615 } else {
2616 distance / scale
2617 };
2618 assert!(
2619 relative_distance <= tolerance,
2620 "{route} production pass-two mismatch: actual={actual:e}, expected={expected:e}, relative delta={relative_distance:e}, tolerance={tolerance:e}"
2621 );
2622 }
2623 }
2624 Ok(())
2625 }
2626}