Skip to main content

gammalooprs/uv/approx/
integrated.rs

1use std::collections::BTreeMap;
2
3use color_eyre::Result;
4use eyre::{WrapErr, eyre};
5use gammaloop_tracing_filter::debug_instrument;
6use idenso::{
7    color::{ColorSimplifier, ColorSimplifySettings},
8    dirac::{AGS, GammaSimplifier, GammaSimplifySettings},
9    representations::Bispinor,
10    shorthands::{
11        UndoShorthands,
12        chain::Chain,
13        metric::MetricSimplifier,
14        schoonschip::{Schoonschip, SchoonschipSettings},
15    },
16    tensor::{SymbolicNetExt, SymbolicNetParse},
17};
18
19use linnet::half_edge::{
20    HedgeGraph,
21    builder::HedgeGraphBuilder,
22    involution::{EdgeIndex, HedgePair},
23    subgraph::{ModifySubSet, SuBitGraph, SubGraphLike, SubSetLike, SubSetOps},
24};
25use spenso::{
26    network::{
27        graph::NetworkEdge,
28        library::symbolic::ETS,
29        parsing::{
30            ParseSettings, SchoonschipExpansionMode, ShorthandParsing, StructureInferenceMode,
31        },
32        tags::SPENSO_TAG,
33    },
34    shadowing::TensorCollectExt,
35    structure::{
36        representation::{Minkowski, RepName},
37        slot::{DummyAind, IsAbstractSlot, ParseableAind, Slot},
38    },
39};
40use symbolica::{
41    atom::{AliasedAtom, Atom, AtomCore, AtomView, FunctionBuilder},
42    domains::{atom::AtomField, integer::Z, rational::Q},
43    function,
44    id::Replacement,
45    parse, parse_lit,
46    poly::{PolyVariable, series::Series},
47    symbol,
48};
49use symbolica_utils::ReplaceBuilderExt;
50use vakint::{Vakint, VakintExpression, vakint_symbol};
51
52use crate::{
53    debug_tags,
54    graph::{LMBext, LoopMomentumBasis},
55    numerator::aind::Aind,
56    utils::{GS, W_},
57    uv::{
58        ApproximationType, UltravioletGraph,
59        approx::{
60            ForestNodeLike, Rooted, UVCtx,
61            local_4d::{FourDSector, Local4dCts},
62        },
63        marker::{UvMarker, UvOperation},
64        settings::VakintSettings,
65        uv_graph::UVE,
66    },
67};
68
69/// Laurent projections of an integrated counterterm.
70///
71/// Connected values store their canonical expansion. Factorized values encode
72/// componentwise pole and signed nonnegative-power products under the same accessors.
73#[derive(Clone, Debug, PartialEq, Eq, Hash)]
74pub(crate) struct IntegratedCts {
75    expansion: Series<AtomField>,
76    scale_power: i64,
77}
78
79impl IntegratedCts {
80    pub(crate) fn factorized_product<'a>(
81        factors: impl IntoIterator<Item = &'a Self>,
82        depth: usize,
83    ) -> Result<Self> {
84        let mut factors = factors.into_iter();
85        let first = factors
86            .next()
87            .ok_or_else(|| eyre!("a factorized integrated counterterm cannot be empty"))?;
88        let mut pole = truncate(&first.expansion, false);
89        let mut finite_counterterm = -truncate(&first.expansion, true);
90        let mut scale_power = first.scale_power;
91
92        for factor in factors {
93            pole *= truncate(&factor.expansion, false);
94            finite_counterterm *= -truncate(&factor.expansion, true);
95            scale_power += factor.scale_power;
96        }
97
98        Ok(Self {
99            // The public projections add the finite-counterterm sign, so store
100            // its negative while keeping the pole product unchanged.
101            expansion: series(&(pole - finite_counterterm), depth)?,
102            scale_power,
103        })
104    }
105
106    fn projected_atom(&self, finite: bool) -> Atom {
107        // The enclosing forest Taylor operation rescales the vacuum mass in
108        // this coefficient along with its active momenta. The bookkeeping
109        // factor restores only consumed loop measures; physical projections
110        // and the next integration set it to one.
111        truncate(&self.expansion, finite)
112            * Atom::var(GS.integrated_loop_scale).pow(self.scale_power)
113    }
114
115    pub(crate) fn pole_atom(&self) -> Atom {
116        self.projected_atom(false)
117    }
118
119    pub(crate) fn finite_counterterm_atom(&self) -> Atom {
120        -self.projected_atom(true)
121    }
122
123    pub(crate) fn physical_pole_atom(&self) -> Atom {
124        self.pole_atom()
125            .replace(GS.integrated_loop_scale)
126            .with(Atom::one())
127    }
128
129    pub(crate) fn physical_finite_counterterm_atom(&self) -> Atom {
130        self.finite_counterterm_atom()
131            .replace(GS.integrated_loop_scale)
132            .with(Atom::one())
133    }
134}
135
136fn series(expr: &Atom, depth: usize) -> Result<Series<AtomField>> {
137    Ok(expr.series(GS.dim_epsilon, 0, depth)?)
138}
139
140fn truncate(series: &Series<AtomField>, finite: bool) -> Atom {
141    let mut truncated = Atom::Zero;
142
143    for (power, p) in series.terms() {
144        if (power >= 0) == finite {
145            truncated += p * Atom::var(GS.dim_epsilon).pow(power);
146        }
147    }
148
149    truncated
150}
151
152impl Rooted for IntegratedCts {
153    fn root() -> Self {
154        Self {
155            expansion: series(&Atom::Zero, 1).expect("zero has a Laurent expansion"),
156            scale_power: 0,
157        }
158    }
159}
160
161fn simplify(integrand: &Atom) -> Result<Atom> {
162    if integrand.contains_symbol(AGS.sigma) {
163        return Err(eyre!(
164            "Sigma tensors are not supported by d-dimensional analytic UV numerator algebra"
165        ));
166    }
167    // Only the analytically integrated UV subgraph reaches this function.
168    // Expose spin/vector slots before closing traces; the representation-specific
169    // collectors below retain factorized scalar coefficients.
170    let integrand =
171        if integrand.contains_symbol(AGS.gamma) || integrand.contains_symbol(SPENSO_TAG.trace) {
172            // Scalar products already bind their contracted slots. Materializing
173            // a powered dot before symbolic multiplication reuses its dummy index
174            // and turns (p.k)^2 into p^2 k^2. Protect completed scalar spectators,
175            // while keeping spin/vector contractions with open slots visible.
176            // The symbolic network also checks hidden shorthand
177            // slots without requiring concrete tensor dimensions or execution.
178            let mut used = integrand.get_all_symbols(true);
179            let mut aliases = BTreeMap::<Atom, Atom>::new();
180            let mut serial = 0usize;
181            let protected = integrand.replace_map(|arg, _context, out| {
182                // The complete scalar expression in a production denominator is
183                // metadata for propagator conversion, not a tensor-network factor.
184                // Protect it alongside scalar spectators before closing spin traces.
185                if matches!(arg, AtomView::Fun(fun) if fun.get_symbol() == GS.den)
186                    || (matches!(arg, AtomView::Fun(fun)
187                    if fun.get_symbol() == SPENSO_TAG.dot || fun.get_symbol() == ETS.metric)
188                        && !arg.contains_symbol(AGS.gamma)
189                        && !arg.contains_symbol(SPENSO_TAG.trace)
190                        && arg
191                            .parse_to_symbolic_net::<Aind>(&ParseSettings {
192                                shorthand_parsing: ShorthandParsing::expand_all(),
193                                ..Default::default()
194                            })
195                            .is_ok_and(|network| network.graph.dangling_indices().is_empty()))
196                {
197                    let alias = aliases.entry(arg.to_owned()).or_insert_with(|| {
198                        loop {
199                            let symbol = symbol!(&format!("gammaloop::uv_scalar_product_{serial}"));
200                            serial += 1;
201                            if used.insert(symbol) {
202                                break Atom::var(symbol);
203                            }
204                        }
205                    });
206                    **out = alias.clone();
207                }
208            });
209            let mut protected = AliasedAtom::from(protected);
210            for (original, alias) in aliases {
211                protected.register_alias(alias, original);
212            }
213            let prepared = protected
214                .get_root()
215                .parse_to_symbolic_net::<Aind>(&ParseSettings {
216                    shorthand_parsing: ShorthandParsing::Expand {
217                        schoonschip: SchoonschipExpansionMode::full(),
218                        trace: false,
219                        chain: true,
220                    },
221                    ..Default::default()
222                })
223                .map_err(|error| eyre!("invalid analytic UV spin tensor notation: {error}"))?
224                .simple_execute::<()>()?;
225            protected.map_root(|_| prepared).into_inner()
226        } else {
227            integrand.clone()
228        };
229    let collected = integrand
230        .collect_rep(Minkowski {}.into())
231        .simplify_metrics()
232        .collect_rep((Bispinor {}).into())
233        .collect_gamma_chains();
234    debug_tags!(#uv,#integrated,#collect;log.expr = collected, "After gamma chain collection");
235
236    let schoonschip = collected
237        .schoonschip_with_settings(&SchoonschipSettings {
238            simplify_chain_like_functions: true,
239            schoonschip_rank1_tensors: true,
240            ..Default::default()
241        })
242        .normalize_chains();
243    debug_tags!(#uv, #integrated, #profile, #trace, #start, #collect;
244        log.expr = schoonschip,
245        "After gamma schoonschip"
246    );
247    // Keep each Taylor term's propagator powers. Vakint receives the individual
248    // denominator topologies: collecting factors across the sum clears denominators
249    // and manufactures higher-rank numerator factors before reduction.
250    // Color is a spectator of the spin algebra; collecting all chains and traces
251    // here distributes its shared coefficient across the kinematic terms.
252    let collected = schoonschip.simplify_metrics().collect_gamma_chains();
253    debug_tags!(#uv, #integrated, #profile, #trace, #start, #collect;
254        log.expr = collected,
255        "After gamma collection"
256    );
257
258    let simplified = collected
259        .simplify_gamma_with(GammaSimplifySettings::canonical())
260        .collect_rep(Minkowski {}.into())
261        .expand_num();
262    debug_tags!(#uv, #integrated, #vakint, #profile, #trace, #start, #gamma;
263        log.expr = simplified,
264        "After gamma simplification"
265    );
266    let schoonschipped = simplified.schoonschip_net::<Aind>()?;
267    debug_tags!(#uv, #integrated, #vakint, #profile, #trace,#schoonschip, #start;
268        log.expr = schoonschipped,
269        "After Schoonschip net"
270    );
271    let dotted = schoonschipped.to_dots().normalize_dots();
272    debug_tags!(#uv, #integrated, #vakint, #profile, #trace, #dots;
273        log.expr = dotted,
274        "After dots"
275    );
276
277    if dotted
278        .replace(function!(
279            SPENSO_TAG.trace,
280            Bispinor {}.to_symbolic([W_.d_]),
281            W_.x___
282        ))
283        .matches()
284    {
285        return Err(eyre!(
286            "an unresolved Dirac trace remains after d-dimensional UV numerator algebra"
287        ));
288    }
289    if dotted
290        .replace(function!(
291            SPENSO_TAG.trace,
292            Minkowski {}.to_symbolic([W_.d_]),
293            W_.x___
294        ))
295        .matches()
296    {
297        return Err(eyre!(
298            "an unresolved Lorentz trace remains after d-dimensional UV numerator algebra"
299        ));
300    }
301    Ok(dotted)
302}
303
304pub(crate) struct Integrated<'a> {
305    pub vakint: &'a Vakint,
306    pub vakint_settings: &'a vakint::VakintSettings,
307}
308
309impl Integrated<'_> {
310    pub(crate) fn new<'a>(
311        vakint: &'a Vakint,
312        vakint_settings: &'a vakint::VakintSettings,
313    ) -> Integrated<'a> {
314        Integrated {
315            vakint,
316            vakint_settings,
317        }
318    }
319
320    pub(crate) fn run<S: super::ForestNodeLike, M: super::ForestNodeLike>(
321        &self,
322        integrand: &Local4dCts,
323        ctx: &UVCtx<'_>,
324        current: &S,
325        given: &S,
326        marker_current: &M,
327        marker_given: &M,
328    ) -> Result<IntegratedCts> {
329        let graph = ctx.graph;
330
331        let n_loops = graph.n_loops(current.subgraph());
332
333        let scheme = current.renormalization_scheme();
334        match scheme {
335            ApproximationType::MUV | ApproximationType::PolePart | ApproximationType::IR => {
336                // Integrate the complete signed operator in each retained frame.
337                // Linearity permits this sector sum; no soft branch is discarded
338                // before the nested forest has supplied its completed coefficient.
339                let mut integrated = Atom::Zero;
340                for sector in integrand.active_sectors() {
341                    let physical = sector
342                        .atom
343                        .replace(GS.integrated_loop_scale)
344                        .with(Atom::one());
345                    let simplified = simplify(&physical)?;
346                    if !simplified.is_zero() {
347                        integrated += self
348                            .integrate(&simplified, sector, ctx, current, given)
349                            .wrap_err_with(|| {
350                                format!(
351                                    "while integrating {scheme} counterterm {} with retained component frames {:?}",
352                                    current.subgraph().string_label(),
353                                    sector.active_components,
354                                )
355                            })?;
356                    }
357                }
358                let marker = UvMarker::new(ctx.settings);
359                let integrated = marker.apply(
360                    UvOperation::Integrate,
361                    marker_current.subgraph(),
362                    marker_given.subgraph(),
363                    &integrated,
364                );
365                let expansion_depth =
366                    usize::try_from(self.vakint_settings.number_of_terms_in_epsilon_expansion)
367                        .wrap_err("Vakint epsilon expansion depth must be nonnegative")?;
368                let expanded = series(&integrated, expansion_depth.max(n_loops + 1))?.map_coeff(
369                    |coefficient| {
370                        marker.apply(
371                            UvOperation::Series,
372                            marker_current.subgraph(),
373                            marker_given.subgraph(),
374                            coefficient,
375                        )
376                    },
377                );
378                let expansion = expanded.map_coeff(|coefficient| {
379                    marker.apply(
380                        UvOperation::Truncate,
381                        marker_current.subgraph(),
382                        marker_given.subgraph(),
383                        coefficient,
384                    )
385                });
386
387                // Retain the consumed loop measures for subsequent UV rescalings. Keep this
388                // marker independent of mUV so enclosing limits still rescale the vacuum mass.
389                Ok(IntegratedCts {
390                    expansion,
391                    scale_power: 4 * n_loops as i64,
392                })
393            }
394            ApproximationType::OS => Err(eyre!("Not yet implemented OS")),
395            ApproximationType::Unsubtracted => {
396                panic!("should have been kept out of the wood");
397            }
398        }
399    }
400
401    #[debug_instrument(
402        current = %current.log_display(),
403        given = %given.log_display(),
404        reduced,
405    )]
406    fn integrate<S: ForestNodeLike>(
407        &self,
408        integrand: &Atom,
409        sector: &FourDSector,
410        ctx: &UVCtx<'_>,
411        current: &S,
412        given: &S,
413    ) -> Result<Atom> {
414        let graph = ctx.graph;
415        let reduced = current.reduced_subgraph(given);
416        let settings = ctx.settings;
417        let reduced_label = reduced.string_label();
418        tracing::Span::current().record("reduced", reduced_label.as_str());
419        debug_tags!(#uv, #integrated, #vakint, #trace, #input;
420            log.integrand = integrand,
421            "Integrating and truncating"
422        );
423        // Match the vacuum result to the unintegrated CFF loop measure through
424        // Vakint's configured per-loop normalization hook. Forest-subtraction
425        // signs are folded into the expression at the integrated CT composition
426        // sites.
427        let integrand_vakint = to_vakint_integrand(
428            integrand,
429            graph,
430            current.subgraph(),
431            given.subgraph(),
432            &sector.active_components,
433            &settings.vakint,
434            false,
435        )?;
436
437        let propagator = function!(
438            vakint::symbols::S.prop,
439            W_.a_,
440            W_.b_,
441            W_.mom_,
442            W_.mass_,
443            W_.e_
444        )
445        .to_pattern();
446        let retained_loop_count = sector
447            .active_components
448            .iter()
449            .flat_map(|(_, _, lmb)| lmb.loop_edges.iter())
450            .collect::<std::collections::HashSet<_>>()
451            .len();
452        let mut res = Atom::Zero;
453        for source_term in integrand_vakint.0 {
454            // A product of independent one-loop denominators can carry a
455            // coupled numerator. Integrate one factor at a time, treating all
456            // spectator loops as fixed vectors for its angular projection.
457            let mut loop_factors = std::collections::BTreeMap::<i64, Atom>::new();
458            let mut masses = std::collections::HashSet::new();
459            let mut independent = true;
460            for matched in source_term.integral.pattern_match(&propagator, None, None) {
461                let momentum = &matched[&W_.mom_];
462                let loop_id = [1, -1].into_iter().find_map(|sign| {
463                    let momentum = momentum * Atom::num(sign);
464                    let AtomView::Fun(vector) = momentum.as_view() else {
465                        return None;
466                    };
467                    (vector.get_symbol() == vakint::symbols::S.k && vector.get_nargs() == 1)
468                        .then(|| i64::try_from(vector.get(0)).ok())
469                        .flatten()
470                });
471                let Some(loop_id) = loop_id else {
472                    independent = false;
473                    break;
474                };
475                masses.insert(matched[&W_.mass_].clone());
476                *loop_factors.entry(loop_id).or_insert_with(Atom::one) *= propagator
477                    .replace_wildcards(&matched)
478                    .map_err(|error| eyre!(error))?;
479            }
480            let factorized = independent
481                && retained_loop_count > 1
482                && loop_factors.len() == retained_loop_count
483                && masses.len() > 1;
484            let loop_ids = loop_factors.keys().copied().collect::<Vec<_>>();
485            let stages = if factorized {
486                loop_factors
487                    .into_iter()
488                    .map(|(loop_id, propagators)| {
489                        (
490                            function!(vakint::symbols::S.topo, propagators),
491                            loop_ids
492                                .iter()
493                                .copied()
494                                .filter(|id| *id != loop_id)
495                                .collect::<Vec<_>>(),
496                        )
497                    })
498                    .collect::<Vec<_>>()
499            } else {
500                vec![(source_term.integral.clone(), Vec::new())]
501            };
502            let mut coefficient = source_term.numerator.clone();
503            if factorized {
504                // Preserve even custom normalization hooks that are not a
505                // product of identical per-loop factors. The converter's
506                // additional normalization already belongs to the full measure.
507                let normalization = self
508                    .vakint_settings
509                    .get_integral_normalization_factor_atom()?;
510                coefficient *= normalization
511                    .replace(vakint::symbols::S.n_loops.to_pattern())
512                    .with(Atom::num(retained_loop_count))
513                    / normalization
514                        .replace(vakint::symbols::S.n_loops.to_pattern())
515                        .with(Atom::one())
516                        .pow(retained_loop_count);
517            }
518            // Keep the same conservative epsilon target at every stage. Later
519            // vacuum poles can consume positive orders, while Vakint reserves
520            // coefficient and normalization poles independently.
521            for (integral, spectators) in stages {
522                let stage_loop_count = if factorized { 1 } else { retained_loop_count };
523                let external = function!(vakint::symbols::S.p, W_.i_, W_.x___).to_pattern();
524                let mut used_ids = coefficient
525                    .pattern_match(&external, None, None)
526                    .map(|matched| i64::try_from(matched[&W_.i_].as_view()))
527                    .collect::<Result<std::collections::HashSet<_>, _>>()
528                    .map_err(|error| eyre!(error))?;
529                let mut next_id = 0i64;
530                let mut restore_spectators = Vec::new();
531                for spectator in spectators {
532                    while used_ids.contains(&next_id) {
533                        next_id = next_id.checked_add(1).ok_or_else(|| {
534                            eyre!("no free external momentum ID for vacuum spectator")
535                        })?;
536                    }
537                    used_ids.insert(next_id);
538                    let source = function!(vakint::symbols::S.k, spectator, W_.x___);
539                    let target = function!(vakint::symbols::S.p, next_id, W_.x___);
540                    coefficient = coefficient
541                        .replace(source.to_pattern())
542                        .with(target.to_pattern());
543                    restore_spectators.push(Replacement::new(target, source));
544                }
545                let mut stage_term = source_term.clone();
546                stage_term.integral = integral;
547                stage_term.numerator = coefficient;
548                let mut integrand_vakint = VakintExpression(vec![stage_term]);
549                let mut same_momentum = std::collections::BTreeMap::<Atom, Vec<_>>::new();
550                for matched in integrand_vakint.0[0]
551                    .integral
552                    .pattern_match(&propagator, None, None)
553                {
554                    let momentum = &matched[&W_.mom_];
555                    same_momentum
556                        .entry(momentum.clone().min(-momentum))
557                        .or_default()
558                        .push((
559                            usize::try_from(matched[&W_.a_].as_view())
560                                .map_err(|error| eyre!(error))?,
561                            matched[&W_.mass_].clone(),
562                            matched[&W_.e_].clone(),
563                        ));
564                }
565                for (momentum, propagators) in same_momentum {
566                    let (_, first_mass, _) = &propagators[0];
567                    if propagators.iter().all(|(_, mass, _)| mass == first_mass)
568                        || momentum.contains_symbol(vakint::symbols::S.p)
569                        || propagators.iter().any(|(_, mass, power)| {
570                            mass.contains_symbol(W_.x_)
571                                || !i64::try_from(power.as_view()).is_ok_and(|power| power > 0)
572                        })
573                    {
574                        continue;
575                    }
576
577                    // A mixed-mass one-loop vacuum is a sum of single-mass
578                    // tadpoles: 1/(D_a D_b) = (1/D_a - 1/D_b)/(m_a²-m_b²).
579                    // The same identity applies to any equal-momentum group
580                    // with other vacuum propagators held fixed. Partial-fraction
581                    // only the denominator, including raised powers; the graph
582                    // numerator remains an unchanged factor.
583                    let loop_square = Atom::var(W_.x_);
584                    let denominator = propagators
585                        .iter()
586                        .fold(Atom::one(), |product, (_, mass, power)| {
587                            product * (&loop_square - mass).pow(-power)
588                        });
589                    let fractions = denominator
590                        .try_to_rational_polynomial::<_, _, u16>(&Q, &Z, [W_.x_])?
591                        .apart_factored_denominators(0);
592                    for term in std::mem::take(&mut integrand_vakint.0) {
593                        let powers = term
594                            .integral
595                            .pattern_match(&propagator, None, None)
596                            .map(|matched| {
597                                Ok((
598                                    usize::try_from(matched[&W_.a_].as_view())
599                                        .map_err(|error| eyre!(error))?,
600                                    matched[&W_.e_].clone(),
601                                ))
602                            })
603                            .collect::<Result<std::collections::BTreeMap<_, _>>>()?;
604                        let graph = vakint::graph::Graph::new_from_atom(
605                            term.integral.as_view(),
606                            *powers.keys().next_back().unwrap(),
607                        )?;
608                        for (coefficient, denominator, power) in &fractions {
609                            let denominator = denominator.to_expression();
610                            let constant = denominator.replace(W_.x_).with(Atom::Zero);
611                            let leading = denominator.replace(W_.x_).with(Atom::one()) - &constant;
612                            let (survivor, _, _) = propagators.iter().find(|(_, mass, _)| {
613                                (&denominator - &leading * (&loop_square - mass)).expand().is_zero()
614                            }).ok_or_else(|| eyre!("vacuum partial fraction has an unexpected denominator {denominator}"))?;
615                            let coefficient = coefficient.to_expression() / leading.pow(*power);
616                            if coefficient.contains_symbol(W_.x_) {
617                                return Err(eyre!(
618                                    "vacuum partial fraction retained loop dependence in its coefficient"
619                                ));
620                            }
621                            let mut contracted = graph.clone();
622                            for (id, _, _) in &propagators {
623                                if id != survivor {
624                                    contracted.contract_edges(&[*id].into_iter().collect());
625                                }
626                            }
627                            // Removing a serial line contracts its endpoints.
628                            // Dropping it without that contraction would silently
629                            // lower a sunset's graph rank although both loop
630                            // generators still occur in its denominators.
631                            if contracted.to_symbolica_graph(false).num_loops() != stage_loop_count
632                            {
633                                return Err(eyre!(
634                                    "vacuum partial fraction changed the retained {stage_loop_count}-loop integration domain for component {reduced_label}"
635                                ));
636                            }
637                            let mut fraction = term.clone();
638                            fraction.numerator *= coefficient;
639                            fraction.integral = function!(
640                                vakint::symbols::S.topo,
641                                contracted
642                                    .edges
643                                    .values()
644                                    .fold(Atom::one(), |product, edge| {
645                                        product
646                                            * function!(
647                                                vakint::symbols::S.prop,
648                                                edge.id,
649                                                function!(
650                                                    vakint::symbols::S.edge,
651                                                    edge.left_node_id,
652                                                    edge.right_node_id
653                                                ),
654                                                &edge.momentum,
655                                                &edge.mass,
656                                                if edge.id == *survivor {
657                                                    Atom::num(*power)
658                                                } else {
659                                                    powers[&edge.id].clone()
660                                                }
661                                            )
662                                    })
663                            );
664                            integrand_vakint.0.push(fraction);
665                        }
666                    }
667                }
668                // Only a vacuum topology whose actual masses all vanish is scaleless.
669                // Certify polynomial loop dependence as well: an external scale hidden
670                // inside a nonpolynomial numerator must not be erased with the soft jet.
671                integrand_vakint.0.retain(|term| {
672                    let propagators = term
673                        .integral
674                        .pattern_match(&propagator, None, None)
675                        .map(|matched| {
676                            matched[&W_.mass_].is_zero()
677                                && matched[&W_.mom_].contains_symbol(vakint::symbols::S.k)
678                        })
679                        .collect::<Vec<_>>();
680                    if propagators.is_empty()
681                        || propagators.iter().any(|massless_loop| !massless_loop)
682                        || term.integral.contains_symbol(vakint::symbols::S.p)
683                    {
684                        return true;
685                    }
686                    let mut pending = vec![term.numerator.as_view()];
687                    let mut scaleless = true;
688                    while let Some(view) = pending.pop() {
689                        if !view.contains_symbol(vakint::symbols::S.k) {
690                            continue;
691                        }
692                        match view {
693                            AtomView::Add(sum) => pending.extend(sum.iter()),
694                            AtomView::Mul(product) => pending.extend(product.iter()),
695                            AtomView::Pow(power) => {
696                                let (base, exponent) = power.get_base_exp();
697                                if i64::try_from(exponent).is_ok_and(|power| power >= 0) {
698                                    pending.push(base);
699                                } else {
700                                    scaleless = false;
701                                    break;
702                                }
703                            }
704                            AtomView::Fun(function)
705                                if function.get_symbol() == vakint::symbols::S.k
706                                    && function.get_nargs() > 0
707                                    && usize::try_from(function.get(0)).is_ok() => {}
708                            AtomView::Fun(function)
709                                if function.get_symbol() == vakint::symbols::S.dot
710                                    && function.get_nargs() == 2
711                                    && function.iter().all(|argument| {
712                                        matches!(argument, AtomView::Fun(vector)
713                                            if [vakint::symbols::S.k, vakint::symbols::S.p]
714                                                .contains(&vector.get_symbol())
715                                                && vector.get_nargs() == 1
716                                                && usize::try_from(vector.get(0)).is_ok())
717                                    }) => {}
718                            _ => {
719                                scaleless = false;
720                                break;
721                            }
722                        }
723                    }
724                    if scaleless {
725                        debug_tags!(#uv, #integrated, #vakint, #trace;
726                            stage = "certified_scaleless_vacuum",
727                            log.integral = term.integral,
728                            log.numerator = term.numerator,
729                            "Dropping a massless vacuum term with polynomial loop numerator"
730                        );
731                    }
732                    !scaleless
733                });
734                if integrand_vakint.0.is_empty() {
735                    coefficient = Atom::Zero;
736                    break;
737                }
738
739                for (term_index, t) in integrand_vakint.0.iter().enumerate() {
740                    debug_tags!(#uv,#integrated,#vakint,#trace,#to_vakint;
741                        term_index = %term_index,
742                        log.integral = t.integral,
743                        log.numerator = t.numerator,
744                        "Vakint term as input"
745                    );
746                }
747                debug_tags!(#uv,#integrated,#vakint;settings = ?&self.vakint_settings,"Vakint args");
748
749                // let mut res = vakint
750                //     .0
751                //     .evaluate(&vakint.1, integrand_vakint.as_view())
752                //     .unwrap();
753
754                // The analytic backends identify a vacuum mass through a
755                // symbol or its square. Alias other exact nonzero mass values
756                // only in propagator mass slots, then restore the values below.
757                let mut mass_aliases = std::collections::BTreeMap::new();
758                let mut used_symbols = integrand_vakint
759                    .0
760                    .iter()
761                    .flat_map(|term| [&term.integral, &term.numerator])
762                    .flat_map(|atom| atom.get_all_symbols(true))
763                    .collect::<std::collections::HashSet<_>>();
764                let mut mass_serial = 0;
765                for term in &mut integrand_vakint.0 {
766                    let mut replacements = Vec::new();
767                    for matched in term.integral.pattern_match(&propagator, None, None) {
768                        let mass = &matched[&W_.mass_];
769                        if mass.contains_symbol(vakint::symbols::S.k)
770                            || mass.contains_symbol(vakint::symbols::S.p)
771                        {
772                            return Err(eyre!(
773                                "a vacuum propagator mass must be independent of momentum"
774                            ));
775                        }
776                        if mass.is_zero()
777                            || matches!(mass.as_view(), AtomView::Var(_))
778                            || matches!(mass.as_view(), AtomView::Pow(power)
779                                if matches!(power.get_base_exp().0, AtomView::Var(_))
780                                    && power.get_base_exp().1 == Atom::num(2).as_view())
781                        {
782                            continue;
783                        }
784                        let alias = mass_aliases.entry(mass.clone()).or_insert_with(|| {
785                            loop {
786                                let symbol = vakint_symbol!(format!(
787                                    "integrated_mass_squared_{mass_serial}"
788                                ));
789                                mass_serial += 1;
790                                if used_symbols.insert(symbol) {
791                                    break Atom::var(symbol);
792                                }
793                            }
794                        });
795                        replacements.push(Replacement::new(
796                            propagator
797                                .replace_wildcards(&matched)
798                                .map_err(|error| eyre!(error))?,
799                            function!(
800                                vakint::symbols::S.prop,
801                                &matched[&W_.a_],
802                                &matched[&W_.b_],
803                                &matched[&W_.mom_],
804                                &*alias,
805                                &matched[&W_.e_]
806                            ),
807                        ));
808                    }
809                    term.integral = term.integral.replace_multiple(&replacements);
810                }
811                integrand_vakint.canonicalize(
812                    self.vakint_settings,
813                    &self.vakint.topologies,
814                    false,
815                )?;
816                for (term_index, t) in integrand_vakint.0.iter().enumerate() {
817                    debug_tags!(#uv,#integrated,#vakint,#trace,#canonicalize;
818                        term_index = %term_index,
819                        log.integral = t.integral,
820                        log.numerator = t.numerator,
821                        "Vakint term after canonicalization"
822                    );
823                }
824                integrand_vakint.tensor_reduce(self.vakint, self.vakint_settings)?;
825                for (term_index, t) in integrand_vakint.0.iter().enumerate() {
826                    debug_tags!(#uv,#integrated,#vakint,#trace,#tensor_reduce;
827                        term_index = %term_index,
828                        log.integral = t.integral,
829                        log.numerator = t.numerator,
830                        "Vakint term after tensor reduction"
831                    );
832                }
833                for (term_index, term) in integrand_vakint.0.iter_mut().enumerate() {
834                    term.numerator =
835                        self.simplify_projected_numerator(&term.numerator, current.topo_order())?;
836                    debug_tags!(#uv, #integrated, #vakint, #trace;
837                        stage = "projected_numerator_after_d_dimensional_algebra",
838                        term_index = %term_index,
839                        log.numerator = term.numerator,
840                        "Completed projected numerator algebra before Laurent expansion"
841                    );
842                }
843                integrand_vakint.evaluate_integral(self.vakint, self.vakint_settings)?;
844                for (term_index, t) in integrand_vakint.0.iter().enumerate() {
845                    debug_tags!(#uv,#integrated,#vakint,#trace,#evaluate;
846                        term_index = %term_index,
847                        log.integral = t.integral,
848                        log.numerator = t.numerator,
849                        "Vakint term after evaluation"
850                    );
851                }
852
853                coefficient = Atom::from(integrand_vakint).replace_multiple(
854                    mass_aliases
855                        .into_iter()
856                        .map(|(mass, alias)| Replacement::new(alias, mass))
857                        .collect::<Vec<_>>(),
858                );
859                if coefficient.contains_symbol(vakint::symbols::S.k) {
860                    return Err(eyre!(
861                        "integrated vacuum factor retained an active loop momentum"
862                    ));
863                }
864                coefficient = coefficient.replace_multiple(&restore_spectators);
865            }
866            res += coefficient;
867        }
868
869        debug_tags!(#uv,#integrated,#vakint,#trace,#raw;
870            log.res = res,
871            "Raw post vakint "
872        );
873
874        let mut res = Self::restore_numerator(res, current.topo_order());
875
876        res = Self::dimensionally_regularized(&res.simplify_metrics().metric_shorthand_to_dot());
877
878        debug_tags!(#uv, #integrated, #vakint, #inspect, #trace, #replace;
879            log.res = res,
880            "Replaced post vakint "
881        );
882
883        // This strips as many dummies as possible after undoing chains and traces,
884        // so that terms can merge later on.
885        let bispinor_rep = Bispinor {}.into();
886        let after_chainify = res.chainify(bispinor_rep);
887        debug_tags!(#uv, #integrated, #vakint, #profile, #trace, #chainify;
888            log.expr = after_chainify,
889            "Integrated UV chain cleanup after chainify"
890        );
891
892        let after_collect_chains = after_chainify.collect_chains(bispinor_rep);
893        debug_tags!(#uv, #integrated, #vakint, #profile, #trace, #collect;
894            log.expr = after_collect_chains,
895            "Integrated UV chain cleanup after collect_chains"
896        );
897
898        res = after_collect_chains.undo_single_length();
899        debug_tags!(#uv, #integrated, #vakint, #profile, #trace, #undo_single_length;
900            log.expr = res,
901            "Integrated UV chain cleanup after undo_single_length"
902        );
903
904        // println!("\nIntegrated CT:\n{}\n", res);
905        // Normalize the fully integrated UV subgraph before Laurent projection
906        // and reinsertion: identical scalar masters can arrive as 2*(A+B) or
907        // 2*A+2*B. No cograph numerator is present at this analytic boundary.
908        Ok(res.expand())
909    }
910
911    fn simplify_projected_numerator(
912        &self,
913        numerator: &Atom,
914        topology_order: usize,
915    ) -> Result<Atom> {
916        // Tensor projection can identify Lorentz slots belonging to different
917        // gamma matrices. Complete their d-dimensional algebra before Vakint
918        // expands scalar coefficients in epsilon; otherwise an evanescent
919        // contraction can multiply a pole only after its finite term was lost.
920        let numerator = Vakint::convert_to_dot_notation(self.vakint_settings, numerator.as_view())?;
921        let numerator = Self::restore_numerator(numerator, topology_order);
922        let numerator = simplify(&numerator)?;
923        Self::ensure_resolved_lorentz_contractions(&numerator)?;
924        let numerator = Self::dimensionally_regularized(&numerator)
925            .undo_schoonschip::<Aind>()?
926            .undo_chain::<Aind>()?
927            .undo_trace::<Aind>()?
928            .metric_shorthand_to_dot();
929        Ok(Self::to_vakint_numerator(&numerator))
930    }
931
932    fn ensure_resolved_lorentz_contractions(numerator: &Atom) -> Result<()> {
933        let minkowski = Minkowski {}.to_symbolic([GS.dim]).get_symbol().unwrap();
934        if !numerator.contains_symbol(minkowski) {
935            return Ok(());
936        }
937        // Inspect each expanded analytic branch using the tensor parser's
938        // incidence rules. Expose compact inter-chain contractions first;
939        // keeping only non-momentum Lorentz tensors allows scalar products and
940        // slashed external momenta while detecting unresolved tensor operators.
941        let explicit = numerator
942            .parse_to_symbolic_net::<Aind>(&ParseSettings {
943                shorthand_parsing: ShorthandParsing::Expand {
944                    schoonschip: SchoonschipExpansionMode::full(),
945                    trace: false,
946                    chain: true,
947                },
948                ..Default::default()
949            })
950            .map_err(|error| eyre!("invalid analytic UV Lorentz tensor notation: {error}"))?
951            .simple_execute::<()>()?
952            .expand();
953        let terms = if let AtomView::Add(sum) = explicit.as_view() {
954            sum.iter().collect::<Vec<_>>()
955        } else {
956            vec![explicit.as_view()]
957        };
958        let settings = ParseSettings {
959            shorthand_parsing: ShorthandParsing::Opaque {
960                inference: StructureInferenceMode::Fast,
961            },
962            ..Default::default()
963        };
964        for term in terms {
965            let mut unresolved_inner_product = false;
966            let tensors = term.replace_map(|view, _, out| {
967                if !matches!(view, AtomView::Num(_)) && !view.contains_symbol(minkowski) {
968                    // Remove scalar subtrees atomically: replacing the d in
969                    // 1/(d-1) alone would manufacture a singular coefficient.
970                    // Numerical exponents of tensor powers keep their value.
971                    **out = Atom::one();
972                    return;
973                }
974                match view {
975                    AtomView::Fun(f) => {
976                        if [SPENSO_TAG.dot, ETS.metric].contains(&f.get_symbol())
977                            && view.contains_symbol(minkowski)
978                            && !f
979                                .iter()
980                                .all(|arg| Slot::<Minkowski, Aind>::try_from(arg).is_ok())
981                        {
982                            // Full shorthand parsing must expose every compact
983                            // Lorentz contraction before scalar coefficients can
984                            // be discarded by this diagnostic.
985                            unresolved_inner_product = true;
986                        }
987                        let momentum = [
988                            GS.emr_mom,
989                            GS.loop_mom,
990                            vakint::symbols::S.p,
991                            vakint::symbols::S.k,
992                        ]
993                        .contains(&f.get_symbol());
994                        **out = if !momentum
995                            && f.iter()
996                                .any(|arg| Slot::<Minkowski, Aind>::try_from(arg).is_ok())
997                        {
998                            view.to_owned()
999                        } else {
1000                            Atom::one()
1001                        };
1002                    }
1003                    AtomView::Var(_) => **out = Atom::one(),
1004                    _ => {}
1005                }
1006            });
1007            if unresolved_inner_product {
1008                return Err(eyre!(
1009                    "an unresolved compact Lorentz inner product remains after d-dimensional UV numerator algebra"
1010                ));
1011            }
1012            let network = tensors
1013                .parse_to_symbolic_net::<Aind>(&settings)
1014                .map_err(|error| {
1015                    eyre!("invalid residual d-dimensional tensor structure: {error}")
1016                })?;
1017            let external = network.graph.dangling_indices();
1018            for (pair, _, edge) in network.graph.graph.iter_edges() {
1019                if pair.is_paired()
1020                    && let NetworkEdge::Slot(slot) = edge.data
1021                    && slot.rep_name().symbol() == minkowski
1022                    && !external.contains(slot)
1023                {
1024                    return Err(eyre!(
1025                        "an unresolved internal Lorentz tensor contraction remains after d-dimensional UV numerator algebra; a d-dimensional tensor-operator reduction is required before analytic integration"
1026                    ));
1027                }
1028            }
1029        }
1030        Ok(())
1031    }
1032
1033    fn dimensionally_regularized(numerator: &Atom) -> Atom {
1034        let minkowski = Minkowski {}.to_symbolic([GS.dim]).get_symbol().unwrap();
1035        let dimension = Atom::var(GS.dim);
1036        let regulated = Atom::num(4) - Atom::num(2) * Atom::var(GS.dim_epsilon);
1037        numerator.replace_map(|view, _, out| {
1038            if view.get_symbol() == Some(minkowski) {
1039                // A retained open Lorentz basis keeps its representation. All
1040                // scalar occurrences, including inside function coefficients,
1041                // must instead participate in the Laurent expansion.
1042                **out = view.to_owned();
1043            } else if view == dimension.as_view() {
1044                **out = regulated.clone();
1045            }
1046        })
1047    }
1048
1049    fn restore_numerator(mut res: Atom, topology_order: usize) -> Atom {
1050        // Projector indices may close two opaque tensor leaves. Restore their
1051        // complete Minkowski representation before exposing the tensor bodies.
1052        let mink = Minkowski {}.new_rep(GS.dim);
1053        let mut projected_indices = std::collections::BTreeMap::new();
1054        res = res.replace_map(|view, _, out| {
1055            if matches!(view, AtomView::Fun(index) if index.get_symbol() == vakint::symbols::S.tensor_index) {
1056                let replacement = projected_indices.entry(view.to_owned()).or_insert_with(|| loop {
1057                    let candidate = mink.to_symbolic([Aind::new_dummy().to_atom()]);
1058                    if res.pattern_match(&candidate.to_pattern(), None, None).next().is_none() {
1059                        break candidate;
1060                    }
1061                });
1062                **out = replacement.clone();
1063            }
1064        });
1065
1066        res = res
1067            // Opaque tensor slots are needed only while Vakint validates and
1068            // projects the Lorentz domain. Restore the caller's tensor leaves.
1069            .replace(function!(vakint::symbols::S.tensor, W_.x_, W_.x___))
1070            .with(Atom::var(W_.x_))
1071            .replace(parse_lit!(vakint::cl2))
1072            .with(parse_lit!(cl2))
1073            .replace(parse_lit!(vakint::sqrt3))
1074            .with(parse_lit!(sqrt(3)));
1075
1076        let vk_metric = vakint_symbol!("g");
1077
1078        // apply metric
1079        res = res
1080            .replace(vakint::symbols::S.p.call_args([W_.i_, W_.j_]))
1081            .when(W_.j_.filter(|r| r.is_integer().is_true()))
1082            .with(
1083                vakint::symbols::S.p.call_args([
1084                    Atom::var(W_.i_),
1085                    mink.to_symbolic([GS
1086                        .uvaind
1087                        .call_args([Atom::num(topology_order), Atom::var(W_.j_)])]),
1088                ]),
1089            )
1090            .replace(
1091                vakint::symbols::S
1092                    .p
1093                    .call_args([Atom::var(W_.i_), vakint::symbols::S.dot_dummy_ind(W_.j_)]),
1094            )
1095            .when(W_.j_.filter(|r| r.is_integer().is_true()))
1096            .with(
1097                vakint::symbols::S.p.call_args([
1098                    Atom::var(W_.i_),
1099                    mink.to_symbolic([GS
1100                        .uvaind
1101                        .call_args([Atom::num(topology_order), Atom::var(W_.j_)])]),
1102                ]),
1103            )
1104            .replace(vakint::symbols::S.p.call_args([W_.x__]))
1105            .with(GS.emr_mom.call_args([W_.x__]));
1106        res = res
1107            .replace(function!(vk_metric, W_.x_, W_.y_) * function!(GS.emr_mom, W_.x___, W_.x_))
1108            .with(function!(GS.emr_mom, W_.x___, W_.y_))
1109            .replace(function!(
1110                vk_metric,
1111                vakint::symbols::S.dot_dummy_ind(W_.x_),
1112                W_.y_
1113            ))
1114            .when(W_.x_.filter(|r| r.is_integer().is_true()))
1115            .with(function!(
1116                vk_metric,
1117                mink.to_symbolic([GS
1118                    .uvaind
1119                    .call_args([Atom::num(topology_order), Atom::var(W_.x_)])]),
1120                W_.y_
1121            ))
1122            .replace(function!(
1123                vk_metric,
1124                W_.x_,
1125                vakint::symbols::S.dot_dummy_ind(W_.y_)
1126            ))
1127            .when(W_.y_.filter(|r| r.is_integer().is_true()))
1128            .with(function!(
1129                vk_metric,
1130                mink.to_symbolic([GS
1131                    .uvaind
1132                    .call_args([Atom::num(topology_order), Atom::var(W_.y_)])]),
1133                W_.x_
1134            ))
1135            .replace(function!(vk_metric, W_.x_, W_.y_))
1136            .with(function!(ETS.metric, W_.x_, W_.y_));
1137
1138        res = res.replace(vakint::symbols::S.cmplx_i).with(Atom::i());
1139
1140        res
1141    }
1142
1143    fn to_vakint_numerator(numerator: &Atom) -> Atom {
1144        let numerator = numerator
1145            .replace(function!(GS.loop_mom, W_.x___))
1146            .with(function!(vakint::symbols::S.k, W_.x___))
1147            .replace(function!(GS.emr_mom, W_.x___))
1148            .with(function!(vakint::symbols::S.p, W_.x___))
1149            .replace(function!(
1150                SPENSO_TAG.dot,
1151                function!(W_.a_, W_.a___, Minkowski {}.new_rep(GS.dim).to_symbolic([])),
1152                function!(W_.b_, W_.b___, Minkowski {}.new_rep(GS.dim).to_symbolic([]))
1153            ))
1154            .with(vakint::symbols::S.dot(function!(W_.a_, W_.a___), function!(W_.b_, W_.b___)))
1155            .replace(function!(
1156                ETS.metric,
1157                Minkowski {}.to_symbolic([W_.a__]),
1158                Minkowski {}.to_symbolic([W_.b__])
1159            ))
1160            .with(function!(
1161                vakint::symbols::S.metric,
1162                Minkowski {}.to_symbolic([W_.a__]),
1163                Minkowski {}.to_symbolic([W_.b__])
1164            ));
1165        // Preserve the Lorentz slots of arbitrary spin tensors without
1166        // teaching Vakint Spenso's representation syntax or tensor algebra.
1167        // Direct slots use the same parser as the tensor network. The original
1168        // leaf stays opaque, including its spin and color indices.
1169        numerator.replace_map(|view, _, out| {
1170            let AtomView::Fun(tensor) = view else { return };
1171            if [
1172                vakint::symbols::S.k,
1173                vakint::symbols::S.p,
1174                vakint::symbols::S.g,
1175                vakint::symbols::S.dot,
1176                vakint::symbols::S.tensor,
1177            ]
1178            .contains(&tensor.get_symbol())
1179            {
1180                **out = view.to_owned();
1181                return;
1182            }
1183            let slots = tensor
1184                .iter()
1185                .filter(|argument| Slot::<Minkowski, Aind>::try_from(*argument).is_ok())
1186                .collect::<Vec<_>>();
1187            if !slots.is_empty() {
1188                let mut wrapped = FunctionBuilder::new(vakint::symbols::S.tensor).add_arg(view);
1189                for slot in slots {
1190                    wrapped = wrapped.add_arg(slot);
1191                }
1192                **out = wrapped.finish();
1193            }
1194        })
1195    }
1196}
1197
1198#[debug_instrument]
1199pub(crate) fn to_vakint_integrand<
1200    E: UVE,
1201    V,
1202    H,
1203    S: SubGraphLike + SubSetLike<Base = SuBitGraph>,
1204    SS: SubGraphLike,
1205>(
1206    integrand: &Atom,
1207    graph: &HedgeGraph<E, V, H>,
1208    reduced: &S,
1209    dependent_subgraph: &SS,
1210    active_components: &[(SuBitGraph, SuBitGraph, LoopMomentumBasis)],
1211    settings: &VakintSettings,
1212    substitute_masses_to_m_uv: bool,
1213) -> Result<VakintExpression> {
1214    let reduced_label = reduced.string_label();
1215    let dependent_subgraph_label = dependent_subgraph.string_label();
1216    let source_lmbs = active_components
1217        .iter()
1218        .map(|(_, _, lmb)| lmb)
1219        .collect::<Vec<_>>();
1220    let mut retained_loop_edges = source_lmbs
1221        .iter()
1222        .flat_map(|lmb| lmb.loop_edges.iter().copied())
1223        .collect::<Vec<_>>();
1224    retained_loop_edges.sort();
1225    retained_loop_edges.dedup();
1226    // Resolve color contractions before opening shorthand traces. Repeated
1227    // color spectators then share their reduced form instead of acquiring
1228    // distinct dummy indices during Lorentz preparation. Keep the kinematic
1229    // coefficients factorized throughout this independent color pass.
1230    let mut integrand_vakint = GS
1231        .erase_uv_momentum_provenance(integrand)
1232        .simplify_color_with(ColorSimplifySettings {
1233            simplify_non_color: false,
1234            ..Default::default()
1235        })
1236        .undo_schoonschip::<Aind>()?
1237        .undo_chain::<Aind>()?
1238        .undo_trace::<Aind>()?;
1239    debug_tags!(#uv, #integrated, #vakint, #trace;
1240        stage = "to_vakint_integrand_after_undo_shorthands",
1241        reduced = %reduced_label,
1242        dependent_subgraph = %dependent_subgraph_label,
1243        substitute_masses_to_m_uv = substitute_masses_to_m_uv,
1244        log.integrand = integrand_vakint,
1245        "Vakint trace after undo shorthands"
1246    );
1247    //Atom::Zero
1248
1249    // The denominator-to-propagator replacements below strip the momentum
1250    // wrapper without distributing the numerator. The former standalone
1251    // denominator rewrite was:
1252    // .replace(function!(
1253    //     GS.den,
1254    //     W_.prop_,
1255    //     function!(GS.emr_mom, W_.prop_, W_.mom_),
1256    //     W_.x__
1257    // ))
1258    // .with(function!(GS.den, W_.prop_, W_.mom_, W_.x__))
1259
1260    // Nested counterterms can expose a boundary metric next to the propagator
1261    // metric of the reduced graph. Contract those metric-only structures before
1262    // the expression is split into Vakint terms.
1263    integrand_vakint = integrand_vakint.simplify_metrics();
1264    debug_tags!(#uv, #integrated, #vakint, #trace;
1265        stage = "to_vakint_integrand_after_simplify_metrics",
1266        reduced = %reduced_label,
1267        dependent_subgraph = %dependent_subgraph_label,
1268        log.integrand = integrand_vakint,
1269        "Vakint trace after metric simplification"
1270    );
1271
1272    // Separate denominator monomials before contracting their graph incidence.
1273    // In A*B*(c*B+d*B^2), fusing only the outside A*B would leave the
1274    // inner B attached to nodes that have already been contracted. Collect
1275    // only complete denominator atoms; numerator sums stay factored coefficients.
1276    let denominator_pattern = function!(GS.den, W_.x___).to_pattern();
1277    let denominators = integrand_vakint
1278        .pattern_match(&denominator_pattern, None, None)
1279        .map(|matched| denominator_pattern.replace_wildcards(&matched).unwrap())
1280        .collect::<std::collections::HashSet<_>>();
1281    integrand_vakint = integrand_vakint
1282        .coefficient_list::<i32>(&denominators.into_iter().collect::<Vec<_>>())
1283        .into_iter()
1284        .map(|(denominator, numerator)| denominator * numerator)
1285        .sum();
1286    let mut propagator_id = 1;
1287    let mut propagator_replacements = Vec::new();
1288    let mut incidence_replacements = Vec::new();
1289    let mut assigned_edges = std::collections::HashSet::new();
1290
1291    let vk_prop = vakint::symbols::S.prop;
1292    let vk_edge = vakint_symbol!("edge");
1293    let vk_topo = vakint_symbol!("topo");
1294
1295    // let contracted_nodes: BTreeSet<NodeIndex> = graph
1296    //     .iter_nodes_of(dependent_subgraph)
1297    //     .map(|(nid, c, v)| nid)
1298    //     .collect();
1299
1300    debug_tags!(#uv, #integrated, #vakint, #graph, #dump;
1301        reduced = %graph.dot(reduced),
1302        "Den to prop for"
1303    );
1304    // let first = contracted_nodes.first();
1305
1306    for (owners, source_scope, _) in active_components {
1307        // Shrink only this component's omitted prefix. Active child propagators
1308        // retain their own incidence; its quotient attaches at the contracted
1309        // vertex. Contract disjoint prefixes separately, preserving their loop
1310        // domains instead of identifying unrelated component vertices.
1311        let mut contracted_nodes = std::collections::HashMap::new();
1312        for prefix in graph.connected_components(&source_scope.subtract(owners)) {
1313            let mut nodes = graph.iter_nodes_of(&prefix).map(|(node, _, _)| node);
1314            if let Some(first) = nodes.next() {
1315                for node in nodes {
1316                    contracted_nodes.insert(node, first);
1317                }
1318            }
1319        }
1320        for (pair, index, _data) in graph.iter_edges_of(owners) {
1321            if let HedgePair::Paired { source, sink } = pair {
1322                if !assigned_edges.insert(index) {
1323                    return Err(eyre!(
1324                        "Vakint denominator owner {index} belongs to multiple active components"
1325                    ));
1326                }
1327                // The former global contraction selected `first` from all
1328                // dependent nodes. Resolve each endpoint with this owner's
1329                // prefix instead, leaving active child endpoints unchanged.
1330                let source = graph.node_id(source);
1331                let sink = graph.node_id(sink);
1332                let contracted_source = contracted_nodes.get(&source).copied().unwrap_or(source);
1333                let contracted_sink = contracted_nodes.get(&sink).copied().unwrap_or(sink);
1334                propagator_replacements.push(Replacement::new(
1335                    function!(
1336                        GS.den,
1337                        usize::from(index) as i64,
1338                        W_.mom_,
1339                        W_.mass_,
1340                        W_.x___
1341                    ),
1342                    function!(
1343                        vk_prop,
1344                        propagator_id,
1345                        function!(vk_edge, usize::from(source), usize::from(sink)),
1346                        W_.mom_,
1347                        if substitute_masses_to_m_uv {
1348                            Atom::var(GS.m_uv_vacuum).pow(2)
1349                        } else {
1350                            Atom::var(W_.mass_)
1351                        },
1352                        1
1353                    ),
1354                ));
1355                incidence_replacements.push(Replacement::new(
1356                    function!(
1357                        vk_prop,
1358                        propagator_id,
1359                        function!(vk_edge, usize::from(source), usize::from(sink)),
1360                        W_.x___
1361                    ),
1362                    function!(
1363                        vk_prop,
1364                        propagator_id,
1365                        function!(
1366                            vk_edge,
1367                            usize::from(contracted_source),
1368                            usize::from(contracted_sink)
1369                        ),
1370                        W_.x___
1371                    ),
1372                ));
1373                propagator_id += 1;
1374            }
1375        }
1376    }
1377    // Edge IDs make these denominator replacements disjoint. Convert all
1378    // propagators together, then encode their powers once for the whole atom.
1379    integrand_vakint = integrand_vakint
1380        .replace_multiple(&propagator_replacements)
1381        .replace(function!(vk_prop, W_.x___, 1).pow(Atom::var(W_.e_)))
1382        .with(function!(vk_prop, W_.x___, -Atom::var(W_.e_)));
1383    debug_tags!(#uv, #integrated, #vakint, #trace;
1384        stage = "to_vakint_integrand_after_den_to_prop",
1385        reduced = %reduced_label,
1386        dependent_subgraph = %dependent_subgraph_label,
1387        log.integrand = integrand_vakint,
1388        "Vakint trace after denominator-to-propagator conversion"
1389    );
1390
1391    debug_tags!(#uv, #integrated, #vakint, #graph, #dump;
1392        reduced = %graph.dot(dependent_subgraph),
1393        "Shrinking each component's omitted prefix for vakint"
1394    );
1395    // Shrink vertices only on propagators belonging to that quotient.
1396    integrand_vakint = integrand_vakint.replace_multiple(&incidence_replacements);
1397    if integrand_vakint.contains_symbol(GS.den) {
1398        return Err(eyre!(
1399            "Vakint component {reduced_label} contains a denominator without an active owner"
1400        ));
1401    }
1402    debug_tags!(#uv, #integrated, #vakint, #trace;
1403        stage = "to_vakint_integrand_after_shrink_subgraph",
1404        reduced = %reduced_label,
1405        dependent_subgraph = %dependent_subgraph_label,
1406        log.integrand = integrand_vakint,
1407        "Vakint trace after shrinking subgraph"
1408    );
1409
1410    // flip edges to positive momentum
1411    // FIXME: how will this work for sums of momenta?
1412    integrand_vakint = integrand_vakint
1413        .replace(function!(
1414            vk_prop,
1415            W_.x_,
1416            function!(vk_edge, W_.a_, W_.b_),
1417            -Atom::var(W_.y_),
1418            W_.e___
1419        ))
1420        .repeat()
1421        .with(function!(
1422            vk_prop,
1423            W_.x_,
1424            function!(vk_edge, W_.b_, W_.a_),
1425            W_.y_,
1426            W_.e___
1427        ));
1428
1429    // fuse raised edges
1430    integrand_vakint = integrand_vakint
1431        .replace(
1432            function!(
1433                vk_prop,
1434                W_.x_,
1435                function!(vk_edge, W_.a_, W_.b_),
1436                W_.x___,
1437                W_.e_
1438            ) * function!(
1439                vk_prop,
1440                W_.y_,
1441                function!(vk_edge, W_.b_, W_.c_),
1442                W_.x___,
1443                W_.f_
1444            ),
1445        )
1446        .repeat()
1447        .with(function!(
1448            vk_prop,
1449            W_.y_,
1450            function!(vk_edge, W_.a_, W_.c_),
1451            W_.x___,
1452            W_.e_ + W_.f_
1453        ));
1454    debug_tags!(#uv, #integrated, #vakint, #trace;
1455        stage = "to_vakint_integrand_after_flip_fuse",
1456        reduced = %reduced_label,
1457        dependent_subgraph = %dependent_subgraph_label,
1458        log.integrand = integrand_vakint,
1459        "Vakint trace after edge flip and fuse"
1460    );
1461
1462    // println!(
1463    //     "Integrand pre vakint: {:}",
1464    //     VakintExpression::try_from(
1465    //         integrand_vakint
1466    //             .replace(function!(vk_prop, W_.x__))
1467    //             .with(function!(vk_topo, function!(vk_prop, W_.x__)))
1468    //             .replace(function!(vk_topo, W_.x_) * function!(vk_topo, W_.y_))
1469    //             .repeat()
1470    //             .with(function!(vk_topo, W_.x_ * W_.y_))
1471    //     )
1472    //     .unwrap()
1473    // );
1474
1475    let vakint_input_atom = integrand_vakint
1476        .replace(function!(vk_prop, W_.x__))
1477        .with(function!(vk_topo, function!(vk_prop, W_.x__)))
1478        .replace(function!(vk_topo, W_.x_) * function!(vk_topo, W_.y_))
1479        .repeat()
1480        .with(function!(vk_topo, W_.x_ * W_.y_));
1481    debug_tags!(#uv, #integrated, #vakint, #trace;
1482        stage = "to_vakint_integrand_before_split_terms",
1483        reduced = %reduced_label,
1484        dependent_subgraph = %dependent_subgraph_label,
1485        log.integrand = vakint_input_atom,
1486        "Vakint trace before split terms"
1487    );
1488
1489    let mut a = VakintExpression::try_from(vakint_input_atom)
1490        .wrap_err("could not split integrand into Vakint terms")?;
1491
1492    for (term_index, t) in a.0.iter_mut().enumerate() {
1493        debug_tags!(#uv, #integrated, #vakint, #inspect, #trace;
1494            stage = "to_vakint_integrand_term_initial",
1495            term_index = %term_index,
1496            reduced = %reduced_label,
1497            dependent_subgraph = %dependent_subgraph_label,
1498            log.integral = t.integral,
1499            log.numerator = t.numerator,
1500            "Starting integral"
1501        );
1502
1503        let mut graph = HedgeGraphBuilder::new();
1504        //prop(<id>, edge(<node_source>,<node_sink>), <mom>, <mass_squared>, <power>)
1505        let pat = function!(
1506            vk_prop,
1507            W_.a_,
1508            function!(vakint::symbols::S.edge, W_.i_, W_.j_),
1509            W_.c_,
1510            W_.d_,
1511            W_.e_
1512        )
1513        .to_pattern();
1514        let mut nodemap = std::collections::HashMap::new();
1515
1516        struct ContractibleEdge {
1517            mom: Atom,
1518            mass_squared: Atom,
1519            power: i32,
1520        }
1521
1522        for m in t.integral.pattern_match(&pat, None, None) {
1523            let i: usize = m[&W_.i_].as_view().try_into().unwrap();
1524            let j: usize = m[&W_.j_].as_view().try_into().unwrap();
1525            nodemap.entry(i).or_insert_with(|| graph.add_node(()));
1526            nodemap.entry(j).or_insert_with(|| graph.add_node(()));
1527
1528            graph.add_edge(
1529                nodemap[&i],
1530                nodemap[&j],
1531                ContractibleEdge {
1532                    mom: m[&W_.c_].clone(),
1533                    mass_squared: m[&W_.d_].clone(),
1534                    power: m[&W_.e_].as_view().try_into().unwrap(),
1535                },
1536                false,
1537            );
1538        }
1539
1540        let mut system = vec![];
1541        let mut momentum_variables = vec![];
1542
1543        let mut graph: HedgeGraph<ContractibleEdge, ()> = graph.build();
1544        let uncontracted_propagator_count =
1545            graph.iter_edges().filter(|(p, _, _)| p.is_paired()).count();
1546        let uncontracted_propagator_power_sum = graph
1547            .iter_edges()
1548            .filter_map(|(p, _, e)| p.is_paired().then_some(e.data.power))
1549            .sum::<i32>();
1550
1551        while let Some(same_mass_two_bond) = graph.a_bond(&|c| {
1552            let mut count = 0;
1553            let mut mass_squared = None;
1554            for (_, _, d) in graph.iter_edges_of(c) {
1555                count += 1;
1556
1557                if let Some(m) = &mass_squared
1558                    && m != &d.data.mass_squared
1559                {
1560                    return false;
1561                } else {
1562                    mass_squared = Some(d.data.mass_squared.clone());
1563                }
1564                if count > 2 {
1565                    return false;
1566                }
1567            }
1568            count == 2
1569        }) {
1570            let mut iter = same_mass_two_bond.included_iter();
1571            let first = graph[&iter.next().unwrap()];
1572            let second = graph[&iter.next().unwrap()];
1573            graph[first].power += graph[second].power;
1574            let mut to_contract: SuBitGraph = graph.empty_subgraph();
1575            to_contract.add(graph[&second].1);
1576            graph.contract_subgraph(&to_contract, ());
1577        }
1578        let mut nodes_to_merge = vec![];
1579
1580        for c in graph.connected_components(&graph.full_filter()) {
1581            let Some((nid, _, _)) = graph.iter_nodes_of(&c).next() else {
1582                continue;
1583            };
1584            nodes_to_merge.push(nid);
1585        }
1586
1587        if !nodes_to_merge.is_empty() {
1588            graph.identify_nodes(&nodes_to_merge, ());
1589        }
1590
1591        graph.forget_identification_history();
1592        debug_tags!(#uv, #integrated, #vakint, #graph, #dump;
1593            log.graph = %graph.base_dot(),
1594            "Graph"
1595        );
1596
1597        let mut new_integral: Atom = 1.into();
1598        for (p, eid, e) in graph.iter_edges() {
1599            let HedgePair::Paired { source, sink } = p else {
1600                continue;
1601            };
1602            new_integral *= function!(
1603                vk_prop,
1604                eid.0 + 1, //vakint propagator ids are 1-indexed
1605                function!(
1606                    vakint::symbols::S.edge,
1607                    graph.node_id(source).0,
1608                    graph.node_id(sink).0
1609                ),
1610                &e.data.mom,
1611                &e.data
1612                    .mass_squared
1613                    .replace(GS.m_uv_expansion)
1614                    .with(GS.m_uv_vacuum),
1615                e.data.power
1616            )
1617        }
1618
1619        // println!("{}->{}", t.integral, new_integral);
1620        t.integral = function!(vakint::symbols::S.topo, new_integral);
1621        let nloops = graph.cyclotomatic_number(&graph.full_filter());
1622        // A cancelled denominator can remove an integration direction without
1623        // leaving that coordinate in the numerator. Never silently replace the
1624        // retained active measure by a lower-loop vacuum integral. Redundant
1625        // EMR variables remain allowed when they span the full retained domain.
1626        if nloops != retained_loop_edges.len() {
1627            return Err(eyre!(
1628                "Vakint term {term_index} for component {reduced_label} after prefix {dependent_subgraph_label} has {nloops} loop generators, expected {} retained active generators {:?}; a changed integration domain requires an explicit scaleless or factorization certificate",
1629                retained_loop_edges.len(),
1630                retained_loop_edges,
1631            ));
1632        }
1633        let contracted_propagator_count =
1634            graph.iter_edges().filter(|(p, _, _)| p.is_paired()).count();
1635        let contracted_propagator_power_sum = graph
1636            .iter_edges()
1637            .filter_map(|(p, _, e)| p.is_paired().then_some(e.data.power))
1638            .sum::<i32>();
1639        debug_tags!(#uv, #integrated, #vakint, #trace;
1640            stage = "to_vakint_integrand_term_after_graph_rebuild",
1641            term_index = %term_index,
1642            reduced = %reduced_label,
1643            dependent_subgraph = %dependent_subgraph_label,
1644            nloops = nloops,
1645            uncontracted_propagator_count = uncontracted_propagator_count,
1646            uncontracted_propagator_power_sum = uncontracted_propagator_power_sum,
1647            contracted_propagator_count = contracted_propagator_count,
1648            contracted_propagator_power_sum = contracted_propagator_power_sum,
1649            log.integral = t.integral,
1650            log.numerator = t.numerator,
1651            "Vakint trace"
1652        );
1653
1654        let lmb = graph.lmb();
1655        let mom_pat = function!(GS.emr_mom, W_.a_).to_pattern();
1656        for (p, e, ed) in graph.iter_edges() {
1657            if p.is_paired() {
1658                // println!("{e}");
1659                let loop_expr = lmb.loop_atom::<Atom>(e, GS.loop_mom, &[], false);
1660
1661                ed.data
1662                    .mom
1663                    .pattern_match(&mom_pat, None, None)
1664                    .for_each(|m| {
1665                        // An affine vacuum routing can contain fixed source
1666                        // external momenta. Solve only for hard coordinates;
1667                        // otherwise H=Q-P can solve for P and leave Q in the
1668                        // integrated numerator as a spurious external variable.
1669                        // A child crown carrier can become an active loop of
1670                        // its parent. Retained hard coordinates take precedence
1671                        // over an external classification in another frame.
1672                        if usize::try_from(m[&W_.a_].as_view()).is_ok_and(|edge| {
1673                            let edge = EdgeIndex(edge);
1674                            source_lmbs.iter().any(|lmb| lmb.ext_from(edge).is_some())
1675                                && !source_lmbs.iter().any(|lmb| lmb.loop_edges.contains(&edge))
1676                        }) {
1677                            return;
1678                        }
1679                        let var = mom_pat.replace_wildcards(&m).unwrap();
1680                        if !momentum_variables.iter().any(|existing| existing == &var) {
1681                            momentum_variables.push(var);
1682                        }
1683                    });
1684
1685                // println!("{loop_expr}");
1686
1687                // println!("{external_expr}");
1688                let is_zero = &ed.data.mom - loop_expr;
1689                // println!("Momentum check for edge {}: {}", e, is_zero);
1690                system.push(is_zero);
1691            }
1692        }
1693
1694        let add_additional_args = [
1695            Replacement::new(
1696                function!(GS.emr_mom, W_.i_).to_pattern(),
1697                function!(GS.emr_mom, W_.i_, W_.a___),
1698            )
1699            .allow_new_wildcards_on_rhs(true),
1700            Replacement::new(
1701                function!(GS.loop_mom, W_.i_).to_pattern(),
1702                function!(GS.loop_mom, W_.i_, W_.a___),
1703            )
1704            .allow_new_wildcards_on_rhs(true),
1705        ];
1706        let momentum_solution =
1707            VakintMomentumSolution::solve(&system, &momentum_variables, &add_additional_args)
1708                .wrap_err("could not solve momentum system for Vakint integrand")?;
1709        t.numerator = momentum_solution.rewrite_numerator(&t.numerator);
1710        t.integral = momentum_solution.rewrite_integral(&t.integral, &add_additional_args);
1711        momentum_solution.ensure_free_variables_eliminated(
1712            term_index,
1713            "integral",
1714            &t.integral,
1715            &add_additional_args,
1716        )?;
1717        momentum_solution.ensure_free_variables_eliminated(
1718            term_index,
1719            "numerator",
1720            &t.numerator,
1721            &add_additional_args,
1722        )?;
1723        let retained_hard_coordinates = retained_loop_edges
1724            .iter()
1725            .map(|edge| function!(GS.emr_mom, usize::from(*edge)))
1726            .collect::<Vec<_>>();
1727        momentum_solution.ensure_variables_eliminated(
1728            term_index,
1729            "numerator",
1730            &t.numerator,
1731            &retained_hard_coordinates,
1732            &add_additional_args,
1733        )?;
1734        debug_tags!(#uv, #integrated, #vakint, #trace;
1735            stage = "to_vakint_integrand_term_after_momentum_solve",
1736            term_index = %term_index,
1737            reduced = %reduced_label,
1738            dependent_subgraph = %dependent_subgraph_label,
1739            log.integral = t.integral,
1740            log.numerator = t.numerator,
1741            "Vakint trace"
1742        );
1743
1744        // debug!(
1745        //     "Graph from vakint expression:\n{}\n{}",
1746        //     graph.dot_lmb_of(&graph.full_filter(), &graph.lmb()),
1747        //     term
1748        // );
1749
1750        let additional_normalization = parse!(&settings.additional_normalization);
1751        t.numerator *= additional_normalization.clone().pow(nloops);
1752        debug_tags!(#uv, #integrated, #vakint, #trace;
1753            stage = "to_vakint_integrand_term_after_loop_normalization",
1754            term_index = %term_index,
1755            reduced = %reduced_label,
1756            dependent_subgraph = %dependent_subgraph_label,
1757            nloops = nloops,
1758            log.additional_normalization = additional_normalization,
1759            log.numerator = t.numerator,
1760            "Vakint trace after loop normalization"
1761        );
1762
1763        // Vakint needs explicit tensor indices; only translate metric shorthands
1764        // to dot notation here, without reintroducing Schoonschip rank-1 factors.
1765        t.numerator = t.numerator.metric_shorthand_to_dot();
1766        debug_tags!(#uv, #integrated, #vakint, #trace;
1767            stage = "to_vakint_integrand_term_after_metric_shorthand_to_dot",
1768            term_index = %term_index,
1769            reduced = %reduced_label,
1770            dependent_subgraph = %dependent_subgraph_label,
1771            log.integral = t.integral,
1772            log.numerator = t.numerator,
1773            "Vakint trace"
1774        );
1775        t.integral = t
1776            .integral
1777            .replace(function!(GS.loop_mom, W_.x___))
1778            .with(function!(vakint::symbols::S.k, W_.x___))
1779            .replace(function!(GS.emr_mom, W_.x___))
1780            .with(function!(vakint::symbols::S.p, W_.x___));
1781        t.numerator = Integrated::to_vakint_numerator(&t.numerator);
1782        debug_tags!(#uv, #integrated, #vakint, #trace;
1783            stage = "to_vakint_integrand_term_after_vakint_symbols",
1784            term_index = %term_index,
1785            reduced = %reduced_label,
1786            dependent_subgraph = %dependent_subgraph_label,
1787            log.integral = t.integral,
1788            log.numerator = t.numerator,
1789            "Vakint trace"
1790        );
1791    }
1792
1793    Ok(a)
1794}
1795
1796struct VakintMomentumSolution {
1797    replacements: Vec<Replacement>,
1798    free_variables: Vec<Atom>,
1799}
1800
1801impl VakintMomentumSolution {
1802    fn solve(
1803        system: &[Atom],
1804        variables: &[Atom],
1805        add_additional_args: &[Replacement],
1806    ) -> Result<Self> {
1807        if variables.is_empty() {
1808            return Ok(Self {
1809                replacements: vec![],
1810                free_variables: vec![],
1811            });
1812        }
1813        if system.is_empty() {
1814            return Ok(Self {
1815                replacements: vec![],
1816                free_variables: variables.to_vec(),
1817            });
1818        }
1819
1820        let solutions = Atom::solve(system)
1821            .wrt_with_exponent::<u8, _>(variables)
1822            .map_err(|source| eyre!("{source}"))?;
1823        let [solution] = solutions.iter().as_slice() else {
1824            return Err(eyre!(
1825                "expected one Vakint momentum solution, got {} branches",
1826                solutions.len()
1827            ));
1828        };
1829        let solution = variables
1830            .iter()
1831            .map(|variable| {
1832                let polynomial_variable =
1833                    PolyVariable::try_from(variable.clone()).map_err(|source| eyre!("{source}"))?;
1834                if solution.free_variables().contains(&polynomial_variable) {
1835                    Ok(variable.clone())
1836                } else {
1837                    solution.get(&polynomial_variable).cloned().ok_or_else(|| {
1838                        eyre!("Vakint momentum solution has no value for {polynomial_variable}")
1839                    })
1840                }
1841            })
1842            .collect::<Result<Vec<_>>>()?;
1843        Ok(Self::from_solution(
1844            &solution,
1845            variables,
1846            add_additional_args,
1847        ))
1848    }
1849
1850    fn from_solution(
1851        solution: &[Atom],
1852        variables: &[Atom],
1853        add_additional_args: &[Replacement],
1854    ) -> Self {
1855        debug_assert_eq!(solution.len(), variables.len());
1856        let mut replacements = vec![];
1857        let mut free_variables = vec![];
1858
1859        for index in (0..variables.len()).rev() {
1860            let replacement = &solution[index];
1861            let variable = &variables[index];
1862            let replacement = replacement.replace_multiple(&replacements);
1863            if replacement == variable {
1864                free_variables.push(variable.clone());
1865            } else {
1866                replacements.push(Replacement::new(
1867                    variable.replace_multiple(add_additional_args).to_pattern(),
1868                    replacement
1869                        .replace_multiple(add_additional_args)
1870                        .to_pattern(),
1871                ));
1872            }
1873        }
1874
1875        Self {
1876            replacements,
1877            free_variables,
1878        }
1879    }
1880
1881    fn ensure_free_variables_eliminated(
1882        &self,
1883        term_index: usize,
1884        expression_kind: &str,
1885        expression: &Atom,
1886        add_additional_args: &[Replacement],
1887    ) -> Result<()> {
1888        self.ensure_variables_eliminated(
1889            term_index,
1890            expression_kind,
1891            expression,
1892            self.free_variables.iter(),
1893            add_additional_args,
1894        )
1895    }
1896
1897    fn ensure_variables_eliminated<'a>(
1898        &self,
1899        term_index: usize,
1900        expression_kind: &str,
1901        expression: &Atom,
1902        variables: impl IntoIterator<Item = &'a Atom>,
1903        add_additional_args: &[Replacement],
1904    ) -> Result<()> {
1905        for variable in variables {
1906            let bare_pattern = variable.to_pattern();
1907            let indexed_pattern = variable.replace_multiple(add_additional_args).to_pattern();
1908            if expression
1909                .pattern_match(&bare_pattern, None, None)
1910                .next()
1911                .is_some()
1912                || expression
1913                    .pattern_match(&indexed_pattern, None, None)
1914                    .next()
1915                    .is_some()
1916            {
1917                return Err(eyre!(
1918                    "Underdetermined Vakint momentum solve left free variable {} in {} of term {}",
1919                    variable,
1920                    expression_kind,
1921                    term_index
1922                ));
1923            }
1924        }
1925        Ok(())
1926    }
1927
1928    fn rewrite_numerator(&self, numerator: &Atom) -> Atom {
1929        numerator
1930            .replace_multiple(&self.replacements)
1931            .normalize_dots()
1932    }
1933
1934    fn rewrite_integral(&self, integral: &Atom, add_additional_args: &[Replacement]) -> Atom {
1935        let free_variable_zero_replacements =
1936            self.free_variable_zero_replacements(add_additional_args);
1937        Self::normalize_topology_momenta(
1938            integral
1939                .replace_multiple(&self.replacements)
1940                .replace_multiple(&free_variable_zero_replacements),
1941        )
1942    }
1943
1944    fn free_variable_zero_replacements(
1945        &self,
1946        add_additional_args: &[Replacement],
1947    ) -> Vec<Replacement> {
1948        let mut replacements = Vec::with_capacity(2 * self.free_variables.len());
1949        for variable in &self.free_variables {
1950            replacements.push(Replacement::new(variable.to_pattern(), Atom::Zero));
1951            let indexed_variable = variable.replace_multiple(add_additional_args);
1952            if indexed_variable != *variable {
1953                replacements.push(Replacement::new(indexed_variable.to_pattern(), Atom::Zero));
1954            }
1955        }
1956        replacements
1957    }
1958
1959    fn normalize_topology_momenta(expression: Atom) -> Atom {
1960        if !expression
1961            .replace(function!(vakint::symbols::S.topo, W_.x_))
1962            .matches()
1963        {
1964            return expression;
1965        }
1966
1967        expression
1968            .replace(function!(
1969                vakint::symbols::S.prop,
1970                W_.edgeid_,
1971                W_.x_,
1972                W_.mom_,
1973                W_.mass_,
1974                W_.prop_
1975            ))
1976            .with_map(|matches| {
1977                function!(
1978                    vakint::symbols::S.prop,
1979                    matches.get(W_.edgeid_).unwrap().to_atom(),
1980                    matches.get(W_.x_).unwrap().to_atom(),
1981                    matches.get(W_.mom_).unwrap().to_atom().expand(),
1982                    matches.get(W_.mass_).unwrap().to_atom(),
1983                    matches.get(W_.prop_).unwrap().to_atom()
1984                )
1985            })
1986    }
1987}
1988
1989#[cfg(test)]
1990mod tests {
1991    use crate::{initialisation::test_initialise, utils::symbolica_ext::Q_I};
1992
1993    use super::*;
1994
1995    #[test]
1996    fn analytic_spin_simplification_preserves_shared_color_factor() {
1997        use idenso::{bis, coad, cof, color_f, color_idx, color_t, gamma};
1998        use spenso::{mink, trace, trace_sym};
1999
2000        test_initialise().unwrap();
2001        let a = coad!(8, color_a);
2002        let b = coad!(8, color_b);
2003        let c = coad!(8, color_c);
2004        let color = trace_sym!(cof!(3), color_t!(&a), color_t!(&b), color_t!(&c))
2005            + Atom::num(2) * color_f!(&a, &b, &c) * color_idx!(2, cof!(3));
2006        let spin = trace!(
2007            bis!(4),
2008            gamma!(mink!(4, mu)),
2009            gamma!(mink!(4, nu)),
2010            gamma!(mink!(4, rho)),
2011            gamma!(mink!(4, sigma))
2012        );
2013        let reduced_spin = simplify(&spin).unwrap();
2014        assert!(!reduced_spin.is_zero());
2015        assert!(!reduced_spin.contains_symbol(AGS.gamma));
2016        // Compare the literal factorization after the complete analytic spin
2017        // pipeline, without expanding the numerator for the assertion.
2018        assert_eq!(simplify(&(&color * spin)).unwrap(), color * reduced_spin);
2019    }
2020
2021    #[test]
2022    fn analytic_spin_expansion_preserves_powered_scalar_products() {
2023        test_initialise().unwrap();
2024        let mink = Minkowski {}.new_rep(GS.dim);
2025        let mu = mink.to_symbolic([Aind::new_dummy().to_atom()]);
2026        let nu = mink.to_symbolic([Aind::new_dummy().to_atom()]);
2027        let bis = Bispinor {}.new_rep(4);
2028        let a = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2029        let b = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2030        let p = function!(GS.loop_mom, 1, mink.to_symbolic([]));
2031        let k = function!(GS.loop_mom, 2, mink.to_symbolic([]));
2032        let spin = function!(AGS.gamma, &a, &b, &mu) * function!(AGS.gamma, &b, &a, &nu);
2033        for product in [spenso::dot!(&p, &k), spenso::g!(&p, &k)] {
2034            for power in [2, 3] {
2035                // A closed Dirac loop makes the analytic spin-expansion path
2036                // active. Its scalar spectator must retain its own contractions.
2037                let expected =
2038                    Atom::num(4) * spenso::g!(&mu, &nu) * spenso::dot!(&p, &k).pow(power);
2039                assert_eq!(
2040                    simplify(&(product.pow(power) * &spin))
2041                        .unwrap()
2042                        .normalize_dots(),
2043                    expected
2044                );
2045                // These compact metrics are tensor contractions, so they must
2046                // remain visible even beside the protected scalar spectator.
2047                assert_eq!(
2048                    simplify(&(product.pow(power) * &spin * spenso::g!(&mu, &p)))
2049                        .unwrap()
2050                        .normalize_dots(),
2051                    Atom::num(4) * function!(GS.loop_mom, 1, &nu) * spenso::dot!(&p, &k).pow(power)
2052                );
2053                assert_eq!(
2054                    simplify(
2055                        &(product.pow(power) * &spin * spenso::g!(&mu, &p) * spenso::g!(&nu, &k)),
2056                    )
2057                    .unwrap()
2058                    .normalize_dots(),
2059                    Atom::num(4) * spenso::dot!(&p, &k).pow(power + 1)
2060                );
2061            }
2062        }
2063    }
2064
2065    #[test]
2066    fn projected_dirac_algebra_precedes_single_and_double_pole_expansion() {
2067        test_initialise().unwrap();
2068        let vakint = Vakint::new().unwrap();
2069        let epsilon = Atom::var(GS.dim_epsilon);
2070        let radial = vakint::symbols::S.dot(
2071            function!(vakint::symbols::S.k, 1),
2072            function!(vakint::symbols::S.k, 2),
2073        );
2074        let mink = Minkowski {}.new_rep(GS.dim);
2075        let mu = mink.to_symbolic([Aind::new_dummy().to_atom()]);
2076        let nu = mink.to_symbolic([Aind::new_dummy().to_atom()]);
2077        let bis = Bispinor {}.new_rep(4);
2078        let a = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2079        let b = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2080        let c = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2081
2082        for project_onto_tensor_integrals in [true, false] {
2083            for use_dot_product_notation in [false, true] {
2084                let settings = vakint::VakintSettings {
2085                    use_dot_product_notation,
2086                    project_onto_tensor_integrals,
2087                    ..VakintSettings::default().true_settings()
2088                };
2089                let integrated = Integrated::new(&vakint, &settings);
2090                for closed in [false, true] {
2091                    // Distinct loop slashes have no repeated gamma index before
2092                    // angular projection. Its projector creates the contraction.
2093                    let numerator = function!(GS.loop_mom, 1, &mu)
2094                        * function!(GS.loop_mom, 2, &nu)
2095                        * function!(AGS.gamma, &a, &b, &mu)
2096                        * function!(AGS.gamma, &b, if closed { &a } else { &c }, &nu);
2097                    let mut term = vakint::VakintTerm {
2098                        integral: vakint::vakint_parse!("topo(I2L(mUVsq,1,1,1))").unwrap(),
2099                        numerator: Integrated::to_vakint_numerator(&numerator),
2100                        vectors: vec![("k".into(), 1), ("k".into(), 2)],
2101                    };
2102                    term.tensor_reduce(&vakint, &settings).unwrap();
2103                    let spin = if closed {
2104                        Atom::num(4)
2105                    } else {
2106                        function!(ETS.metric, &a, &c)
2107                    };
2108                    let late_four_dimensional = simplify(
2109                        &Integrated::restore_numerator(
2110                            Vakint::convert_to_dot_notation(&settings, term.numerator.as_view())
2111                                .unwrap(),
2112                            0,
2113                        )
2114                        .replace(GS.dim)
2115                        .with(4),
2116                    )
2117                    .unwrap();
2118                    assert!(
2119                        !truncate(
2120                            &series(
2121                                &(late_four_dimensional
2122                                    .replace(radial.to_pattern())
2123                                    .with(epsilon.pow(-1))
2124                                    - &spin * epsilon.pow(-1)),
2125                                1,
2126                            )
2127                            .unwrap(),
2128                            true,
2129                        )
2130                        .is_zero(),
2131                        "the old late-4D contraction must miss a nonzero finite term"
2132                    );
2133                    for evanescent_power in 0..=2 {
2134                        let projected = &term.numerator
2135                            * (Atom::var(GS.dim) - Atom::num(4)).pow(evanescent_power);
2136                        let resolved = integrated
2137                            .simplify_projected_numerator(&projected, 0)
2138                            .unwrap();
2139                        assert!(!resolved.contains_symbol(AGS.gamma));
2140                        assert!(!resolved.contains_symbol(SPENSO_TAG.trace));
2141                        assert!(!resolved.contains_symbol(GS.dim));
2142                        let coefficient = &spin * (Atom::num(-2) * &epsilon).pow(evanescent_power);
2143                        for pole in [
2144                            epsilon.pow(-1) + Atom::num(5) + Atom::num(7) * &epsilon,
2145                            Atom::num(2) * epsilon.pow(-2)
2146                                + Atom::num(3) * epsilon.pow(-1)
2147                                + Atom::num(5)
2148                                + Atom::num(7) * &epsilon,
2149                        ] {
2150                            // Mock only the scalar master value: the angular
2151                            // projection and d-dimensional Dirac algebra above use
2152                            // the production pipeline. This isolates the pole ×
2153                            // evanescent finite terms from master normalization.
2154                            let evaluated = resolved
2155                                .replace(radial.to_pattern())
2156                                .with(pole.to_pattern());
2157                            assert!(
2158                                series(&(evaluated - &coefficient * pole), 2)
2159                                    .unwrap()
2160                                    .to_atom()
2161                                    .is_zero(),
2162                                "project={project_onto_tensor_integrals}, closed={closed}, dot={use_dot_product_notation}, evanescent power={evanescent_power}"
2163                            );
2164                        }
2165                    }
2166                }
2167            }
2168        }
2169    }
2170
2171    #[test]
2172    fn projected_dirac_numerator_matches_scalar_master_before_backend_truncation() {
2173        test_initialise().unwrap();
2174        let vakint = Vakint::new().unwrap();
2175        let mut results = Vec::new();
2176        for project_onto_tensor_integrals in [true, false] {
2177            let settings = vakint::VakintSettings {
2178                // A two-loop vacuum master is retained only through its finite
2179                // term, so omitting the numerator's evanescent factors fails here.
2180                number_of_terms_in_epsilon_expansion: 3,
2181                project_onto_tensor_integrals,
2182                ..VakintSettings::default().true_settings()
2183            };
2184            let integrated = Integrated::new(&vakint, &settings);
2185            let mink = Minkowski {}.new_rep(GS.dim);
2186            let mu = mink.to_symbolic([Aind::new_dummy().to_atom()]);
2187            let nu = mink.to_symbolic([Aind::new_dummy().to_atom()]);
2188            let bis = Bispinor {}.new_rep(4);
2189            let a = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2190            let b = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2191            let numerator = function!(GS.loop_mom, 1, &mu)
2192                * function!(GS.loop_mom, 2, &nu)
2193                * function!(AGS.gamma, &a, &b, &mu)
2194                * function!(AGS.gamma, &b, &a, &nu);
2195            let mut projected = vakint::VakintTerm {
2196                integral: vakint::vakint_parse!("topo(I2L(mUVsq,1,1,1))").unwrap(),
2197                numerator: Integrated::to_vakint_numerator(&numerator),
2198                vectors: vec![("k".into(), 1), ("k".into(), 2)],
2199            };
2200            projected.tensor_reduce(&vakint, &settings).unwrap();
2201            let d_minus_four = Atom::var(GS.dim) - Atom::num(4);
2202            projected.numerator = integrated
2203                .simplify_projected_numerator(
2204                    &(&projected.numerator * (&d_minus_four + d_minus_four.pow(2))),
2205                    0,
2206                )
2207                .unwrap();
2208            let epsilon = Atom::var(GS.dim_epsilon);
2209            let mut scalar = vakint::VakintTerm {
2210                numerator: Atom::num(4)
2211                    * (Atom::num(-2) * &epsilon + Atom::num(4) * epsilon.pow(2))
2212                    * vakint::symbols::S.dot(
2213                        function!(vakint::symbols::S.k, 1),
2214                        function!(vakint::symbols::S.k, 2),
2215                    ),
2216                ..projected.clone()
2217            };
2218            let resolved_input = projected.numerator.clone();
2219            // The projector contains 1/(4-2ε) before symbolic-d gamma
2220            // contraction. Cancel the analytic oracle's rational denominators
2221            // exactly, using the unfactorized polynomial representation.
2222            assert!(
2223                (&resolved_input - &scalar.numerator)
2224                    .try_to_rational_polynomial::<_, _, u32>(&*Q_I, &*Q_I, None)
2225                    .expect("the analytic scalar input difference is rational over Q(i)")
2226                    .numerator
2227                    .is_zero(),
2228                "project={project_onto_tensor_integrals}: resolved input {resolved_input} differs from scalar oracle {}",
2229                scalar.numerator
2230            );
2231            projected.evaluate_integral(&vakint, &settings).unwrap();
2232            scalar.evaluate_integral(&vakint, &settings).unwrap();
2233            assert!(
2234                !truncate(&series(&scalar.numerator, 1).unwrap(), true).is_zero(),
2235                "the independently integrated evanescent scalar master has a nonzero finite term"
2236            );
2237            let difference = series(&(&projected.numerator - &scalar.numerator), 1)
2238                .unwrap()
2239                .to_atom();
2240            // These are analytically integrated subgraph coefficients. Expand
2241            // their difference to compare exact Laurent coefficients, including
2242            // algebraically equal backend output with different factorization.
2243            assert!(
2244                difference.expand().is_zero(),
2245                "project={project_onto_tensor_integrals}: resolved input {resolved_input}; projected result {}; scalar result {}; difference {difference}; expanded difference {}",
2246                projected.numerator,
2247                scalar.numerator,
2248                difference.expand()
2249            );
2250            results.push(projected.numerator);
2251        }
2252        assert!(
2253            series(&(&results[0] - &results[1]), 1)
2254                .unwrap()
2255                .to_atom()
2256                .expand()
2257                .is_zero()
2258        );
2259    }
2260
2261    #[test]
2262    fn scalar_dimensions_are_substituted_inside_functions_but_not_lorentz_slots() {
2263        test_initialise().unwrap();
2264        let slot = Minkowski {}
2265            .new_rep(GS.dim)
2266            .to_symbolic([Aind::new_dummy().to_atom()]);
2267        let logarithm = symbolica::symbol!("log");
2268        let tensor = symbolica::symbol!("dimension_test_tensor");
2269        let input = function!(logarithm, GS.dim) * function!(tensor, &slot);
2270        let expected = function!(
2271            logarithm,
2272            Atom::num(4) - Atom::num(2) * Atom::var(GS.dim_epsilon)
2273        ) * function!(tensor, &slot);
2274        assert_eq!(Integrated::dimensionally_regularized(&input), expected);
2275    }
2276
2277    #[test]
2278    fn analytic_uv_spin_algebra_rejects_unsupported_residual_contractions() {
2279        test_initialise().unwrap();
2280        let bis = Bispinor {}.new_rep(4);
2281        let a = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2282        let b = bis.to_symbolic([Aind::new_dummy().to_atom()]);
2283        let mu = Minkowski {}
2284            .new_rep(GS.dim)
2285            .to_symbolic([Aind::new_dummy().to_atom()]);
2286        let nu = Minkowski {}
2287            .new_rep(GS.dim)
2288            .to_symbolic([Aind::new_dummy().to_atom()]);
2289        let unknown = function!(
2290            symbolica::symbol!("unsupported_dirac_factor"),
2291            SPENSO_TAG.chain_in,
2292            SPENSO_TAG.chain_out
2293        );
2294        let gamma = function!(AGS.gamma, SPENSO_TAG.chain_in, SPENSO_TAG.chain_out, &mu);
2295        for (input, message) in [
2296            (function!(AGS.sigma, &a, &b, &mu, &nu), "Sigma tensors"),
2297            (
2298                function!(SPENSO_TAG.trace, bis.to_symbolic([]), &unknown),
2299                "unresolved Dirac trace",
2300            ),
2301            (
2302                function!(
2303                    SPENSO_TAG.trace,
2304                    Minkowski {}.new_rep(GS.dim).to_symbolic([]),
2305                    &unknown
2306                ),
2307                "unresolved Lorentz trace",
2308            ),
2309        ] {
2310            assert!(simplify(&input).unwrap_err().to_string().contains(message));
2311        }
2312        let residual = function!(SPENSO_TAG.chain, &a, &b, &gamma, &unknown, &gamma);
2313        assert!(
2314            Integrated::ensure_resolved_lorentz_contractions(&simplify(&residual).unwrap())
2315                .is_err()
2316        );
2317        // An open gamma is a valid d-dimensional tensor basis element.
2318        let open = simplify(&function!(AGS.gamma, &a, &b, &mu)).unwrap();
2319        assert!(Integrated::ensure_resolved_lorentz_contractions(&open).is_ok());
2320    }
2321
2322    #[test]
2323    fn residual_tensor_contractions_are_checked_per_analytic_branch() {
2324        test_initialise().unwrap();
2325        let bis = Bispinor {}.new_rep(4);
2326        let [a, b, c, d] =
2327            std::array::from_fn::<_, 4, _>(|_| bis.to_symbolic([Aind::new_dummy().to_atom()]));
2328        let mink = Minkowski {}.new_rep(GS.dim);
2329        let [mu, nu, rho] =
2330            std::array::from_fn::<_, 3, _>(|_| mink.to_symbolic([Aind::new_dummy().to_atom()]));
2331        let left = function!(AGS.gamma, &a, &b, &mu);
2332        let right = function!(AGS.gamma, &c, &d, &mu);
2333        assert!(Integrated::ensure_resolved_lorentz_contractions(&(&left * &right)).is_err());
2334        let compact = function!(
2335            SPENSO_TAG.dot,
2336            function!(AGS.gamma, &a, &b, mink.to_symbolic([])),
2337            function!(AGS.gamma, &c, &d, mink.to_symbolic([]))
2338        );
2339        assert!(Integrated::ensure_resolved_lorentz_contractions(&compact).is_err());
2340        let same_chain = function!(
2341            SPENSO_TAG.dot,
2342            function!(AGS.gamma, &a, &b, mink.to_symbolic([])),
2343            function!(AGS.gamma, &b, &c, mink.to_symbolic([]))
2344        );
2345        let same_chain = simplify(&same_chain).unwrap();
2346        assert!(
2347            (&same_chain - Atom::var(GS.dim) * function!(ETS.metric, &a, &c))
2348                .expand()
2349                .is_zero(),
2350            "a compact contraction within one gamma chain must close to d times its spinor identity: {same_chain}"
2351        );
2352        assert!(Integrated::ensure_resolved_lorentz_contractions(&same_chain).is_ok());
2353        let free =
2354            &left * function!(AGS.gamma, &c, &d, &nu) + function!(AGS.gamma, &a, &b, &nu) * &right;
2355        assert!(Integrated::ensure_resolved_lorentz_contractions(&free).is_ok());
2356        assert!(
2357            Integrated::ensure_resolved_lorentz_contractions(&function!(ETS.metric, &mu, &nu))
2358                .is_ok()
2359        );
2360        let ambiguous = function!(
2361            SPENSO_TAG.dot,
2362            function!(
2363                AGS.sigma,
2364                &a,
2365                &b,
2366                mink.to_symbolic([]),
2367                mink.to_symbolic([])
2368            ),
2369            function!(
2370                AGS.sigma,
2371                &c,
2372                &d,
2373                mink.to_symbolic([]),
2374                mink.to_symbolic([])
2375            )
2376        );
2377        assert!(Integrated::ensure_resolved_lorentz_contractions(&ambiguous).is_err());
2378        let slash = function!(
2379            AGS.gamma,
2380            &a,
2381            &b,
2382            function!(GS.emr_mom, 0, mink.to_symbolic([]))
2383        );
2384        assert!(Integrated::ensure_resolved_lorentz_contractions(&slash).is_ok());
2385        let slash_product = &slash
2386            * function!(
2387                AGS.gamma,
2388                &c,
2389                &d,
2390                function!(GS.emr_mom, 1, mink.to_symbolic([]))
2391            );
2392        assert!(Integrated::ensure_resolved_lorentz_contractions(&slash_product).is_ok());
2393        assert!(
2394            Integrated::ensure_resolved_lorentz_contractions(
2395                &(&left / (Atom::var(GS.dim) - Atom::one()))
2396            )
2397            .is_ok()
2398        );
2399
2400        // Diagnostic tensor operator, not a physical graph/locality oracle:
2401        // crossed spinor closure of two contracted triple-gamma chains gives
2402        // 4d(-d²+6d-4). Its evanescent term changes the finite part of a pole.
2403        let factors = [&mu, &nu, &rho]
2404            .map(|index| function!(AGS.gamma, SPENSO_TAG.chain_in, SPENSO_TAG.chain_out, index));
2405        let operator = function!(
2406            SPENSO_TAG.chain,
2407            &a,
2408            &b,
2409            &factors[0],
2410            &factors[1],
2411            &factors[2]
2412        ) * function!(
2413            SPENSO_TAG.chain,
2414            &c,
2415            &d,
2416            &factors[0],
2417            &factors[1],
2418            &factors[2]
2419        );
2420        assert!(Integrated::ensure_resolved_lorentz_contractions(&operator).is_err());
2421        let closed = function!(
2422            SPENSO_TAG.trace,
2423            bis.to_symbolic([]),
2424            &factors[0],
2425            &factors[1],
2426            &factors[2],
2427            &factors[0],
2428            &factors[1],
2429            &factors[2]
2430        );
2431        let closed = Integrated::dimensionally_regularized(&simplify(&closed).unwrap());
2432        let epsilon = Atom::var(GS.dim_epsilon);
2433        assert_eq!(
2434            truncate(&series(&(closed / epsilon), 0).unwrap(), true),
2435            Atom::num(32)
2436        );
2437    }
2438
2439    #[test]
2440    fn integrated_counterterm_projects_one_laurent_expansion() {
2441        test_initialise().unwrap();
2442
2443        let epsilon = Atom::var(GS.dim_epsilon);
2444        let expansion = series(
2445            &(Atom::num(2) * epsilon.pow(-2)
2446                + Atom::num(3) * epsilon.pow(-1)
2447                + Atom::num(5)
2448                + Atom::num(7) * &epsilon),
2449            2,
2450        )
2451        .unwrap();
2452        let integrated = IntegratedCts {
2453            expansion,
2454            scale_power: 4,
2455        };
2456        let scale = Atom::var(GS.integrated_loop_scale).pow(4);
2457
2458        assert_eq!(
2459            integrated.pole_atom().expand(),
2460            ((Atom::num(2) * epsilon.pow(-2) + Atom::num(3) * epsilon.pow(-1)) * &scale).expand()
2461        );
2462        assert_eq!(
2463            integrated.finite_counterterm_atom().expand(),
2464            (-(Atom::num(5) + Atom::num(7) * epsilon) * scale).expand()
2465        );
2466        assert_eq!(
2467            integrated.physical_finite_counterterm_atom(),
2468            -(Atom::num(5) + Atom::num(7) * Atom::var(GS.dim_epsilon))
2469        );
2470    }
2471
2472    #[test]
2473    fn finite_projection_retains_child_epsilon_terms_until_parent_poles_multiply() {
2474        test_initialise().unwrap();
2475        let epsilon = Atom::var(GS.dim_epsilon);
2476        let child = IntegratedCts {
2477            expansion: series(
2478                &(Atom::num(2) + Atom::num(3) * &epsilon + Atom::num(5) * epsilon.pow(2)),
2479                3,
2480            )
2481            .unwrap(),
2482            scale_power: 4,
2483        };
2484        let parent_poles = epsilon.pow(-2) + Atom::num(7) * epsilon.pow(-1);
2485        let nested = IntegratedCts {
2486            expansion: series(
2487                &(child.physical_finite_counterterm_atom() * parent_poles),
2488                1,
2489            )
2490            .unwrap(),
2491            scale_power: 8,
2492        };
2493        // The finite projection retains nonnegative powers for further nesting.
2494        let finite = nested.physical_finite_counterterm_atom();
2495        assert_eq!(
2496            finite.replace(GS.dim_epsilon).with(Atom::Zero),
2497            Atom::num(26)
2498        );
2499        assert!(
2500            (finite - Atom::num(26) - Atom::num(35) * &epsilon)
2501                .expand()
2502                .is_zero()
2503        );
2504        assert_eq!(
2505            nested.physical_pole_atom(),
2506            -Atom::num(2) * epsilon.pow(-2) - Atom::num(17) * epsilon.pow(-1),
2507        );
2508    }
2509
2510    #[test]
2511    fn factorized_product_projects_each_component() {
2512        test_initialise().unwrap();
2513
2514        let epsilon = Atom::var(GS.dim_epsilon);
2515        let factors = [2, 3, 5].map(|finite| IntegratedCts {
2516            expansion: series(&(epsilon.pow(-1) + Atom::num(finite)), 1).unwrap(),
2517            scale_power: 0,
2518        });
2519        let product = IntegratedCts::factorized_product(&factors[..2], 1).unwrap();
2520
2521        assert_eq!(product.physical_pole_atom(), epsilon.pow(-2));
2522        assert_eq!(product.physical_finite_counterterm_atom(), Atom::num(6));
2523
2524        let product = IntegratedCts::factorized_product(&factors, 1).unwrap();
2525        assert_eq!(product.physical_pole_atom(), epsilon.pow(-3));
2526        assert_eq!(product.physical_finite_counterterm_atom(), Atom::num(-30));
2527    }
2528
2529    // #[test]
2530    // fn integrated_triangle_norm_is_euclidean() {
2531    //     test_initialise().unwrap();
2532
2533    //     let edge = EdgeIndex(7);
2534    //     let euclidean_norm = GS.emr_mom(edge, GS.cind(1)).pow(2)
2535    //         + GS.emr_mom(edge, GS.cind(2)).pow(2)
2536    //         + GS.emr_mom(edge, GS.cind(3)).pow(2);
2537    //     let minkowski = Minkowski {}.new_rep(4).to_symbolic([]);
2538    //     let minkowski_norm = function!(
2539    //         SPENSO_TAG.dot,
2540    //         GS.emr_vec(edge, minkowski.as_view()),
2541    //         GS.emr_vec(edge, minkowski.as_view())
2542    //     );
2543
2544    //     assert_eq!(
2545    //         euclidean_norm,
2546    //         GS.emr_mom(edge, GS.cind(1)).pow(2)
2547    //             + GS.emr_mom(edge, GS.cind(2)).pow(2)
2548    //             + GS.emr_mom(edge, GS.cind(3)).pow(2)
2549    //     );
2550    //     assert_ne!(euclidean_norm, minkowski_norm);
2551    // }
2552
2553    #[test]
2554    fn vakint_dot_conversion_keeps_loop_momentum_tagged_until_to_dots() {
2555        test_initialise().unwrap();
2556
2557        let mink = Minkowski {}.new_rep(GS.dim).to_symbolic([]);
2558        let numerator = function!(
2559            ETS.metric,
2560            function!(GS.emr_mom, 0, mink.clone()),
2561            function!(GS.loop_mom, 1, mink.clone())
2562        );
2563
2564        let converted = numerator
2565            .simplify_metrics()
2566            .to_dots()
2567            .replace(function!(GS.loop_mom, W_.x___))
2568            .with(function!(vakint::symbols::S.k, W_.x___))
2569            .replace(function!(GS.emr_mom, W_.x___))
2570            .with(function!(vakint::symbols::S.p, W_.x___))
2571            .replace(function!(
2572                SPENSO_TAG.dot,
2573                function!(W_.a_, W_.a___, Minkowski {}.new_rep(GS.dim).to_symbolic([])),
2574                function!(W_.b_, W_.b___, Minkowski {}.new_rep(GS.dim).to_symbolic([]))
2575            ))
2576            .with(vakint::symbols::S.dot(function!(W_.a_, W_.a___), function!(W_.b_, W_.b___)));
2577
2578        assert_eq!(
2579            converted,
2580            vakint::symbols::S.dot(
2581                function!(vakint::symbols::S.p, 0),
2582                function!(vakint::symbols::S.k, 1)
2583            )
2584        );
2585    }
2586
2587    #[test]
2588    fn underdetermined_vakint_momentum_solve_tracks_free_variables() {
2589        test_initialise().unwrap();
2590
2591        let q0 = function!(GS.emr_mom, 0);
2592        let q1 = function!(GS.emr_mom, 1);
2593        let q2 = function!(GS.emr_mom, 2);
2594        let k0 = function!(GS.loop_mom, 0);
2595        let k1 = function!(GS.loop_mom, 1);
2596        let edge_3 = -&q0 - &q1 - &q2;
2597        let system = vec![&q0 - &k0, &edge_3 - &k1];
2598        let variables = vec![q0.clone(), q1.clone(), q2.clone()];
2599
2600        let solution = VakintMomentumSolution::solve(&system, &variables, &[]).unwrap();
2601
2602        assert_eq!(solution.free_variables, vec![q2]);
2603        assert!(
2604            solution
2605                .ensure_free_variables_eliminated(0, "numerator", &solution.free_variables[0], &[])
2606                .is_err()
2607        );
2608    }
2609
2610    #[test]
2611    fn underdetermined_vakint_momentum_solve_projects_topology() {
2612        test_initialise().unwrap();
2613
2614        let q1 = function!(GS.emr_mom, 1);
2615        let q2 = function!(GS.emr_mom, 2);
2616        let k0 = function!(GS.loop_mom, 0);
2617        let system = vec![-&q1 - &q2 - &k0];
2618        let variables = vec![q1.clone(), q2.clone()];
2619        let topology = function!(
2620            vakint::symbols::S.topo,
2621            function!(
2622                vakint::symbols::S.prop,
2623                1,
2624                function!(vakint::symbols::S.edge, 0, 0),
2625                -&q1 - &q2,
2626                GS.m_uv_vacuum,
2627                1
2628            )
2629        );
2630
2631        let solution = VakintMomentumSolution::solve(&system, &variables, &[]).unwrap();
2632
2633        solution
2634            .ensure_free_variables_eliminated(0, "numerator", &Atom::from(1), &[])
2635            .unwrap();
2636        assert_eq!(
2637            solution.rewrite_integral(&topology, &[]),
2638            function!(
2639                vakint::symbols::S.topo,
2640                function!(
2641                    vakint::symbols::S.prop,
2642                    1,
2643                    function!(vakint::symbols::S.edge, 0, 0),
2644                    k0,
2645                    GS.m_uv_vacuum,
2646                    1
2647                )
2648            )
2649        );
2650    }
2651
2652    #[test]
2653    fn empty_vakint_momentum_solve_treats_variables_as_free() {
2654        test_initialise().unwrap();
2655
2656        let q0 = function!(GS.emr_mom, 0);
2657        let no_variables = VakintMomentumSolution::solve(&[], &[], &[]).unwrap();
2658        let free_variable =
2659            VakintMomentumSolution::solve(&[], std::slice::from_ref(&q0), &[]).unwrap();
2660
2661        assert!(no_variables.free_variables.is_empty());
2662        assert_eq!(free_variable.free_variables, vec![q0]);
2663    }
2664
2665    #[test]
2666    fn vakint_preparation_reduces_color_before_opening_traces() {
2667        use crate::{
2668            dot,
2669            graph::{Graph, parse::IntoGraph},
2670        };
2671        use idenso::{coad, cof, color_cas, color_f, color_idx, color_t};
2672        use spenso::{g, trace_sym};
2673
2674        test_initialise().unwrap();
2675        let graph: Graph = dot!(digraph color_vakint_tadpole {
2676            edge [num=1 mass=1]
2677            node [num=1]
2678            incoming [style=invis]
2679            outgoing [style=invis]
2680            incoming -> a [id=0]
2681            a -> a [id=1 lmb_id=0]
2682            a -> outgoing [id=2]
2683        })
2684        .unwrap();
2685        let [a, b, c, d] = [
2686            "color_vakint_a",
2687            "color_vakint_b",
2688            "color_vakint_c",
2689            "color_vakint_d",
2690        ]
2691        .map(|name| coad!(8, Atom::var(symbol!(name))));
2692        let trace = trace_sym!(cof!(3), color_t!(&a), color_t!(&b), color_t!(&c));
2693        let index = color_idx!(2, cof!(3));
2694        let color = color_f!(&d, &b, &c) * (trace + Atom::i() / 2 * color_f!(&a, &b, &c) * &index);
2695        let coefficient = parse!("(color_x+color_y)^3*(color_u+color_v)");
2696        let denominator = function!(
2697            GS.den,
2698            1,
2699            function!(GS.emr_mom, 1),
2700            Atom::var(GS.m_uv_vacuum).pow(2)
2701        );
2702        let actual = to_vakint_integrand(
2703            &(color * &coefficient / denominator.pow(2)),
2704            &graph,
2705            &graph.full_filter(),
2706            &graph.empty_subgraph::<SuBitGraph>(),
2707            &[(
2708                graph.full_filter(),
2709                graph.full_filter(),
2710                graph.loop_momentum_basis.clone(),
2711            )],
2712            &VakintSettings {
2713                additional_normalization: "1".to_string(),
2714                ..Default::default()
2715            },
2716            true,
2717        )
2718        .unwrap();
2719
2720        // The symmetric trace annihilates against f; the remaining f*f
2721        // contraction is C_A times the adjoint metric, independent of kinematics.
2722        let expected = Atom::i() / 2 * color_cas!(2, coad!(8)) * index * g!(&a, &d) * coefficient;
2723        assert_eq!(actual.0.len(), 1);
2724        assert_eq!(actual.0[0].numerator, expected);
2725    }
2726
2727    #[test]
2728    fn vakint_conversion_preserves_squared_masses() {
2729        use crate::{
2730            dot,
2731            graph::{Graph, parse::IntoGraph},
2732        };
2733
2734        test_initialise().unwrap();
2735        let graph: Graph = dot!(digraph vakint_mass_tadpole {
2736            edge [num=1 mass=2]
2737            node [num=1]
2738            incoming [style=invis]
2739            outgoing [style=invis]
2740            incoming -> a [id=0]
2741            a -> a [id=1 lmb_id=0]
2742            a -> outgoing [id=2]
2743        })
2744        .unwrap();
2745        let momentum = function!(GS.emr_mom, 1);
2746        let indexed_momentum =
2747            function!(GS.emr_mom, 1, Minkowski {}.new_rep(GS.dim).to_symbolic([]));
2748        let settings = VakintSettings {
2749            additional_normalization: "1".to_string(),
2750            ..Default::default()
2751        };
2752        // Both wrappers carry squared masses. A nonunit physical mass detects
2753        // an accidental second square, while the UV substitution remains MUV^2.
2754        for mass in [Atom::num(2), parse!("m_phys")] {
2755            let mass_squared = mass.pow(2);
2756            let denominator = function!(
2757                GS.den,
2758                1,
2759                &momentum,
2760                &mass_squared,
2761                function!(SPENSO_TAG.dot, &indexed_momentum, &indexed_momentum) - &mass_squared
2762            );
2763            for substitute_masses_to_m_uv in [false, true] {
2764                let actual = to_vakint_integrand(
2765                    &denominator.pow(-3),
2766                    &graph,
2767                    &graph.full_filter(),
2768                    &graph.empty_subgraph::<SuBitGraph>(),
2769                    &[(
2770                        graph.full_filter(),
2771                        graph.full_filter(),
2772                        graph.loop_momentum_basis.clone(),
2773                    )],
2774                    &settings,
2775                    substitute_masses_to_m_uv,
2776                )
2777                .unwrap();
2778                assert_eq!(actual.0.len(), 1);
2779                assert_eq!(actual.0[0].numerator, Atom::one());
2780                assert_eq!(
2781                    actual.0[0].integral,
2782                    function!(
2783                        vakint::symbols::S.topo,
2784                        function!(
2785                            vakint::symbols::S.prop,
2786                            1,
2787                            function!(vakint::symbols::S.edge, 0, 0),
2788                            function!(vakint::symbols::S.k, 0),
2789                            if substitute_masses_to_m_uv {
2790                                Atom::var(GS.m_uv_vacuum).pow(2)
2791                            } else {
2792                                mass_squared.clone()
2793                            },
2794                            3
2795                        )
2796                    )
2797                );
2798            }
2799        }
2800    }
2801
2802    #[test]
2803    fn vakint_conversion_rejects_a_lost_active_loop() {
2804        use crate::{
2805            dot,
2806            graph::{Graph, parse::IntoGraph},
2807        };
2808
2809        test_initialise().unwrap();
2810        let graph: Graph = dot!(digraph cancelled_tadpole_loop {
2811            edge [num=1 mass=2]
2812            node [num=1]
2813            a -> a [id=0 lmb_id=0]
2814            a -> a [id=1 lmb_id=1]
2815        })
2816        .unwrap();
2817        let remaining = graph.get_edge_subgraph(EdgeIndex(1));
2818        // This is the denominator domain after the other tadpole propagator
2819        // cancels against its edge-local numerator. The original two-loop
2820        // measure cannot be replaced by the remaining massive one-loop value.
2821        let input = graph.denominator(&remaining, |_| -3);
2822        let error = to_vakint_integrand(
2823            &input,
2824            &graph,
2825            &graph.full_filter(),
2826            &graph.empty_subgraph::<SuBitGraph>(),
2827            &[(
2828                graph.full_filter(),
2829                graph.full_filter(),
2830                graph.loop_momentum_basis.clone(),
2831            )],
2832            &VakintSettings::default(),
2833            false,
2834        )
2835        .unwrap_err();
2836        assert!(
2837            error
2838                .to_string()
2839                .contains("has 1 loop generators, expected 2")
2840        );
2841        let remaining_lmb = graph.lmb_of(&remaining);
2842        let converted = to_vakint_integrand(
2843            &input,
2844            &graph,
2845            &graph.full_filter(),
2846            &graph.empty_subgraph::<SuBitGraph>(),
2847            &[(
2848                graph.full_filter(),
2849                graph.full_filter(),
2850                remaining_lmb.clone(),
2851            )],
2852            &VakintSettings::default(),
2853            false,
2854        )
2855        .unwrap();
2856        assert_eq!(converted.0.len(), 1);
2857    }
2858
2859    #[test]
2860    fn affine_vakint_routing_keeps_external_momenta_fixed() {
2861        use crate::{
2862            dot,
2863            graph::{Graph, parse::IntoGraph},
2864        };
2865
2866        test_initialise().unwrap();
2867        let graph: Graph = dot!(digraph affine_vakint_tadpole {
2868            edge [num=1 mass=1]
2869            node [num=1]
2870            incoming [style=invis]
2871            outgoing [style=invis]
2872            incoming -> a [id=0]
2873            a -> a [id=1 lmb_id=0]
2874            a -> outgoing [id=2]
2875        })
2876        .unwrap();
2877        let hard = function!(GS.emr_mom, 1) - function!(GS.emr_mom, 0);
2878        let minkowski = Minkowski {}.new_rep(GS.dim).to_symbolic([]);
2879        let indexed_hard =
2880            function!(GS.emr_mom, 1, &minkowski) - function!(GS.emr_mom, 0, &minkowski);
2881        let numerator = function!(
2882            SPENSO_TAG.dot,
2883            function!(GS.emr_mom, 0, &minkowski),
2884            &indexed_hard
2885        ) * function!(
2886            SPENSO_TAG.dot,
2887            function!(GS.emr_mom, 2, &minkowski),
2888            &indexed_hard
2889        );
2890        let settings = VakintSettings {
2891            additional_normalization: "1".to_string(),
2892            ..Default::default()
2893        };
2894        // A retained parent loop overrides an earlier component's external
2895        // classification. This adapter diagnostic changes coordinate metadata
2896        // only; both signs must still preserve the fixed physical externals.
2897        let mut child_frame = graph.empty_lmb();
2898        child_frame.ext_edges.push(EdgeIndex(1));
2899        for source_lmbs in [
2900            vec![&graph.loop_momentum_basis],
2901            vec![&child_frame, &graph.loop_momentum_basis],
2902        ] {
2903            for sign in [-1, 1] {
2904                let denominator = function!(
2905                    GS.den,
2906                    1,
2907                    Atom::num(sign) * &hard,
2908                    Atom::var(GS.m_uv_vacuum).pow(2),
2909                    function!(SPENSO_TAG.dot, &indexed_hard, &indexed_hard)
2910                        - Atom::var(GS.m_uv_vacuum).pow(2)
2911                );
2912                let actual = to_vakint_integrand(
2913                    &(&numerator / denominator.pow(3)),
2914                    &graph,
2915                    &graph.full_filter(),
2916                    &graph.empty_subgraph::<SuBitGraph>(),
2917                    &source_lmbs
2918                        .iter()
2919                        .map(|lmb| {
2920                            let owners = if lmb.loop_edges.is_empty() {
2921                                graph.empty_subgraph::<SuBitGraph>()
2922                            } else {
2923                                graph.full_filter()
2924                            };
2925                            (owners.clone(), owners, (*lmb).clone())
2926                        })
2927                        .collect::<Vec<_>>(),
2928                    &settings,
2929                    true,
2930                )
2931                .unwrap();
2932                // Compare the complete rational integrand through Vakint's public
2933                // expression conversion, independent of topology IDs and term layout.
2934                let actual = Atom::from(actual)
2935                    .replace(function!(vakint::symbols::S.topo, W_.x_))
2936                    .with(W_.x_)
2937                    .replace(function!(
2938                        vakint::symbols::S.prop,
2939                        W_.a_,
2940                        W_.b_,
2941                        W_.c_,
2942                        W_.d_,
2943                        W_.e_
2944                    ))
2945                    .with(
2946                        (function!(vakint::symbols::S.dot, W_.c_, W_.c_) - Atom::var(W_.d_))
2947                            .pow(-Atom::var(W_.e_)),
2948                    );
2949                let expected_numerator = function!(
2950                    vakint::symbols::S.dot,
2951                    function!(vakint::symbols::S.p, 0),
2952                    function!(vakint::symbols::S.k, 0)
2953                ) * function!(
2954                    vakint::symbols::S.dot,
2955                    function!(vakint::symbols::S.p, 2),
2956                    function!(vakint::symbols::S.k, 0)
2957                );
2958                let radial = function!(
2959                    vakint::symbols::S.dot,
2960                    function!(vakint::symbols::S.k, 0),
2961                    function!(vakint::symbols::S.k, 0)
2962                ) - Atom::var(GS.m_uv_vacuum).pow(2);
2963                assert!(
2964                    (actual.collect_factors()
2965                        - (expected_numerator / radial.pow(3)).collect_factors())
2966                    .is_zero(),
2967                    "the complete integrand must retain the fixed external momenta for either D(H) spelling"
2968                );
2969            }
2970        }
2971    }
2972
2973    #[test]
2974    fn nested_active_sunset_keeps_its_two_loop_incidence() {
2975        use crate::{
2976            dot,
2977            graph::{Graph, parse::IntoGraph},
2978            momentum::sample::LoopIndex,
2979        };
2980
2981        test_initialise().unwrap();
2982        let graph: Graph = dot!(digraph nested_active_sunset {
2983            edge [num=1 mass=1]
2984            node [num=1]
2985            ext [style=invis]
2986            ext -> a:0 [id=0]
2987            d:1 -> ext [id=1]
2988            a -> b [id=2]
2989            b -> c [id=3 lmb_id=0]
2990            b -> c [id=4]
2991            c -> d [id=5]
2992            a -> d [id=6 lmb_id=2]
2993            b -> c [id=7 lmb_id=1]
2994        })
2995        .unwrap();
2996        let full = graph
2997            .full_filter()
2998            .subtract(&graph.external_filter::<SuBitGraph>());
2999        let mut child = graph.empty_subgraph::<SuBitGraph>();
3000        for edge in [3, 4, 7] {
3001            child.add(graph[&EdgeIndex(edge)].1);
3002        }
3003        let quotient = full.subtract(&child);
3004        let child_lmb = graph
3005            .try_compatible_sub_lmb(
3006                &child,
3007                graph.dummy_less_full_crown(&child),
3008                &graph.loop_momentum_basis,
3009            )
3010            .unwrap();
3011        let mut quotient_lmb = graph.loop_momentum_basis.clone();
3012        for index in (0..quotient_lmb.loop_edges.len()).rev() {
3013            let index = LoopIndex(index);
3014            if child_lmb
3015                .loop_edges
3016                .contains(&quotient_lmb.loop_edges[index])
3017            {
3018                quotient_lmb.put_loop_to_ext(index);
3019            }
3020        }
3021        let [k, l, p] = [3, 7, 6].map(|edge| function!(GS.emr_mom, edge));
3022        let mass_squared = Atom::var(GS.m_uv_vacuum).pow(2);
3023        let child_denominators = function!(GS.den, 3, &k, &mass_squared)
3024            * function!(GS.den, 7, &l, &mass_squared)
3025            * function!(GS.den, 4, -&k - &l, &mass_squared).pow(3);
3026        let quotient_denominators = function!(GS.den, 2, -&p, &mass_squared)
3027            * function!(GS.den, 5, -&p, &mass_squared)
3028            * function!(GS.den, 6, &p, &mass_squared);
3029        let numerator = parse!("(a+b)*(c+d)");
3030        for child_active in [true, false] {
3031            let mut components = Vec::new();
3032            let input = if child_active {
3033                components.push((child.clone(), child.clone(), child_lmb.clone()));
3034                &numerator / (&child_denominators * &quotient_denominators)
3035            } else {
3036                &numerator / &quotient_denominators
3037            };
3038            components.push((quotient.clone(), full.clone(), quotient_lmb.clone()));
3039            let converted = to_vakint_integrand(
3040                &input,
3041                &graph,
3042                &full,
3043                &child,
3044                &components,
3045                &VakintSettings::default(),
3046                false,
3047            )
3048            .unwrap();
3049            assert_eq!(converted.0.len(), 1);
3050            let term = &converted.0[0];
3051            assert_eq!(term.numerator, numerator);
3052            let propagator = function!(
3053                vakint::symbols::S.prop,
3054                W_.a_,
3055                W_.b_,
3056                W_.mom_,
3057                W_.mass_,
3058                W_.e_
3059            )
3060            .to_pattern();
3061            let mut powers = term
3062                .integral
3063                .pattern_match(&propagator, None, None)
3064                .map(|matched| {
3065                    assert_eq!(matched[&W_.mass_], mass_squared);
3066                    assert!(!matched[&W_.mom_].contains_symbol(vakint::symbols::S.p));
3067                    i32::try_from(matched[&W_.e_].as_view()).unwrap()
3068                })
3069                .collect::<Vec<_>>();
3070            powers.sort();
3071            assert_eq!(
3072                powers,
3073                if child_active {
3074                    vec![1, 1, 3, 3]
3075                } else {
3076                    vec![3]
3077                }
3078            );
3079        }
3080    }
3081
3082    #[test]
3083    fn nested_vacuum_denominators_preserve_incidence_and_factorized_numerators() {
3084        use crate::{
3085            dot,
3086            graph::{Graph, parse::IntoGraph},
3087        };
3088
3089        test_initialise().unwrap();
3090        let graph: Graph = dot!(digraph nested_vacuum_bubble {
3091            edge [num=1 mass=1]
3092            node [num=1]
3093            incoming [style=invis]
3094            outgoing [style=invis]
3095            incoming -> a [id=0]
3096            b -> outgoing [id=1]
3097            a -> b [id=2 lmb_id=0]
3098            a -> b [id=3]
3099        })
3100        .unwrap();
3101        let denominators = [(2, 1), (3, -1)].map(|(edge, sign)| {
3102            function!(
3103                GS.den,
3104                edge,
3105                Atom::num(sign) * function!(GS.emr_mom, 2),
3106                Atom::var(GS.m_uv_vacuum).pow(2)
3107            )
3108        });
3109        let numerator = parse!("(a+b)*(c+d)");
3110        let [first, second] = [parse!("s"), parse!("t")];
3111        // Include powers beyond signed eight-bit range, matching the i32
3112        // propagator powers used by the incidence conversion below.
3113        for base_power in [1, 127] {
3114            let input = &numerator / (&denominators[0] * denominators[1].pow(base_power))
3115                * (&first / &denominators[1] + &second / denominators[1].pow(2));
3116            let actual = to_vakint_integrand(
3117                &input,
3118                &graph,
3119                &graph.full_filter(),
3120                &graph.empty_subgraph::<SuBitGraph>(),
3121                &[(
3122                    graph.full_filter(),
3123                    graph.full_filter(),
3124                    graph.loop_momentum_basis.clone(),
3125                )],
3126                &VakintSettings {
3127                    // This conversion oracle uses unit loop normalization in every prefix.
3128                    additional_normalization: "1".to_string(),
3129                    ..VakintSettings::default()
3130                },
3131                true,
3132            )
3133            .unwrap();
3134            let actual = Atom::from(actual)
3135                .replace(function!(vakint::symbols::S.topo, W_.x_))
3136                .with(W_.x_)
3137                .replace(function!(
3138                    vakint::symbols::S.prop,
3139                    W_.a_,
3140                    W_.b_,
3141                    W_.c_,
3142                    W_.d_,
3143                    W_.e_
3144                ))
3145                .with(
3146                    (function!(vakint::symbols::S.dot, W_.c_, W_.c_) - Atom::var(W_.d_))
3147                        .pow(-Atom::var(W_.e_)),
3148                );
3149            let radial = function!(
3150                vakint::symbols::S.dot,
3151                function!(vakint::symbols::S.k, 0),
3152                function!(vakint::symbols::S.k, 0)
3153            ) - Atom::var(GS.m_uv_vacuum).pow(2);
3154            let expected = &numerator
3155                * (&first / radial.pow(base_power + 2) + &second / radial.pow(base_power + 3));
3156            assert_eq!(actual.collect_factors(), expected.collect_factors());
3157        }
3158    }
3159}