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};
17use 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 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 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 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 thermal_edges.len().saturating_sub(1).max(2)
118 } else {
119 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 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 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 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 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 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 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 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 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 pub(crate) fn validate_real_masses(&self, model: &Model) -> eyre::Result<()> {
359 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, ¶meters)
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 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 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 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 (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 _ => 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 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
733impl 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; }
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 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 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 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 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 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 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 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}