Skip to main content

gammalooprs/graph/
feynman_graph.rs

1use std::{borrow::Borrow, ops::Deref};
2
3use itertools::Itertools;
4use linnet::half_edge::{
5    HedgeGraph, NodeIndex,
6    involution::{EdgeData, EdgeIndex, EdgeVec, Flow, HedgePair},
7    subgraph::{
8        HedgeNode, InternalSubGraph, ModifySubSet, OrientedCut, SuBitGraph, SubGraphLike,
9        SubSetLike, SubSetOps,
10    },
11};
12
13use spenso::{
14    algebra::{algebraic_traits::IsZero, complex::Complex},
15    structure::concrete_index::ExpandedIndex,
16};
17// use petgraph::Direction::Outgoing;
18use symbolica::{
19    atom::{Atom, AtomCore, AtomView, Indeterminate},
20    evaluate::{FunctionMap, InliningPolicy},
21    function,
22    id::Replacement,
23    symbol,
24    transcendental::{coth, csch, sech, tanh},
25};
26use three_dimensional_reps::{MediumMode, ThermalDistributionCall, ThermalDistributionFactor};
27use typed_index_collections::TiVec;
28
29use crate::{
30    cff::{generation::ShiftRewrite, surface::HsurfaceID},
31    integrands::process::param_builder::{FnMapEntry, ParamBuilderGraph, SplitPolarizations},
32    model::{ArcParticle, Model, UFOSymbol},
33    momentum::sample::{ExternalFourMomenta, ExternalIndex, LoopMomenta},
34    momentum::signature::{ExternalSignature, SignatureLike},
35    momentum::{PolDef, SignOrZero},
36    numerator::{graph::ReversibleEdge, ufo::UFO},
37    utils::{F, FloatLike, GS, external_energy_atom_from_index, ose_atom_from_index},
38    uv::uv_graph::UVE,
39};
40
41use super::{
42    Edge, Graph, HedgeData, LoopMomentumBasis, Vertex, edge::PossibleParticle,
43    get_cff_inverse_energy_product_impl,
44};
45
46pub trait FeynmanGraph {
47    fn get_emr_vec_cache<T: FloatLike>(
48        &self,
49        loop_moms: &LoopMomenta<F<T>>,
50        external_moms: &ExternalFourMomenta<F<T>>,
51        lmb: &LoopMomentumBasis,
52    ) -> Vec<F<T>>;
53
54    fn num_virtual_edges(&self, subgraph: SuBitGraph) -> usize;
55    fn is_incoming_to(&self, edge: EdgeIndex, vertex: NodeIndex) -> bool;
56    // fn denominator(&self, edge: EdgeIndex) -> (Atom, Atom);
57    // fn get_local_edge_position(
58    //     &self,
59    //     node_id: NodeIndex,
60    //     edge_id: EdgeIndex,
61    //     skip_one: bool,
62    // ) -> usize;
63    fn add_signs_to_edges(&self, node_id: NodeIndex) -> Vec<isize>;
64    fn get_cff_inverse_energy_product(&self) -> Atom;
65    fn get_loop_number(&self) -> usize;
66    fn get_real_mass_vector<T: FloatLike>(&self, model: &Model) -> EdgeVec<F<T>>;
67    fn get_external_masses<T: FloatLike>(&self, model: &Model) -> TiVec<ExternalIndex, F<T>>;
68    fn get_energy_cache<T: FloatLike>(
69        &self,
70        model: &Model,
71
72        loop_moms: &LoopMomenta<F<T>>,
73        external_moms: &ExternalFourMomenta<F<T>>,
74        lmb: &LoopMomentumBasis,
75    ) -> EdgeVec<F<T>>;
76    fn get_esurface_canonization(&self, lmb: &LoopMomentumBasis) -> Option<ShiftRewrite>;
77    fn external_in_or_out_signature(&self) -> ExternalSignature;
78
79    fn get_external_partcles(&self) -> Vec<ArcParticle>;
80    fn get_external_signature(&self) -> SignatureLike<ExternalIndex>;
81    fn get_energy_atoms(&self) -> Vec<Atom>;
82    // fn get_external_energy_atoms(&self) -> Vec<Atom>;
83    // fn explicit_ose_atom(&self, edge: EdgeIndex) -> Atom;
84    // fn get_ose_replacements(&self) -> Vec<Replacement>;
85    fn expected_scale(&self, e_cm: F<f64>, model: &Model) -> F<f64>;
86    fn dummy_list(&self) -> Vec<EdgeIndex>;
87    fn no_dummy(&self) -> SuBitGraph;
88    fn all_st_cuts_for_cs(
89        &self,
90        source_nodes: HedgeNode,
91        target_nodes: HedgeNode,
92        initial_state_tree: &SuBitGraph,
93    ) -> Vec<(SuBitGraph, OrientedCut, SuBitGraph)>;
94}
95
96impl Graph {
97    /// Thermal functions are needed only after a process selects a medium.
98    /// Replacing the mode also removes definitions from a previous generation.
99    pub(crate) fn set_medium_mode(&mut self, medium_mode: MediumMode) -> eyre::Result<()> {
100        if self.param_builder.medium_mode == medium_mode {
101            return Ok(());
102        }
103        let mut entries = self.param_builder.reps.iter().filter(|entry| {
104            !matches!(entry.lhs.as_view(), AtomView::Fun(f) if f.get_symbol() == GS.thermal_distribution)
105        }).cloned().collect_vec();
106        if medium_mode != MediumMode::Vacuum {
107            let thermal_sign = symbol!("thermal_sign");
108            let orientation_sign = symbol!("orientation_sign");
109            let thermal_edges = self
110                .iter_loop_edges()
111                .map(|(_, edge, _)| edge)
112                .collect_vec();
113            let max_order = if medium_mode.is_finite_temperature() {
114                // A common-pole cycle of n edges needs order n - 1. This graph-wide
115                // bound also covers chains formed by UV contraction. Keep the
116                // closed-form first two derivatives available for direct calls.
117                thermal_edges.len().saturating_sub(1).max(2)
118            } else {
119                // Positive zero-temperature orders belong to Fermi-surface
120                // extraction, including the vanishing derivatives of bosons and
121                // of particles without a chemical potential.
122                0
123            };
124            let temperature_flag = Atom::num(i64::from(medium_mode.is_finite_temperature()));
125            for edge in thermal_edges {
126                let energy = Indeterminate::try_from(ose_atom_from_index(edge)).unwrap();
127                let mut previous_body: Option<Atom> = None;
128                for derivative_order in 0..=max_order {
129                    let body = if derivative_order > 2 {
130                        // Reuse the preceding order rather than recomputing the
131                        // whole derivative chain for every registered order.
132                        previous_body
133                            .as_ref()
134                            .expect("lower derivative orders were generated")
135                            .derivative(&energy)
136                            .expand()
137                    } else {
138                        self.explicit_thermal_distribution_atom(
139                            edge,
140                            derivative_order,
141                            Atom::var(thermal_sign),
142                            Atom::var(orientation_sign),
143                            medium_mode,
144                        )?
145                    };
146                    // The contour offset is a tag, so each body folds its vacuum
147                    // weight symbolically and the s = -1 body is the bare occupation.
148                    // As a runtime argument, evaluator optimization may add the
149                    // constant to an exponentially small occupation before s.
150                    // Keep all offsets available to direct distribution calls,
151                    // including positive orders not emitted by CFF recursion.
152                    for contour in [-1, 0, 1] {
153                        let lhs = ThermalDistributionCall::atom(
154                            edge.0 as i64,
155                            derivative_order as i64,
156                            temperature_flag.clone(),
157                            contour,
158                            orientation_sign,
159                        );
160                        let AtomView::Fun(call) = lhs.as_view() else {
161                            unreachable!("thermal distribution builder returns a function");
162                        };
163                        // Function-map tags are the call prefix before its one
164                        // runtime argument, the physical orientation.
165                        let tags = call
166                            .iter()
167                            .take(call.get_nargs() - 1)
168                            .map(|arg| arg.to_owned())
169                            .collect();
170                        entries.push(FnMapEntry {
171                            inlining: InliningPolicy::Always,
172                            is_alias: false,
173                            lhs,
174                            rhs: body.replace(thermal_sign).with(Atom::num(contour)),
175                            args: vec![orientation_sign.into()],
176                            tags,
177                        });
178                    }
179                    previous_body = Some(body);
180                }
181            }
182        }
183        let mut function_map = FunctionMap::new();
184        for entry in &entries {
185            entry
186                .register(&mut function_map)
187                .map_err(|error| eyre::eyre!(error))?;
188        }
189        self.param_builder.fn_map = function_map;
190        self.param_builder.reps = entries;
191        self.param_builder.medium_mode = medium_mode;
192        Ok(())
193    }
194
195    /// Check the thermal domain after refreshing model parameter slots, including DOT mass overrides.
196    /// Vacuum graphs do not consume temperature or chemical potentials.
197    pub(crate) fn validate_medium_parameters(
198        &self,
199        model: &Model,
200        inverse_temperature: f64,
201    ) -> eyre::Result<()> {
202        let parameters = &self.param_builder;
203        let mode = parameters.medium_mode;
204        if mode == MediumMode::Vacuum {
205            return Ok(());
206        }
207        if mode.is_finite_temperature()
208            && (!inverse_temperature.is_finite() || inverse_temperature <= 0.0)
209        {
210            return Err(eyre::eyre!(
211                "Thermal graph '{}' requires finite positive runtime.general.inverse_temperature; got {inverse_temperature}",
212                self.name
213            ));
214        }
215        for (_, edge_id, edge) in self.iter_loop_edges() {
216            if edge.data.is_dummy {
217                continue;
218            }
219            let chemical_potential = edge.data.chemical_potential_atom();
220            let mu_value = |potential: Option<&Atom>| -> eyre::Result<Complex<F<f64>>> {
221                let mu = match potential {
222                    Some(atom) => parameters
223                        .pairs
224                        .model_parameters
225                        .params
226                        .iter()
227                        .zip(parameters.model_values())
228                        .find_map(|(parameter, value)| (parameter == atom).then_some(*value))
229                        .ok_or_else(|| {
230                            eyre::eyre!(
231                                "Thermal graph '{}' has no value for chemical potential {atom} on edge {edge_id}",
232                                self.name
233                            )
234                        })?,
235                    None => Complex::new_re(F(0.0)),
236                };
237                if !mu.im.is_zero() || !mu.re.0.is_finite() {
238                    return Err(eyre::eyre!(
239                        "Thermal graph '{}' requires a finite real chemical potential on edge {edge_id}; got {mu}",
240                        self.name
241                    ));
242                }
243                Ok(mu)
244            };
245            let mu = mu_value(chemical_potential.as_ref())?;
246            // Reversing an edge conjugates its label and flips its orientation.
247            // The occupation's E - sigma * mu must describe the same state.
248            if let Some(particle) = edge.data.particle() {
249                if particle.is_self_antiparticle() {
250                    if !mu.re.is_zero() {
251                        return Err(eyre::eyre!(
252                            "Thermal graph '{}' requires zero chemical potential for self-conjugate particle '{}' on edge {edge_id}; got {mu}",
253                            self.name,
254                            particle.name
255                        ));
256                    }
257                } else {
258                    let antiparticle =
259                        PossibleParticle::from(model.try_get_particle(&particle.antiname)?);
260                    let anti_mu = mu_value(antiparticle.chemical_potential_atom().as_ref())?;
261                    if anti_mu.re != -mu.re {
262                        return Err(eyre::eyre!(
263                            "Thermal graph '{}' requires opposite chemical potentials for particle '{}' and antiparticle '{}' on edge {edge_id}; got {mu} and {anti_mu}",
264                            self.name,
265                            particle.name,
266                            particle.antiname
267                        ));
268                    }
269                }
270            }
271            // Finite-temperature occupations and zero-temperature fermion steps,
272            // including any Fermi surface they localize, need a real shell energy.
273            let needs_real_mass = mode.is_finite_temperature() || chemical_potential.is_some();
274            if edge.data.is_fermion() && !needs_real_mass {
275                continue;
276            }
277            let mass = match edge.data.mass_value::<f64>(model, parameters) {
278                Some(mass) => mass,
279                None if edge.data.mass_atom().is_zero() => Complex::new_re(F(0.0)),
280                None => {
281                    return Err(eyre::eyre!(
282                        "Thermal graph '{}' has no value for mass '{}' on edge {edge_id}",
283                        self.name,
284                        edge.data.mass_atom()
285                    ));
286                }
287            };
288            if edge.data.is_fermion() {
289                if !mass.im.is_zero() || !mass.re.0.is_finite() {
290                    return Err(eyre::eyre!(
291                        "Thermal graph '{}' requires a finite real fermion mass for thermal occupations on edge {edge_id}; got {mass}",
292                        self.name
293                    ));
294                }
295                continue;
296            }
297            if !mass.im.is_zero() || !mass.re.0.is_finite() || mass.re.0 < 0.0 {
298                return Err(eyre::eyre!(
299                    "Thermal graph '{}' requires a finite nonnegative real boson mass on edge {edge_id}; got {mass}",
300                    self.name
301                ));
302            }
303            // Massless bosons at zero chemical potential retain their infrared endpoint.
304            // Massive saturation would require condensate support, which is not implemented.
305            if !(mass.re.is_zero() && mu.re.is_zero()) && mu.re.0.abs() >= mass.re.0 {
306                return Err(eyre::eyre!(
307                    "Thermal graph '{}' requires |mu| < m for bosons, except m = mu = 0; edge {edge_id} has mu = {} and m = {} (boson condensation is unsupported)",
308                    self.name,
309                    mu.re,
310                    mass.re
311                ));
312            }
313        }
314        Ok(())
315    }
316
317    /// Reject mixed-sign denominator surfaces that vanish for every loop momentum,
318    /// since they divide by zero at every sample. Generation replaces coinciding poles
319    /// by derivatives only for edges sharing a mass parameter and momentum routing, so
320    /// distinct mass parameters with equal values up to sign, or exceptional external
321    /// momenta, leave such a surface behind. Check after refreshing model parameter slots.
322    pub(crate) fn validate_denominator_hsurfaces(
323        &self,
324        model: &Model,
325        externals: &ExternalFourMomenta<F<f64>>,
326        denominators: &[HsurfaceID],
327    ) -> eyre::Result<()> {
328        let masses = self.new_edgevec(|edge, _, _| {
329            edge.mass_value::<f64>(model, &self.param_builder)
330                .or_else(|| edge.mass_atom().is_zero().then(|| Complex::new_re(F(0.0))))
331        });
332        for &id in denominators {
333            let surface = &self.surface_cache.hsurface_cache[id];
334            if !surface.vanishes_identically(&self.loop_momentum_basis, &masses, externals)? {
335                continue;
336            }
337            let edges = surface
338                .positive_energies
339                .iter()
340                .chain(&surface.negative_energies)
341                .map(|&edge| match masses[edge] {
342                    Some(mass) => format!("{edge}: {} = {mass}", self[edge].mass_atom()),
343                    None => format!("{edge}: {}", self[edge].mass_atom()),
344                })
345                .join(", ");
346            return Err(eyre::eyre!(
347                "Graph '{}' divides by the surface {} ({edges}), which vanishes for every loop momentum at the current masses and external momenta: its on-shell energies cancel between edges of equal mass and spatial momentum up to sign, and its external energy shift is zero. Generation replaces such coinciding poles by derivatives only for edges sharing a mass parameter and momentum routing; give these edges distinct squared masses or external momenta, or regenerate with a shared mass parameter.",
348                self.name,
349                surface.to_atom(&[])
350            ));
351        }
352        Ok(())
353    }
354
355    /// LU uses real on-shell energies and stable-particle cuts; width metadata alone
356    /// does not turn a real mass into a complex-mass-scheme propagator.
357    /// Used UFO denominators must agree with the standard pole reconstructed from the mass.
358    pub(crate) fn validate_real_masses(&self, model: &Model) -> eyre::Result<()> {
359        // DOT mass expressions use evaluator slots, which may predate a model update.
360        let mut parameters = self.param_builder.clone();
361        parameters.update_model_values(model)?;
362        for (pair, edge_id, edge) in self.iter_edges() {
363            if let Some(mass) = edge.data.mass_value::<f64>(model, &parameters)
364                && !mass.im.is_zero()
365            {
366                return Err(eyre::eyre!(
367                    "Cross-section graph '{}' has unsupported complex mass '{}' = {} on edge {}. LU requires real masses; the complex-mass scheme and finite-width propagators are not supported.",
368                    self.name,
369                    edge.data.mass_atom(),
370                    mass,
371                    edge_id
372                ));
373            }
374            if matches!(pair, HedgePair::Paired { .. })
375                && let Some(particle) = edge.data.particle()
376            {
377                let propagator = model.get_propagator_for_particle(&particle.name);
378                let momentum = function!(UFO.momentum, 1);
379                let expected = momentum.pow(2) - Atom::var(particle.mass.0.0).pow(2);
380                // Normalize only the model denominator, never the graph numerator.
381                // DOT mass overrides replace the declared pole after this compatibility check.
382                let difference = (propagator
383                    .denominator
384                    .replace(function!(UFO.momentum, function!(UFO.idx, 1, 1)))
385                    .with(momentum)
386                    - &expected)
387                    .replace(UFOSymbol::zero().0)
388                    .with(Atom::Zero);
389                if !difference.expand().is_zero() {
390                    return Err(eyre::eyre!(
391                        "Cross-section graph '{}' has unsupported propagator denominator '{}' for particle '{}' on edge {}. LU reconstructs the standard real pole '{}'; custom denominators, including explicit finite-width terms, are not supported.",
392                        self.name,
393                        propagator.denominator,
394                        particle.name,
395                        edge_id,
396                        expected
397                    ));
398                }
399            }
400        }
401        Ok(())
402    }
403}
404
405impl Deref for Graph {
406    type Target = HedgeGraph<Edge, Vertex, HedgeData>;
407
408    fn deref(&self) -> &Self::Target {
409        &self.underlying
410    }
411}
412
413impl SplitPolarizations for Graph {
414    fn polarizations(&self) -> Vec<Atom> {
415        self.polarizations.iter().map(|a| a.1.clone()).collect()
416    }
417}
418
419impl<'a, V, E: UVE> SplitPolarizations
420    for (&'a Vec<(PolDef, Atom)>, &'a HedgeGraph<E, V, HedgeData>)
421{
422    fn polarizations(&self) -> Vec<Atom> {
423        self.0.iter().map(|a| a.1.clone()).collect()
424    }
425}
426
427impl<'a, V, E: UVE> ParamBuilderGraph for (&'a Vec<(PolDef, Atom)>, &'a HedgeGraph<E, V, HedgeData>)
428where
429    for<'b> EdgeData<&'b E>: ReversibleEdge,
430{
431    fn iter_edge_ids(&self) -> impl Iterator<Item = EdgeIndex> + '_ {
432        self.1.iter_edge_ids()
433    }
434
435    fn get_external_energy_atoms(&self) -> Vec<Atom> {
436        self.1.get_external_energy_atoms()
437    }
438
439    fn get_ose_replacements(&self) -> Vec<Replacement> {
440        self.1.get_ose_replacements()
441    }
442
443    fn explicit_ose_atom(&self, edge: EdgeIndex) -> Atom {
444        self.1.explicit_ose_atom(edge)
445    }
446
447    fn explicit_thermal_distribution_atom(
448        &self,
449        edge: EdgeIndex,
450        derivative_order: usize,
451        thermal_sign: Atom,
452        orientation_sign: Atom,
453        limit: MediumMode,
454    ) -> eyre::Result<Atom> {
455        self.1.explicit_thermal_distribution_atom(
456            edge,
457            derivative_order,
458            thermal_sign,
459            orientation_sign,
460            limit,
461        )
462    }
463
464    fn loop_mom_params(&self, lmb: &LoopMomentumBasis) -> Vec<Atom> {
465        lmb.loop_edges
466            .iter()
467            .flat_map(|edge_id| {
468                vec![
469                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([1]))),
470                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([2]))),
471                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([3]))),
472                ]
473            })
474            .collect()
475    }
476
477    fn external_spatial_params(&self) -> Vec<Atom> {
478        self.1.external_spatial_params()
479    }
480}
481
482impl<V, E: UVE> ParamBuilderGraph for HedgeGraph<E, V, HedgeData>
483where
484    for<'a> EdgeData<&'a E>: ReversibleEdge,
485{
486    fn iter_edge_ids(&self) -> impl Iterator<Item = EdgeIndex> + '_ {
487        self.iter_edges().map(|(_, e, _)| e)
488    }
489
490    fn get_external_energy_atoms(&self) -> Vec<Atom> {
491        let external_filter: SuBitGraph = self.external_filter();
492
493        external_filter
494            .included_iter()
495            .map(|hedge| external_energy_atom_from_index(self[&hedge]))
496            .collect()
497    }
498
499    fn explicit_ose_atom(&self, edge: EdgeIndex) -> Atom {
500        let mass = self[edge].mass_atom();
501        let mass2 = &mass * &mass;
502
503        // println!("{}", mass2);
504
505        let dot = GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([1])))
506            * GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([1])))
507            + GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([2])))
508                * GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([2])))
509            + GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([3])))
510                * GS.emr_mom(edge, Atom::from(ExpandedIndex::from_iter([3])));
511
512        (dot + mass2).sqrt()
513    }
514
515    fn explicit_thermal_distribution_atom(
516        &self,
517        edge: EdgeIndex,
518        derivative_order: usize,
519        thermal_sign: Atom,
520        orientation_sign: Atom,
521        limit: MediumMode,
522    ) -> eyre::Result<Atom> {
523        if limit == MediumMode::Vacuum {
524            return Ok(ThermalDistributionFactor::vacuum_atom(
525                derivative_order,
526                thermal_sign,
527            ));
528        }
529        let chemical_potential = self[edge].chemical_potential_atom();
530        let shifted_ose = match &chemical_potential {
531            Some(mu) => ose_atom_from_index(edge) - &orientation_sign * mu,
532            None => ose_atom_from_index(edge),
533        };
534        match limit {
535            MediumMode::ThermodynamicEquilibrium => {
536                let beta = Atom::var(GS.inverse_temperature);
537                let arg = beta.clone() * shifted_ose / Atom::num(2);
538                match (self[edge].is_fermion(), derivative_order) {
539                    (is_fermion, 0) => {
540                        // Order zero is (1 + s)/2 - n_F or (1 + s)/2 + n_B. Factoring
541                        // exp(-(a + |a|)) out of n_F = 1/(exp(2a) + 1) and
542                        // n_B = 1/(exp(2a) - 1) leaves nonpositive exponents and a
543                        // remainder free of cancellation, so exponentially small
544                        // occupations are not lost in s + tanh(a) once tanh(a) rounds
545                        // to one. coth keeps n_B accurate in the soft limit.
546                        let abs_arg = arg.abs();
547                        let tail = (-(&arg + &abs_arg)).exp();
548                        let occupation = if is_fermion {
549                            -tail / (Atom::one() + (Atom::num(-2) * &abs_arg).exp())
550                        } else {
551                            tail * (&arg / &abs_arg + coth().call_args([&arg])) / Atom::num(2)
552                        };
553                        Ok((thermal_sign + Atom::one()) / Atom::num(2) + occupation)
554                    }
555                    (true, 1) => Ok(beta * sech().call_args([arg]).pow(2) / Atom::num(4)),
556                    (false, 1) => Ok(-beta * csch().call_args([arg]).pow(2) / Atom::num(4)),
557                    (true, 2) => Ok(-beta.pow(2)
558                        * sech().call_args([&arg]).pow(2)
559                        * tanh().call_args([arg])
560                        / Atom::num(4)),
561                    (false, 2) => Ok(beta.pow(2)
562                        * csch().call_args([&arg]).pow(2)
563                        * coth().call_args([arg])
564                        / Atom::num(4)),
565                    (_, _) => {
566                        let energy = Indeterminate::try_from(ose_atom_from_index(edge)).unwrap();
567                        let mut body = self.explicit_thermal_distribution_atom(
568                            edge,
569                            2,
570                            thermal_sign,
571                            orientation_sign,
572                            limit,
573                        )?;
574                        for _ in 2..derivative_order {
575                            body = body.derivative(&energy).expand();
576                        }
577                        Ok(body)
578                    }
579                }
580            }
581            MediumMode::ZeroTemperatureEquilibrium => {
582                match (chemical_potential, derivative_order) {
583                    // The contour offset may be zero for a symmetric terminal;
584                    // its physical orientation still fixes E - sigma*mu.
585                    (Some(_), 0) => Ok(
586                        (thermal_sign + Atom::one()) / Atom::num(2) - GS.heaviside(-shifted_ose)
587                    ),
588                    (None, _) => Ok(ThermalDistributionFactor::vacuum_atom(
589                        derivative_order,
590                        thermal_sign,
591                    )),
592                    // Zero-temperature derivatives are distributions, consumed by
593                    // production Fermi-surface localization rather than a pointwise map.
594                    _ => Err(eyre::eyre!(
595                        "Thermal distribution for edge {edge}, derivative order {derivative_order}, \
596                         and limit {limit:?} has no pointwise body; zero-temperature derivatives \
597                         require Fermi-surface localization"
598                    )),
599                }
600            }
601            MediumMode::Vacuum => unreachable!("vacuum handled before medium parameters"),
602        }
603    }
604
605    fn loop_mom_params(&self, lmb: &LoopMomentumBasis) -> Vec<Atom> {
606        lmb.loop_edges
607            .iter()
608            .flat_map(|edge_id| {
609                vec![
610                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([1]))),
611                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([2]))),
612                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([3]))),
613                ]
614            })
615            .collect()
616    }
617
618    fn external_spatial_params(&self) -> Vec<Atom> {
619        let external_filter: SuBitGraph = self.external_filter();
620
621        external_filter
622            .included_iter()
623            .flat_map(|hedge| {
624                let edge_id = self[&hedge];
625                vec![
626                    GS.emr_mom(edge_id, Atom::from(ExpandedIndex::from_iter([1]))),
627                    GS.emr_mom(edge_id, Atom::from(ExpandedIndex::from_iter([2]))),
628                    GS.emr_mom(edge_id, Atom::from(ExpandedIndex::from_iter([3]))),
629                ]
630            })
631            .collect()
632    }
633
634    fn get_ose_replacements(&self) -> Vec<Replacement> {
635        self.iter_edges()
636            .filter(|(pair, _, _)| matches!(pair, HedgePair::Paired { .. }))
637            .map(|(_, edge_id, _)| {
638                let ose_atom = ose_atom_from_index(edge_id);
639                let explicit = self.explicit_ose_atom(edge_id);
640                Replacement::new(ose_atom.to_pattern(), explicit.to_pattern())
641            })
642            .collect()
643    }
644}
645
646impl ParamBuilderGraph for Graph {
647    // fn polarizations(&self) -> Vec<Atom> {
648    //     self.polarizations.iter().map(|a| a.1.clone()).collect()
649    // }
650
651    fn iter_edge_ids(&self) -> impl Iterator<Item = EdgeIndex> + '_ {
652        self.underlying.iter_edge_ids()
653    }
654
655    fn get_external_energy_atoms(&self) -> Vec<Atom> {
656        self.external_momentum_edge_order()
657            .into_iter()
658            .map(external_energy_atom_from_index)
659            .collect()
660    }
661
662    fn get_ose_replacements(&self) -> Vec<Replacement> {
663        if self.initial_state_cut.nedges(&self.underlying) == 0 {
664            self.underlying.get_ose_replacements()
665        } else {
666            let underlying_without_is_cut = self
667                .underlying
668                .full_filter()
669                .subtract(&self.initial_state_cut.left)
670                .subtract(&self.initial_state_cut.right);
671
672            self.underlying
673                .iter_edges_of(&underlying_without_is_cut)
674                .map(|(_, edge_id, _)| {
675                    let ose_atom = ose_atom_from_index(edge_id);
676                    let explicit = self.underlying.explicit_ose_atom(edge_id);
677                    Replacement::new(ose_atom.to_pattern(), explicit.to_pattern())
678                })
679                .collect()
680        }
681    }
682
683    fn explicit_ose_atom(&self, edge: EdgeIndex) -> Atom {
684        self.underlying.explicit_ose_atom(edge)
685    }
686
687    fn explicit_thermal_distribution_atom(
688        &self,
689        edge: EdgeIndex,
690        derivative_order: usize,
691        thermal_sign: Atom,
692        orientation_sign: Atom,
693        limit: MediumMode,
694    ) -> eyre::Result<Atom> {
695        self.underlying.explicit_thermal_distribution_atom(
696            edge,
697            derivative_order,
698            thermal_sign,
699            orientation_sign,
700            limit,
701        )
702    }
703
704    #[allow(unused_variables)]
705    fn loop_mom_params(&self, lmb: &LoopMomentumBasis) -> Vec<Atom> {
706        self.loop_momentum_basis
707            .loop_edges
708            .iter()
709            .flat_map(|edge_id| {
710                vec![
711                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([1]))),
712                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([2]))),
713                    GS.emr_mom(*edge_id, Atom::from(ExpandedIndex::from_iter([3]))),
714                ]
715            })
716            .collect()
717    }
718
719    fn external_spatial_params(&self) -> Vec<Atom> {
720        self.external_momentum_edge_order()
721            .into_iter()
722            .flat_map(|edge_id| {
723                vec![
724                    GS.emr_mom(edge_id, Atom::from(ExpandedIndex::from_iter([1]))),
725                    GS.emr_mom(edge_id, Atom::from(ExpandedIndex::from_iter([2]))),
726                    GS.emr_mom(edge_id, Atom::from(ExpandedIndex::from_iter([3]))),
727                ]
728            })
729            .collect()
730    }
731}
732
733// impl FeynmanGraph for Graph {
734//     fn add_signs_to_edges(&self, node_id: NodeIndex) -> Vec<isize> {
735//         self.underlying.add_signs_to_edges(node_id)
736//     }
737
738//     fn dummy_list(&self) -> Vec<EdgeIndex> {
739//         self.underlying.dummy_list()
740//     }
741
742//     fn expected_scale(&self, e_cm: F<f64>, model: &Model) -> F<f64> {
743//         self.underlying.expected_scale(e_cm, model)
744//     }
745
746//     // fn explicit_ose_atom(&self, edge: EdgeIndex) -> Atom {
747//     //     self.underlying.explicit_ose_atom(edge)
748//     // }
749
750//     fn external_in_or_out_signature(&self) -> ExternalSignature {
751//         self.underlying.external_in_or_out_signature()
752//     }
753
754//     fn get_cff_inverse_energy_product(&self) -> Atom {
755//         self.underlying.get_cff_inverse_energy_product()
756//     }
757
758//     fn get_emr_vec_cache<T: FloatLike>(
759//         &self,
760//         loop_moms: &LoopMomenta<F<T>>,
761//         external_moms: &ExternalFourMomenta<F<T>>,
762//         lmb: &LoopMomentumBasis,
763//     ) -> Vec<F<T>> {
764//         self.underlying
765//             .get_emr_vec_cache(loop_moms, external_moms, lmb)
766//     }
767
768//     fn get_energy_atoms(&self) -> Vec<Atom> {
769//         self.underlying.get_energy_atoms()
770//     }
771
772//     fn get_energy_cache<T: FloatLike>(
773//         &self,
774//         model: &Model,
775//         paramb: &ParamBuilder,
776//         loop_moms: &LoopMomenta<F<T>>,
777//         external_moms: &ExternalFourMomenta<F<T>>,
778//         lmb: &LoopMomentumBasis,
779//     ) -> EdgeVec<F<T>> {
780//         self.underlying
781//             .get_energy_cache(model, paramb, loop_moms, external_moms, lmb)
782//     }
783
784//     fn get_esurface_canonization(&self, lmb: &LoopMomentumBasis) -> Option<ShiftRewrite> {
785//         self.underlying.get_esurface_canonization(lmb)
786//     }
787
788//     // fn get_external_energy_atoms(&self) -> Vec<Atom> {
789//     //     self.underlying.get_external_energy_atoms()
790//     // }
791
792//     fn get_external_partcles(&self) -> Vec<ArcParticle> {
793//         self.underlying.get_external_partcles()
794//     }
795
796//     fn get_external_signature(&self) -> SignatureLike<ExternalIndex> {
797//         self.underlying.get_external_signature()
798//     }
799
800//     // fn get_local_edge_position(
801//     //     &self,
802//     //     node_id: NodeIndex,
803//     //     edge_id: EdgeIndex,
804//     //     skip_one: bool,
805//     // ) -> usize {
806//     //     self.underlying
807//     //         .get_local_edge_position(node_id, edge_id, skip_one)
808//     // }
809
810//     fn get_loop_number(&self) -> usize {
811//         self.underlying.get_loop_number()
812//     }
813
814//     fn get_real_mass_vector<T: FloatLike>(
815//         &self,
816//         model: &Model,
817//         paramb: &ParamBuilder,
818//     ) -> EdgeVec<F<T>> {
819//         self.underlying.get_real_mass_vector(model, paramb)
820//     }
821//     fn is_incoming_to(&self, edge: EdgeIndex, vertex: NodeIndex) -> bool {
822//         self.underlying.is_incoming_to(edge, vertex)
823//     }
824
825//     fn no_dummy(&self) -> SuBitGraph {
826//         self.underlying.no_dummy()
827//     }
828
829//     fn num_virtual_edges(&self, subgraph: SuBitGraph) -> usize {
830//         self.underlying.num_virtual_edges(subgraph)
831//     }
832
833//     fn substitute_lmb(&self, edge: EdgeIndex, atom: Atom, lmb: &LoopMomentumBasis) -> Atom {
834//         self.underlying.substitute_lmb(edge, atom, lmb)
835//     }
836// }
837
838impl FeynmanGraph for Graph {
839    fn num_virtual_edges(&self, subgraph: SuBitGraph) -> usize {
840        let internal_subgraph = InternalSubGraph::cleaned_filter_pessimist(subgraph, self);
841        self.count_internal_edges(&internal_subgraph)
842    }
843
844    fn is_incoming_to(&self, edge: EdgeIndex, vertex: NodeIndex) -> bool {
845        let (_, pair) = self[&edge];
846        match pair {
847            HedgePair::Unpaired { hedge, flow } => {
848                self.node_id(hedge) == vertex && matches!(flow, Flow::Sink)
849            }
850            HedgePair::Paired { source: _, sink } => self.node_id(sink) == vertex,
851            HedgePair::Split {
852                source: _,
853                sink,
854                split: _,
855            } => self.node_id(sink) == vertex,
856        }
857    }
858
859    // fn denominator(&self, edge: EdgeIndex) -> (Atom, Atom) {
860    //     let mom = parse!(&format!("Q{}", Into::<usize>::into(edge)));
861    //     let mass = self[edge]
862    //         .particle
863    //         .0
864    //         .mass
865    //         .expression
866    //         .clone()
867    //         .unwrap_or(Atom::num(0));
868
869    //     (mom, mass)
870    // }
871
872    // fn get_local_edge_position(
873    //     &self,
874    //     node_id: NodeIndex,
875    //     edge_id: EdgeIndex,
876    //     skip_one: bool,
877    // ) -> usize {
878    //     unimplemented!()
879    // }
880
881    fn add_signs_to_edges(&self, node_id: NodeIndex) -> Vec<isize> {
882        let node_hairs: SuBitGraph = self.iter_crown(node_id).into();
883
884        self.iter_edges_of(&node_hairs)
885            .map(|(_, edge_index, _)| {
886                if !self.is_incoming_to(edge_index, node_id) {
887                    -(Into::<usize>::into(edge_index) as isize)
888                } else {
889                    Into::<usize>::into(edge_index) as isize
890                }
891            })
892            .collect()
893    }
894
895    /// This includes the factor 2 for each edge, inversion already performed
896    fn get_cff_inverse_energy_product(&self) -> Atom {
897        let full_subgraph = self.full_filter();
898        get_cff_inverse_energy_product_impl(self, &full_subgraph, &[])
899    }
900
901    fn get_loop_number(&self) -> usize {
902        let internal_subgraph =
903            InternalSubGraph::cleaned_filter_pessimist(self.full_filter(), self);
904        let n_initial = self.initial_state_cut.nedges(self);
905        self.cyclotomatic_number(&internal_subgraph) - n_initial
906    }
907
908    fn get_real_mass_vector<T: FloatLike>(&self, model: &Model) -> EdgeVec<F<T>> {
909        self.new_edgevec(|edge, _edge_id, _| {
910            let c = edge
911                .mass_value(model, &self.param_builder)
912                .unwrap_or(Complex {
913                    re: F::from_f64(0.0),
914                    im: F::from_f64(0.0),
915                });
916
917            if c.im.is_zero() {
918                c.re
919            } else {
920                panic!(
921                    "Complex masses not yet supported in gammaLoop for {}:{}",
922                    edge.mass_atom(),
923                    c
924                )
925            }
926        })
927    }
928
929    fn get_external_masses<T: FloatLike>(&self, model: &Model) -> TiVec<ExternalIndex, F<T>> {
930        let external_filter: SuBitGraph = self.external_filter();
931
932        self.iter_edges_of(&external_filter)
933            .sorted_by_key(|(pair, _, _)| match pair {
934                HedgePair::Unpaired { hedge, .. } => *hedge,
935                _ => unreachable!(),
936            })
937            .map(|(_, _, edge)| {
938                let c = edge
939                    .data
940                    .mass_value(model, &self.param_builder)
941                    .unwrap_or(Complex {
942                        re: F::from_f64(0.0),
943                        im: F::from_f64(0.0),
944                    });
945
946                if c.im.is_zero() {
947                    c.re
948                } else {
949                    panic!(
950                        "Complex masses not yet supported in gammaLoop for {}:{}",
951                        edge.data.mass_atom(),
952                        c
953                    )
954                }
955            })
956            .collect()
957    }
958
959    fn get_energy_cache<T: FloatLike>(
960        &self,
961        model: &Model,
962        loop_moms: &LoopMomenta<F<T>>,
963        external_moms: &ExternalFourMomenta<F<T>>,
964        lmb: &LoopMomentumBasis,
965    ) -> EdgeVec<F<T>> {
966        self.new_edgevec_from_iter(
967            lmb.edge_signatures
968                .borrow()
969                .into_iter()
970                .map(|(_, sig)| sig.compute_four_momentum_from_three(loop_moms, external_moms))
971                .zip(self.iter_edges())
972                .map(|(emr_mom, (p, _, edge))| {
973                    if p.is_paired() {
974                        emr_mom
975                            .spatial
976                            .on_shell_energy(edge.data.mass_value(model, &self.param_builder).map(
977                                |m| {
978                                    if m.im.is_non_zero() {
979                                        panic!("Complex masses not yet supported in gammaLoop")
980                                    }
981                                    F::<T>::from_ff64(m.re)
982                                },
983                            ))
984                            .value
985                    } else {
986                        emr_mom.temporal.value // a wierd way of just obtaining the energy of the external particles
987                    }
988                }),
989        )
990        .unwrap()
991    }
992
993    fn get_emr_vec_cache<T: FloatLike>(
994        &self,
995        loop_moms: &LoopMomenta<F<T>>,
996        external_moms: &ExternalFourMomenta<F<T>>,
997        lmb: &LoopMomentumBasis,
998    ) -> Vec<F<T>> {
999        if self.initial_state_cut.nedges(&self.underlying) == 0 {
1000            self.iter_edges()
1001                .flat_map(|(pair, edge_id, _)| {
1002                    if let HedgePair::Paired { .. } = pair {
1003                        let emr_vec = lmb.edge_signatures[edge_id]
1004                            .compute_three_momentum_from_four(loop_moms, external_moms);
1005                        vec![emr_vec.px, emr_vec.py, emr_vec.pz]
1006                    } else {
1007                        vec![]
1008                    }
1009                })
1010                .collect()
1011        } else {
1012            let underlying_without_is_cut = self
1013                .underlying
1014                .full_filter()
1015                .subtract(&self.initial_state_cut.left)
1016                .subtract(&self.initial_state_cut.right);
1017
1018            self.underlying
1019                .iter_edges_of(&underlying_without_is_cut)
1020                .sorted_by(|a, b| a.1.cmp(&b.1))
1021                .flat_map(|(_pair, edge_id, _)| {
1022                    let emr_vec = lmb.edge_signatures[edge_id]
1023                        .compute_three_momentum_from_four(loop_moms, external_moms);
1024                    vec![emr_vec.px, emr_vec.py, emr_vec.pz]
1025                })
1026                .collect()
1027        }
1028    }
1029
1030    fn get_esurface_canonization(&self, lmb: &LoopMomentumBasis) -> Option<ShiftRewrite> {
1031        // let external_edges: TiVec<ExternalIndex, _> =
1032        // self
1033        // .iter_edges()
1034        // .filter(|(pair, _, _)| matches!(pair, HedgePair::Unpaired { .. }))
1035        // .collect();
1036
1037        // find the external leg which does not appear in it's own signature
1038        lmb.ext_edges
1039            .iter_enumerated()
1040            .find(|(external_index, edge_id)| {
1041                lmb.edge_signatures[**edge_id].external[*external_index] == SignOrZero::Zero
1042            })
1043            .map(|(_, dep_mom_edge_id)| {
1044                let dep_mom_signatrue = &lmb.edge_signatures[*dep_mom_edge_id].external;
1045
1046                let external_shift = lmb
1047                    .ext_edges
1048                    .iter()
1049                    .zip(dep_mom_signatrue)
1050                    .filter(|(_, dep_mom_sign)| dep_mom_sign.is_sign())
1051                    .map(|(external_edge, dep_mom_sign)| (*external_edge, dep_mom_sign as i64))
1052                    .collect_vec();
1053
1054                ShiftRewrite {
1055                    dependent_momentum: *dep_mom_edge_id,
1056                    dependent_momentum_expr: external_shift,
1057                }
1058            })
1059    }
1060
1061    fn external_in_or_out_signature(&self) -> ExternalSignature {
1062        let external_filter: SuBitGraph = self.external_filter();
1063
1064        self.iter_edges_of(&external_filter)
1065            .sorted_by_key(|(pair, _, _)| match pair {
1066                HedgePair::Unpaired { hedge, .. } => *hedge,
1067                _ => unreachable!(),
1068            })
1069            .map(|(pair, _, _)| match pair {
1070                HedgePair::Unpaired {
1071                    flow: Flow::Sink, ..
1072                } => 1i8,
1073                HedgePair::Unpaired {
1074                    flow: Flow::Source, ..
1075                } => -1i8,
1076                _ => unreachable!(),
1077            })
1078            .collect()
1079    }
1080
1081    fn get_external_partcles(&self) -> Vec<ArcParticle> {
1082        let external_filter: SuBitGraph = self.external_filter();
1083
1084        self.iter_edges_of(&external_filter)
1085            .sorted_by_key(|(pair, _, _)| match pair {
1086                HedgePair::Unpaired { hedge, .. } => *hedge,
1087                _ => unreachable!(),
1088            })
1089            .filter_map(|(_, _, data)| data.data.particle())
1090            .collect()
1091    }
1092
1093    fn get_external_signature(&self) -> SignatureLike<ExternalIndex> {
1094        let externals: SuBitGraph = self.external_filter();
1095
1096        SignatureLike::from_iter(
1097            self.iter_edges_of(&externals)
1098                .sorted_by_key(|(pair, _, _)| match pair {
1099                    HedgePair::Unpaired { hedge, .. } => *hedge,
1100                    _ => unreachable!(),
1101                })
1102                .map(|(pair, _, _)| match pair {
1103                    HedgePair::Unpaired {
1104                        flow: Flow::Source, ..
1105                    } => SignOrZero::Minus,
1106                    HedgePair::Unpaired {
1107                        flow: Flow::Sink, ..
1108                    } => SignOrZero::Plus,
1109                    _ => unreachable!(),
1110                }),
1111        )
1112    }
1113
1114    fn get_energy_atoms(&self) -> Vec<Atom> {
1115        self.iter_edges()
1116            .map(|(pair, edge_id, _)| match pair {
1117                HedgePair::Paired { .. } => ose_atom_from_index(edge_id),
1118                HedgePair::Unpaired { .. } => external_energy_atom_from_index(edge_id),
1119                _ => unreachable!(),
1120            })
1121            .collect_vec()
1122    }
1123
1124    fn expected_scale(&self, e_cm: F<f64>, model: &Model) -> F<f64> {
1125        let mut scale = Complex::new_re(F(1.0));
1126        for (_, _, vertex) in self.iter_nodes() {
1127            // include the values of all couplings
1128            let coupling_value = vertex
1129                .vertex_rule
1130                .as_ref()
1131                .map(|r| {
1132                    r.couplings
1133                        .iter()
1134                        .flat_map(|couplings| {
1135                            couplings.iter().map(|coupling| {
1136                                coupling
1137                                    .as_ref()
1138                                    .map(|coupling| {
1139                                        model.couplings[coupling]
1140                                            .value
1141                                            .map(|x| Complex::new(F(x.re), F(x.im)))
1142                                            .unwrap_or(Complex::new_re(F(1.0)))
1143                                    })
1144                                    .unwrap_or(Complex::new_re(F(1.0)))
1145                            })
1146                        })
1147                        .fold(Complex::new_re(F(1.0)), |product, term| product * term)
1148                })
1149                .unwrap_or(Complex::new_re(F(1.0)));
1150
1151            scale *= Complex::new_re(e_cm.powi(vertex.dod.value)) * coupling_value;
1152        }
1153
1154        for (_, _, edge_data) in self.iter_edges_of(
1155            &self
1156                .full_filter()
1157                .subtract(&self.external_filter::<SuBitGraph>()),
1158        ) {
1159            scale *= Complex::new_re(e_cm.powi(edge_data.data.dod.value))
1160        }
1161
1162        scale.norm_squared().sqrt()
1163    }
1164
1165    fn no_dummy(&self) -> SuBitGraph {
1166        let mut subgraph = self.full_filter();
1167        for (hedge_pair, _, edge) in self.iter_edges() {
1168            if edge.data.is_dummy {
1169                subgraph.sub(hedge_pair)
1170            }
1171        }
1172        subgraph
1173    }
1174
1175    fn dummy_list(&self) -> Vec<EdgeIndex> {
1176        self.iter_edges()
1177            .filter_map(|(_, edge_index, edge_data)| {
1178                if edge_data.data.is_dummy {
1179                    Some(edge_index)
1180                } else {
1181                    None
1182                }
1183            })
1184            .collect()
1185    }
1186
1187    fn all_st_cuts_for_cs(
1188        &self,
1189        source_nodes: HedgeNode,
1190        target_nodes: HedgeNode,
1191        initial_state_tree: &SuBitGraph,
1192    ) -> Vec<(SuBitGraph, OrientedCut, SuBitGraph)> {
1193        self.underlying
1194            .all_cuts(source_nodes, target_nodes)
1195            .into_iter()
1196            .map(|(mut l, mut c, mut r)| {
1197                // remove initial state cut edges from cut
1198                c.left.subtract_with(&self.initial_state_cut.left);
1199                c.right.subtract_with(&self.initial_state_cut.left);
1200                c.left.subtract_with(&self.initial_state_cut.right);
1201                c.right.subtract_with(&self.initial_state_cut.right);
1202                l.subtract_with(initial_state_tree);
1203                r.subtract_with(initial_state_tree);
1204
1205                (l, c, r)
1206            })
1207            .collect_vec()
1208    }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214    use crate::{
1215        dot,
1216        graph::parse::from_dot::IntoGraph,
1217        initialisation::test_initialise,
1218        utils::{ArbPrec, load_generic_model},
1219    };
1220    use symbolica::{
1221        evaluate::{CompileOptions, ExportSettings, FunctionMap, OptimizationSettings},
1222        symbol,
1223    };
1224
1225    #[test]
1226    fn thermal_parameter_validation_tracks_modes_and_runtime_values() -> eyre::Result<()> {
1227        test_initialise()?;
1228        let mut model = load_generic_model("sm");
1229        let mut graph: Graph = r#"
1230            digraph thermal_domain {
1231                node [num=1]; edge [num=1];
1232                A -> A [id=0 particle="G+" mass=2];
1233                A -> A [id=1 particle="d" mass=0];
1234                A -> A [id=2 particle="a"];
1235            }
1236        "#
1237        .into_graph(&model)?;
1238        // Dense fermions and massless zero-mu bosons are both supported.
1239        model.get_parameter_mut("mud")?.value = Some(Complex::new_re(F(3.0)));
1240        model.get_parameter_mut("minus_mud")?.value = Some(Complex::new_re(F(-3.0)));
1241        for mode in [
1242            MediumMode::Vacuum,
1243            MediumMode::ThermodynamicEquilibrium,
1244            MediumMode::ZeroTemperatureEquilibrium,
1245        ] {
1246            graph.set_medium_mode(mode)?;
1247            graph.param_builder.update_model_values(&model)?;
1248            for beta in [1.0, 0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1249                let result = graph.validate_medium_parameters(&model, beta);
1250                assert_eq!(result.is_ok(), !mode.is_finite_temperature() || beta == 1.0);
1251                if let Err(error) = result {
1252                    assert!(error.to_string().contains("inverse_temperature"));
1253                }
1254            }
1255            for mu in [0.0, 1.0, -1.0, 2.0, -2.0, 3.0, f64::NAN, f64::INFINITY] {
1256                model.get_parameter_mut("muG")?.value = Some(Complex::new_re(F(mu)));
1257                model.get_parameter_mut("minus_muG")?.value = Some(Complex::new_re(F(-mu)));
1258                graph.param_builder.update_model_values(&model)?;
1259                let result = graph.validate_medium_parameters(&model, 1.0);
1260                assert_eq!(result.is_ok(), mode == MediumMode::Vacuum || mu.abs() < 2.0);
1261                if let Err(error) = result {
1262                    assert!(error.to_string().contains("thermal_domain"));
1263                }
1264            }
1265            model.get_parameter_mut("muG")?.value = Some(Complex::new_re(F(0.0)));
1266            model.get_parameter_mut("minus_muG")?.value = Some(Complex::new_re(F(0.0)));
1267        }
1268        Ok(())
1269    }
1270
1271    #[test]
1272    fn thermal_boson_validation_uses_mass_overrides_and_rejects_complex_parameters()
1273    -> eyre::Result<()> {
1274        test_initialise()?;
1275        let mut model = load_generic_model("sm");
1276        let mass_parameter = Atom::var(model.get_parameter("MT").name.0).to_canonical_string();
1277        let mut graph: Graph = format!(
1278            r#"digraph thermal_mass {{
1279                node [num=1]; edge [num=1];
1280                A -> A [particle="G+" mass="{mass_parameter}+1"];
1281            }}"#
1282        )
1283        .into_graph(&model)?;
1284        graph.set_medium_mode(MediumMode::ThermodynamicEquilibrium)?;
1285        for (mass, mu, valid) in [
1286            (Complex::new_re(F(2.0)), Complex::new_re(F(2.0)), true),
1287            (Complex::new_re(F(1.0)), Complex::new_re(F(2.0)), false),
1288            (Complex::new_re(F(-1.0)), Complex::new_re(F(0.0)), true),
1289            (Complex::new_re(F(-1.0)), Complex::new_re(F(0.1)), false),
1290            (Complex::new_re(F(-2.0)), Complex::new_re(F(0.0)), false),
1291            (Complex::new(F(2.0), F(1.0)), Complex::new_re(F(0.0)), false),
1292            (Complex::new_re(F(2.0)), Complex::new(F(0.0), F(1.0)), false),
1293        ] {
1294            model.get_parameter_mut("MT")?.value = Some(mass);
1295            model.get_parameter_mut("muG")?.value = Some(mu);
1296            model.get_parameter_mut("minus_muG")?.value = Some(-mu);
1297            graph.param_builder.update_model_values(&model)?;
1298            assert_eq!(
1299                graph.validate_medium_parameters(&model, 1.0).is_ok(),
1300                valid,
1301                "mass override MT+1 with MT={mass}, mu={mu}"
1302            );
1303        }
1304        Ok(())
1305    }
1306
1307    #[test]
1308    fn thermal_chemical_potentials_validate_conjugation_at_runtime() -> eyre::Result<()> {
1309        test_initialise()?;
1310        let serialized = serde_json::to_value(load_generic_model("sm").to_serializable())?;
1311        for anti_parameter in [Some("muB"), Some("muQ"), None, Some("ZERO")] {
1312            let mut input = serialized.clone();
1313            for particle in input["particles"].as_array_mut().unwrap() {
1314                match particle["name"].as_str().unwrap() {
1315                    "G+" => particle["chemical_potential"] = serde_json::json!("muQ"),
1316                    "G-" => particle["chemical_potential"] = serde_json::json!(anti_parameter),
1317                    _ => {}
1318                }
1319            }
1320            // Import remains independent of the medium. Distinct external parameters
1321            // are valid when their current values are opposite, without a symbolic tie.
1322            let mut model = Model::from_str(serde_json::to_string(&input)?, "json")?;
1323            let mut graphs = ["G+", "G-"]
1324                .map(|particle| {
1325                    format!(
1326                        r#"digraph conjugate_mu {{
1327                            node [num=1]; edge [num=1];
1328                            A -> A [particle="{particle}" mass=4];
1329                        }}"#
1330                    )
1331                    .into_graph(&model)
1332                })
1333                .into_iter()
1334                .collect::<eyre::Result<Vec<Graph>>>()?;
1335            for (mu, other) in [
1336                (0.0, 0.0),
1337                (1.0, -1.0),
1338                (-1.0, 1.0),
1339                (1.0, 1.0),
1340                (1.0, 0.0),
1341                (0.0, 1.0),
1342            ] {
1343                let mut card = model.default_param_card();
1344                card.insert("muQ".into(), Complex::new_re(F(mu)));
1345                card.insert("muB".into(), Complex::new_re(F(other)));
1346                model.apply_param_card(&card)?;
1347                let anti_mu = match anti_parameter {
1348                    Some("muB") => other,
1349                    Some("muQ") => mu,
1350                    _ => 0.0,
1351                };
1352                for graph in &mut graphs {
1353                    for mode in [
1354                        MediumMode::Vacuum,
1355                        MediumMode::ThermodynamicEquilibrium,
1356                        MediumMode::ZeroTemperatureEquilibrium,
1357                    ] {
1358                        graph.set_medium_mode(mode)?;
1359                        graph.param_builder.update_model_values(&model)?;
1360                        let result = graph.validate_medium_parameters(&model, 1.0);
1361                        assert_eq!(
1362                            result.is_ok(),
1363                            mode == MediumMode::Vacuum || anti_mu == -mu,
1364                            "{mode:?}, mu={mu}, anti_mu={anti_mu}, anti_parameter={anti_parameter:?}"
1365                        );
1366                        if let Err(error) = result {
1367                            assert!(
1368                                error.to_string().contains("opposite chemical potentials"),
1369                                "{error}"
1370                            );
1371                        }
1372                    }
1373                }
1374            }
1375            if anti_parameter == Some("muB") {
1376                // The unused antiparticle label must still have a finite real value.
1377                model.get_parameter_mut("muB")?.value = Some(Complex::new(F(0.0), F(0.5)));
1378                graphs[0].param_builder.update_model_values(&model)?;
1379                let error = graphs[0]
1380                    .validate_medium_parameters(&model, 1.0)
1381                    .unwrap_err();
1382                assert!(
1383                    error.to_string().contains("finite real chemical potential"),
1384                    "{error}"
1385                );
1386            }
1387        }
1388        Ok(())
1389    }
1390
1391    #[test]
1392    fn thermal_self_conjugate_particles_require_zero_chemical_potential() -> eyre::Result<()> {
1393        test_initialise()?;
1394        let serialized = serde_json::to_value(load_generic_model("sm").to_serializable())?;
1395        for parameter in [None, Some("ZERO"), Some("muQ")] {
1396            let mut input = serialized.clone();
1397            let particle = input["particles"]
1398                .as_array_mut()
1399                .unwrap()
1400                .iter_mut()
1401                .find(|particle| particle["name"] == "H")
1402                .unwrap();
1403            particle["chemical_potential"] = serde_json::json!(parameter);
1404            let mut model = Model::from_str(serde_json::to_string(&input)?, "json")?;
1405            let mut graph: Graph = r#"digraph self_conjugate_mu {
1406                node [num=1]; edge [num=1];
1407                A -> A [particle="H" mass=4];
1408            }"#
1409            .into_graph(&model)?;
1410            for mu in [0.0, 1.0, -1.0] {
1411                let mut card = model.default_param_card();
1412                card.insert("muQ".into(), Complex::new_re(F(mu)));
1413                model.apply_param_card(&card)?;
1414                for mode in [
1415                    MediumMode::Vacuum,
1416                    MediumMode::ThermodynamicEquilibrium,
1417                    MediumMode::ZeroTemperatureEquilibrium,
1418                ] {
1419                    graph.set_medium_mode(mode)?;
1420                    graph.param_builder.update_model_values(&model)?;
1421                    let result = graph.validate_medium_parameters(&model, 1.0);
1422                    assert_eq!(
1423                        result.is_ok(),
1424                        mode == MediumMode::Vacuum || parameter != Some("muQ") || mu == 0.0,
1425                        "{mode:?}, mu={mu}, parameter={parameter:?}"
1426                    );
1427                    if let Err(error) = result {
1428                        assert!(
1429                            error
1430                                .to_string()
1431                                .contains("zero chemical potential for self-conjugate"),
1432                            "{error}"
1433                        );
1434                    }
1435                }
1436            }
1437        }
1438        Ok(())
1439    }
1440
1441    #[test]
1442    fn thermal_denominators_reject_poles_that_coincide_at_runtime() -> eyre::Result<()> {
1443        use crate::cff::surface::HybridSurfaceID;
1444        use crate::momentum::FourMomentum;
1445
1446        test_initialise()?;
1447        let mut model = load_generic_model("sm");
1448        let [top, w] = ["MT", "MW"]
1449            .map(|name| Atom::var(model.get_parameter(name).name.0).to_canonical_string());
1450        let thermal_denominators = |graph: &mut Graph| -> eyre::Result<Vec<HsurfaceID>> {
1451            let mut options = graph.denominator_only_cff_3d_expression_options();
1452            options.medium_mode = MediumMode::ThermodynamicEquilibrium;
1453            let generated =
1454                graph.generate_3d_expression_for_integrand(&[], &None, &options, None)?;
1455            let denominators = generated
1456                .expression
1457                .orientations
1458                .iter()
1459                .flat_map(|orientation| orientation.iter_denominator_nodes())
1460                .filter_map(|node| match node.data {
1461                    HybridSurfaceID::Hsurface(id) => Some(id),
1462                    _ => None,
1463                })
1464                .sorted()
1465                .dedup()
1466                .collect_vec();
1467            assert!(!denominators.is_empty());
1468            Ok(denominators)
1469        };
1470        let set_masses = |model: &mut Model, graph: &mut Graph, masses: [f64; 2]| {
1471            for (name, mass) in ["MT", "MW"].into_iter().zip(masses) {
1472                model.get_parameter_mut(name)?.value = Some(Complex::new_re(F(mass)));
1473            }
1474            graph.param_builder.update_model_values(model)
1475        };
1476        let degenerate = |result: eyre::Result<()>| {
1477            result
1478                .unwrap_err()
1479                .to_string()
1480                .contains("vanishes for every loop momentum")
1481        };
1482
1483        // Distinct mass parameters keep the cycle as a divided difference, including
1484        // equal and opposite fermion masses with identical on-shell energies.
1485        let no_externals = ExternalFourMomenta::default();
1486        for (particle, coincident_mass) in [("G+", 2.0), ("t", -2.0)] {
1487            let mut cycle: Graph = format!(
1488                r#"digraph thermal_cycle {{
1489                    node [num=1]; edge [num=1];
1490                    A -> B [particle="{particle}" mass="{top}"];
1491                    B -> A [particle="{particle}" mass="{w}"];
1492                }}"#
1493            )
1494            .into_graph(&model)?;
1495            cycle.set_medium_mode(MediumMode::ThermodynamicEquilibrium)?;
1496            let denominators = thermal_denominators(&mut cycle)?;
1497            set_masses(&mut model, &mut cycle, [2.0, 3.0])?;
1498            cycle.validate_denominator_hsurfaces(&model, &no_externals, &denominators)?;
1499            set_masses(&mut model, &mut cycle, [2.0, coincident_mass])?;
1500            cycle.validate_medium_parameters(&model, 1.0)?;
1501            assert!(degenerate(cycle.validate_denominator_hsurfaces(
1502                &model,
1503                &no_externals,
1504                &denominators
1505            )));
1506        }
1507
1508        // A shared mass parameter on k and k + p degenerates only at p = 0.
1509        let mut bubble: Graph = format!(
1510            r#"digraph thermal_bubble {{
1511                node [num=1]; edge [num=1];
1512                incoming [style=invis]; outgoing [style=invis];
1513                incoming -> A [particle="H"];
1514                B -> outgoing [particle="H"];
1515                A -> B [particle="G+" mass="{top}"];
1516                B -> A [particle="G+" mass="{top}"];
1517            }}"#
1518        )
1519        .into_graph(&model)?;
1520        let denominators = thermal_denominators(&mut bubble)?;
1521        set_masses(&mut model, &mut bubble, [2.0, 3.0])?;
1522        let externals = |energy: f64, pz: f64| -> ExternalFourMomenta<F<f64>> {
1523            let p = FourMomentum::from_args(F(energy), F(0.0), F(0.0), F(pz));
1524            [p, p].into_iter().collect()
1525        };
1526        for moving in [externals(0.5, 0.0), externals(0.0, 0.5)] {
1527            bubble.validate_denominator_hsurfaces(&model, &moving, &denominators)?;
1528        }
1529        assert!(degenerate(bubble.validate_denominator_hsurfaces(
1530            &model,
1531            &externals(0.0, 0.0),
1532            &denominators
1533        )));
1534        Ok(())
1535    }
1536
1537    #[test]
1538    fn thermal_fermion_distributions_require_real_masses() -> eyre::Result<()> {
1539        test_initialise()?;
1540        let mut model = load_generic_model("sm");
1541        let mass_parameter = Atom::var(model.get_parameter("MT").name.0).to_canonical_string();
1542        let mut graph: Graph = format!(
1543            r#"digraph thermal_fermion {{
1544                node [num=1]; edge [num=1];
1545                A -> A [particle="d" mass="{mass_parameter}"];
1546            }}"#
1547        )
1548        .into_graph(&model)?;
1549        let named_mu = graph[EdgeIndex(0)].particle().unwrap().chemical_potential;
1550        for (chemical_potential, has_mu) in [
1551            (None, false),
1552            (Some(crate::model::ParameterName(UFOSymbol::zero())), false),
1553            (named_mu, true),
1554        ] {
1555            let crate::graph::edge::PossibleParticle::MassOverriddenParticle { particle, .. } =
1556                &mut graph.underlying[EdgeIndex(0)].particle
1557            else {
1558                unreachable!("the fixture has a DOT mass override")
1559            };
1560            std::sync::Arc::make_mut(&mut particle.0).chemical_potential = chemical_potential;
1561            for (mass, valid) in [
1562                (Complex::new_re(F(-2.0)), true),
1563                (Complex::new_re(F(0.0)), true),
1564                (Complex::new_re(F(2.0)), true),
1565                (Complex::new(F(2.0), F(0.5)), false),
1566                (Complex::new_re(F(f64::NAN)), false),
1567                (Complex::new_re(F(f64::INFINITY)), false),
1568            ] {
1569                model.get_parameter_mut("MT")?.value = Some(mass);
1570                for mode in [
1571                    MediumMode::Vacuum,
1572                    MediumMode::ThermodynamicEquilibrium,
1573                    MediumMode::ZeroTemperatureEquilibrium,
1574                ] {
1575                    graph.set_medium_mode(mode)?;
1576                    graph.param_builder.update_model_values(&model)?;
1577                    let result = graph.validate_medium_parameters(&model, 1.0);
1578                    // Vacuum has no thermal occupations; absent and literal-zero
1579                    // potentials have no zero-temperature occupation step.
1580                    assert_eq!(
1581                        result.is_ok(),
1582                        valid
1583                            || mode == MediumMode::Vacuum
1584                            || (mode == MediumMode::ZeroTemperatureEquilibrium && !has_mu),
1585                        "{mode:?}, MT={mass}, chemical_potential={chemical_potential:?}"
1586                    );
1587                    if let Err(error) = result {
1588                        assert!(error.to_string().contains("real fermion mass"), "{error}");
1589                    }
1590                }
1591            }
1592        }
1593        Ok(())
1594    }
1595
1596    #[test]
1597    fn cross_section_real_masses_accept_zero_real_and_width_metadata() -> eyre::Result<()> {
1598        test_initialise()?;
1599        let model = load_generic_model("sm");
1600        assert!(!model.get_parameter("WT").value.unwrap().re.is_zero());
1601        let graph: Graph = r#"
1602            digraph real_masses {
1603                node [num=1];
1604                edge [num=1];
1605                A -> B [id=0 mass=0];
1606                A -> B [id=1 mass=2];
1607                A -> B [id=2 particle="t"];
1608            }
1609        "#
1610        .into_graph(&model)?;
1611        graph.validate_real_masses(&model)
1612    }
1613
1614    #[test]
1615    fn cross_section_standard_denominators_accept_wrapping_and_factorization() -> eyre::Result<()> {
1616        test_initialise()?;
1617        let mut model = load_generic_model("sm");
1618        for name in ["g", "t"] {
1619            let position = model
1620                .propagators
1621                .iter()
1622                .position(|propagator| propagator.particle.name == name)
1623                .unwrap();
1624            let mass = Atom::var(model.get_particle(name).mass.0.0);
1625            for momentum in [
1626                function!(UFO.momentum, 1),
1627                function!(UFO.momentum, function!(UFO.idx, 1, 1)),
1628            ] {
1629                // The factored form certifies denominator-only normalization.
1630                std::sync::Arc::make_mut(&mut model.propagators[position]).denominator =
1631                    (&momentum - &mass) * (&momentum + &mass);
1632                let graph: Graph = format!(
1633                    r#"digraph standard_denominator {{
1634                        node [num=1]; edge [num=1];
1635                        A -> B [particle="{name}"];
1636                    }}"#
1637                )
1638                .into_graph(&model)?;
1639                graph.validate_real_masses(&model)?;
1640            }
1641        }
1642        Ok(())
1643    }
1644
1645    #[test]
1646    fn cross_section_custom_denominators_reject_used_lines_only() -> eyre::Result<()> {
1647        test_initialise()?;
1648        let mut model = load_generic_model("sm");
1649        let position = model
1650            .propagators
1651            .iter()
1652            .position(|propagator| propagator.particle.name == "H")
1653            .unwrap();
1654        let width_term = Atom::i()
1655            * Atom::var(model.get_parameter("MH").name.0)
1656            * Atom::var(model.get_parameter("WH").name.0);
1657        std::sync::Arc::make_mut(&mut model.propagators[position]).denominator += width_term;
1658        let unused: Graph = r#"digraph unused_width {
1659            node [num=1]; edge [num=1];
1660            A -> B [particle="g"];
1661        }"#
1662        .into_graph(&model)?;
1663        unused.validate_real_masses(&model)?;
1664        let amputated: Graph = r#"digraph amputated_width {
1665            ext [style=invis]; vertex [num=1]; edge [num=1];
1666            ext -> vertex [particle="H"];
1667        }"#
1668        .into_graph(&model)?;
1669        assert!(amputated.iter_edges().all(|(pair, _, _)| !pair.is_paired()));
1670        amputated.validate_real_masses(&model)?;
1671        let used: Graph = r#"digraph explicit_width {
1672            node [num=1]; edge [num=1];
1673            A -> B [particle="H"];
1674        }"#
1675        .into_graph(&model)?;
1676        let error = used.validate_real_masses(&model).unwrap_err().to_string();
1677        assert!(error.contains("explicit_width"));
1678        assert!(error.contains("unsupported propagator denominator"));
1679        assert!(error.contains("finite-width"));
1680        Ok(())
1681    }
1682
1683    #[test]
1684    fn cross_section_denominator_validation_uses_the_current_model() -> eyre::Result<()> {
1685        test_initialise()?;
1686        let mut model = load_generic_model("sm");
1687        let mass = Atom::var(model.get_parameter("MT").name.0).to_canonical_string();
1688        let graph: Graph = format!(
1689            r#"digraph updated_denominator {{
1690                node [num=1]; edge [num=1];
1691                A -> B [particle="t" mass="{mass}+1"];
1692            }}"#
1693        )
1694        .into_graph(&model)?;
1695        graph.validate_real_masses(&model)?;
1696        let position = model
1697            .propagators
1698            .iter()
1699            .position(|propagator| propagator.particle.name == "t")
1700            .unwrap();
1701        // A loaded graph must not keep validating its old propagator declaration.
1702        // The same owner runs again at direct integrand warm-up.
1703        std::sync::Arc::make_mut(&mut model.propagators[position]).denominator += Atom::one();
1704        let error = graph.validate_real_masses(&model).unwrap_err().to_string();
1705        assert!(error.contains("updated_denominator"));
1706        assert!(error.contains("unsupported propagator denominator"));
1707        Ok(())
1708    }
1709
1710    #[test]
1711    fn cross_section_complex_mass_is_rejected_at_generation() -> eyre::Result<()> {
1712        test_initialise()?;
1713        let model = load_generic_model("sm");
1714        let graph: Graph = r#"
1715            digraph complex_mass {
1716                node [num=1];
1717                edge [num=1];
1718                A -> B [id=0 mass="1+1𝑖"];
1719                A -> B [id=1 mass=0];
1720            }
1721        "#
1722        .into_graph(&model)?;
1723        let error = graph.validate_real_masses(&model).unwrap_err().to_string();
1724        assert!(error.contains("complex_mass"));
1725        assert!(error.contains("unsupported complex mass"));
1726        assert!(error.contains("edge e0"));
1727        assert!(error.contains("complex-mass scheme"));
1728        Ok(())
1729    }
1730
1731    #[test]
1732    fn cross_section_mass_validation_refreshes_runtime_model_values() -> eyre::Result<()> {
1733        test_initialise()?;
1734        for offset in ["", "+1"] {
1735            let mut model = load_generic_model("sm");
1736            let mass = Atom::var(model.get_parameter("MT").name.0).to_canonical_string();
1737            let graph: Graph = format!(
1738                r#"digraph runtime_mass {{
1739                    node [num=1];
1740                    edge [num=1];
1741                    A -> B [id=0 mass="{mass}{offset}"];
1742                    A -> B [id=1 mass=0];
1743                }}"#
1744            )
1745            .into_graph(&model)?;
1746            graph.validate_real_masses(&model)?;
1747
1748            model.get_parameter_mut("MT")?.value = Some(Complex::new(F(2.0), F(0.5)));
1749            // The DOT evaluator may still hold the old real value until its
1750            // slots refresh; validation must use the current model value.
1751            let error = graph.validate_real_masses(&model).unwrap_err().to_string();
1752            assert!(error.contains("runtime_mass"));
1753            assert!(error.contains("unsupported complex mass"));
1754
1755            model.get_parameter_mut("MT")?.value = Some(Complex::new_re(F(3.0)));
1756            graph.validate_real_masses(&model)?;
1757        }
1758        Ok(())
1759    }
1760
1761    #[test]
1762    fn zero_temperature_treats_literal_zero_chemical_potential_as_absent() {
1763        test_initialise().unwrap();
1764        let mut graph: Graph = dot!(digraph thermal {
1765            node [num=1]; edge [num=1 particle="d"];
1766            A -> B; B -> A;
1767        })
1768        .unwrap();
1769        let edge = EdgeIndex(0);
1770        let mut particle = graph[edge].particle().unwrap();
1771        for (chemical_potential, has_mu) in [
1772            (None, false),
1773            (Some(crate::model::ParameterName(UFOSymbol::zero())), false),
1774            (particle.chemical_potential, true),
1775        ] {
1776            std::sync::Arc::make_mut(&mut particle.0).chemical_potential = chemical_potential;
1777            graph.underlying[edge].particle = particle.clone().into();
1778            assert_eq!(graph[edge].chemical_potential_atom().is_some(), has_mu);
1779            for thermal_sign in [-1, 0, 1] {
1780                for order in 0..=3 {
1781                    let vacuum = graph
1782                        .explicit_thermal_distribution_atom(
1783                            edge,
1784                            order,
1785                            Atom::num(thermal_sign),
1786                            GS.sign(edge),
1787                            MediumMode::Vacuum,
1788                        )
1789                        .unwrap();
1790                    assert_eq!(
1791                        vacuum,
1792                        if order == 0 {
1793                            Atom::num(1 + thermal_sign) / Atom::num(2)
1794                        } else {
1795                            Atom::Zero
1796                        }
1797                    );
1798                    let zero_temperature = graph.explicit_thermal_distribution_atom(
1799                        edge,
1800                        order,
1801                        Atom::num(thermal_sign),
1802                        GS.sign(edge),
1803                        MediumMode::ZeroTemperatureEquilibrium,
1804                    );
1805                    if !has_mu {
1806                        assert_eq!(zero_temperature.unwrap(), vacuum);
1807                    } else {
1808                        assert_eq!(zero_temperature.is_ok(), order == 0);
1809                        if order == 0 {
1810                            assert!(zero_temperature.unwrap().contains_symbol(GS.heaviside));
1811                        } else {
1812                            let error = zero_temperature.unwrap_err().to_string();
1813                            assert!(error.contains(&format!("edge {edge}")));
1814                            assert!(error.contains(&format!("derivative order {order}")));
1815                            assert!(error.contains("ZeroTemperatureEquilibrium"));
1816                            assert!(error.contains("has no pointwise body"));
1817                        }
1818                    }
1819                }
1820            }
1821        }
1822    }
1823
1824    #[test]
1825    fn symmetric_thermal_distributions_keep_the_chemical_potential_orientation() {
1826        test_initialise().unwrap();
1827        let graph: Graph = dot!(digraph symmetric_thermal {
1828            node [num=1]
1829            edge [num=1]
1830            A -> A [particle="d"]
1831            A -> A [particle="G+"]
1832        })
1833        .unwrap();
1834        for edge in graph.iter_edge_ids() {
1835            let mu = graph[edge].chemical_potential_atom().unwrap();
1836            assert!(!mu.is_zero());
1837            for orientation in [-1, 1] {
1838                let energy = Atom::num(orientation) * &mu + symbol!("shifted_thermal_energy");
1839                for limit in [
1840                    MediumMode::ThermodynamicEquilibrium,
1841                    MediumMode::ZeroTemperatureEquilibrium,
1842                ] {
1843                    let bodies = [-1, 0, 1].map(|contour| {
1844                        graph
1845                            .explicit_thermal_distribution_atom(
1846                                edge,
1847                                0,
1848                                Atom::num(contour),
1849                                Atom::num(orientation),
1850                                limit,
1851                            )
1852                            .unwrap()
1853                    });
1854                    assert!(
1855                        ((&bodies[0] + &bodies[2]) / Atom::num(2) - &bodies[1])
1856                            .expand()
1857                            .is_zero()
1858                    );
1859                    // With E = sigma*mu + x, the body depends only on x = E - sigma*mu.
1860                    let shifted = bodies[1]
1861                        .replace(ose_atom_from_index(edge))
1862                        .with(energy.clone());
1863                    assert!(!shifted.contains(&mu), "{limit:?}: {shifted}");
1864                    for order in 1..=3 {
1865                        let derivatives = [-1, 0, 1].map(|contour| {
1866                            graph
1867                                .explicit_thermal_distribution_atom(
1868                                    edge,
1869                                    order,
1870                                    Atom::num(contour),
1871                                    Atom::num(orientation),
1872                                    limit,
1873                                )
1874                                .map_err(|error| error.to_string())
1875                        });
1876                        assert_eq!(derivatives[0], derivatives[1]);
1877                        assert_eq!(derivatives[1], derivatives[2]);
1878                        assert_eq!(derivatives[1].is_ok(), limit.is_finite_temperature());
1879                    }
1880                }
1881            }
1882        }
1883    }
1884
1885    #[test]
1886    fn thermal_distribution_bodies_are_energy_derivatives() {
1887        test_initialise().unwrap();
1888        let graph: Graph = dot!(digraph thermal {
1889            node [num=1]
1890            edge [num=1]
1891            A -> B [particle="d"]
1892            B -> A [particle="d"]
1893            A -> B [particle="g"]
1894        })
1895        .unwrap();
1896        let energy = symbol!("thermal_derivative_energy");
1897        let thermal_sign = symbol!("thermal_derivative_sign").to_atom();
1898        for fermion in [true, false] {
1899            let edge = graph
1900                .iter_edge_ids()
1901                .find(|&edge| graph[edge].is_fermion() == fermion)
1902                .unwrap();
1903            let mut bodies = [0, 1, 2, 3, 4, 5, 6].map(|order| {
1904                graph
1905                    .explicit_thermal_distribution_atom(
1906                        edge,
1907                        order,
1908                        thermal_sign.clone(),
1909                        GS.sign(edge),
1910                        MediumMode::ThermodynamicEquilibrium,
1911                    )
1912                    .unwrap()
1913                    .replace(ose_atom_from_index(edge))
1914                    .with(energy)
1915            });
1916            // Order zero selects its decaying exponential through |E - sigma*mu|, which
1917            // has no symbolic derivative. Start from its hyperbolic form instead;
1918            // native_thermal_distributions checks that the two agree numerically.
1919            let shifted_energy = match graph[edge].chemical_potential_atom() {
1920                Some(mu) => Atom::var(energy) - GS.sign(edge) * mu,
1921                None => Atom::var(energy),
1922            };
1923            let argument = Atom::var(GS.inverse_temperature) * shifted_energy / Atom::num(2);
1924            let hyperbolic = if fermion { tanh() } else { coth() };
1925            bodies[0] = (&thermal_sign + hyperbolic.call_args([argument])) / Atom::num(2);
1926            // Temperature, chemical potential, and orientation stay symbolic and fixed.
1927            for (order, pair) in bodies.windows(2).enumerate() {
1928                assert!(
1929                    (pair[0].derivative(energy) - &pair[1]).expand().is_zero(),
1930                    "energy derivative of thermal distribution order {order} on edge {edge}"
1931                );
1932            }
1933        }
1934    }
1935
1936    #[test]
1937    fn native_thermal_distributions() {
1938        test_initialise().unwrap();
1939        let graph: Graph = dot!(digraph thermal {
1940            node [num=1]
1941            edge [num=1]
1942            A -> B [particle="d"]
1943            B -> A [particle="d"]
1944            A -> B [particle="g"]
1945        })
1946        .unwrap();
1947        let directory =
1948            std::env::temp_dir().join(format!("gammaloop-native-thermal-{}", std::process::id()));
1949        std::fs::create_dir_all(&directory).unwrap();
1950        let energy = symbol!("thermal_test_energy").to_atom();
1951        let mu = symbol!("thermal_test_mu").to_atom();
1952        let orientation = symbol!("thermal_test_orientation").to_atom();
1953        let thermal_sign = symbol!("thermal_test_sign").to_atom();
1954        for fermion in [true, false] {
1955            let edge = graph
1956                .iter_edge_ids()
1957                .find(|&edge| graph[edge].is_fermion() == fermion)
1958                .unwrap();
1959            let chemical_potential = graph[edge].chemical_potential_atom();
1960            let has_mu = chemical_potential.is_some();
1961            assert_eq!(has_mu, fermion);
1962            for order in 0..=4 {
1963                let mut expression = graph
1964                    .explicit_thermal_distribution_atom(
1965                        edge,
1966                        order,
1967                        thermal_sign.clone(),
1968                        orientation.clone(),
1969                        MediumMode::ThermodynamicEquilibrium,
1970                    )
1971                    .unwrap()
1972                    .replace(ose_atom_from_index(edge))
1973                    .with(energy.clone())
1974                    .replace(GS.inverse_temperature)
1975                    .with(Atom::num(2));
1976                if has_mu {
1977                    expression = expression
1978                        .replace(chemical_potential.clone().unwrap())
1979                        .with(mu.clone());
1980                }
1981                let evaluator = expression
1982                    .as_view()
1983                    .to_evaluation_tree(
1984                        &FunctionMap::new(),
1985                        &[
1986                            energy.clone(),
1987                            mu.clone(),
1988                            orientation.clone(),
1989                            thermal_sign.clone(),
1990                        ],
1991                    )
1992                    .unwrap()
1993                    .linearize(&OptimizationSettings::default());
1994                let mut double = evaluator.clone().map_coeff(&|c| F::<f64>::from(&c.re));
1995                let mut precise = evaluator.clone().map_coeff(&|c| F::<ArbPrec>::from(&c.re));
1996                let path = directory.join(format!("thermal_{}_{}", edge.0, order));
1997                let mut compiled = evaluator
1998                    .export_cpp::<f64>(path.with_extension("cpp"), "thermal", ExportSettings::new())
1999                    .unwrap()
2000                    .compile(path.with_extension("so"), CompileOptions::default())
2001                    .unwrap()
2002                    .load()
2003                    .unwrap();
2004                for x in [
2005                    -1000.0_f64,
2006                    -40.0,
2007                    -1.0,
2008                    -2.0_f64.powi(-26),
2009                    0.0,
2010                    2.0_f64.powi(-26),
2011                    1.0,
2012                    40.0,
2013                    1000.0,
2014                ] {
2015                    if !fermion && x == 0.0 {
2016                        continue; // The Bose distribution has a pole at zero.
2017                    }
2018                    let q = (-2.0 * x.abs()).exp();
2019                    let denominator = if fermion {
2020                        1.0 + q
2021                    } else {
2022                        -(-2.0 * x.abs()).exp_m1()
2023                    };
2024                    let hyperbolic = x.signum()
2025                        * if fermion {
2026                            -(-2.0 * x.abs()).exp_m1() / denominator
2027                        } else {
2028                            (1.0 + q) / denominator
2029                        };
2030                    let squared = 4.0 * q / denominator.powi(2);
2031                    // n_F = 1/(exp(2x) + 1) and n_B = 1/(exp(2x) - 1) without cancellation.
2032                    let occupation = match (fermion, x > 0.0) {
2033                        (_, true) => q,
2034                        (true, false) => 1.0,
2035                        (false, false) => -1.0,
2036                    } / denominator;
2037                    let signed_occupation = if fermion { -occupation } else { occupation };
2038                    for sign in [-1.0, 1.0] {
2039                        for chemical_potential in [0.0, 0.5] {
2040                            let energy_value = x + if has_mu {
2041                                sign * chemical_potential
2042                            } else {
2043                                0.0
2044                            };
2045                            let expected = match order {
2046                                0 => (sign + 1.0) / 2.0 + signed_occupation,
2047                                1 => {
2048                                    if fermion {
2049                                        squared / 2.0
2050                                    } else {
2051                                        -squared / 2.0
2052                                    }
2053                                }
2054                                2 => {
2055                                    if fermion {
2056                                        -squared * hyperbolic
2057                                    } else {
2058                                        squared * hyperbolic
2059                                    }
2060                                }
2061                                3 => {
2062                                    if fermion {
2063                                        2.0 * squared * hyperbolic.powi(2) - squared.powi(2)
2064                                    } else {
2065                                        -2.0 * squared * hyperbolic.powi(2) - squared.powi(2)
2066                                    }
2067                                }
2068                                4 => {
2069                                    if fermion {
2070                                        -4.0 * squared * hyperbolic.powi(3)
2071                                            + 8.0 * squared.powi(2) * hyperbolic
2072                                    } else {
2073                                        4.0 * squared * hyperbolic.powi(3)
2074                                            + 8.0 * squared.powi(2) * hyperbolic
2075                                    }
2076                                }
2077                                _ => unreachable!(),
2078                            };
2079                            let input = [energy_value, chemical_potential, sign, sign];
2080                            let mut output = [0.0];
2081                            compiled.evaluate(&input, &mut output);
2082                            // Preserve the computed binary64 inputs across backends.
2083                            let precise_input = input.map(|v| F(ArbPrec::from_f64_exact_binary(v)));
2084                            let results = [
2085                                double.evaluate_single(&input.map(F)).0,
2086                                precise.evaluate_single(&precise_input).into_ff64().0,
2087                                output[0],
2088                            ];
2089                            for result in results {
2090                                // For s = -1 the scale is the occupation itself, so its
2091                                // exponential tail is tested relatively.
2092                                let tolerance = if order == 0 {
2093                                    2e-15 * ((sign + 1.0) / 2.0 + occupation.abs()) + 1e-300
2094                                } else {
2095                                    2e-13 * expected.abs() + 1e-300
2096                                };
2097                                assert!(
2098                                    (result - expected).abs() <= tolerance,
2099                                    "fermion={fermion}, order={order}, x={x}, mu={chemical_potential}, sign={sign}: {result} != {expected}"
2100                                );
2101                            }
2102                        }
2103                    }
2104                }
2105            }
2106        }
2107        std::fs::remove_dir_all(directory).unwrap();
2108    }
2109
2110    #[test]
2111    fn zero_temperature_steps_are_exact_outside_the_fermi_sea() {
2112        use symbolica::domains::float::Complex as SymComplex;
2113
2114        test_initialise().unwrap();
2115        let mut graph: Graph = dot!(digraph zero_temperature_step {
2116            node [num=1]
2117            edge [num=1]
2118            A -> B [particle="d"]
2119            B -> A [particle="d"]
2120        })
2121        .unwrap();
2122        graph
2123            .set_medium_mode(MediumMode::ZeroTemperatureEquilibrium)
2124            .unwrap();
2125        let edge = EdgeIndex(0);
2126        let chemical_potential = graph[edge].chemical_potential_atom().unwrap();
2127        let energy = symbol!("zero_temperature_test_energy").to_atom();
2128        let mu = symbol!("zero_temperature_test_mu").to_atom();
2129        let mu_value = 0.5;
2130        let directory = std::env::temp_dir().join(format!(
2131            "gammaloop-zero-temperature-step-{}",
2132            std::process::id()
2133        ));
2134        std::fs::create_dir_all(&directory).unwrap();
2135        // Vacuum subtraction relies on exact vacuum weights outside the Fermi sea.
2136        // For about a fifth of these full-mantissa energies y*inv(y) != 1 in each
2137        // backend, so a step written as theta(E - sigma*mu) would leave one-ulp
2138        // residues that survive the subtraction as noise times the vacuum integrand.
2139        let shifted_energies =
2140            (1..=64).map(|k| 1.0 + 997.0 * (f64::from(k) * 0.618_033_988_749_894_9).fract());
2141        for orientation in [-1, 1] {
2142            for thermal_sign in [-1, 0, 1] {
2143                let expression = graph
2144                    .explicit_thermal_distribution_atom(
2145                        edge,
2146                        0,
2147                        Atom::num(thermal_sign),
2148                        Atom::num(orientation),
2149                        MediumMode::ZeroTemperatureEquilibrium,
2150                    )
2151                    .unwrap()
2152                    .replace(ose_atom_from_index(edge))
2153                    .with(energy.clone())
2154                    .replace(chemical_potential.clone())
2155                    .with(mu.clone());
2156                let evaluator = expression
2157                    .as_view()
2158                    .to_evaluation_tree(&graph.param_builder.fn_map, &[energy.clone(), mu.clone()])
2159                    .unwrap()
2160                    .linearize(&OptimizationSettings::default());
2161                let mut double = evaluator.clone().map_coeff(&|c| F::<f64>::from(&c.re));
2162                let mut complex = evaluator
2163                    .clone()
2164                    .map_coeff(&|c| SymComplex::new(c.re.to_f64(), c.im.to_f64()));
2165                let mut precise = evaluator.clone().map_coeff(&|c| F::<ArbPrec>::from(&c.re));
2166                let path = directory.join(format!("step_{}_{}", orientation + 1, thermal_sign + 1));
2167                let mut compiled = evaluator
2168                    .export_cpp::<f64>(path.with_extension("cpp"), "step", ExportSettings::new())
2169                    .unwrap()
2170                    .compile(path.with_extension("so"), CompileOptions::default())
2171                    .unwrap()
2172                    .load()
2173                    .unwrap();
2174                let vacuum = f64::from(1 + thermal_sign) / 2.0;
2175                for x in shifted_energies.clone() {
2176                    let input = [x + f64::from(orientation) * mu_value, mu_value];
2177                    let context = format!("x={x}, orientation={orientation}, s={thermal_sign}");
2178                    assert_eq!(double.evaluate_single(&input.map(F)).0, vacuum, "{context}");
2179                    let complex_result =
2180                        complex.evaluate_single(&input.map(|value| SymComplex::new(value, 0.0)));
2181                    assert_eq!(complex_result.re, vacuum, "complex {context}");
2182                    assert_eq!(complex_result.im, 0.0, "complex {context}");
2183                    let mut output = [0.0];
2184                    compiled.evaluate(&input, &mut output);
2185                    assert_eq!(output[0], vacuum, "compiled {context}");
2186                    // Compare in Arb itself: rounding to f64 would hide a 2^-1000 residue.
2187                    let precise_input = input.map(|value| F(ArbPrec::from_f64_exact_binary(value)));
2188                    assert!(
2189                        precise.evaluate_single(&precise_input)
2190                            == F(ArbPrec::from_f64_exact_binary(vacuum)),
2191                        "Arb {context}"
2192                    );
2193                }
2194                // Keep E positive for both orientations while probing inside
2195                // and exactly on the Fermi surface E = sigma*mu.
2196                for x in [-0.25, 0.0] {
2197                    let input = [x + mu_value, f64::from(orientation) * mu_value];
2198                    let context = format!("x={x}, orientation={orientation}, s={thermal_sign}");
2199                    let complex_result =
2200                        complex.evaluate_single(&input.map(|value| SymComplex::new(value, 0.0)));
2201                    let mut output = [0.0];
2202                    compiled.evaluate(&input, &mut output);
2203                    let precise_input = input.map(|value| F(ArbPrec::from_f64_exact_binary(value)));
2204                    let results = [
2205                        ("double", double.evaluate_single(&input.map(F)).0),
2206                        ("Arb", precise.evaluate_single(&precise_input).into_ff64().0),
2207                        ("compiled", output[0]),
2208                    ];
2209                    if x == 0.0 {
2210                        // The registered theta has no finite boundary value.
2211                        for (backend, result) in results {
2212                            assert!(result.is_nan(), "{backend} {context}: {result}");
2213                        }
2214                        assert!(
2215                            complex_result.re.is_nan() || complex_result.im.is_nan(),
2216                            "complex {context}: {complex_result}"
2217                        );
2218                    } else {
2219                        let expected = vacuum - 1.0;
2220                        for (backend, result) in results {
2221                            assert!(
2222                                (result - expected).abs() <= 2.0 * f64::EPSILON,
2223                                "{backend} {context}: {result} != {expected}"
2224                            );
2225                        }
2226                        assert!(
2227                            (complex_result.re - expected).abs() <= 2.0 * f64::EPSILON,
2228                            "complex {context}: {complex_result} != {expected}"
2229                        );
2230                        assert_eq!(complex_result.im, 0.0, "complex {context}");
2231                    }
2232                }
2233            }
2234        }
2235        std::fs::remove_dir_all(directory).unwrap();
2236    }
2237}