Skip to main content

gammalooprs/utils/
symbols.rs

1use std::{cell::RefCell, collections::HashMap, sync::LazyLock};
2
3use itertools::Itertools;
4use linnet::half_edge::involution::{EdgeIndex, Orientation};
5
6use spenso::{
7    network::{library::symbolic::ETS, tags::SPENSO_TAG},
8    shadowing::symbolica_utils::SpensoPrintSettings,
9    structure::{
10        abstract_index::AIND_SYMBOLS,
11        concrete_index::ExpandedIndex,
12        representation::{Minkowski, RepName},
13    },
14    utils::{to_subscript, to_superscript},
15};
16use symbolica::{
17    atom::{Atom, AtomCore, AtomOrView, AtomView, FunctionBuilder, Symbol},
18    domains::rational::Rational,
19    function, get_symbol,
20    id::Replacement,
21    printer::{PrintState, PrintUserData},
22    symbol,
23};
24use symbolica_utils::{PrintSettingsExt, TypstMode};
25
26use crate::{cff::orientations::GraphOrientation, graph::LoopMomentumBasis};
27
28/// Persistent ownership carried by an EMR momentum through a local 4D UV
29/// Taylor expansion. Wire value two is deliberately reserved for the
30/// transient denominator marker used while the Taylor operator is acting.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[repr(i64)]
33pub(crate) enum UvMomentumProvenanceRole {
34    /// A pre-existing numerator factor fixed to its original owner during raw
35    /// Taylor recursion. Completed UV projection may certify a denominator
36    /// class and transfer this factor to that class's occurrence pool.
37    TaylorFixed = 0,
38    /// A hard numerator factor created by differentiating a denominator. Raw
39    /// provenance permits copies of the same owner; completed UV projection
40    /// may certify equivalent signed channels before class-wide dispatch.
41    DenominatorDerived = 1,
42    /// A physical source momentum reconstructed from its stored hard lift,
43    /// independently of occurrence-local contact samples.
44    PhysicalSourceFixed = 3,
45    /// A soft momentum created by a denominator Taylor derivative. Its exact
46    /// off-shell routing may be chosen when the outer numerator is complete;
47    /// it is not an occurrence of the differentiated hard denominator.
48    DenominatorDerivedSoft = 4,
49}
50
51/// An algebraic denominator channel in one completed UV projection view.
52/// This namespace is distinct from physical source edges and CFF occurrences.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub(crate) struct UvDenominatorClassId(pub usize);
55
56impl From<bool> for UvMomentumProvenanceRole {
57    fn from(denominator_derived: bool) -> Self {
58        if denominator_derived {
59            Self::DenominatorDerived
60        } else {
61            Self::TaylorFixed
62        }
63    }
64}
65
66pub struct WildCards {
67    pub edgeid_: Symbol,
68    pub mom_: Symbol,
69    pub mass_: Symbol,
70    pub prop_: Symbol,
71    pub a_: Symbol,
72    pub b_: Symbol,
73    pub c_: Symbol,
74    pub d_: Symbol,
75    pub e_: Symbol,
76    pub f_: Symbol,
77    pub g_: Symbol,
78    pub h_: Symbol,
79    pub i_: Symbol,
80    pub j_: Symbol,
81    pub k_: Symbol,
82    pub l_: Symbol,
83    pub m_: Symbol,
84    pub n_: Symbol,
85    pub o_: Symbol,
86    pub p_: Symbol,
87    pub q_: Symbol,
88    pub r_: Symbol,
89    pub s_: Symbol,
90    pub t_: Symbol,
91    pub u_: Symbol,
92    pub v_: Symbol,
93    pub w_: Symbol,
94    pub x_: Symbol,
95    pub y_: Symbol,
96    pub z_: Symbol,
97    pub a__: Symbol,
98    pub b__: Symbol,
99    pub c__: Symbol,
100    pub d__: Symbol,
101    pub e__: Symbol,
102    pub f__: Symbol,
103    pub g__: Symbol,
104    pub h__: Symbol,
105    pub i__: Symbol,
106    pub j__: Symbol,
107    pub k__: Symbol,
108    pub l__: Symbol,
109    pub m__: Symbol,
110    pub n__: Symbol,
111    pub o__: Symbol,
112    pub p__: Symbol,
113    pub q__: Symbol,
114    pub r__: Symbol,
115    pub s__: Symbol,
116    pub t__: Symbol,
117    pub u__: Symbol,
118    pub v__: Symbol,
119    pub w__: Symbol,
120    pub x__: Symbol,
121    pub y__: Symbol,
122    pub z__: Symbol,
123    pub a___: Symbol,
124    pub b___: Symbol,
125    pub c___: Symbol,
126    pub d___: Symbol,
127    pub e___: Symbol,
128    pub f___: Symbol,
129    pub g___: Symbol,
130    pub h___: Symbol,
131    pub i___: Symbol,
132    pub j___: Symbol,
133    pub k___: Symbol,
134    pub l___: Symbol,
135    pub m___: Symbol,
136    pub n___: Symbol,
137    pub o___: Symbol,
138    pub p___: Symbol,
139    pub q___: Symbol,
140    pub r___: Symbol,
141    pub s___: Symbol,
142    pub t___: Symbol,
143    pub u___: Symbol,
144    pub v___: Symbol,
145    pub w___: Symbol,
146    pub x___: Symbol,
147    pub y___: Symbol,
148    pub z___: Symbol,
149}
150
151pub struct GammaloopSymbols {
152    pub integrand: Symbol,
153    /// Exact generalized-3D-representation residue-map identifier selected by
154    /// the runtime evaluator. This is independent of physical edge signs.
155    pub residue_map_id: Symbol,
156    /// wrapper function for the 4d bridge denominators
157    pub tree_denom_wrapper: Symbol,
158
159    pub killing_func: Symbol,
160    pub is_function: Symbol,
161    pub is_symbol: Symbol,
162    pub ufozero: Symbol,
163    pub _linear: Symbol,
164    pub linearize: Symbol,
165    pub loop_mom: Symbol,
166    pub edgeid: Symbol,
167    pub uvaind: Symbol,
168    pub edgeaind: Symbol,
169    pub dummyaind: Symbol,
170    pub hedgeaind: Symbol,
171    pub vertexaind: Symbol,
172    pub vertexid: Symbol,
173    pub source_id: Symbol,
174    pub sink_id: Symbol,
175    pub orientation_delta: Symbol,
176    pub ubar: Symbol,
177    pub hfunction_lu_cut: Symbol,
178    pub hfunction_left_th: Symbol,
179    pub hfunction_right_th: Symbol,
180    pub eta: Symbol,
181    pub eta_left: Symbol,
182    pub eta_right: Symbol,
183    pub deta_lu_cut: Symbol,
184    pub deta_left_th: Symbol,
185    pub deta_right_th: Symbol,
186    pub vbar: Symbol,
187    pub ose: Symbol,
188    /// Positive on-shell energy with no additive energy shift. Arguments carry
189    /// the component owner and the invariant mass squared plus spatial norm squared.
190    pub on_shell_energy: Symbol,
191    /// A CFF energy sum, including its shift, with every dependency in its arguments.
192    pub esurface: Symbol,
193    pub energy: Symbol,
194    pub v: Symbol,
195    pub u: Symbol,
196    pub color_wrap: Symbol,
197    /// Epsilon for dimensional regularization
198    pub dim_epsilon: Symbol,
199
200    pub epsilon: Symbol,
201    pub epsilonbar: Symbol,
202    pub rescale: Symbol,
203    /// Bookkeeping scale that restores consumed loop measures in integrated projections.
204    pub integrated_loop_scale: Symbol,
205    pub rescale_mass: Symbol,
206    pub rescale_star: Symbol,
207    pub pi: Symbol,
208
209    //Parameters for UV renormalization and localization
210    /// UV mass used only as the auxiliary expansion/deformation scale in local UV series.
211    ///
212    /// This symbol prints as `mUVexp`. Public runtime output should not depend on this
213    /// name after the endpoint mass-role collapse.
214    pub m_uv_expansion: Symbol,
215    /// UV vacuum mass used by integrated counterterms and the vacuum-integral basis.
216    ///
217    /// This symbol prints as `mUV` and is the public symbolic representative of the
218    /// `general.m_uv` runtime setting.
219    pub m_uv_vacuum: Symbol,
220    /// UV localization scale factor
221    pub renormalization_localization_scale: Symbol,
222    pub mu_r_sq: Symbol,
223    pub numerator_sampling_scale: Symbol,
224    pub sign: Symbol,
225    pub theta: Symbol,
226    pub broadcasting_sqrt: Symbol,
227    pub heaviside: Symbol,
228    ///for selecting orientations at generation
229    pub selected: Symbol,
230
231    /// For analytic UV profiling
232    pub expansion: Symbol,
233    ///For selecting a concete index.
234    pub delta_vec: Symbol,
235    /// Canonical spatial component `Q(edge, index___)`.
236    pub emr_mom: Symbol,
237    /// UV-local provenance stored in the tag slot of `Q(...)` while applying a
238    /// Taylor operator. Its arguments are the immutable source edge, its
239    /// `UvMomentumProvenanceRole`, and the literal momentum payload. Hard roles
240    /// retain their complete projection in the frozen child LMB; newly derived
241    /// soft factors retain their crown carrier until outer-CFF routing.
242    pub uv_momentum_provenance: Symbol,
243    /// Projection-owned denominator class, never a physical edge identifier.
244    pub uv_class: Symbol,
245    /// Raw `Q3` symbol for pattern construction; stored atoms always carry a
246    /// representation or `cind` through the canonical constructor.
247    pub emr_vec: Symbol,
248    pub dot: Symbol,
249    pub external_mom: Symbol,
250    pub dim: Symbol,
251    pub coeff: Symbol,
252
253    pub localizing_integrand: Symbol,
254
255    pub uv_subgraph: Symbol,
256    pub uv_approx: Symbol,
257    pub uv_integrate: Symbol,
258    pub uv_series: Symbol,
259    pub uv_truncate: Symbol,
260    /// Marker for the ct term in the UV integrand
261    pub ct_marker: Symbol,
262
263    pub nc2_1: Symbol,
264    pub expr: Symbol,
265    pub num: Symbol,
266    /// Denominator wrapper `den(edge, momentum, mass, full_expr)` (no power and
267    /// should not be multiplied in but divided).
268    pub den: Symbol,
269    pub radius_left: Symbol,
270    pub radius_star_left: Symbol,
271    pub uv_damp_plus_left: Symbol,
272    pub uv_damp_minus_left: Symbol,
273    pub radius_right: Symbol,
274    pub radius_star_right: Symbol,
275    pub uv_damp_plus_right: Symbol,
276    pub uv_damp_minus_right: Symbol,
277    pub thermal_distribution: Symbol,
278    pub thermal_weight_wrapper: Symbol,
279    pub inverse_temperature: Symbol,
280}
281
282impl GammaloopSymbols {
283    pub fn collect_orientation_if<'a>(&self, arg: impl Into<AtomOrView<'a>>) -> Atom {
284        let arg = arg.into();
285        if !arg.as_view().contains_symbol(self.theta) && !arg.as_view().contains_symbol(Symbol::IF)
286        {
287            return arg.into_owned();
288        }
289        let mut collected = arg
290            .replace(self.sign_theta(W_.a_))
291            .with(Symbol::IF.call(Atom::var(W_.a_) + 1))
292            .replace(Symbol::IF.call(W_.a_) * Symbol::IF.call(W_.b_))
293            .repeat()
294            .with(Symbol::IF.call(W_.a_ * W_.b_))
295            .replace(Symbol::IF.call(W_.a_) * W_.b___)
296            .with(Symbol::IF.call_args([Atom::var(W_.a_), Atom::var(W_.b___), Atom::Zero]))
297            .replace(Symbol::IF.call_args([Atom::var(W_.a_), Atom::Zero]))
298            .with(Symbol::IF.call_args([Atom::var(W_.a_), Atom::one(), Atom::Zero]))
299            .replace(Symbol::IF.call_args([Atom::var(W_.a_)]))
300            .with(Symbol::IF.call_args([Atom::var(W_.a_), Atom::one(), Atom::Zero]));
301        // Tensor contractions are complete here. Residue-map deltas use
302        // IF(current_id-key, 0, body). Move complete scalar weights into their
303        // selected arms before merging same-key contributions. This
304        // keeps inactive inverses lazy and gives the evaluator one shared region.
305        // Collection can expose more weighted guards, so repeat until stable.
306        collected.repeat_map(Self::collect_orientation_if_terms);
307        collected
308    }
309
310    /// Absorb scalar weights into zero-arm guards without expanding products,
311    /// then merge same-key terms into one `IF(key, 0, sum of bodies)`. Unlike a
312    /// sweep of `.replace(IF(a_, 0, b_) + IF(a_, 0, c_)).with(IF(a_, 0, b_ + c_))`,
313    /// which merges one pair per sum, this merges all of them, so repeating it
314    /// does not retraverse the whole expression for every pair of a nested sum.
315    fn collect_orientation_if_terms(expr: AtomView<'_>) -> Atom {
316        fn key_and_body(term: AtomView<'_>) -> Option<(AtomView<'_>, AtomView<'_>)> {
317            let AtomView::Fun(call) = term else {
318                return None;
319            };
320            let (key, zero, body) = call.iter().collect_tuple()?;
321            (call.get_symbol() == Symbol::IF && zero == 0).then_some((key, body))
322        }
323
324        expr.replace_map(|term, _, out| {
325            if let AtomView::Fun(call) = term
326                && call.get_symbol() != Symbol::IF
327            {
328                // Function arguments can be literal parameter or alias tags.
329                // Keep the complete call as a factor so its lookup key survives.
330                out.set_from_view(&term);
331                return;
332            }
333            if let AtomView::Mul(product) = term {
334                let Some((key, _)) = product.iter().find_map(key_and_body) else {
335                    return;
336                };
337                // Same-key factors belong to one live arm. Different keys keep
338                // their own guards, and functions/powers remain complete factors.
339                let body = Atom::mul_many(product.iter().map(|factor| {
340                    key_and_body(factor)
341                        .filter(|(factor_key, _)| *factor_key == key)
342                        .map_or(factor, |(_, body)| body)
343                }));
344                let guarded = Symbol::IF.call_args([key, Atom::Zero.as_view(), body.as_view()]);
345                **out = Self::collect_orientation_if_terms(guarded.as_view());
346                return;
347            }
348            let AtomView::Add(sum) = term else {
349                return;
350            };
351            let mut groups = HashMap::<_, Vec<_>>::new();
352            for term in sum.iter() {
353                if let Some((key, _)) = key_and_body(term) {
354                    groups.entry(key).or_default().push(term);
355                }
356            }
357            groups.retain(|_, group| group.len() > 1);
358            if groups.is_empty() {
359                return;
360            }
361            let mut terms = sum
362                .iter()
363                .filter(|&term| {
364                    key_and_body(term).is_none_or(|(key, _)| !groups.contains_key(&key))
365                })
366                .map(AtomOrView::from)
367                .collect_vec();
368            for (key, mut group) in groups {
369                // Add bodies in the order of that pairwise replacement, which
370                // matches the first two same-key terms of the sorted sum, so
371                // floating-point coefficients round identically.
372                group.sort_by(|a, b| b.get_data().cmp(a.get_data()));
373                let mut group = group.into_iter().map(AtomOrView::from).collect_vec();
374                while group.len() > 1 {
375                    let pair = [group.pop().unwrap(), group.pop().unwrap()];
376                    let bodies = pair
377                        .iter()
378                        .map(|term| key_and_body(term.as_view()).unwrap().1);
379                    let body = Atom::add_many(bodies);
380                    let merged = Symbol::IF.call_args([key, Atom::Zero.as_view(), body.as_view()]);
381                    let data = merged.as_view().get_data();
382                    let at = group.partition_point(|term| term.as_view().get_data() > data);
383                    group.insert(at, merged.into());
384                }
385                terms.extend(group);
386            }
387            **out = Self::collect_orientation_if_terms(Atom::add_many(terms).as_view());
388        })
389    }
390
391    pub fn den<'a>(
392        &self,
393        eid: impl Into<AtomOrView<'a>>,
394        mom: impl Into<AtomOrView<'a>>,
395        mass: impl Into<AtomOrView<'a>>,
396        full_expr: impl Into<AtomOrView<'a>>,
397    ) -> Atom {
398        self.den.call_args([
399            eid.into().as_view(),
400            mom.into().as_view(),
401            mass.into().as_view(),
402            full_expr.into().as_view(),
403        ])
404    }
405
406    pub(crate) fn orientation_delta<O: GraphOrientation>(&self, orientation: &O) -> Atom {
407        let args: Vec<i32> = orientation
408            .orientation()
409            .iter()
410            .map(|(_, t)| match t {
411                Orientation::Default => 1,
412                Orientation::Reversed => -1,
413                Orientation::Undirected => 0,
414            })
415            .collect_vec();
416        FunctionBuilder::new(self.orientation_delta)
417            .add_args(&args)
418            .finish()
419    }
420
421    pub(crate) fn sign_theta<'a>(&self, arg: impl Into<AtomOrView<'a>>) -> Atom {
422        let arg = arg.into();
423
424        function!(self.theta, arg.as_view())
425    }
426
427    pub(crate) fn sign(&self, edge: EdgeIndex) -> Atom {
428        function!(self.sign, Atom::num(edge.0 as i64))
429    }
430}
431
432pub static W_: LazyLock<WildCards> = LazyLock::new(|| WildCards {
433    edgeid_: symbol!("eid_"),
434    mom_: symbol!("mom_"),
435    mass_: symbol!("mass_"),
436    prop_: symbol!("prop_"),
437    a_: symbol!("a_"),
438    b_: symbol!("b_"),
439    c_: symbol!("c_"),
440    d_: symbol!("d_"),
441    e_: symbol!("e_"),
442    f_: symbol!("f_"),
443    g_: symbol!("g_"),
444    h_: symbol!("h_"),
445    i_: symbol!("i_"),
446    j_: symbol!("j_"),
447    k_: symbol!("k_"),
448    l_: symbol!("l_"),
449    m_: symbol!("m_"),
450    n_: symbol!("n_"),
451    o_: symbol!("o_"),
452    p_: symbol!("p_"),
453    q_: symbol!("q_"),
454    r_: symbol!("r_"),
455    s_: symbol!("s_"),
456    t_: symbol!("t_"),
457    u_: symbol!("u_"),
458    v_: symbol!("v_"),
459    w_: symbol!("w_"),
460    x_: symbol!("x_"),
461    y_: symbol!("y_"),
462    z_: symbol!("z_"),
463    a__: symbol!("a__"),
464    b__: symbol!("b__"),
465    c__: symbol!("c__"),
466    d__: symbol!("d__"),
467    e__: symbol!("e__"),
468    f__: symbol!("f__"),
469    g__: symbol!("g__"),
470    h__: symbol!("h__"),
471    i__: symbol!("i__"),
472    j__: symbol!("j__"),
473    k__: symbol!("k__"),
474    l__: symbol!("l__"),
475    m__: symbol!("m__"),
476    n__: symbol!("n__"),
477    o__: symbol!("o__"),
478    p__: symbol!("p__"),
479    q__: symbol!("q__"),
480    r__: symbol!("r__"),
481    s__: symbol!("s__"),
482    t__: symbol!("t__"),
483    u__: symbol!("u__"),
484    v__: symbol!("v__"),
485    w__: symbol!("w__"),
486    x__: symbol!("x__"),
487    y__: symbol!("y__"),
488    z__: symbol!("z__"),
489    a___: symbol!("a___"),
490    b___: symbol!("b___"),
491    c___: symbol!("c___"),
492    d___: symbol!("d___"),
493    e___: symbol!("e___"),
494    f___: symbol!("f___"),
495    g___: symbol!("g___"),
496    h___: symbol!("h___"),
497    i___: symbol!("i___"),
498    j___: symbol!("j___"),
499    k___: symbol!("k___"),
500    l___: symbol!("l___"),
501    m___: symbol!("m___"),
502    n___: symbol!("n___"),
503    o___: symbol!("o___"),
504    p___: symbol!("p___"),
505    q___: symbol!("q___"),
506    r___: symbol!("r___"),
507    s___: symbol!("s___"),
508    t___: symbol!("t___"),
509    u___: symbol!("u___"),
510    v___: symbol!("v___"),
511    w___: symbol!("w___"),
512    x___: symbol!("x___"),
513    y___: symbol!("y___"),
514    z___: symbol!("z___"),
515});
516
517macro_rules! spenso_print_scripted_indexed {
518    ($a:ident, $opt:ident, $symbol:expr) => {
519        spenso_print_scripted_indexed!($a, $opt, $symbol, $symbol)
520    };
521    ($a:ident, $opt:ident, $symbol:expr, $typst_symbol:expr) => {{
522        match $opt.custom_print_mode.get("spenso") {
523            Some(PrintUserData::Integer(i)) => {
524                let SpensoPrintSettings {
525                    parens,
526                    symbol_scripts,
527                    commas,
528                    with_dim,
529                    ..
530                } = SpensoPrintSettings::from(*i as usize);
531
532                let AtomView::Fun(f) = $a else {
533                    return None;
534                };
535
536                let mut argiter = f.iter();
537                let id = argiter.next()?;
538                let Ok(i) = usize::try_from(id) else {
539                    return None;
540                };
541
542                let is_typst = $opt.typst_mode().is_some();
543                let mut out = if is_typst {
544                    $typst_symbol.to_string()
545                } else {
546                    $symbol.to_string()
547                };
548                if is_typst {
549                    out.push('_');
550                    out.push_str(&i.to_string());
551                } else {
552                    out.push_str(&to_subscript(i as isize));
553                }
554                if $opt.color_builtin_symbols && !is_typst {
555                    out = nu_ansi_term::Color::Magenta.paint(out).to_string();
556                }
557
558                let mut printed_args = false;
559                for arg in argiter {
560                    let hidden_representation = matches!(
561                        arg,
562                        AtomView::Fun(a)
563                            if a.get_symbol().has_tag(&SPENSO_TAG.representation)
564                                && a.get_nargs() == 1
565                                && !with_dim
566                    );
567                    if hidden_representation {
568                        continue;
569                    }
570
571                    if printed_args {
572                        out.push(if commas { ',' } else { ' ' });
573                    } else {
574                        if symbol_scripts {
575                            out.push('^');
576                        }
577                        if parens {
578                            out.push('(');
579                        }
580                        printed_args = true;
581                    }
582
583                    arg.format(&mut out, $opt, PrintState::new()).unwrap();
584                }
585                if printed_args && parens {
586                    out.push(')');
587                }
588                Some(out)
589            }
590            _ => None,
591        }
592    }};
593}
594
595macro_rules! spenso_print_simple_indexed {
596    ($a:ident, $opt:ident, $symbol:expr) => {
597        spenso_print_simple_indexed!($a, $opt, $symbol, $symbol)
598    };
599    ($a:ident, $opt:ident, $symbol:expr, $typst_symbol:expr) => {{
600        match $opt.custom_print_mode.get("spenso") {
601            Some(PrintUserData::Integer(_)) => {
602                let AtomView::Fun(f) = $a else {
603                    return None;
604                };
605
606                let mut out = $symbol.to_string();
607                let mut args = f.iter();
608
609                let id = args.next().unwrap();
610                let Ok(i) = usize::try_from(id) else {
611                    return None;
612                };
613
614                if $opt.typst_mode().is_some() {
615                    out = $typst_symbol.to_string();
616                    out.push('_');
617                    out.push_str(&i.to_string());
618                } else {
619                    out.push_str(&to_subscript(i as isize));
620                }
621                let mut first = true;
622                for arg in args {
623                    if first {
624                        first = false;
625                        out.push('(');
626                    } else {
627                        out.push(',');
628                    }
629                    arg.format(&mut out, $opt, PrintState::new()).unwrap();
630                }
631                if !first {
632                    out.push(')');
633                }
634                Some(out)
635            }
636            _ => None,
637        }
638    }};
639}
640
641macro_rules! spenso_print_uv_unary {
642    ($a:ident, $opt:ident, $prefix:expr, $suffix:expr) => {{
643        match $opt.custom_print_mode.get("spenso") {
644            Some(PrintUserData::Integer(_)) => {
645                let AtomView::Fun(f) = $a else {
646                    return None;
647                };
648                if f.get_nargs() != 1 {
649                    return None;
650                }
651
652                let mut out = $prefix.to_string();
653                f.iter()
654                    .next()
655                    .unwrap()
656                    .format(&mut out, $opt, PrintState::new())
657                    .unwrap();
658                out.push_str($suffix);
659                Some(out)
660            }
661            _ => None,
662        }
663    }};
664}
665
666spenso::symbolica_init_lazy_static! {
667pub static GS, GS_INNER: GammaloopSymbols = || GammaloopSymbols {
668    renormalization_localization_scale: symbol!("rls"),
669    integrand: symbol!("integrand"),
670    residue_map_id: symbol!("residue_map_id"),
671    tree_denom_wrapper: symbol!("tree_denoms"),
672    dim_epsilon: symbol!("ε"),
673    killing_func: symbol!(
674        "killing_func",
675        norm = |f, out| {
676            if let AtomView::Fun(_f) = f {
677                **out = Atom::one()
678            }
679        }
680    ),
681    ufozero: symbol!(
682        "UFO::ZERO",
683        norm = |_, out| {
684            **out = Atom::Zero;
685        }
686    ),
687
688    localizing_integrand: symbol!("int_loc"),
689    uvaind: symbol!(
690        "uvind",
691        print = |a, opt, _state| {
692            match opt.custom_print_mode.get("spenso") {
693                Some(PrintUserData::Integer(_i)) => {
694                    let AtomView::Fun(f) = a else {
695                        return None;
696                    };
697
698                    let mut out = "ᵘ".to_string();
699                    let mut first = true;
700                    for arg in f.iter() {
701                        let Ok(i) = isize::try_from(arg) else {
702                            return None;
703                        };
704
705                        if !first {
706                            out.push('.');
707                        } else {
708                            first = false;
709                        }
710                        out.push_str(&to_superscript(i));
711                    }
712                    Some(out)
713                }
714                _ => None,
715            }
716        },
717        tags = [SPENSO_TAG.index.clone()]
718    ),
719    edgeaind: symbol!(
720        "edge",
721        print = |a, opt, _state| {
722            match opt.custom_print_mode.get("spenso") {
723                Some(PrintUserData::Integer(_i)) => {
724                    let AtomView::Fun(f) = a else {
725                        return None;
726                    };
727
728                    let mut out = "ᵉ".to_string();
729                    let mut first = true;
730                    for arg in f.iter() {
731                        let Ok(i) = isize::try_from(arg) else {
732                            return None;
733                        };
734
735                        if !first {
736                            out.push('.');
737                        }
738                        first = false;
739
740                        out.push_str(&to_superscript(i));
741                    }
742                    Some(out)
743                }
744                _ => None,
745            }
746        },
747        tags = [SPENSO_TAG.index.clone()]
748    ),
749    vertexaind: symbol!(
750        "vertex",
751        print = |a, opt, _state| {
752            match opt.custom_print_mode.get("spenso") {
753                Some(PrintUserData::Integer(_i)) => {
754                    let AtomView::Fun(f) = a else {
755                        return None;
756                    };
757
758                    let mut out = "ᵛ".to_string();
759
760                    let mut first = true;
761                    for arg in f.iter() {
762                        let Ok(i) = isize::try_from(arg) else {
763                            return None;
764                        };
765
766                        if !first {
767                            out.push('.');
768                        }
769                        first = false;
770
771                        out.push_str(&to_superscript(i));
772                    }
773                    Some(out)
774                }
775                _ => None,
776            }
777        },
778        tags = [SPENSO_TAG.index.clone()]
779    ),
780    dummyaind: symbol!(
781        "dummy",
782        print = |a, opt, _state| {
783            match opt.custom_print_mode.get("spenso") {
784                Some(PrintUserData::Integer(_i)) => {
785                    let AtomView::Fun(f) = a else {
786                        return None;
787                    };
788
789                    let mut out = "ᵈ".to_string();
790                    let mut first = true;
791                    for arg in f.iter() {
792                        let Ok(i) = isize::try_from(arg) else {
793                            return None;
794                        };
795
796                        if !first {
797                            out.push('.');
798                        }
799                        first = false;
800
801                        out.push_str(&to_superscript(i));
802                    }
803                    Some(out)
804                }
805                _ => None,
806            }
807        },
808        tags = [SPENSO_TAG.index.clone()]
809    ),
810    hedgeaind: symbol!(
811        "hedge",
812        print = |a, opt, _state| {
813            match opt.custom_print_mode.get("spenso") {
814                Some(PrintUserData::Integer(i)) => {
815                    let AtomView::Fun(f) = a else {
816                        return None;
817                    };
818                    let SpensoPrintSettings {
819                        index_subscripts, ..
820                    } = SpensoPrintSettings::from(*i as usize);
821
822                    let mut out = "".to_string();
823                    let mut first = true;
824                    for arg in f.iter() {
825                        let Ok(i) = isize::try_from(arg) else {
826                            return None;
827                        };
828
829                        if !first {
830                            out.push('.');
831                        }
832                        first = false;
833                        if index_subscripts {
834                            out.push_str(&to_superscript(i));
835                        } else {
836                            out.push_str(&to_subscript(i));
837                        }
838                    }
839                    Some(out)
840                }
841                _ => None,
842            }
843        },
844        tags = [SPENSO_TAG.index.clone()]
845    ),
846    uv_subgraph: symbol!(
847        "gammalooprs::uv::subgraph",
848        print = |a, opt, _state| {
849            match opt.custom_print_mode.get("spenso") {
850                Some(PrintUserData::Integer(_)) => {
851                    let AtomView::Fun(f) = a else {
852                        return None;
853                    };
854                    if f.get_nargs() != 2 {
855                        return None;
856                    }
857
858                    let mut args = f.iter();
859                    let current = args.next().unwrap();
860                    let mut out = String::new();
861                    if let AtomView::Var(current) = current
862                        && current.get_symbol().get_stripped_name().starts_with("S_")
863                    {
864                        out.push_str(current.get_symbol().get_stripped_name());
865                    } else {
866                        out.push_str("S_");
867                        current
868                            .format(&mut out, opt, PrintState::new())
869                            .unwrap();
870                    }
871                    out.push('⊛');
872                    let given = args.next().unwrap();
873                    if let AtomView::Var(given) = given
874                        && let Some(label) =
875                            given.get_symbol().get_stripped_name().strip_prefix("S_")
876                    {
877                        out.push_str(label);
878                    } else {
879                        given.format(&mut out, opt, PrintState::new()).unwrap();
880                    }
881                    Some(out)
882                }
883                _ => None,
884            }
885        }
886    ),
887    uv_approx: symbol!(
888        "Approx",
889        print = |a, opt, _state| spenso_print_uv_unary!(a, opt, "K[", "]")
890    ),
891    uv_integrate: symbol!(
892        "Integrate",
893        print = |a, opt, _state| spenso_print_uv_unary!(a, opt, "⟨", "⟩")
894    ),
895    uv_series: symbol!(
896        "Series",
897        print = |a, opt, _state| spenso_print_uv_unary!(a, opt, "Σ(", ")")
898    ),
899    uv_truncate: symbol!(
900        "Truncate",
901        print = |a, opt, _state| {
902            let prefix = if opt.typst_mode().is_some() {
903                r#"op("Tr")("#
904            } else {
905                "Tr("
906            };
907            spenso_print_uv_unary!(a, opt, prefix, ")")
908        }
909    ),
910    ct_marker: symbol!(
911        "CT",
912        print = |a, opt, _state| {
913            spenso_print_uv_unary!(a, opt, "", "").map(|out| {
914                if opt.color_builtin_symbols {
915                    nu_ansi_term::Color::Magenta.paint(out).to_string()
916                } else {
917                    out
918                }
919            })
920        }
921    ),
922    is_function: symbol!("is_function"),
923    is_symbol: symbol!("is_symbol"),
924    nc2_1: symbol!("NC2_1"),
925    rescale: symbol!("t";Scalar,Real,Positive),
926    integrated_loop_scale: symbol!("uvIntegratedLoopScale";Scalar,Real,Positive),
927    rescale_mass: symbol!("t_m";Scalar,Real,Positive),
928    _linear: symbol!("_linear";Linear),
929    linearize: symbol!(
930        "linearize",
931        norm = |f, out| {
932            if let AtomView::Fun(ff) = f
933                && ff.get_nargs() == 1
934                && let AtomView::Fun(arg) = ff.iter().next().unwrap()
935            {
936                let mut args = vec![];
937                for a in arg.iter() {
938                    if let AtomView::Fun(a) = a
939                        && a.get_symbol().get_wildcard_level() > 0
940                    {
941                        return;
942                    }
943                    if let AtomView::Var(a) = a
944                        && a.get_symbol().get_wildcard_level() > 0
945                    {
946                        return;
947                    }
948
949                    args.push(a);
950                }
951                let expr = GS
952                    ._linear
953                    .call_args(args.as_slice())
954                    .replace(GS._linear.call_args([W_.a___]))
955                    .with(arg.get_symbol().call_args([W_.a___]));
956                println!(":{}->{}", f, expr);
957                **out = expr;
958            }
959        }
960    ),
961    broadcasting_sqrt: symbol!(
962        "broadcasting_sqrt",
963        tag = SPENSO_TAG.broadcast,
964        der = |a, _, out| {
965            **out = Atom::num(1) / (Atom::num(2) * a);
966        }
967    ),
968    heaviside: symbol!("heaviside"),
969    expansion: symbol!("expansion"),
970    rescale_star: symbol!("t⃰"),
971    hfunction_lu_cut: symbol!("h_lu_cut"),
972    hfunction_left_th: symbol!("h_left_th"),
973    hfunction_right_th: symbol!("h_right_th"),
974    eta: symbol!("η"),
975    eta_left: symbol!("η_left"),
976    eta_right: symbol!("η_right"),
977    deta_lu_cut: symbol!("∇η_lu_cut"),
978    deta_left_th: symbol!("∇η_left_th"),
979    deta_right_th: symbol!("∇η_right_th"),
980    edgeid: symbol!("eid"),
981    vertexid: symbol!("vid"),
982    source_id: symbol!("source"),
983    sink_id: symbol!("sink"),
984    sign: symbol!("σ"; Scalar),
985    selected: symbol!("selected"),
986    theta: symbol!("θ"),
987    m_uv_expansion: symbol!(
988        "mUVexp",
989        print = |a, opt, _state| {
990            let AtomView::Var(_a) = a else {
991                return None;
992            };
993            match opt.custom_print_mode.get("spenso") {
994                Some(PrintUserData::Integer(_)) if opt.typst_mode().is_some() => {
995                    Some("m_\"UVexp\"".to_string())
996                }
997                _ => None,
998            }
999        }
1000    ),
1001    m_uv_vacuum: symbol!(
1002        "mUV",
1003        print = |a, opt, _state| {
1004            let AtomView::Var(_a) = a else {
1005                return None;
1006            };
1007            match opt.custom_print_mode.get("spenso") {
1008                Some(PrintUserData::Integer(_)) if opt.typst_mode().is_some() => {
1009                    Some("m_\"UV\"".to_string())
1010                }
1011                _ => None,
1012            }
1013        }
1014    ),
1015    mu_r_sq: symbol!(
1016        "μᵣ²",
1017        print = |a, opt, _state| {
1018            let AtomView::Var(_a) = a else {
1019                return None;
1020            };
1021            match opt.custom_print_mode.get("spenso") {
1022                Some(PrintUserData::Integer(_)) if opt.typst_mode().is_some() => {
1023                    Some("mu_r^2".to_string())
1024                }
1025                _ => None,
1026            }
1027        }
1028    ),
1029    numerator_sampling_scale: symbol!("M"),
1030    delta_vec: ETS.delta,
1031    expr: symbol!(
1032        "expr",
1033        print = |_, opt, _state| {
1034            Some(match opt.typst_mode()? {
1035                TypstMode::Math | TypstMode::Markup => "#expr".to_string(),
1036                TypstMode::Code => "expr".to_string(),
1037            })
1038        }
1039    ),
1040    num: symbol!("num"),
1041    den: symbol!(
1042        "denom",
1043        der = |_, arg, out| {
1044            if arg != 3 {
1045                **out = Atom::Zero;
1046            } else {
1047                **out = Atom::num(1);
1048            }
1049        }
1050    ),
1051    ubar: symbol!(
1052        "ubar",
1053        print = |a, opt, _state| {
1054            spenso_print_scripted_indexed!(a, opt, "u̅", "overline(u)")
1055        },
1056        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1057    ),
1058    vbar: symbol!(
1059        "vbar",
1060        print = |a, opt, _state| {
1061            spenso_print_scripted_indexed!(a, opt, "v̅", "overline(v)")
1062        },
1063        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1064    ),
1065    dot: symbol!("dot"),
1066    dim: symbol!("dim"),
1067    v: symbol!(
1068        "v",
1069        print = |a, opt, _state| { spenso_print_scripted_indexed!(a, opt, "v") },
1070        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1071    ),
1072    u: symbol!(
1073        "u",
1074        print = |a, opt, _state| { spenso_print_scripted_indexed!(a, opt, "u") },
1075        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1076    ),
1077    emr_mom: symbol!(
1078        "Q",
1079        print = |a, opt, _state| { spenso_print_scripted_indexed!(a, opt, "q") },
1080        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1081    ),
1082    uv_momentum_provenance: symbol!("gammalooprs::uv::momentum_provenance"),
1083    uv_class: symbol!("gammalooprs::uv::class"),
1084    orientation_delta: symbol!("orientation_delta"),
1085    emr_vec: symbol!(
1086        "Q3",
1087        norm = |f, out| {
1088            if let AtomView::Fun(ff) = f
1089                && ff.get_nargs() == 2
1090            {
1091                let mut iter = ff.iter();
1092                let eid = iter.next().unwrap();
1093                let structure = iter.next().unwrap();
1094                let component = if let AtomView::Fun(cind) = structure
1095                    && cind.get_symbol() == AIND_SYMBOLS.cind
1096                    && cind.get_nargs() == 1
1097                {
1098                    cind.iter()
1099                        .next()
1100                        .and_then(|i| i64::try_from(i).ok())
1101                        .and_then(|i| usize::try_from(i).ok())
1102                } else {
1103                    None
1104                };
1105                if i64::try_from(eid).is_ok_and(|eid| eid >= 0)
1106                    && let Some(i @ 0..=3) = component
1107                {
1108                    if i == 0 {
1109                        **out = Atom::Zero;
1110                    } else {
1111                        let cind = AIND_SYMBOLS.cind.call_args([i as i64]);
1112                        **out = get_symbol!("Q")
1113                            .unwrap()
1114                            .call_args([eid, cind.as_view()])
1115                    }
1116                }
1117            }
1118        },
1119        print = |a, opt, _state| {
1120            spenso_print_scripted_indexed!(a, opt, "q⃗", "arrow(q)")
1121        },
1122        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1123    ),
1124    ose: symbol!(
1125        "OSE"; Scalar;
1126        print = |a, opt, _state| {
1127            spenso_print_simple_indexed!(a, opt, "Eᵒˢ", r#"E^("os")"#)
1128        },
1129            der = |_, arg, out| {
1130                if arg == 1 {
1131                    **out = Atom::num(1);
1132                }
1133            }
1134    ),
1135    on_shell_energy: symbol!(
1136        "OnShellEnergy"; Scalar;
1137        norm = |view, out| {
1138            if let AtomView::Fun(call) = view && call.get_nargs() == 2 {
1139                // Series reconstruction repeatedly normalizes identical energy
1140                // invariants. Keep a bounded cache per worker, with no borrow
1141                // held during symbolic operations or recursive normalization.
1142                thread_local! {
1143                    static CACHE: RefCell<(HashMap<Atom, Atom>, usize)> = RefCell::default();
1144                }
1145                let input = call.get(1);
1146                let cached = CACHE.with_borrow(|(cache, _)| cache.get(input.get_data()).cloned());
1147                // Canonicalize opposite routed momenta inside squared factors.
1148                // Distribute numeric coefficients only; keep the invariant's
1149                // momentum sums squared and graph numerators outside this call.
1150                let invariant = cached.unwrap_or_else(|| {
1151                    let invariant = input.replace_map(|part, _, output| {
1152                        if let AtomView::Pow(power) = part {
1153                            let (base, exponent) = power.get_base_exp();
1154                            if i64::try_from(exponent) == Ok(2) {
1155                                let direct = base.expand_num();
1156                                let reversed = (-&direct).expand_num();
1157                                **output = direct.min(reversed).pow(2);
1158                            }
1159                        }
1160                    }).expand_num();
1161                    let bytes = input.get_byte_size() + invariant.as_view().get_byte_size();
1162                    const MAX_BYTES: usize = 1024 * 1024;
1163                    if bytes <= MAX_BYTES {
1164                        CACHE.with_borrow_mut(|(cache, retained_bytes)| {
1165                            if cache.len() >= 1024 || *retained_bytes + bytes > MAX_BYTES {
1166                                cache.clear();
1167                                *retained_bytes = 0;
1168                            }
1169                            cache.insert(input.to_owned(), invariant.clone());
1170                            *retained_bytes += bytes;
1171                        });
1172                    }
1173                    invariant
1174                });
1175                if invariant.as_view() != input {
1176                    **out = call.get_symbol().call_args([call.get(0).to_owned(), invariant]);
1177                }
1178            }
1179        },
1180        der = |view, argument, out| {
1181            if let AtomView::Fun(call) = view && call.get_nargs() == 2 {
1182                **out = if argument == 1 {
1183                    Atom::num((1, 2)) / view
1184                } else {
1185                    Atom::Zero
1186                };
1187            }
1188        }
1189    ),
1190    esurface: symbol!(
1191        "Esurface"; Scalar;
1192        norm = |view, out| {
1193            if let AtomView::Fun(call) = view {
1194                let sum = Atom::add_many(call.iter());
1195                if let AtomView::Add(terms) = sum.as_view() {
1196                    // Pull explicit Laurent monomials outside the function
1197                    // before series expansion. Otherwise a function callback
1198                    // receives already truncated pole-valued arguments and can
1199                    // lose absolute precision when their leading terms cancel.
1200                    let mut poles = std::collections::BTreeMap::<Symbol, i64>::new();
1201                    let mut record = |factor: AtomView<'_>| {
1202                        if let AtomView::Pow(power) = factor
1203                            && let AtomView::Var(variable) = power.get_base()
1204                            && let Ok(exponent) = i64::try_from(power.get_exp())
1205                            && exponent < 0
1206                        {
1207                            poles.entry(variable.get_symbol())
1208                                .and_modify(|power| *power = (*power).min(exponent))
1209                                .or_insert(exponent);
1210                        }
1211                    };
1212                    for term in terms.iter() {
1213                        if let AtomView::Mul(product) = term {
1214                            product.iter().for_each(&mut record);
1215                        } else {
1216                            record(term);
1217                        }
1218                    }
1219                    if let Some((variable, exponent)) = poles.first_key_value() {
1220                        let factor = Atom::var(*variable).pow(*exponent);
1221                        let regular = call.get_symbol().call_args(
1222                            terms.iter().map(|term| term / factor.as_view())
1223                        );
1224                        **out = factor * regular;
1225                        return;
1226                    }
1227                    if !call.iter().eq(terms.iter()) {
1228                        **out = call.get_symbol().call_args(terms.iter());
1229                    }
1230                } else {
1231                    **out = sum;
1232                }
1233            }
1234        },
1235        der = |_, _, out| { **out = Atom::one(); },
1236        eval = symbolica::atom::EvaluationInfo::new()
1237            .register(|arguments: &[f64]| arguments.iter().sum::<f64>())
1238            .register(|arguments: &[symbolica::domains::float::Complex<symbolica::domains::float::Float>]| {
1239                arguments.iter().cloned().reduce(|left, right| left + right)
1240                    .expect("a retained CFF surface has at least two summands")
1241            })
1242    ),
1243    energy: symbol!(
1244        "E",
1245        print = |a, opt, _state| { spenso_print_simple_indexed!(a, opt, "E") }
1246    ),
1247    external_mom: symbol!(
1248        "P",
1249        print = |a, opt, _state| { spenso_print_scripted_indexed!(a, opt, "p") },
1250        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1251    ),
1252    loop_mom: symbol!(
1253        "K",
1254        print = |a, opt, _state| { spenso_print_scripted_indexed!(a, opt, "k") },
1255        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1256    ),
1257    epsilon: symbol!(
1258        "ϵ",
1259        print = |a, opt, _state| { spenso_print_scripted_indexed!(a, opt, "ϵ") },
1260        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1261    ),
1262    pi: Symbol::PI,
1263    color_wrap: symbol!("color"),
1264    epsilonbar: symbol!(
1265        "ϵbar",
1266        print = |a, opt, _state| {
1267            spenso_print_scripted_indexed!(a, opt, "ϵ̅", "overline(epsilon.alt)")
1268        },
1269        tags = [SPENSO_TAG.rank1.clone(), SPENSO_TAG.tensor.clone()]
1270    ),
1271    coeff: symbol!("coef"),
1272    radius_left: symbol!("r_left"),
1273    radius_star_left: symbol!("r⃰_left"),
1274    uv_damp_plus_left: symbol!("damp_plus_left"),
1275    uv_damp_minus_left: symbol!("damp_minus_left"),
1276    radius_right: symbol!("r_right"),
1277    radius_star_right: symbol!("r⃰_right"),
1278    uv_damp_plus_right: symbol!("damp_plus_right"),
1279    uv_damp_minus_right: symbol!("damp_minus_right"),
1280    thermal_distribution: three_dimensional_reps::symbols::thermal_distribution_symbol(),
1281    thermal_weight_wrapper: *three_dimensional_reps::symbols::THERMAL_WEIGHT_WRAPPER,
1282    inverse_temperature: symbol!("β"),
1283};
1284}
1285
1286impl GammaloopSymbols {
1287    pub fn wrap_tree_denoms<'a>(&self, arg: impl Into<AtomOrView<'a>>) -> Atom {
1288        self.tree_denom_wrapper.call_args([arg.into().as_view()])
1289    }
1290
1291    pub fn do_dot_product_in_sqrt<'a>(&self, arg: impl Into<AtomOrView<'a>>) -> Atom {
1292        let a = arg.into();
1293
1294        let mink = Minkowski {}.new_rep(4).to_symbolic([Atom::var(W_.i_)]);
1295
1296        let pat = function!(self.emr_vec, W_.a_, mink) * function!(self.emr_vec, W_.b_, mink);
1297        let rhs = function!(
1298            self.emr_mom,
1299            W_.a_,
1300            Atom::from(ExpandedIndex::from_iter([1]))
1301        ) * function!(
1302            self.emr_mom,
1303            W_.b_,
1304            Atom::from(ExpandedIndex::from_iter([1]))
1305        ) + function!(
1306            self.emr_mom,
1307            W_.a_,
1308            Atom::from(ExpandedIndex::from_iter([2]))
1309        ) * function!(
1310            self.emr_mom,
1311            W_.b_,
1312            Atom::from(ExpandedIndex::from_iter([2]))
1313        ) + function!(
1314            self.emr_mom,
1315            W_.a_,
1316            Atom::from(ExpandedIndex::from_iter([3]))
1317        ) * function!(
1318            self.emr_mom,
1319            W_.b_,
1320            Atom::from(ExpandedIndex::from_iter([3]))
1321        );
1322
1323        let a = a.replace(pat).with(rhs);
1324
1325        let pat = function!(self.emr_vec, W_.a_, mink).pow(2);
1326        let rhs = function!(
1327            self.emr_mom,
1328            W_.a_,
1329            Atom::from(ExpandedIndex::from_iter([1]))
1330        )
1331        .pow(2)
1332            + function!(
1333                self.emr_mom,
1334                W_.a_,
1335                Atom::from(ExpandedIndex::from_iter([2]))
1336            )
1337            .pow(2)
1338            + function!(
1339                self.emr_mom,
1340                W_.a_,
1341                Atom::from(ExpandedIndex::from_iter([3]))
1342            )
1343            .pow(2);
1344        // * GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([1])))
1345        // + GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([2])))
1346        //     * GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([2])))
1347        // + GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([3])))
1348        //     * GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([3])));
1349
1350        // let dot = self.emr_vec(e, mink.to_atom()) * self.emr_vec(e, mink.to_atom())
1351        a.replace(pat).with(rhs)
1352
1353        // a.replace_map(|a, ctx, out| {
1354        //     if let AtomView::Pow(p) = a {
1355        //         let (a, b) = p.get_base_exp();
1356        //         let Ok(exp) = Rational::try_from(b) else {
1357        //             return;
1358        //         };
1359
1360        //         if exp.denominator() == Integer::from(2) {
1361        //             **out = self
1362        //                 .broadcasting_sqrt
1363        //                 .call_args([a])
1364        //                 .pow(Atom::num(exp.numerator()));
1365        //         }
1366        //     }
1367        // });
1368    }
1369
1370    pub fn to_broadcasting_sqrt<'a>(&self, arg: impl Into<AtomOrView<'a>>) -> Atom {
1371        let a = arg.into();
1372        a.replace_map(|a, _ctx, out| {
1373            if let AtomView::Pow(p) = a {
1374                let (a, b) = p.get_base_exp();
1375                let Ok(exp) = Rational::try_from(b) else {
1376                    return;
1377                };
1378
1379                if exp.denominator() == 2 {
1380                    **out = self
1381                        .broadcasting_sqrt
1382                        .call_args([a])
1383                        .pow(Atom::num(exp.numerator()));
1384                }
1385            }
1386        })
1387    }
1388
1389    pub(crate) fn heaviside<'a>(&self, arg: impl Into<AtomOrView<'a>>) -> Atom {
1390        let a = arg.into();
1391        function!(self.heaviside, a.as_view())
1392    }
1393
1394    pub(crate) fn emr_mom<'a>(&self, e: EdgeIndex, arg: impl Into<AtomOrView<'a>>) -> Atom {
1395        let a = arg.into();
1396        function!(self.emr_mom, usize::from(e) as i64, a.as_view())
1397    }
1398
1399    pub(crate) fn uv_momentum_provenance_tag<'a>(
1400        &self,
1401        edge: impl Into<AtomOrView<'a>>,
1402        role: impl Into<UvMomentumProvenanceRole>,
1403        momentum: impl Into<AtomOrView<'a>>,
1404    ) -> Atom {
1405        let role = role.into();
1406        self.uv_momentum_provenance.call_args([
1407            edge.into().as_view(),
1408            Atom::num(role as i64).as_view(),
1409            momentum.into().as_view(),
1410        ])
1411    }
1412
1413    pub(crate) fn uv_momentum_provenance_data(
1414        &self,
1415        argument: AtomView<'_>,
1416    ) -> Option<(EdgeIndex, UvMomentumProvenanceRole, Atom)> {
1417        let AtomView::Fun(provenance) = argument else {
1418            return None;
1419        };
1420        if provenance.get_symbol() != self.uv_momentum_provenance || provenance.get_nargs() != 3 {
1421            return None;
1422        }
1423        let edge = EdgeIndex(usize::try_from(provenance.get(0)).ok()?);
1424        let role = match i64::try_from(provenance.get(1)).ok()? {
1425            0 => UvMomentumProvenanceRole::TaylorFixed,
1426            1 => UvMomentumProvenanceRole::DenominatorDerived,
1427            3 => UvMomentumProvenanceRole::PhysicalSourceFixed,
1428            4 => UvMomentumProvenanceRole::DenominatorDerivedSoft,
1429            _ => return None,
1430        };
1431        Some((edge, role, provenance.get(2).to_owned()))
1432    }
1433
1434    pub(crate) fn uv_class_ref(&self, class: UvDenominatorClassId) -> Atom {
1435        self.uv_class.call(class.0)
1436    }
1437
1438    pub(crate) fn uv_class_data(&self, argument: AtomView<'_>) -> Option<UvDenominatorClassId> {
1439        let AtomView::Fun(class) = argument else {
1440            return None;
1441        };
1442        (class.get_symbol() == self.uv_class && class.get_nargs() == 1)
1443            .then(|| usize::try_from(class.get(0)).ok().map(UvDenominatorClassId))
1444            .flatten()
1445    }
1446
1447    /// Attach the same component indices to every vector in a linear momentum.
1448    pub(crate) fn indexed_momentum(&self, momentum: &Atom, indices: &[Atom]) -> Atom {
1449        momentum.replace_map(|view, _, output| {
1450            let AtomView::Fun(vector) = view else {
1451                return;
1452            };
1453            if vector.get_symbol() == self.emr_mom && vector.get_nargs() == 1 {
1454                let mut component = FunctionBuilder::new(self.emr_mom).add_arg(vector.get(0));
1455                for index in indices {
1456                    component = component.add_arg(index);
1457                }
1458                **output = component.finish();
1459            }
1460        })
1461    }
1462
1463    /// Remove only the UV Taylor provenance from EMR tags. This is used at
1464    /// algebraic boundaries such as Vakint and by exact test oracles; the local
1465    /// 4D-to-CFF route deliberately retains the tag until energy ownership has
1466    /// been planned.
1467    pub(crate) fn erase_uv_momentum_provenance(&self, atom: &Atom) -> Atom {
1468        atom.replace_map(|view, _, output| {
1469            let AtomView::Fun(momentum) = view else {
1470                return;
1471            };
1472            if momentum.get_symbol() != self.emr_mom || momentum.get_nargs() < 1 {
1473                return;
1474            }
1475            let AtomView::Fun(provenance) = momentum.get(0) else {
1476                return;
1477            };
1478            if provenance.get_symbol() != self.uv_momentum_provenance || provenance.get_nargs() != 3
1479            {
1480                return;
1481            }
1482            let indices = momentum
1483                .iter()
1484                .skip(1)
1485                .map(|index| index.to_owned())
1486                .collect::<Vec<_>>();
1487            **output = self.indexed_momentum(&provenance.get(2).to_owned(), &indices);
1488        })
1489    }
1490
1491    pub(crate) fn localizing_integrand(&self, lmb: &LoopMomentumBasis) -> Atom {
1492        // Normalize each factor with int d^3k / (|k|^2 + rls^2)^2 = pi^2 / rls,
1493        // so the localizing integrand itself integrates to one.
1494        let pi_atom = (Symbol::PI).to_atom();
1495        let normalization_term_integral = (pi_atom.pow(2)) / GS.renormalization_localization_scale;
1496
1497        let mut res = Atom::one();
1498
1499        for l in lmb.loop_edges.iter() {
1500            // Orientation ownership is applied by `Localizer`: orientation-local 3D uses one
1501            // deterministic valid host, while selector-free 3D and local 4D explicitly sum their
1502            // source orientations. It is therefore not an extra factor in this normalized radial
1503            // kernel.
1504            res /= normalization_term_integral.as_view();
1505
1506            let spatial_norm_sq = function!(self.emr_mom, l.0, GS.cind(1)).pow(2)
1507                + function!(self.emr_mom, l.0, GS.cind(2)).pow(2)
1508                + function!(self.emr_mom, l.0, GS.cind(3)).pow(2);
1509
1510            // Per-orientation CFF localizer of the normalized cubic tadpole.
1511            let denominator = spatial_norm_sq
1512                + GS.renormalization_localization_scale * GS.renormalization_localization_scale;
1513            res /= denominator.as_view() * denominator.as_view();
1514        }
1515
1516        if res.is_one() {
1517            res
1518        } else {
1519            function!(self.localizing_integrand, res)
1520        }
1521    }
1522
1523    /// Construct the canonical represented or indexed spatial momentum `Q3(e, structure)`.
1524    ///
1525    /// `e` is a non-negative graph edge ID. Stored abstract vectors use a
1526    /// four-dimensional Minkowski representation. Tensor execution transiently
1527    /// emits `Q3(e,cind(i))`; the symbol normalizer immediately maps that image
1528    /// to `Q(e,cind(i))` (or zero for the temporal component), so it is never a
1529    /// persistent second convention. An indexed Minkowski argument still carries
1530    /// an abstract tensor index and must not be interpreted as a component. The
1531    /// trailing argument may instead be one wildcard when constructing a
1532    /// replacement pattern. This API deliberately has no bare `Q3(e)` form.
1533    pub(crate) fn emr_vec<'a>(&self, e: EdgeIndex, structure: impl Into<AtomOrView<'a>>) -> Atom {
1534        function!(
1535            GS.emr_vec,
1536            usize::from(e) as i64,
1537            structure.into().as_view()
1538        )
1539    }
1540
1541    pub(crate) fn cind(&self, e: usize) -> Atom {
1542        AIND_SYMBOLS.cind.call_args([e as i64])
1543    }
1544
1545    pub(crate) fn energy_delta<'a>(&self, index: impl Into<AtomOrView<'a>>) -> Atom {
1546        self.delta_vec
1547            .call_args([self.cind(0), index.into().into_owned()])
1548    }
1549
1550    /// Keep a surface or one of its Taylor coefficients opaque to scalar
1551    /// factor algebra, without hiding any physical dependency behind an ID.
1552    pub(crate) fn wrap_esurface(&self, sum: &Atom) -> Atom {
1553        if let AtomView::Add(terms) = sum.as_view() {
1554            self.esurface.call_args(terms.iter())
1555        } else {
1556            sum.clone()
1557        }
1558    }
1559
1560    /// Apply only to the denominator-only CFF, before attaching a numerator.
1561    pub(crate) fn retain_esurfaces(&self, cff: &Atom) -> Atom {
1562        cff.replace_map(|view, _, out| {
1563            if let AtomView::Pow(power) = view {
1564                let (base, exponent) = power.get_base_exp();
1565                if matches!(base, AtomView::Add(_))
1566                    && i64::try_from(exponent).is_ok_and(|power| power < 0)
1567                    && (base.contains_symbol(self.ose) || base.contains_symbol(self.energy))
1568                {
1569                    **out = self.wrap_esurface(&base.to_owned()).pow(exponent);
1570                }
1571            }
1572        })
1573    }
1574
1575    /// Lower on an evaluator or diagnostic copy, after all forest operations.
1576    /// This unfolds sums only; it never distributes a numerator product.
1577    pub(crate) fn lower_esurfaces(&self, atom: &Atom) -> Atom {
1578        if !atom.contains_symbol(self.esurface) {
1579            return atom.clone();
1580        }
1581        atom.replace_map_bottom_up(|view, _, out| {
1582            if let AtomView::Fun(call) = view
1583                && call.get_symbol() == self.esurface
1584            {
1585                **out = Atom::add_many(call.iter());
1586            }
1587        })
1588    }
1589
1590    pub(crate) fn ose_full(
1591        &self,
1592        e: EdgeIndex,
1593        lmb_id: EdgeIndex,
1594        e_mass: Atom,
1595        index: Option<Atom>,
1596    ) -> Atom {
1597        let ose = function!(
1598            self.on_shell_energy,
1599            lmb_id.0,
1600            e_mass.pow(2)
1601                + self.emr_vec(e, self.cind(1)).pow(2)
1602                + self.emr_vec(e, self.cind(2)).pow(2)
1603                + self.emr_vec(e, self.cind(3)).pow(2)
1604        );
1605
1606        if let Some(index) = index {
1607            ose * self.energy_delta(index)
1608        } else {
1609            ose
1610        }
1611    }
1612
1613    pub(crate) fn split_mom_pattern(
1614        &self,
1615        e: EdgeIndex,
1616        lmb_id: EdgeIndex,
1617        e_mass: Atom,
1618    ) -> Replacement {
1619        let id = Minkowski {}.to_symbolic([W_.a__]);
1620        Replacement::new(
1621            self.emr_mom(e, &id).to_pattern(),
1622            self.emr_vec(e, &id) + self.ose_full(e, lmb_id, e_mass, Some(id)) * sign_atom(e),
1623        )
1624    }
1625
1626    /// Split Q(i,mu) into its spatial part and the already-generated on-shell
1627    /// energy, with the production sign attached to the time component. The
1628    /// typed 4D CFF source owns mass-dependent on-shell reconstruction before
1629    /// this late numerator/export boundary. The split is
1630    /// `Q(i,mink(4,mu)) -> Q3(i,mink(4,mu)) + OSE(i)σ(i)δ(cind(0),mink(4,mu))`.
1631    pub fn split_mom_pattern_simple(&self, e: EdgeIndex) -> Replacement {
1632        let eidc = usize::from(e) as i64;
1633        let index = Minkowski {}.to_symbolic([W_.a__]);
1634        Replacement::new(
1635            self.emr_mom(e, &index).to_pattern(),
1636            function!(GS.ose, eidc) * sign_atom(e) * self.energy_delta(&index)
1637                + function!(GS.emr_vec, eidc, &index),
1638        )
1639    }
1640
1641    pub(crate) fn ose(&self, e: EdgeIndex) -> Atom {
1642        function!(GS.ose, usize::from(e) as i64)
1643    }
1644}
1645
1646pub(crate) fn sign_atom(eid: EdgeIndex) -> Atom {
1647    FunctionBuilder::new(symbol!("σ"))
1648        .add_arg(usize::from(eid) as i64)
1649        .finish()
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654    use insta::assert_snapshot;
1655    use spenso::shadowing::symbolica_utils::LogPrint;
1656
1657    use super::*;
1658
1659    #[test]
1660    fn esurface_preserves_momentum_and_mass_substitutions() {
1661        let t = symbol!("esurface_substitution_test::t"; Scalar);
1662        let m = symbol!("esurface_substitution_test::m"; Scalar);
1663        let q = GS.emr_vec(EdgeIndex(3), GS.cind(1));
1664        let shift = GS.emr_mom(EdgeIndex(0), GS.cind(0));
1665        let energy = function!(GS.on_shell_energy, 3, q.pow(2) + Atom::var(m).pow(2));
1666        let sum = energy + &shift;
1667        let surface = GS.wrap_esurface(&sum);
1668        assert!(surface.contains_symbol(GS.esurface));
1669        for scale in [Atom::var(t), Atom::var(t).pow(-1)] {
1670            let replacements = [
1671                Replacement::new(q.clone(), &q * &scale),
1672                Replacement::new(Atom::var(m), Atom::var(m) * &scale),
1673                Replacement::new(shift.clone(), &shift * &scale),
1674            ];
1675            let transformed = surface.replace_multiple(&replacements);
1676            assert!(transformed.contains_symbol(t));
1677            // Laurent normalization can factor a power of t out of the sum.
1678            // Check scalar algebraic equality, including the chain rule.
1679            assert!(
1680                (GS.lower_esurfaces(&transformed) - sum.replace_multiple(&replacements))
1681                    .expand()
1682                    .is_zero()
1683            );
1684            assert!(
1685                (GS.lower_esurfaces(&transformed.derivative(t))
1686                    - sum.replace_multiple(&replacements).derivative(t))
1687                .expand()
1688                .is_zero()
1689            );
1690        }
1691    }
1692
1693    #[test]
1694    fn esurface_retention_is_idempotent_and_preserves_energy_shifts() {
1695        let first = GS.ose(EdgeIndex(1));
1696        let second = GS.ose(EdgeIndex(2));
1697        let shift = GS.emr_mom(EdgeIndex(0), GS.cind(0));
1698        let cff = (&first + &second - &shift).pow(-2) / &first;
1699        let retained = GS.retain_esurfaces(&cff);
1700        assert!(retained.contains_symbol(GS.esurface));
1701        assert_eq!(GS.retain_esurfaces(&retained), retained);
1702        assert_eq!(GS.lower_esurfaces(&retained), cff);
1703        assert_eq!(
1704            GS.lower_esurfaces(&retained.replace(shift.to_pattern()).with(0)),
1705            cff.replace(shift.to_pattern()).with(0),
1706        );
1707    }
1708
1709    #[test]
1710    fn esurface_series_preserves_cancelling_and_singular_leading_terms() {
1711        use symbolica::poly::series::SeriesDepth;
1712
1713        let t = symbol!("esurface_laurent_test::t"; Scalar);
1714        let x = Atom::var(t);
1715        for sum in [
1716            (Atom::one() + &x).pow((1, 2)) - 1,
1717            (Atom::one() + &x).pow((1, 2)) - 1 - &x / 2,
1718            &x + x.pow(-1),
1719            (Atom::one() + &x).pow((1, 2)) / &x - x.pow(-1) - Atom::num((1, 2)),
1720            (Atom::one() + &x).pow((1, 2)) / x.pow(2) - x.pow(-2) - Atom::num((1, 2)) / &x
1721                + Atom::num((1, 8)),
1722            (Atom::one() + &x).pow((1, 2)) / &x - x.pow(-1),
1723        ] {
1724            let wrapped = GS.wrap_esurface(&sum);
1725            assert!((GS.lower_esurfaces(&wrapped) - &sum).expand().is_zero());
1726            for power in [-2, -1, 1, 2] {
1727                let actual = wrapped
1728                    .pow(power)
1729                    .series(t, 0, SeriesDepth::absolute(2))
1730                    .unwrap();
1731                let expected = sum
1732                    .pow(power)
1733                    .series(t, 0, SeriesDepth::absolute(2))
1734                    .unwrap();
1735                assert_eq!(actual.absolute_order(), expected.absolute_order());
1736                // Denominator-only identity: no graph numerator is distributed.
1737                assert!(
1738                    (GS.lower_esurfaces(&actual.to_atom()) - expected.to_atom())
1739                        .expand()
1740                        .is_zero()
1741                );
1742            }
1743        }
1744    }
1745
1746    #[test]
1747    fn esurface_coefficients_accept_an_enclosing_momentum_deformation() {
1748        use symbolica::poly::series::SeriesDepth;
1749
1750        let t = symbol!("esurface_nested_test::t"; Scalar);
1751        let u = symbol!("esurface_nested_test::u"; Scalar);
1752        let q = symbol!("esurface_nested_test::q"; Scalar);
1753        let invariant = Atom::var(q).pow(2) + 1;
1754        let sum = (&invariant + Atom::var(t)).pow((1, 2)) + &invariant;
1755        let first = GS
1756            .wrap_esurface(&sum)
1757            .pow(-1)
1758            .series(t, 0, SeriesDepth::absolute(2))
1759            .unwrap()
1760            .to_atom();
1761        let expected_first = sum
1762            .pow(-1)
1763            .series(t, 0, SeriesDepth::absolute(2))
1764            .unwrap()
1765            .to_atom();
1766        assert!(first.contains_symbol(GS.esurface));
1767        let deform = |atom: Atom| atom.replace(q).with(Atom::var(u) + 2);
1768        let actual = deform(first)
1769            .series(u, 0, SeriesDepth::absolute(1))
1770            .unwrap()
1771            .to_atom();
1772        let expected = deform(expected_first)
1773            .series(u, 0, SeriesDepth::absolute(1))
1774            .unwrap()
1775            .to_atom();
1776        assert!((GS.lower_esurfaces(&actual) - expected).expand().is_zero());
1777    }
1778
1779    #[test]
1780    fn compact_energy_derivatives_keep_shift_and_mass_dependence() {
1781        crate::initialisation::test_initialise().unwrap();
1782        let (t, u, m, k, p, q) = symbol!(
1783            "compact_energy_test::t",
1784            "compact_energy_test::u",
1785            "compact_energy_test::m",
1786            "compact_energy_test::k",
1787            "compact_energy_test::p",
1788            "compact_energy_test::q"
1789        );
1790        let momentum = Atom::var(k) + Atom::var(t) * p + Atom::var(u) * q;
1791        let transverse = Atom::var(t) * q;
1792        let longitudinal = Atom::var(u) * p;
1793        let energy = function!(
1794            GS.on_shell_energy,
1795            3,
1796            Atom::var(m).pow(2) + momentum.pow(2) + transverse.pow(2) + longitudinal.pow(2)
1797        );
1798        assert_eq!(
1799            energy,
1800            function!(
1801                GS.on_shell_energy,
1802                3,
1803                Atom::var(m).pow(2)
1804                    + (-&momentum).expand_num().pow(2)
1805                    + (-&transverse).expand_num().pow(2)
1806                    + (-&longitudinal).expand_num().pow(2)
1807            )
1808        );
1809        assert_eq!(energy.derivative(m), Atom::var(m) / &energy);
1810        assert!(
1811            (energy.derivative(t) - (&momentum * p + &transverse * q) / &energy)
1812                .expand()
1813                .is_zero()
1814        );
1815        assert!(
1816            (energy.derivative(u) - (momentum * q + longitudinal * p) / &energy)
1817                .expand()
1818                .is_zero()
1819        );
1820        assert!(energy.contains_symbol(t) && energy.contains_symbol(u));
1821
1822        let lower = |atom: Atom| {
1823            atom.replace(function!(GS.on_shell_energy, W_.a_, W_.prop_))
1824                .with(Atom::var(W_.prop_).pow((1, 2)))
1825        };
1826        let specialize = |atom: Atom| {
1827            atom.replace(m)
1828                .with(3)
1829                .replace(k)
1830                .with(4)
1831                .replace(p)
1832                .with(2)
1833                .replace(q)
1834                .with(1)
1835        };
1836        // Positive mass and a nonzero routed spatial momentum fix the same
1837        // energy branch in both independent constructions.
1838        for exponent in [-3, -1, 1, 2, 3] {
1839            let compact = specialize(energy.pow(exponent));
1840            let explicit = lower(compact.clone());
1841            let actual = compact
1842                .series(t, Atom::Zero, 3)
1843                .unwrap()
1844                .to_atom()
1845                .series(u, Atom::Zero, 2)
1846                .unwrap()
1847                .to_atom();
1848            let expected = explicit
1849                .series(t, Atom::Zero, 3)
1850                .unwrap()
1851                .to_atom()
1852                .series(u, Atom::Zero, 2)
1853                .unwrap()
1854                .to_atom();
1855            // Only this small scalar energy oracle is distributed; graph
1856            // numerators never enter this comparison.
1857            assert!(
1858                (lower(actual) - expected).expand().is_zero(),
1859                "power={exponent}"
1860            );
1861        }
1862    }
1863
1864    #[test]
1865    fn compact_energy_invariant_matches_high_order_laurent_coefficients() {
1866        crate::initialisation::test_initialise().unwrap();
1867        let t = symbol!("compact_energy_laurent_test::t");
1868        let parameter = Atom::var(t);
1869        // Three independently shifted components feed one differentiated
1870        // invariant. This reproduces the order-six probe needed by a t^-6 body.
1871        let squared = (&parameter + 1).pow(2)
1872            + (&parameter * 2 + 2).pow(2)
1873            + (Atom::num(2) - &parameter).pow(2);
1874        let energy = function!(GS.on_shell_energy, 3, &squared);
1875        let actual = (energy / parameter.pow(6))
1876            .series(t, Atom::Zero, 0)
1877            .unwrap();
1878        let expected = (squared.pow((1, 2)) / parameter.pow(6))
1879            .series(t, Atom::Zero, 0)
1880            .unwrap();
1881        assert_eq!(actual.terms().count(), 7);
1882        // Native square-root series can include zero half-integer slots.
1883        // Compare the exact polynomials rather than zipping their storage grids.
1884        assert_eq!(
1885            actual
1886                .to_atom()
1887                .replace(function!(GS.on_shell_energy, 3, 9))
1888                .with(3),
1889            expected.to_atom()
1890        );
1891    }
1892
1893    #[test]
1894    fn compact_energy_normalization_preserves_owners_after_cache_eviction() {
1895        crate::initialisation::test_initialise().unwrap();
1896        let t = symbol!("compact_energy_cache_test::t");
1897        let parameter = Atom::var(t);
1898        let invariant = ((&parameter + 1).pow(2) + 3).pow(2) + 4;
1899        let cold = function!(GS.on_shell_energy, 3, &invariant);
1900        let other_owner = function!(GS.on_shell_energy, 7, &invariant);
1901        let AtomView::Fun(cold_call) = cold.as_view() else {
1902            panic!("Expected an owned energy function");
1903        };
1904        let AtomView::Fun(other_call) = other_owner.as_view() else {
1905            panic!("Expected an owned energy function");
1906        };
1907        assert_eq!(other_call.get(0), Atom::num(7).as_view());
1908        assert_eq!(cold_call.get(1), other_call.get(1));
1909        assert_ne!(cold, other_owner);
1910
1911        // More than the entry budget evicts earlier invariants. Eviction must
1912        // only repeat normalization, including for nested squared expressions.
1913        for offset in 0..1050 {
1914            let _ = function!(GS.on_shell_energy, 3, &invariant + offset);
1915        }
1916        assert_eq!(function!(GS.on_shell_energy, 3, &invariant), cold);
1917        assert_eq!(function!(GS.on_shell_energy, 7, &invariant), other_owner);
1918
1919        let lower = |atom: Atom| {
1920            atom.replace(function!(GS.on_shell_energy, W_.a_, W_.prop_))
1921                .with(Atom::var(W_.prop_).pow((1, 2)))
1922        };
1923        let expected = invariant
1924            .pow((1, 2))
1925            .series(t, Atom::Zero, 3)
1926            .unwrap()
1927            .to_atom();
1928        for energy in [cold, other_owner] {
1929            let actual = energy.series(t, Atom::Zero, 3).unwrap().to_atom();
1930            // The coefficients contain radicals: expansion alone need not
1931            // identify equivalent roots such as sqrt(1/5) and sqrt(5)/5.
1932            let (_, difference) = (lower(actual) - &expected)
1933                .to_polynomial_in_algebraic_extension::<u16>(t, &[])
1934                .unwrap();
1935            assert!(difference.is_zero());
1936        }
1937    }
1938
1939    #[test]
1940    fn orientation_collection_preserves_selector_free_factorization() {
1941        let (a, b, c, d, x) = symbol!(
1942            "selector_free_a",
1943            "selector_free_b",
1944            "selector_free_c",
1945            "selector_free_d",
1946            "selector_free_x"
1947        );
1948        let expression = (Atom::var(a) + b).pow(7) * (Atom::var(c) + d).pow(5)
1949            / (Atom::var(x) + Atom::one()).pow(2);
1950        assert_eq!(GS.collect_orientation_if(expression.clone()), expression);
1951        assert_eq!(GS.collect_orientation_if(expression.as_view()), expression);
1952    }
1953
1954    #[test]
1955    fn orientation_collection_merges_same_key_scalar_contributions() {
1956        let (key, a, b, c, d, x) = symbol!(
1957            "selector_merge_key",
1958            "selector_merge_a",
1959            "selector_merge_b",
1960            "selector_merge_c",
1961            "selector_merge_d",
1962            "selector_merge_x"
1963        );
1964        let condition = Atom::var(key) - 4;
1965        let bodies = [
1966            (Atom::var(a) + b).pow(3) * (Atom::var(c) + d).pow(2),
1967            Atom::var(x).pow(-1),
1968            Atom::var(c) + 7,
1969        ];
1970        // Unrelated siblings and other branch forms keep their own conditions.
1971        let unrelated = Atom::var(d)
1972            + Symbol::IF.call_args([Atom::var(key) - 9, Atom::Zero, Atom::var(x).pow(-2)])
1973            + Symbol::IF.call_args([condition.clone(), Atom::var(a), Atom::Zero])
1974            + Symbol::IF.call_args([condition.clone(), Atom::var(b), Atom::var(c)]);
1975        let expression = Atom::add_many(
1976            bodies
1977                .iter()
1978                .map(|body| Symbol::IF.call_args([condition.clone(), Atom::Zero, body.clone()])),
1979        ) + &unrelated;
1980        let expected =
1981            Symbol::IF.call_args([condition, Atom::Zero, Atom::add_many(bodies)]) + unrelated;
1982        let collected = GS.collect_orientation_if(expression);
1983        assert_eq!(collected, expected);
1984        assert_eq!(GS.collect_orientation_if(collected.clone()), collected);
1985    }
1986
1987    #[test]
1988    fn orientation_collection_merges_nested_same_key_contributions() {
1989        let (key, a, b, c, d, w) = symbol!(
1990            "nested_selector_key",
1991            "nested_selector_a",
1992            "nested_selector_b",
1993            "nested_selector_c",
1994            "nested_selector_d",
1995            "nested_selector_w"
1996        );
1997        let select = |offset: i64, body: Atom| {
1998            Symbol::IF.call_args([Atom::var(key) - offset, Atom::Zero, body])
1999        };
2000        // Same-key siblings inside a factorized sum merge where they are, and
2001        // a merged body merges the same-key siblings it now contains.
2002        let inner = select(1, select(2, Atom::var(a)))
2003            + select(1, select(2, Atom::var(b)))
2004            + select(3, Atom::var(c));
2005        let expression = Atom::var(w) * (Atom::var(d) + Atom::var(w) * inner)
2006            + select(1, Atom::var(c))
2007            + select(1, Atom::var(d));
2008        let expected = Atom::var(w)
2009            * (Atom::var(d)
2010                + Atom::var(w)
2011                    * (select(1, select(2, Atom::var(a) + b)) + select(3, Atom::var(c))))
2012            + select(1, Atom::var(c) + d);
2013        let collected = GS.collect_orientation_if(expression);
2014        assert_eq!(collected, expected);
2015        assert_eq!(GS.collect_orientation_if(collected.clone()), collected);
2016    }
2017
2018    #[test]
2019    fn orientation_collection_merges_weighted_factorized_contributions() {
2020        let (key, a, b, c, d, x, alias) = symbol!(
2021            "weighted_selector_key",
2022            "weighted_selector_a",
2023            "weighted_selector_b",
2024            "weighted_selector_c",
2025            "weighted_selector_d",
2026            "weighted_selector_x",
2027            "weighted_selector_alias"
2028        );
2029        let condition = Atom::var(key) - 4;
2030        let select = |body: Atom| Symbol::IF.call_args([condition.clone(), Atom::Zero, body]);
2031        let numerator = (Atom::var(a) + b).pow(3);
2032        let weight = (Atom::var(c) + d).pow(2);
2033        let handle = alias.call(7);
2034        let expression = select(numerator.clone()) * &weight
2035            + Atom::i() * &handle * select(Atom::var(x).pow(-1));
2036        let expected = select(numerator * weight + Atom::i() * &handle * Atom::var(x).pow(-1));
2037        let collected = GS.collect_orientation_if(expression);
2038        assert_eq!(collected, expected);
2039        assert!(collected.contains(&handle));
2040        assert_eq!(GS.collect_orientation_if(collected.clone()), collected);
2041    }
2042
2043    #[test]
2044    fn orientation_collection_reaches_nested_weighted_guards() {
2045        let (key, a, b, c, x, y, z, w) = symbol!(
2046            "nested_weighted_key",
2047            "nested_weighted_a",
2048            "nested_weighted_b",
2049            "nested_weighted_c",
2050            "nested_weighted_x",
2051            "nested_weighted_y",
2052            "nested_weighted_z",
2053            "nested_weighted_w"
2054        );
2055        let select = |body: Atom| Symbol::IF.call_args([Atom::var(key), Atom::Zero, body]);
2056        let expression = Atom::var(w) * (select(Atom::var(a)) * x + select(Atom::var(b)) * y)
2057            + select(Atom::var(c)) * z;
2058        let expected =
2059            select(Atom::var(w) * (Atom::var(a) * x + Atom::var(b) * y) + Atom::var(c) * z);
2060        let collected = GS.collect_orientation_if(expression);
2061        assert_eq!(collected, expected);
2062        assert_eq!(GS.collect_orientation_if(collected.clone()), collected);
2063    }
2064
2065    #[test]
2066    fn orientation_collection_preserves_distinct_weighted_conditions() {
2067        let (key, a, b, c, x, y, z) = symbol!(
2068            "distinct_weighted_key",
2069            "distinct_weighted_a",
2070            "distinct_weighted_b",
2071            "distinct_weighted_c",
2072            "distinct_weighted_x",
2073            "distinct_weighted_y",
2074            "distinct_weighted_z"
2075        );
2076        let select = |offset: i64, body: Atom| {
2077            Symbol::IF.call_args([Atom::var(key) - offset, Atom::Zero, body])
2078        };
2079        let ordinary = Symbol::IF.call_args([Atom::var(key), Atom::var(b), Atom::var(c)]);
2080        let expression = select(1, Atom::var(a)) * x + select(2, Atom::var(b)) * y + &ordinary * z;
2081        let expected = select(1, Atom::var(a) * x) + select(2, Atom::var(b) * y) + ordinary * z;
2082        let collected = GS.collect_orientation_if(expression);
2083        assert_eq!(collected, expected);
2084        assert_eq!(GS.collect_orientation_if(collected.clone()), collected);
2085    }
2086
2087    #[test]
2088    fn orientation_collection_preserves_literal_function_tags() {
2089        let (key, a, w, alias) = symbol!(
2090            "opaque_weighted_key",
2091            "opaque_weighted_a",
2092            "opaque_weighted_w",
2093            "opaque_weighted_alias"
2094        );
2095        let tag = Symbol::IF.call_args([Atom::var(key), Atom::Zero, Atom::var(a)]) * Atom::var(w);
2096        let handle = alias.call(tag);
2097        assert_eq!(GS.collect_orientation_if(handle.clone()), handle);
2098    }
2099
2100    #[test]
2101    fn orientation_collection_combines_same_key_guard_factors() {
2102        let (key, a, b, w) = symbol!(
2103            "guard_product_key",
2104            "guard_product_a",
2105            "guard_product_b",
2106            "guard_product_w"
2107        );
2108        let select = |body: Atom| Symbol::IF.call_args([Atom::var(key), Atom::Zero, body]);
2109        let expression = select(Atom::var(a)) * select(Atom::var(b)) * w;
2110        let expected = select(Atom::var(a) * b * w);
2111        let collected = GS.collect_orientation_if(expression);
2112        assert_eq!(collected, expected);
2113        assert_eq!(GS.collect_orientation_if(collected.clone()), collected);
2114    }
2115
2116    #[test]
2117    fn canonical_uv_class_references_are_distinct_from_physical_owners() {
2118        let class = UvDenominatorClassId(7);
2119        assert_eq!(
2120            GS.uv_class_data(GS.uv_class_ref(class).as_view()),
2121            Some(class)
2122        );
2123        assert_eq!(GS.uv_class_data(Atom::num(7).as_view()), None);
2124        assert_eq!(GS.uv_class_data(GS.uv_class.call(-1).as_view()), None);
2125        assert_eq!(
2126            GS.uv_class_data(
2127                GS.uv_class
2128                    .call_args([Atom::num(7), Atom::num(8)])
2129                    .as_view()
2130            ),
2131            None
2132        );
2133        let provenance = GS.uv_momentum_provenance_tag(
2134            7,
2135            UvMomentumProvenanceRole::TaylorFixed,
2136            GS.emr_mom.call(7),
2137        );
2138        assert_eq!(GS.uv_class_data(provenance.as_view()), None);
2139    }
2140
2141    #[test]
2142    fn test_print() {
2143        let p = GS.emr_mom(EdgeIndex(1), Atom::Zero);
2144
2145        assert_snapshot!(p.log_print(None),@"q₁(0)")
2146    }
2147
2148    #[test]
2149    fn typst_uses_structural_accents_and_scripts() {
2150        let expressions = [
2151            (
2152                GS.ubar.call_args([Atom::num(1), Atom::num(11)]),
2153                "overline(u)_1^(11)",
2154            ),
2155            (
2156                GS.vbar.call_args([Atom::num(2), Atom::num(12)]),
2157                "overline(v)_2^(12)",
2158            ),
2159            (
2160                GS.ose.call_args([Atom::num(3), Atom::num(13)]),
2161                r#"E^("os")_3(13)"#,
2162            ),
2163            (
2164                GS.emr_vec(EdgeIndex(4), Minkowski {}.new_rep(4).to_symbolic([])),
2165                "arrow(q)_4",
2166            ),
2167            (
2168                GS.epsilonbar.call_args([Atom::num(5), Atom::num(15)]),
2169                "overline(epsilon.alt)_5^(15)",
2170            ),
2171            (
2172                GS.emr_mom.call_args([Atom::num(6), Atom::num(16)]),
2173                "q_6^(16)",
2174            ),
2175        ];
2176
2177        for (expression, expected) in expressions {
2178            assert_eq!(
2179                expression
2180                    .printer(SpensoPrintSettings::typst_options())
2181                    .to_string(),
2182                expected
2183            );
2184        }
2185    }
2186
2187    #[test]
2188    fn typst_syntax_requires_real_typst_mode() {
2189        let mut symbolica = SpensoPrintSettings::typst().nice_symbolica();
2190        symbolica.color_builtin_symbols = false;
2191
2192        assert_eq!(
2193            GS.ubar
2194                .call_args([Atom::num(1), Atom::num(11)])
2195                .printer(symbolica.clone())
2196                .to_string(),
2197            "u̅₁^(11)"
2198        );
2199        assert_eq!(
2200            Atom::var(GS.m_uv_vacuum).printer(symbolica).to_string(),
2201            "mUV"
2202        );
2203
2204        assert_eq!(
2205            Atom::var(GS.m_uv_expansion)
2206                .printer(SpensoPrintSettings::typst_options())
2207                .to_string(),
2208            r#"m_"UVexp""#
2209        );
2210        assert_eq!(
2211            Atom::var(GS.m_uv_vacuum)
2212                .printer(SpensoPrintSettings::typst_options())
2213                .to_string(),
2214            r#"m_"UV""#
2215        );
2216        assert_eq!(
2217            Atom::var(GS.mu_r_sq)
2218                .printer(SpensoPrintSettings::typst_options())
2219                .to_string(),
2220            "mu_r^2"
2221        );
2222    }
2223}