Skip to main content

gammalooprs/cff/
hsurface.rs

1use crate::cff::VertexSet;
2use crate::graph::LoopMomentumBasis;
3use crate::momentum::sample::{ExternalFourMomenta, ExternalThreeMomenta};
4use crate::utils::{F, cut_energy, external_energy_atom_from_index, ose_atom_from_index};
5use bincode_trait_derive::{Decode, Encode};
6use itertools::Itertools;
7use linnet::half_edge::involution::{EdgeIndex, EdgeVec};
8use rug::Rational;
9use serde::{Deserialize, Serialize};
10use spenso::algebra::complex::Complex;
11use symbolica::atom::Atom;
12use tracing::warn;
13use typed_index_collections::TiVec;
14
15use super::esurface::Esurface;
16use super::esurface::ExternalShift;
17pub use super::surface::HsurfaceID;
18
19pub type HsurfaceCollection = TiVec<HsurfaceID, Hsurface>;
20pub type HsurfaceCache<T> = TiVec<HsurfaceID, T>;
21
22#[derive(Serialize, Deserialize, Debug, Clone, Encode, Decode)]
23/// H-surface of the supergraph, is most likely E-surface of the amplitude, kind of badly named.
24pub struct Hsurface {
25    pub positive_energies: Vec<EdgeIndex>,
26    pub negative_energies: Vec<EdgeIndex>,
27    pub external_shift: ExternalShift,
28    pub vertex_set: VertexSet,
29}
30
31impl PartialEq for Hsurface {
32    fn eq(&self, other: &Self) -> bool {
33        self.positive_energies == other.positive_energies
34            && self.negative_energies == other.negative_energies
35            && self.external_shift == other.external_shift
36    }
37}
38
39impl Eq for Hsurface {}
40
41impl Hsurface {
42    pub(crate) fn to_atom(&self, cut_edges: &[EdgeIndex]) -> Atom {
43        let (symbolic_positive_energies, symbolic_negative_energies) =
44            [&self.positive_energies, &self.negative_energies]
45                .iter()
46                .map(|energies| {
47                    energies
48                        .iter()
49                        .map(|i| {
50                            if cut_edges.contains(i) {
51                                cut_energy(*i)
52                            } else {
53                                ose_atom_from_index(*i)
54                            }
55                        })
56                        .collect_vec()
57                })
58                .collect_tuple()
59                .unwrap_or_else(|| unreachable!());
60
61        let symbolic_shift = self
62            .external_shift
63            .iter()
64            .fold(Atom::new(), |sum, (i, sign)| {
65                Atom::num(*sign) * external_energy_atom_from_index(*i) + &sum
66            });
67
68        let symbolic_sum_positive_energies = symbolic_positive_energies
69            .iter()
70            .fold(Atom::new(), |sum, e| sum + e);
71
72        let symbolic_sum_negative_energies = symbolic_negative_energies
73            .iter()
74            .fold(Atom::new(), |sum, e| sum + e);
75
76        symbolic_sum_positive_energies - &symbolic_sum_negative_energies + &symbolic_shift
77    }
78
79    /// Whether the surface vanishes for every loop momentum, so that a denominator
80    /// built from it divides by zero at every sample. On-shell energies of different
81    /// spatial routings or squared masses are independent functions of the loop momenta,
82    /// so this happens exactly when the positive and negative energies pair up into
83    /// edges with both mass and spatial momentum equal up to sign, and the external
84    /// energy shift is zero. All comparisons are exact: a near coincidence stays
85    /// finite in some precision and is left to the stability checks.
86    pub(crate) fn vanishes_identically(
87        &self,
88        lmb: &LoopMomentumBasis,
89        masses: &EdgeVec<Option<Complex<F<f64>>>>,
90        externals: &ExternalFourMomenta<F<f64>>,
91    ) -> eyre::Result<bool> {
92        let spatial_externals: ExternalThreeMomenta<F<f64>> =
93            externals.iter().map(|momentum| momentum.spatial).collect();
94        let mut unpaired = self.negative_energies.clone();
95        for &positive in &self.positive_energies {
96            let mut partner = None;
97            for (position, &negative) in unpaired.iter().enumerate() {
98                // An edge without a mass value never pairs.
99                if masses[positive]
100                    .is_some_and(|m| masses[negative].is_some_and(|n| m == n || m == -n))
101                    && lmb.edge_signatures[positive].spatial_equality_up_to_sign(
102                        &lmb.edge_signatures[negative],
103                        &spatial_externals,
104                    )?
105                {
106                    partner = Some(position);
107                    break;
108                }
109            }
110            let Some(position) = partner else {
111                return Ok(false);
112            };
113            unpaired.swap_remove(position);
114        }
115        if !unpaired.is_empty() {
116            return Ok(false);
117        }
118        let mut shift = Rational::new();
119        for (edge, sign) in &self.external_shift {
120            let signature = lmb.edge_signatures[*edge].external.to_momtrop_format();
121            for (coefficient, momentum) in signature.into_iter().zip(externals) {
122                if coefficient != 0 {
123                    let energy =
124                        Rational::from_f64(momentum.temporal.value.0).ok_or_else(|| {
125                            eyre::eyre!("nonfinite external energy {}", momentum.temporal.value)
126                        })?;
127                    shift += energy * (sign * coefficient as i64);
128                }
129            }
130        }
131        Ok(shift == 0)
132    }
133
134    #[allow(dead_code)]
135    pub(crate) fn equality_under_energy_conservation(
136        &self,
137        other: &Esurface,
138        constraints: &[&Esurface],
139    ) -> Option<bool> {
140        if !self.external_shift.is_empty() {
141            warn!("this is not handled yet");
142            return None;
143        }
144        constraints
145            .iter()
146            .find(|esurface| {
147                self.negative_energies
148                    .iter()
149                    .all(|index| esurface.energies.contains(index))
150            })
151            .map(|constraint| {
152                let energies_to_be_added = constraint
153                    .energies
154                    .iter()
155                    .filter(|index| !self.negative_energies.contains(index));
156
157                let mut new_positive_energies = self.positive_energies.clone();
158                new_positive_energies.extend(energies_to_be_added);
159                new_positive_energies.sort();
160
161                let new_esurface = Esurface {
162                    energies: new_positive_energies,
163                    external_shift: constraint.external_shift.clone(),
164                    vertex_set: VertexSet::dummy(),
165                };
166
167                other == &new_esurface
168            })
169    }
170
171    pub fn equality_by_try_convert(&self, other: &Esurface) -> bool {
172        let negative_as_external_shift = self
173            .negative_energies
174            .iter()
175            .map(|e| (*e, -1))
176            .collect_vec();
177
178        let self_as_esurface = Esurface {
179            energies: self.positive_energies.clone(),
180            external_shift: negative_as_external_shift,
181            vertex_set: VertexSet::dummy(),
182        };
183
184        self_as_esurface == *other
185    }
186}
187
188#[cfg(test)]
189mod tests {
190
191    use linnet::half_edge::involution::EdgeIndex;
192    use symbolica::atom::{Atom, AtomCore};
193
194    use symbolica::parse;
195
196    use crate::cff::{VertexSet, esurface::Esurface};
197
198    use super::Hsurface;
199
200    #[test]
201    fn test_equality_under_energy_conservation() {
202        let constraint = Esurface {
203            energies: vec![EdgeIndex::from(1), EdgeIndex::from(2)],
204            external_shift: vec![(EdgeIndex::from(0), -1)],
205            vertex_set: VertexSet::dummy(),
206        };
207
208        let hsurface = Hsurface {
209            positive_energies: vec![EdgeIndex::from(3), EdgeIndex::from(5)],
210            negative_energies: vec![EdgeIndex::from(2)],
211            external_shift: vec![],
212            vertex_set: VertexSet::dummy(),
213        };
214
215        let other = Esurface {
216            energies: vec![EdgeIndex::from(1), EdgeIndex::from(3), EdgeIndex::from(5)],
217            external_shift: vec![(EdgeIndex::from(0), -1)],
218            vertex_set: VertexSet::dummy(),
219        };
220
221        assert!(
222            hsurface
223                .equality_under_energy_conservation(&other, &[&constraint])
224                .unwrap()
225        );
226    }
227
228    #[test]
229    fn surfaces_vanish_identically_only_for_paired_energies_without_energy_shift() {
230        use linnet::half_edge::subgraph::{SuBitGraph, SubSetLike};
231        use spenso::algebra::complex::Complex;
232
233        use crate::{
234            graph::LoopMomentumBasis,
235            momentum::{
236                FourMomentum,
237                sample::{ExternalFourMomenta, ExternalIndex},
238                signature::LoopExtSignature,
239            },
240            utils::{F, test_utils::dummy_hedge_graph},
241        };
242
243        // Edges 0 and 4 carry k, edge 1 carries k + p, and edges 2 and 3 are the
244        // incoming and outgoing external p.
245        let graph = dummy_hedge_graph(5);
246        let edge_signatures = graph
247            .new_edgevec_from_iter(
248                [
249                    (vec![1], vec![0, 0]),
250                    (vec![1], vec![1, 0]),
251                    (vec![0], vec![1, 0]),
252                    (vec![0], vec![0, 1]),
253                    (vec![1], vec![0, 0]),
254                ]
255                .map(LoopExtSignature::from),
256            )
257            .unwrap();
258        let lmb = LoopMomentumBasis {
259            tree: SuBitGraph::empty(0),
260            loop_edges: vec![EdgeIndex(0)].into(),
261            ext_edges: vec![EdgeIndex(2), EdgeIndex(3)].into(),
262            edge_signatures,
263        };
264        let surface = |positive: &[usize], negative: &[usize], external_shift| Hsurface {
265            positive_energies: positive.iter().copied().map(EdgeIndex).collect(),
266            negative_energies: negative.iter().copied().map(EdgeIndex).collect(),
267            external_shift,
268            vertex_set: VertexSet::dummy(),
269        };
270        let masses = |values: [Option<f64>; 5]| {
271            graph
272                .new_edgevec_from_iter(values.map(|mass| mass.map(|m| Complex::new_re(F(m)))))
273                .unwrap()
274        };
275        let externals = |energy: f64, py: f64| -> ExternalFourMomenta<F<f64>> {
276            let p = FourMomentum::from_args(F(energy), F(0.0), F(py), F(0.0));
277            [p, p].into_iter().collect()
278        };
279        let equal = masses([Some(1.0), Some(1.0), None, None, Some(1.0)]);
280        let rest = externals(0.0, 0.0);
281
282        // A divided difference over distinct mass parameters with equal values.
283        let cycle = surface(&[4], &[0], vec![]);
284        assert!(cycle.vanishes_identically(&lmb, &equal, &rest).unwrap());
285        let split = masses([Some(1.0), Some(1.0), None, None, Some(2.0)]);
286        assert!(!cycle.vanishes_identically(&lmb, &split, &rest).unwrap());
287        for (mass, vanishes) in [(-1.0, true), ((-1.0_f64).next_up(), false), (-2.0, false)] {
288            let signed = masses([Some(1.0), Some(1.0), None, None, Some(mass)]);
289            assert_eq!(
290                cycle.vanishes_identically(&lmb, &signed, &rest).unwrap(),
291                vanishes,
292                "mass {mass} paired with 1"
293            );
294        }
295        // Reversing the spatial routing preserves the on-shell energy as well.
296        let mut reversed_lmb = lmb.clone();
297        reversed_lmb.edge_signatures[EdgeIndex(4)] = LoopExtSignature::from((vec![-1], vec![0, 0]));
298        let opposite = masses([Some(1.0), Some(1.0), None, None, Some(-1.0)]);
299        assert!(
300            cycle
301                .vanishes_identically(&reversed_lmb, &opposite, &rest)
302                .unwrap()
303        );
304        // Complex masses agree up to sign, but conjugates need not have equal squares.
305        for (mass, vanishes) in [
306            (Complex::new(F(1.0), F(2.0)), true),
307            (Complex::new(F(-1.0), F(-2.0)), true),
308            (Complex::new(F(1.0), F(-2.0)), false),
309        ] {
310            let mut complex = equal.clone();
311            complex[EdgeIndex(0)] = Some(Complex::new(F(1.0), F(2.0)));
312            complex[EdgeIndex(4)] = Some(mass);
313            assert_eq!(
314                cycle.vanishes_identically(&lmb, &complex, &rest).unwrap(),
315                vanishes,
316                "mass {mass} paired with 1 + 2i"
317            );
318        }
319        let unknown = masses([Some(1.0), Some(1.0), None, None, None]);
320        assert!(!cycle.vanishes_identically(&lmb, &unknown, &rest).unwrap());
321        let unpaired = surface(&[0, 1], &[4], vec![]);
322        assert!(!unpaired.vanishes_identically(&lmb, &equal, &rest).unwrap());
323
324        // The bubble E(k + p) - E(k) - p0 vanishes only at p = 0. A spatial momentum
325        // splits the energies, and an energy alone leaves the constant -p0.
326        let bubble = surface(&[1], &[0], vec![(EdgeIndex(2), -1)]);
327        assert!(bubble.vanishes_identically(&lmb, &equal, &rest).unwrap());
328        for moving in [externals(0.0, 0.5), externals(0.5, 0.0)] {
329            assert!(!bubble.vanishes_identically(&lmb, &equal, &moving).unwrap());
330        }
331        let heavy = masses([Some(1.0), Some(2.0), None, None, Some(1.0)]);
332        assert!(!bubble.vanishes_identically(&lmb, &heavy, &rest).unwrap());
333        let mut nonfinite = rest.clone();
334        nonfinite[ExternalIndex(0)].temporal.value = F(f64::NAN);
335        assert!(
336            bubble
337                .vanishes_identically(&lmb, &equal, &nonfinite)
338                .is_err()
339        );
340    }
341
342    #[test]
343    fn cache_equality_includes_the_external_shift_but_not_the_shore() {
344        let base = Hsurface {
345            positive_energies: vec![EdgeIndex::from(0)],
346            negative_energies: vec![EdgeIndex::from(1)],
347            external_shift: vec![(EdgeIndex::from(2), 1)],
348            vertex_set: VertexSet::from_usize(0),
349        };
350        let different_shore = Hsurface {
351            vertex_set: VertexSet::from_usize(1),
352            ..base.clone()
353        };
354        let different_shift = Hsurface {
355            external_shift: vec![(EdgeIndex::from(3), 1)],
356            ..base.clone()
357        };
358
359        assert_eq!(base, different_shore);
360        assert_ne!(base, different_shift);
361        assert_ne!(
362            base.to_atom(&[]).to_canonical_string(),
363            different_shift.to_atom(&[]).to_canonical_string()
364        );
365    }
366
367    #[test]
368    fn test_to_atom() {
369        let external_shift = vec![(EdgeIndex::from(4), -1), (EdgeIndex::from(5), 1)];
370        let h_surface = Hsurface {
371            positive_energies: vec![EdgeIndex::from(0), EdgeIndex::from(1)],
372            negative_energies: vec![EdgeIndex::from(2), EdgeIndex::from(3)],
373            external_shift,
374            vertex_set: VertexSet::dummy(),
375        };
376
377        let h_surface_atom = h_surface.to_atom(&[]);
378        let expected_atom = parse!(
379            "OSE(0) + OSE(1) - OSE(2) - OSE(3) - Q(4, spenso::cind(0)) + Q(5, spenso::cind(0))"
380        );
381        let diff = h_surface_atom - &expected_atom;
382        let diff = diff.expand();
383        assert_eq!(diff, Atom::new());
384    }
385}