Skip to main content

gammalooprs/momentum/
sample.rs

1use std::fmt::Display;
2
3use crate::graph::{Graph, LMBext, LmbIndex, LoopMomentumBasis};
4use crate::integrands::process::evaluators::SingleOrAllOrientations;
5use crate::momentum::{FourMomentum, Polarization, Rotatable, Rotation, SignOrZero, ThreeMomentum};
6
7use crate::momentum::signature::LoopSignature;
8use crate::utils::hyperdual_utils::new_constant;
9use crate::utils::{ArbPrec, F, FloatLike, Length, PrecisionUpgradable};
10use crate::{DependentMomentaConstructor, define_index, settings::runtime::kinematic::Externals};
11use bincode_trait_derive::{Decode, Encode};
12use color_eyre::Result;
13use derive_more::{From, Into};
14use eyre::eyre;
15use linnet::half_edge::HedgeGraph;
16use linnet::half_edge::involution::{EdgeIndex, EdgeVec, Orientation};
17use linnet::half_edge::nodestore::NodeStorageOps;
18use linnet::half_edge::subgraph::subset::SubSet;
19use linnet::half_edge::subgraph::{
20    Inclusion, InternalSubGraph, ModifySubSet, SuBitGraph, SubSetLike, SubSetOps,
21};
22use linnet::half_edge::typed_vec::IndexLike;
23use serde::{Deserialize, Serialize};
24use std::ops::{Add, Index, IndexMut, Sub};
25use symbolica::domains::dual::HyperDual;
26use tabled::settings::Style;
27
28use typed_index_collections::TiVec;
29
30#[derive(
31    From,
32    Into,
33    Copy,
34    Clone,
35    Hash,
36    Eq,
37    Ord,
38    PartialEq,
39    PartialOrd,
40    bincode::Encode,
41    bincode::Decode,
42    Debug,
43    Serialize,
44    Deserialize,
45)]
46pub struct LoopIndex(pub usize);
47
48// #[derive(
49//     From,
50//     Into,
51//     Copy,
52//     Clone,
53//     Hash,
54//     Eq,
55//     Ord,
56//     PartialEq,
57//     PartialOrd,
58//     Encode,
59//     Decode,
60//     Debug,
61//     Serialize,
62//     Deserialize,
63// )]
64// pub struct ExternalIndex(pub usize);
65
66define_index!(
67    pub struct ExternalIndex;
68);
69
70// define_indexed_vec!(
71//     EdgeIndex;
72
73//     pub struct ExternalThr;
74// );
75
76impl Display for LoopIndex {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        write!(f, "{}", self.0)
79    }
80}
81
82impl Display for ExternalIndex {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "{}", self.0)
85    }
86}
87
88#[derive(From, Into, Serialize, Deserialize, Clone, PartialEq, Debug, Encode, Decode)]
89pub struct LoopMomenta<T>(pub Vec<ThreeMomentum<T>>);
90
91impl<T: Display> Display for LoopMomenta<T> {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        let mut table_builder = tabled::builder::Builder::new();
94        for (i, mom) in self.0.iter().enumerate() {
95            let mut row = Vec::new();
96            row.push(i.to_string());
97            for m in mom {
98                row.push(m.to_string());
99            }
100            table_builder.push_record(row);
101        }
102
103        write!(f, "{}", table_builder.build().with(Style::modern_rounded()))
104    }
105}
106
107impl<T> Length for LoopMomenta<T> {
108    fn is_empty(&self) -> bool {
109        self.0.is_empty()
110    }
111
112    fn len(&self) -> usize {
113        self.0.len()
114    }
115}
116
117pub type Subspace<'a> = Option<&'a [LoopIndex]>; // None means full space
118
119#[derive(Clone, Debug, bincode::Encode, bincode::Decode)]
120pub struct SubspaceData {
121    subgraph: InternalSubGraph,
122    #[bincode(with_serde)]
123    complement_subgraph: SuBitGraph,
124    lmb: LmbIndex,
125    lmb_indices: Vec<LoopIndex>,
126}
127
128impl SubspaceData {
129    /// Canonical coordinates constrained by one threshold solve. Each defining edge is paired
130    /// with its complete signed fundamental cycle, oriented positively along that edge. Parent
131    /// basis slots and changes of the fixed complement do not change this physical identity.
132    pub fn solve_signature(
133        &self,
134        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
135    ) -> Vec<(EdgeIndex, Vec<(EdgeIndex, SignOrZero)>)> {
136        let lmb = self.get_lmb(all_lmbs);
137        let mut signature = self
138            .lmb_indices
139            .iter()
140            .copied()
141            .map(|loop_index| {
142                let defining_edge = lmb.loop_edges[loop_index];
143                let orientation = lmb.edge_signatures[defining_edge].internal[loop_index];
144                assert!(
145                    orientation.is_sign(),
146                    "a defining edge must carry its own loop momentum"
147                );
148                let mut cycle = lmb
149                    .edge_signatures
150                    .iter()
151                    .filter_map(|(edge, signature)| {
152                        let sign = signature.internal[loop_index] * orientation;
153                        sign.is_sign().then_some((edge, sign))
154                    })
155                    .collect::<Vec<_>>();
156                cycle.sort_by_key(|(edge, _)| *edge);
157                (defining_edge, cycle)
158            })
159            .collect::<Vec<_>>();
160        signature.sort_by_key(|(edge, _)| *edge);
161        signature
162    }
163
164    #[cfg(test)]
165    pub(crate) fn has_same_embedding(&self, other: &Self) -> bool {
166        self.lmb == other.lmb
167            && self.lmb_indices == other.lmb_indices
168            && self.subgraph.filter == other.subgraph.filter
169            && self.complement_subgraph == other.complement_subgraph
170    }
171
172    pub(crate) fn has_equivalent_embedding(
173        &self,
174        other: &Self,
175        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
176    ) -> bool {
177        // Different parent bases are equivalent when their selected edge momenta vary along
178        // the same signed cycles. Runtime data may still retain either parent's coordinates.
179        self.solve_signature(all_lmbs) == other.solve_signature(all_lmbs)
180    }
181
182    pub(crate) fn is_mergable_with(
183        &self,
184        other: &Self,
185        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
186    ) -> bool {
187        // Independent radial projections must leave each other's defining edge momenta fixed.
188        // This is invariant under changes of parent basis and permits adding their physical
189        // momentum displacements without copying unrelated native complement coordinates.
190        [(self, other), (other, self)]
191            .into_iter()
192            .all(|(active, fixed)| {
193                let lmb = active.get_lmb(all_lmbs);
194                fixed.iter_basis_edges(all_lmbs).all(|edge| {
195                    active.iter_lmb_indices().all(|loop_index| {
196                        lmb.edge_signatures[edge].internal[loop_index] == SignOrZero::Zero
197                    })
198                })
199            })
200    }
201
202    /// Build the smallest active subspace containing every supplied subspace in one parent LMB.
203    ///
204    /// This test scaffold checks that the overlap wrapper partitions different subspaces before
205    /// solving. Runtime groups never union incompatible coordinates: each threshold retains its
206    /// selected subspace and fixes its complementary coordinates at its own sampled point.
207    #[cfg(test)]
208    pub(crate) fn union_in_common_parent<'a>(
209        subspaces: impl IntoIterator<Item = &'a Self>,
210        graph: &Graph,
211        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
212    ) -> Result<Self> {
213        let mut subspaces = subspaces.into_iter();
214        let first = subspaces
215            .next()
216            .ok_or_else(|| eyre!("Cannot build a common threshold subspace from an empty set"))?;
217        let parent_lmb_index = first.lmb;
218        let mut active_subgraph = first.subgraph.filter.clone();
219        let mut expected_indices = first
220            .lmb_indices
221            .iter()
222            .copied()
223            .collect::<std::collections::BTreeSet<_>>();
224
225        for subspace in subspaces {
226            if subspace.lmb != parent_lmb_index {
227                return Err(eyre!(
228                    "Threshold subspaces use different parent LMBs {} and {}",
229                    usize::from(parent_lmb_index),
230                    usize::from(subspace.lmb),
231                ));
232            }
233            active_subgraph = active_subgraph.union(&subspace.subgraph.filter);
234            expected_indices.extend(subspace.lmb_indices.iter().copied());
235        }
236
237        let mut union =
238            Self::new_with_user_selected_lmb(active_subgraph, parent_lmb_index, graph, all_lmbs)?;
239        let resolved_indices = union
240            .lmb_indices
241            .iter()
242            .copied()
243            .collect::<std::collections::BTreeSet<_>>();
244        if !expected_indices.is_subset(&resolved_indices) {
245            return Err(eyre!(
246                "Common threshold subspace resolved active indices {:?}, which do not contain the requested union {:?}",
247                resolved_indices,
248                expected_indices,
249            ));
250        }
251        // Joining projected subgraphs can close an additional graph-theoretic cycle even though
252        // none of the participating threshold variants varies that coordinate. The common
253        // object is a center-coordinate frame, not another threshold surface: keep precisely the
254        // union of the variants' parent-LMB coordinates and leave every other coordinate fixed.
255        union.lmb_indices = expected_indices.into_iter().collect();
256        Ok(union)
257    }
258
259    fn cleaned_filter_pessimist<E, V, H, N: NodeStorageOps<NodeData = V>>(
260        mut filter: SuBitGraph,
261        graph: &HedgeGraph<E, V, H, N>,
262    ) -> InternalSubGraph {
263        let mut to_remove = SuBitGraph::empty(filter.size());
264
265        for i in filter.included_iter() {
266            if !filter.includes(&graph.inv(i)) {
267                to_remove.add(i);
268            }
269        }
270        filter.subtract_with(&to_remove);
271
272        InternalSubGraph {
273            filter,
274            loopcount: None,
275        }
276    }
277
278    pub(crate) fn new_with_user_selected_lmb(
279        subgraph: SuBitGraph,
280        lmb: LmbIndex,
281        graph: &Graph,
282        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
283    ) -> Result<Self> {
284        let bridges = graph.bridges_of(&subgraph);
285
286        let mut subgraph = Self::cleaned_filter_pessimist(subgraph.subtract(&bridges), graph);
287        subgraph.set_loopcount(graph);
288
289        let edges_in_subgraph = graph
290            .iter_edges_of(&subgraph.filter)
291            .map(|ed| ed.1)
292            .collect::<Vec<_>>();
293        let complement_subgraph = graph.full_filter().subtract(&subgraph.filter);
294
295        let parent_lmb = &all_lmbs[lmb];
296        let compatible_sub_lmb =
297            graph.try_compatible_sub_lmb(&subgraph, graph.full_crown(&subgraph), parent_lmb)?;
298        let mut lmb_indices = compatible_sub_lmb
299            .loop_edges
300            .iter()
301            .map(|edge_index| {
302                parent_lmb
303                    .loop_edges
304                    .iter_enumerated()
305                    .find_map(|(loop_index, parent_edge)| {
306                        (parent_edge == edge_index).then_some(loop_index)
307                    })
308                    .ok_or_else(|| {
309                        eyre!(
310                            "Compatible subgraph LMB edge {:?} is not a defining edge of parent LMB {:?}",
311                            edge_index,
312                            parent_lmb.loop_edges,
313                        )
314                    })
315            })
316            .collect::<Result<Vec<_>>>()?;
317        lmb_indices.sort();
318
319        if lmb_indices.len() != subgraph.loopcount.unwrap()
320            || compatible_sub_lmb.loop_edges.len() != subgraph.loopcount.unwrap()
321        {
322            return Err(color_eyre::eyre::eyre!(
323                "Provided loop momentum basis is not topologically compatible with subgraph, lmb_indices: {:?}, lmb_edges: {:?}, compatible_sub_lmb_edges: {:?}, subgraph loopcount: {}, edges_in_subgraph: {:?}",
324                lmb_indices,
325                parent_lmb.loop_edges,
326                compatible_sub_lmb.loop_edges,
327                subgraph.loopcount.unwrap(),
328                edges_in_subgraph,
329            ));
330        }
331
332        Ok(Self {
333            subgraph,
334            complement_subgraph,
335            lmb,
336            lmb_indices,
337        })
338    }
339
340    /// Build a subspace from defining edges in an already selected parent LMB.
341    ///
342    /// The requested order is intentionally not stored here: [`SubspaceData`] describes the
343    /// geometric active-coordinate set and canonicalizes its loop indices. Callers that attach
344    /// semantic meaning to the user's ordering must retain the original edge list separately.
345    pub(crate) fn new_from_parent_basis_edges(
346        requested_basis_edges: &[EdgeIndex],
347        containing_subgraph: &SuBitGraph,
348        parent_lmb_index: LmbIndex,
349        graph: &Graph,
350        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
351    ) -> Result<Self> {
352        if requested_basis_edges.is_empty() {
353            return Err(eyre!(
354                "A threshold-counterterm subspace must contain at least one basis edge"
355            ));
356        }
357
358        let parent_lmb = all_lmbs.get(parent_lmb_index).ok_or_else(|| {
359            eyre!(
360                "Threshold-counterterm parent LMB {} is out of range ({} generated LMBs)",
361                usize::from(parent_lmb_index),
362                all_lmbs.len(),
363            )
364        })?;
365        let mut requested_indices = Vec::with_capacity(requested_basis_edges.len());
366        let mut seen_basis_edges = std::collections::BTreeSet::new();
367        for &edge in requested_basis_edges {
368            if !seen_basis_edges.insert(edge) {
369                return Err(eyre!(
370                    "Threshold-counterterm subspace repeats basis edge {edge}"
371                ));
372            }
373            let loop_index = parent_lmb
374                .loop_edges
375                .iter_enumerated()
376                .find_map(|(loop_index, &parent_edge)| {
377                    (parent_edge == edge).then_some(loop_index)
378                })
379                .ok_or_else(|| {
380                    eyre!(
381                        "Threshold-counterterm subspace edge {edge} is not a defining edge of parent LMB {:?}",
382                        parent_lmb.loop_edges,
383                    )
384                })?;
385            requested_indices.push(loop_index);
386        }
387
388        // A parent's fundamental cycle contains precisely the edges whose signature carries the
389        // corresponding defining loop momentum. Restricting the union to the inferred cut side
390        // produces the active topology while preserving the sampled complement outside it.
391        let mut cycle_union: SuBitGraph = graph.empty_subgraph();
392        for (edge_pair, edge, _) in graph.iter_edges() {
393            if requested_indices.iter().any(|&loop_index| {
394                parent_lmb.edge_signatures[edge].internal[loop_index] != SignOrZero::Zero
395            }) {
396                cycle_union.add(edge_pair);
397            }
398        }
399        let active_subgraph = cycle_union.intersection(containing_subgraph);
400        let subspace =
401            Self::new_with_user_selected_lmb(active_subgraph, parent_lmb_index, graph, all_lmbs)?;
402
403        let resolved_basis_edges = subspace.iter_basis_edges(all_lmbs).collect::<Vec<_>>();
404        let requested_set = requested_basis_edges
405            .iter()
406            .copied()
407            .collect::<std::collections::BTreeSet<_>>();
408        let resolved_set = resolved_basis_edges
409            .iter()
410            .copied()
411            .collect::<std::collections::BTreeSet<_>>();
412        if subspace.loopcount() != requested_basis_edges.len() || resolved_set != requested_set {
413            return Err(eyre!(
414                "Requested threshold-counterterm basis {:?} resolves to basis {:?} with loop count {} in parent LMB {:?}",
415                requested_basis_edges,
416                resolved_basis_edges,
417                subspace.loopcount(),
418                parent_lmb.loop_edges,
419            ));
420        }
421
422        Ok(subspace)
423    }
424
425    /// this function chooses the lmb automatically based on the subgraph
426    #[allow(dead_code)]
427    pub(crate) fn new(
428        subgraph: SuBitGraph,
429        graph: &Graph,
430        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
431    ) -> Result<Self> {
432        let mut errors = Vec::new();
433        for (lmb_index, _) in all_lmbs.iter_enumerated() {
434            match Self::new_with_user_selected_lmb(subgraph.clone(), lmb_index, graph, all_lmbs) {
435                Ok(subspace) => return Ok(subspace),
436                Err(error) => errors.push(format!("LMB {}: {error:#}", usize::from(lmb_index))),
437            }
438        }
439
440        Err(eyre!(
441            "No topology-compatible loop momentum basis found for subgraph:\n{}",
442            errors.join("\n")
443        ))
444    }
445    pub(crate) fn get_lmb<'a>(
446        &self,
447        all_lmbs: &'a TiVec<LmbIndex, LoopMomentumBasis>,
448    ) -> &'a LoopMomentumBasis {
449        &all_lmbs[self.lmb]
450    }
451
452    pub(crate) fn parent_lmb_index(&self) -> LmbIndex {
453        self.lmb
454    }
455
456    pub(crate) fn does_not_contain<'a>(
457        &'a self,
458        edges: &'a [EdgeIndex],
459        graph: &'a Graph,
460    ) -> impl Iterator<Item = EdgeIndex> + 'a {
461        graph
462            .iter_edges_of(&self.complement_subgraph)
463            .map(|ed| ed.1)
464            .filter(|e| edges.contains(e))
465    }
466
467    pub(crate) fn contains<'a>(
468        &'a self,
469        edges: &'a [EdgeIndex],
470        graph: &'a Graph,
471    ) -> impl Iterator<Item = EdgeIndex> + 'a {
472        graph
473            .iter_edges_of(&self.subgraph)
474            .map(|ed| ed.1)
475            .filter(|e| edges.contains(e))
476    }
477
478    pub(crate) fn project_loop_signature<'a>(
479        &'a self,
480        signature: &'a LoopSignature,
481    ) -> impl Iterator<Item = SignOrZero> + 'a {
482        signature.iter_enumerated().map(|(loop_index, sign)| {
483            if self.lmb_indices.contains(&loop_index) {
484                *sign
485            } else {
486                SignOrZero::Zero
487            }
488        })
489    }
490
491    pub(crate) fn project_loop_signature_filtered<'a>(
492        &'a self,
493        signature: &'a LoopSignature,
494    ) -> impl Iterator<Item = SignOrZero> + 'a {
495        signature
496            .iter_enumerated()
497            .filter_map(move |(loop_index, sign)| {
498                if self.lmb_indices.contains(&loop_index) {
499                    Some(*sign)
500                } else {
501                    None
502                }
503            })
504    }
505
506    pub(crate) fn project_complement_loop_signature<'a>(
507        &'a self,
508        signature: &'a LoopSignature,
509    ) -> impl Iterator<Item = SignOrZero> + 'a {
510        signature.iter_enumerated().map(|(loop_index, sign)| {
511            if !self.lmb_indices.contains(&loop_index) {
512                *sign
513            } else {
514                SignOrZero::Zero
515            }
516        })
517    }
518
519    pub(crate) fn iter_lmb_indices<'a>(&'a self) -> impl Iterator<Item = LoopIndex> + 'a {
520        self.lmb_indices.iter().copied()
521    }
522
523    pub(crate) fn iter_basis_edges<'a>(
524        &'a self,
525        all_lmbs: &'a TiVec<LmbIndex, LoopMomentumBasis>,
526    ) -> impl Iterator<Item = EdgeIndex> + 'a {
527        self.lmb_indices
528            .iter()
529            .map(|&loop_index| self.get_lmb(all_lmbs).loop_edges[loop_index])
530    }
531
532    pub(crate) fn contains_loop_index(&self, loop_index: LoopIndex) -> bool {
533        self.lmb_indices.contains(&loop_index)
534    }
535
536    pub(crate) fn loopcount(&self) -> usize {
537        // `lmb_indices` is the runtime definition of an active coordinate. For ordinary
538        // threshold subspaces its length equals the graph-theoretic subgraph loop count. A
539        // projected common-center frame may deliberately exclude incidental cycles closed by
540        // the union of several variant subgraphs.
541        self.lmb_indices.len()
542    }
543
544    pub(crate) fn as_subspace_simple(&self) -> Subspace<'_> {
545        Some(self.lmb_indices.as_slice())
546    }
547}
548
549// #[comemo::track]
550// impl<T> LoopMomenta<T> {}
551
552impl<T> LoopMomenta<T> {
553    pub(crate) fn iter(&self) -> std::slice::Iter<'_, ThreeMomentum<T>> {
554        self.0.iter()
555    }
556
557    pub(crate) fn first(&self) -> Option<&ThreeMomentum<T>> {
558        self.0.first()
559    }
560
561    pub(crate) fn iter_enumerated(&self) -> impl Iterator<Item = (LoopIndex, &ThreeMomentum<T>)> {
562        self.0.iter().enumerate().map(|(i, m)| (LoopIndex(i), m))
563    }
564}
565
566impl<T> IntoIterator for LoopMomenta<T> {
567    type Item = ThreeMomentum<T>;
568    type IntoIter = std::vec::IntoIter<ThreeMomentum<T>>;
569
570    fn into_iter(self) -> Self::IntoIter {
571        self.0.into_iter()
572    }
573}
574
575impl<T> FromIterator<ThreeMomentum<T>> for LoopMomenta<T> {
576    fn from_iter<I: IntoIterator<Item = ThreeMomentum<T>>>(iter: I) -> Self {
577        LoopMomenta(iter.into_iter().collect())
578    }
579}
580
581impl<T> Index<LoopIndex> for LoopMomenta<T> {
582    type Output = ThreeMomentum<T>;
583
584    fn index(&self, index: LoopIndex) -> &Self::Output {
585        &self.0[index.0]
586    }
587}
588
589impl<T> IndexMut<LoopIndex> for LoopMomenta<T> {
590    fn index_mut(&mut self, index: LoopIndex) -> &mut Self::Output {
591        &mut self.0[index.0]
592    }
593}
594
595impl<T: FloatLike> LoopMomenta<F<T>> {
596    pub(crate) fn hyper_radius_squared(&self, subspace: Subspace) -> F<T> {
597        let zero = self.0[0].px.zero();
598        match subspace {
599            None => self.iter().fold(zero, |acc, x| acc + x.norm_squared()),
600            Some(subspace) => subspace
601                .iter()
602                .fold(zero, |acc, &i| acc + self[i].norm_squared()),
603        }
604    }
605
606    pub(crate) fn rescale(&self, factor: &F<T>, subspace: Subspace) -> Self {
607        match subspace {
608            None => LoopMomenta::from_iter(self.iter().map(|k| k * factor)),
609            // this branch is wrong
610            Some(subspace) => LoopMomenta::from_iter(self.iter_enumerated().map(|(i, k)| {
611                if subspace.contains(&i) {
612                    k * factor
613                } else {
614                    k.clone()
615                }
616            })),
617        }
618    }
619
620    pub(crate) fn rescale_with_hyper_dual(
621        &self,
622        factor: &HyperDual<F<T>>,
623        subspace: Subspace,
624    ) -> LoopMomenta<HyperDual<F<T>>> {
625        match subspace {
626            None => LoopMomenta::from_iter(self.iter().map(|k| {
627                ThreeMomentum::new(
628                    new_constant(factor, &k.px) * factor,
629                    new_constant(factor, &k.py) * factor,
630                    new_constant(factor, &k.pz) * factor,
631                )
632            })),
633            Some(subspace) => LoopMomenta::from_iter(self.iter_enumerated().map(|(i, k)| {
634                if subspace.contains(&i) {
635                    ThreeMomentum::new(
636                        new_constant(factor, &k.px) * factor,
637                        new_constant(factor, &k.py) * factor,
638                        new_constant(factor, &k.pz) * factor,
639                    )
640                } else {
641                    ThreeMomentum::new(
642                        new_constant(factor, &k.px),
643                        new_constant(factor, &k.py),
644                        new_constant(factor, &k.pz),
645                    )
646                }
647            })),
648        }
649    }
650
651    pub(crate) fn rotate(&self, rotation: &Rotation) -> Self {
652        LoopMomenta::from_iter(self.iter().map(|k| k.rotate(rotation)))
653    }
654
655    pub(crate) fn lmb_transform(
656        &self,
657        from: &LoopMomentumBasis,
658        to: &LoopMomentumBasis,
659        externals: &ExternalThreeMomenta<F<T>>,
660    ) -> Self {
661        LoopMomenta::from_iter(
662            to.loop_edges
663                .iter()
664                .map(|e_id| from.edge_signatures[*e_id].compute_momentum(self, externals)),
665        )
666    }
667}
668
669impl<T: FloatLike> LoopMomenta<HyperDual<F<T>>> {
670    pub fn lmb_transform(
671        &self,
672        from: &LoopMomentumBasis,
673        to: &LoopMomentumBasis,
674        externals: &ExternalThreeMomenta<HyperDual<F<T>>>,
675    ) -> Self {
676        LoopMomenta::from_iter(to.loop_edges.iter().map(|e_id| {
677            from.edge_signatures[*e_id]
678                .try_compute_momentum(&self.0, &externals.raw)
679                .unwrap()
680        }))
681    }
682
683    pub fn rescale(&self, factor: &HyperDual<F<T>>, subspace: Subspace) -> Self {
684        match subspace {
685            None => LoopMomenta::from_iter(self.iter().map(|k| k * factor)),
686            // this branch is wrong
687            Some(subspace) => LoopMomenta::from_iter(self.iter_enumerated().map(|(i, k)| {
688                if subspace.contains(&i) {
689                    k * factor
690                } else {
691                    k.clone()
692                }
693            })),
694        }
695    }
696}
697
698impl LoopMomenta<F<f64>> {
699    pub(crate) fn cast<T: FloatLike>(&self) -> LoopMomenta<F<T>> {
700        LoopMomenta::from_iter(self.iter().map(|m| m.map(&|x| F::from_ff64(x))))
701    }
702}
703
704impl<T: FloatLike> Sub<&LoopMomenta<F<T>>> for &LoopMomenta<F<T>> {
705    type Output = LoopMomenta<F<T>>;
706
707    fn sub(self, rhs: &LoopMomenta<F<T>>) -> Self::Output {
708        LoopMomenta::from_iter(self.iter().zip(rhs.iter()).map(|(l, r)| l - r))
709    }
710}
711
712impl<T: FloatLike> Add<&LoopMomenta<F<T>>> for &LoopMomenta<F<T>> {
713    type Output = LoopMomenta<F<T>>;
714
715    fn add(self, rhs: &LoopMomenta<F<T>>) -> Self::Output {
716        LoopMomenta::from_iter(self.iter().zip(rhs.iter()).map(|(l, r)| l + r))
717    }
718}
719
720// define_indexed_vec!(
721//     MyIdx;
722//     pub struct ExternalMomentumtwo<ThreeMomentum<T>>;
723// );
724pub type ExternalThreeMomenta<T> = TiVec<ExternalIndex, ThreeMomentum<T>>;
725// define_indexed_vec!()
726pub type ExternalFourMomenta<T> = TiVec<ExternalIndex, FourMomentum<T>>;
727// impl<T: Display> Display for ExternalFourMomenta<T> {
728//     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729//         let mut table_builder = tabled::builder::Builder::new();
730//         for (i, mom) in self.iter_enumerated() {
731//             let mut row = Vec::new();
732//             row.push(i.to_string());
733//             for m in mom {
734//                 row.push(m.to_string());
735//             }
736//             table_builder.push_record(row);
737//         }
738
739//         write!(f, "{}", table_builder.build().with(Style::modern_rounded()))
740//     }
741// }
742pub type PolarizationVectors<T> = TiVec<ExternalIndex, Polarization<T>>; // should be the same length as #externals
743
744fn extract_external_spatial<T: Clone>(
745    external_four_momenta: &ExternalFourMomenta<T>,
746) -> ExternalThreeMomenta<T> {
747    ExternalThreeMomenta::from_iter(external_four_momenta.iter().map(|fm| fm.spatial.clone()))
748}
749
750#[derive(Debug, Clone)]
751pub struct BareMomentumSample<T: FloatLike> {
752    pub loop_moms: LoopMomenta<F<T>>,
753    pub dual_loop_moms: Option<LoopMomenta<HyperDual<F<T>>>>,
754    pub loop_mom_cache_id: usize,
755    /// Base cache ID for the fundamental loop momentum configuration (before transformations)
756    pub loop_mom_base_cache_id: usize,
757    pub external_moms: ExternalFourMomenta<F<T>>,
758    pub external_mom_cache_id: usize,
759    /// Base cache ID for the fundamental external momentum configuration (before transformations)
760    pub external_mom_base_cache_id: usize,
761    pub jacobian: F<T>,
762    pub orientation: Option<usize>,
763    pub parameterization_branch: Option<usize>,
764}
765
766#[derive(Debug, Clone)]
767pub struct MomentumSample<T: FloatLike> {
768    pub sample: BareMomentumSample<T>,
769    // pub uuid: Uuid,
770}
771
772impl MomentumSample<ArbPrec> {
773    /// Materialize the retained canonical point directly in a physical lane.
774    /// This is not a cast of a previously evaluated lower-precision sample.
775    pub(crate) fn materialize<T: FloatLike>(&self) -> Result<MomentumSample<T>> {
776        let sample = &self.sample;
777        if sample.dual_loop_moms.is_some() {
778            return Err(eyre!(
779                "canonical draw materialization requires scalar source momenta"
780            ));
781        }
782        let spatial = |p: &ThreeMomentum<F<ArbPrec>>| -> Result<ThreeMomentum<F<T>>> {
783            Ok(ThreeMomentum::new(
784                F::from_arb(&p.px.0)?,
785                F::from_arb(&p.py.0)?,
786                F::from_arb(&p.pz.0)?,
787            ))
788        };
789        Ok(MomentumSample {
790            sample: BareMomentumSample {
791                loop_moms: sample
792                    .loop_moms
793                    .iter()
794                    .map(spatial)
795                    .collect::<Result<_>>()?,
796                dual_loop_moms: None,
797                loop_mom_cache_id: sample.loop_mom_cache_id,
798                loop_mom_base_cache_id: sample.loop_mom_base_cache_id,
799                external_moms: sample
800                    .external_moms
801                    .iter()
802                    .map(|p| {
803                        Ok(FourMomentum {
804                            temporal: crate::momentum::Energy {
805                                value: F::from_arb(&p.temporal.value.0)?,
806                            },
807                            spatial: spatial(&p.spatial)?,
808                        })
809                    })
810                    .collect::<Result<_>>()?,
811                external_mom_cache_id: sample.external_mom_cache_id,
812                external_mom_base_cache_id: sample.external_mom_base_cache_id,
813                jacobian: F::from_arb(&sample.jacobian.0)?,
814                orientation: sample.orientation,
815                parameterization_branch: sample.parameterization_branch,
816            },
817        })
818    }
819}
820
821impl<T: FloatLike> Display for MomentumSample<T> {
822    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
823        let mut table = tabled::builder::Builder::new();
824
825        table.push_record(["Sample"]);
826
827        table.push_record(["Loop Momenta", "p_x", "p_y", "p_z"]);
828        // table
829
830        for (index, loop_mom) in self.loop_moms().0.iter().enumerate() {
831            table.push_record([
832                index.to_string(),
833                loop_mom.px.to_string(),
834                loop_mom.py.to_string(),
835                loop_mom.pz.to_string(),
836            ]);
837        }
838
839        table.push_record(["External Momenta", "E", "p_x", "p_y", "p_z"]);
840        for (index, external_mom) in self.external_moms().iter_enumerated() {
841            table.push_record([
842                index.to_string(),
843                external_mom.temporal.to_string(),
844                external_mom.spatial.px.to_string(),
845                external_mom.spatial.py.to_string(),
846                external_mom.spatial.pz.to_string(),
847            ]);
848        }
849
850        table.push_record(["Jacobian".into(), format!("{:+e}", self.sample.jacobian)]);
851        table.build().with(Style::rounded()).fmt(f)
852    }
853}
854
855impl<T: FloatLike> BareMomentumSample<T> {
856    #[inline(never)]
857    pub(crate) fn new(
858        loop_moms: LoopMomenta<F<T>>,
859        loop_mom_cache_id: usize,
860        external_moms: &Externals,
861        external_mom_cache_id: usize,
862        jacobian: F<T>,
863        dependent_momenta_constructor: DependentMomentaConstructor,
864        orientation: Option<usize>,
865    ) -> Result<Self> {
866        let external_moms = external_moms.get_dependent_externals(dependent_momenta_constructor)?;
867
868        Ok(Self {
869            loop_moms,
870            dual_loop_moms: None,
871            loop_mom_cache_id,
872            loop_mom_base_cache_id: loop_mom_cache_id, // Initially same as cache_id
873            external_mom_cache_id,
874            external_mom_base_cache_id: external_mom_cache_id, // Initially same as cache_id
875            external_moms,
876            jacobian,
877            orientation,
878            parameterization_branch: None,
879        })
880    }
881
882    pub(crate) fn one(&self) -> F<T> {
883        if let Some(f) = self.loop_moms.first() {
884            f.px.one()
885        } else if let Some(f) = self.external_moms.first() {
886            f.spatial.px.one()
887        } else {
888            panic!("No momenta in sample")
889        }
890    }
891
892    pub(crate) fn zero(&self) -> F<T> {
893        if let Some(f) = self.loop_moms.first() {
894            f.px.zero()
895        } else if let Some(f) = self.external_moms.first() {
896            f.spatial.px.zero()
897        } else {
898            panic!("No momenta in sample")
899        }
900    }
901
902    /// Cast the sample to a different precision
903    #[inline]
904    fn cast_sample<T2: FloatLike>(&self) -> BareMomentumSample<T2>
905    where
906        F<T2>: From<F<T>>,
907    {
908        BareMomentumSample {
909            loop_mom_cache_id: self.loop_mom_cache_id,
910            loop_mom_base_cache_id: self.loop_mom_base_cache_id,
911            external_mom_cache_id: self.external_mom_cache_id,
912            external_mom_base_cache_id: self.external_mom_base_cache_id,
913            loop_moms: self.loop_moms.iter().map(ThreeMomentum::cast).collect(),
914            dual_loop_moms: self
915                .dual_loop_moms
916                .as_ref()
917                .map(|_dlm| todo!("make sure the cast works if there are hyperdual momenta")),
918            external_moms: self.external_moms.iter().map(FourMomentum::cast).collect(),
919            jacobian: self.jacobian.clone().into(),
920            orientation: self.orientation,
921            parameterization_branch: self.parameterization_branch,
922        }
923    }
924
925    pub(crate) fn higher_precision(&self) -> BareMomentumSample<T::Higher>
926    where
927        T::Higher: FloatLike,
928        T::Lower: FloatLike,
929    {
930        BareMomentumSample {
931            loop_moms: self.loop_moms.iter().map(ThreeMomentum::higher).collect(),
932            dual_loop_moms: self.dual_loop_moms.as_ref().map(|dlm| {
933                LoopMomenta::from_iter(dlm.iter().map(|m| m.clone().map(&|x| x.higher())))
934            }),
935            external_moms: self
936                .external_moms
937                .iter()
938                .map(FourMomentum::higher)
939                .collect(),
940            loop_mom_cache_id: self.loop_mom_cache_id,
941            loop_mom_base_cache_id: self.loop_mom_base_cache_id,
942            external_mom_cache_id: self.external_mom_cache_id,
943            external_mom_base_cache_id: self.external_mom_base_cache_id,
944            jacobian: self.jacobian.higher(),
945            orientation: self.orientation,
946            parameterization_branch: self.parameterization_branch,
947        }
948    }
949
950    pub(crate) fn lower_precision(&self) -> BareMomentumSample<T::Lower>
951    where
952        T::Higher: FloatLike,
953        T::Lower: FloatLike,
954    {
955        BareMomentumSample {
956            loop_moms: self.loop_moms.iter().map(ThreeMomentum::lower).collect(),
957            dual_loop_moms: self.dual_loop_moms.as_ref().map(|dlm| {
958                LoopMomenta::from_iter(dlm.iter().map(|m| m.clone().map(&|x| x.lower())))
959            }),
960            external_moms: self.external_moms.iter().map(FourMomentum::lower).collect(),
961            jacobian: self.jacobian.lower(),
962            orientation: self.orientation,
963            loop_mom_cache_id: self.loop_mom_cache_id,
964            loop_mom_base_cache_id: self.loop_mom_base_cache_id,
965            external_mom_cache_id: self.external_mom_cache_id,
966            external_mom_base_cache_id: self.external_mom_base_cache_id,
967            parameterization_branch: self.parameterization_branch,
968        }
969    }
970
971    #[inline]
972    pub(crate) fn rotate(
973        &self,
974        rotation: &Rotation,
975        loop_mom_cache_id: usize,
976        external_mom_cache_id: usize,
977    ) -> Self {
978        Self {
979            loop_moms: self.loop_moms.iter().map(|l| l.rotate(rotation)).collect(),
980            dual_loop_moms: self
981                .dual_loop_moms
982                .as_ref()
983                .map(|dlm| LoopMomenta::from_iter(dlm.iter().map(|l| l.rotate(rotation)))),
984            external_moms: self
985                .external_moms
986                .iter()
987                .map(|l| l.rotate(rotation))
988                .collect(),
989            loop_mom_cache_id,
990            loop_mom_base_cache_id: self.loop_mom_base_cache_id, // Preserve base cache ID
991            external_mom_cache_id,
992            external_mom_base_cache_id: self.external_mom_base_cache_id, // Preserve base cache ID
993            jacobian: self.jacobian.clone(),
994            orientation: self.orientation,
995            parameterization_branch: self.parameterization_branch,
996        }
997    }
998
999    #[inline]
1000    #[allow(dead_code)]
1001    pub(crate) fn rescaled_loop_momenta(&self, factor: &F<T>, subspace: Subspace) -> Self {
1002        Self {
1003            loop_moms: self.loop_moms.rescale(factor, subspace),
1004            dual_loop_moms: self
1005                .dual_loop_moms
1006                .as_ref()
1007                .map(|dlm| dlm.rescale(&new_constant(&dlm[LoopIndex(0)].px, factor), subspace)),
1008            loop_mom_cache_id: self.loop_mom_cache_id + 1,
1009            loop_mom_base_cache_id: self.loop_mom_base_cache_id, // Preserve base cache ID
1010            external_moms: self.external_moms.clone(),
1011            external_mom_cache_id: self.external_mom_cache_id,
1012            external_mom_base_cache_id: self.external_mom_base_cache_id, // Preserve base cache ID
1013            jacobian: self.jacobian.clone(),
1014            orientation: self.orientation,
1015            parameterization_branch: self.parameterization_branch,
1016        }
1017    }
1018
1019    #[inline]
1020    pub(crate) fn lmb_transform(&self, from: &LoopMomentumBasis, to: &LoopMomentumBasis) -> Self {
1021        Self {
1022            loop_moms: self.loop_moms.lmb_transform(
1023                from,
1024                to,
1025                &extract_external_spatial(&self.external_moms),
1026            ),
1027            dual_loop_moms: self.dual_loop_moms.as_ref().map(|dlm| {
1028                let dual_externals = self
1029                    .external_moms
1030                    .iter()
1031                    .map(|four_mom| {
1032                        ThreeMomentum::new(
1033                            new_constant(&dlm[LoopIndex(0)].px, &four_mom.spatial.px),
1034                            new_constant(&dlm[LoopIndex(0)].px, &four_mom.spatial.py),
1035                            new_constant(&dlm[LoopIndex(0)].px, &four_mom.spatial.pz),
1036                        )
1037                    })
1038                    .collect();
1039                dlm.lmb_transform(from, to, &dual_externals)
1040            }),
1041            loop_mom_cache_id: self.loop_mom_cache_id + 1,
1042            loop_mom_base_cache_id: self.loop_mom_base_cache_id, // Preserve base cache ID
1043            external_moms: self.external_moms.clone(),
1044            external_mom_cache_id: self.external_mom_cache_id,
1045            external_mom_base_cache_id: self.external_mom_base_cache_id, // Preserve base cache ID
1046            jacobian: self.jacobian.clone(),
1047            orientation: self.orientation,
1048            parameterization_branch: self.parameterization_branch,
1049        }
1050    }
1051}
1052
1053impl<T: FloatLike> MomentumSample<T> {
1054    pub(crate) fn orientations<'a, OID: From<usize> + Copy + IndexLike>(
1055        &self,
1056        filter: &'a SubSet<OID>,
1057        orientations: &'a TiVec<OID, EdgeVec<Orientation>>,
1058    ) -> SingleOrAllOrientations<'a, OID>
1059    where
1060        usize: From<OID>,
1061    {
1062        if let Some(o) = self.sample.orientation {
1063            let id = if filter.is_full() {
1064                OID::from(o)
1065            } else {
1066                filter
1067                    .included_iter()
1068                    .nth(o)
1069                    .unwrap_or_else(|| {
1070                        panic!(
1071                            "sampled orientation index {o} must resolve within the filtered orientation subset"
1072                        )
1073                    })
1074            };
1075            SingleOrAllOrientations::Single {
1076                id,
1077                orientation: &orientations[id],
1078            }
1079        } else {
1080            SingleOrAllOrientations::All {
1081                all: orientations,
1082                filter,
1083            }
1084        }
1085    }
1086
1087    // pub(crate) fn uuid(&self) -> Option<Uuid> {
1088    //     if self.rotated_sample.is_some() {
1089    //         None
1090    //     } else {
1091    //         Some(self.uuid)
1092    //     }
1093    // }
1094
1095    pub(crate) fn loop_moms(&self) -> &LoopMomenta<F<T>> {
1096        &self.sample.loop_moms
1097    }
1098
1099    pub(crate) fn external_moms(&self) -> &ExternalFourMomenta<F<T>> {
1100        &self.sample.external_moms
1101    }
1102
1103    pub(crate) fn jacobian(&self) -> F<T> {
1104        self.sample.jacobian.clone()
1105    }
1106
1107    pub(crate) fn new(
1108        loop_moms: LoopMomenta<F<T>>,
1109        loop_mom_cache_id: usize,
1110        external_moms: &Externals,
1111        external_mom_cache_id: usize,
1112        jacobian: F<T>,
1113        dependent_momenta_constructor: DependentMomentaConstructor,
1114        orientation: Option<usize>,
1115    ) -> Result<Self> {
1116        // info!("New with ext cache id:{external_mom_cache_id}");
1117        Ok(Self {
1118            sample: BareMomentumSample::new(
1119                loop_moms,
1120                loop_mom_cache_id,
1121                external_moms,
1122                external_mom_cache_id,
1123                jacobian,
1124                dependent_momenta_constructor,
1125                orientation,
1126            )?,
1127            // uuid: Uuid::new_v4(),
1128        })
1129    }
1130
1131    pub(crate) fn one(&self) -> F<T> {
1132        self.sample.one()
1133    }
1134
1135    pub(crate) fn zero(&self) -> F<T> {
1136        self.sample.zero()
1137    }
1138
1139    /// Cast the sample to a different precision
1140    #[inline]
1141    pub(crate) fn cast_sample<T2: FloatLike>(&self) -> MomentumSample<T2>
1142    where
1143        F<T2>: From<F<T>>,
1144    {
1145        MomentumSample {
1146            sample: self.sample.cast_sample(),
1147        }
1148    }
1149
1150    pub(crate) fn higher_precision(&self) -> MomentumSample<T::Higher>
1151    where
1152        T::Higher: FloatLike + Default,
1153        T::Lower: FloatLike + Default,
1154    {
1155        MomentumSample {
1156            sample: self.sample.higher_precision(),
1157        }
1158    }
1159
1160    pub(crate) fn lower_precision(&self) -> MomentumSample<T::Lower>
1161    where
1162        T::Lower: FloatLike + Default,
1163        T::Higher: FloatLike + Default,
1164    {
1165        MomentumSample {
1166            sample: self.sample.lower_precision(),
1167        }
1168    }
1169
1170    #[inline]
1171    pub(crate) fn rotate(
1172        &self,
1173        rotation: &Rotation,
1174        loop_mom_cache_id: usize,
1175        external_mom_cache_id: usize,
1176    ) -> Self {
1177        Self {
1178            sample: self
1179                .sample
1180                .rotate(rotation, loop_mom_cache_id, external_mom_cache_id),
1181        }
1182    }
1183
1184    #[allow(dead_code)]
1185    #[inline]
1186    pub(crate) fn rescaled_loop_momenta(&self, factor: &F<T>, subspace: Subspace) -> Self {
1187        Self {
1188            sample: self.sample.rescaled_loop_momenta(factor, subspace),
1189        }
1190    }
1191
1192    #[inline]
1193    pub(crate) fn lmb_transform(&self, from: &LoopMomentumBasis, to: &LoopMomentumBasis) -> Self {
1194        Self {
1195            sample: self.sample.lmb_transform(from, to),
1196        }
1197    }
1198}
1199
1200#[cfg(test)]
1201mod subspace_tests {
1202    use super::*;
1203    use crate::{
1204        dot,
1205        graph::{lmb::LMBwithEdges, parse::from_dot::IntoGraph},
1206        initialisation::test_initialise,
1207    };
1208    use typed_index_collections::ti_vec;
1209
1210    #[test]
1211    fn canonical_momentum_materialization_preserves_source_metadata() {
1212        let one = F::<ArbPrec>::default().one();
1213        let mut source = MomentumSample {
1214            sample: BareMomentumSample {
1215                loop_moms: LoopMomenta::from_iter([ThreeMomentum::new(
1216                    one.clone(),
1217                    -&one,
1218                    one.zero(),
1219                )]),
1220                dual_loop_moms: None,
1221                loop_mom_cache_id: 17,
1222                loop_mom_base_cache_id: 11,
1223                external_moms: ti_vec![FourMomentum::from_args(
1224                    one.from_i64(4),
1225                    one.zero(),
1226                    one.clone(),
1227                    one.zero()
1228                )],
1229                external_mom_cache_id: 23,
1230                external_mom_base_cache_id: 19,
1231                jacobian: one.from_i64(3),
1232                orientation: Some(7),
1233                parameterization_branch: Some(2),
1234            },
1235        };
1236        fn check<T: FloatLike>(source: &MomentumSample<ArbPrec>) {
1237            let native = source.materialize::<T>().unwrap();
1238            assert_eq!(native.sample.loop_mom_cache_id, 17);
1239            assert_eq!(native.sample.loop_mom_base_cache_id, 11);
1240            assert_eq!(native.sample.external_mom_cache_id, 23);
1241            assert_eq!(native.sample.external_mom_base_cache_id, 19);
1242            assert_eq!(native.sample.orientation, Some(7));
1243            assert_eq!(native.sample.parameterization_branch, Some(2));
1244            assert_eq!(native.sample.jacobian, native.one().from_i64(3));
1245            assert_eq!(native.sample.loop_moms[LoopIndex(0)].py, -native.one());
1246        }
1247        check::<f64>(&source);
1248        check::<crate::utils::QuadFloat>(&source);
1249        check::<ArbPrec>(&source);
1250        source.sample.dual_loop_moms = Some(LoopMomenta(vec![]));
1251        assert!(
1252            source
1253                .materialize::<f64>()
1254                .unwrap_err()
1255                .to_string()
1256                .contains("scalar source")
1257        );
1258    }
1259
1260    #[test]
1261    fn selected_cycle_identity_and_radial_projection_survive_parent_changes() {
1262        test_initialise().unwrap();
1263        let graph: Graph = dot!(digraph selected_cycles {
1264            ext [style=invis]
1265            edge [num=1 mass=0]
1266            node [num=1]
1267            ext->a:0 [id=0]
1268            a->b [id=1 name=a1]
1269            b->a [id=2 name=a2]
1270            a->b [id=3 name=a3]
1271            b->c [id=4 name=b1]
1272            c->b [id=5 name=b2]
1273            b->c [id=6 name=b3]
1274            ext->c:1 [id=7]
1275        })
1276        .unwrap();
1277        let edges = ["a1", "a2", "a3", "b1", "b2", "b3"]
1278            .map(|name| graph.edge_name_to_index(name).unwrap());
1279        let parent = graph
1280            .lmb_with_loop_edges([edges[0], edges[2], edges[3], edges[5]].as_slice())
1281            .unwrap();
1282        let mut alternate = graph
1283            .lmb_with_loop_edges([edges[0], edges[2], edges[4], edges[5]].as_slice())
1284            .unwrap();
1285        let old_slot = alternate
1286            .loop_edges
1287            .iter_enumerated()
1288            .find_map(|(index, edge)| (*edge == edges[0]).then_some(index))
1289            .unwrap();
1290        alternate.swap_loops(
1291            old_slot,
1292            LoopIndex((old_slot.0 + 1) % alternate.loop_edges.len()),
1293        );
1294        let different_cycle = graph
1295            .lmb_with_loop_edges([edges[0], edges[1], edges[3], edges[5]].as_slice())
1296            .unwrap();
1297        let lmbs = ti_vec![parent, alternate, different_cycle];
1298        let subspaces = (0..3)
1299            .map(|index| {
1300                SubspaceData::new_from_parent_basis_edges(
1301                    &[edges[0]],
1302                    &graph.full_filter(),
1303                    LmbIndex::from(index),
1304                    &graph,
1305                    &lmbs,
1306                )
1307                .unwrap()
1308            })
1309            .collect::<Vec<_>>();
1310        assert_ne!(subspaces[0].lmb_indices, subspaces[1].lmb_indices);
1311        assert_eq!(
1312            subspaces[0].solve_signature(&lmbs),
1313            subspaces[1].solve_signature(&lmbs)
1314        );
1315        assert!(subspaces[0].has_equivalent_embedding(&subspaces[1], &lmbs));
1316        assert_ne!(
1317            subspaces[0].solve_signature(&lmbs),
1318            subspaces[2].solve_signature(&lmbs)
1319        );
1320        assert!(!subspaces[0].has_equivalent_embedding(&subspaces[2], &lmbs));
1321        let independent = SubspaceData::new_from_parent_basis_edges(
1322            &[edges[5]],
1323            &graph.full_filter(),
1324            LmbIndex::from(1),
1325            &graph,
1326            &lmbs,
1327        )
1328        .unwrap();
1329        assert!(subspaces[0].is_mergable_with(&independent, &lmbs));
1330        assert!(!subspaces[0].is_mergable_with(&subspaces[2], &lmbs));
1331
1332        let momenta = LoopMomenta::from_iter(
1333            (0..4).map(|i| ThreeMomentum::new(F(i as f64 + 1.0), F(2.0), F(-3.0))),
1334        );
1335        let externals = [
1336            ThreeMomentum::new(F(3.0), F(2.0), F(1.0)),
1337            ThreeMomentum::new(F(-3.0), F(-2.0), F(-1.0)),
1338        ]
1339        .into_iter()
1340        .collect();
1341        let transformed = momenta.lmb_transform(
1342            &lmbs[LmbIndex::from(0)],
1343            &lmbs[LmbIndex::from(1)],
1344            &externals,
1345        );
1346        let mut centers = [momenta.clone(), momenta.clone()];
1347        for (center, subspace) in centers.iter_mut().zip(&subspaces) {
1348            for momentum in &mut center.0 {
1349                *momentum = ThreeMomentum::new(F(0.0), F(0.0), F(0.0));
1350            }
1351            center[subspace.lmb_indices[0]] = ThreeMomentum::new(F(4.0), F(-5.0), F(6.0));
1352        }
1353        let shifted = &momenta - &centers[0];
1354        let alternate_shifted = &transformed - &centers[1];
1355        assert_eq!(
1356            shifted.hyper_radius_squared(subspaces[0].as_subspace_simple()),
1357            alternate_shifted.hyper_radius_squared(subspaces[1].as_subspace_simple()),
1358        );
1359        let projected = &shifted.rescale(&F(0.37), subspaces[0].as_subspace_simple()) + &centers[0];
1360        let alternate_projected =
1361            &alternate_shifted.rescale(&F(0.37), subspaces[1].as_subspace_simple()) + &centers[1];
1362        let expected = projected.lmb_transform(
1363            &lmbs[LmbIndex::from(0)],
1364            &lmbs[LmbIndex::from(1)],
1365            &externals,
1366        );
1367        for (actual, expected) in alternate_projected.iter().zip(expected.iter()) {
1368            assert!((actual - expected).norm_squared() < F(1e-24));
1369        }
1370    }
1371
1372    #[test]
1373    fn explicit_parent_basis_edges_recover_their_fundamental_cycle_subspace() {
1374        test_initialise().unwrap();
1375        let graph: Graph = dot!(digraph explicit_basis_subspace {
1376            ext [style=invis]
1377            edge [num=1 mass=0]
1378            node [num=1]
1379            ext->v1:0 [id=0]
1380            v1->v2 [id=1]
1381            v2->v1 [id=2]
1382            v1->v2 [id=3]
1383            v2->v1 [id=4]
1384            ext->v2:1 [id=5]
1385        })
1386        .unwrap();
1387        let all_lmbs = ti_vec![graph.loop_momentum_basis.clone()];
1388        let requested = [
1389            graph.loop_momentum_basis.loop_edges[LoopIndex(0)],
1390            graph.loop_momentum_basis.loop_edges[LoopIndex(2)],
1391        ];
1392        let containing_subgraph: SuBitGraph = graph.full_filter();
1393
1394        let subspace = SubspaceData::new_from_parent_basis_edges(
1395            &requested,
1396            &containing_subgraph,
1397            LmbIndex::from(0),
1398            &graph,
1399            &all_lmbs,
1400        )
1401        .unwrap();
1402
1403        assert_eq!(subspace.loopcount(), requested.len());
1404        assert_eq!(
1405            subspace
1406                .iter_basis_edges(&all_lmbs)
1407                .collect::<std::collections::BTreeSet<_>>(),
1408            requested
1409                .into_iter()
1410                .collect::<std::collections::BTreeSet<_>>(),
1411        );
1412        let nested_subspace = SubspaceData::new_from_parent_basis_edges(
1413            &[requested[0]],
1414            &containing_subgraph,
1415            LmbIndex::from(0),
1416            &graph,
1417            &all_lmbs,
1418        )
1419        .unwrap();
1420        let common_subspace =
1421            SubspaceData::union_in_common_parent([&nested_subspace, &subspace], &graph, &all_lmbs)
1422                .unwrap();
1423        assert_eq!(
1424            common_subspace
1425                .iter_basis_edges(&all_lmbs)
1426                .collect::<std::collections::BTreeSet<_>>(),
1427            requested
1428                .into_iter()
1429                .collect::<std::collections::BTreeSet<_>>(),
1430        );
1431        let disjoint_subspace = SubspaceData::new_from_parent_basis_edges(
1432            &[requested[1]],
1433            &containing_subgraph,
1434            LmbIndex::from(0),
1435            &graph,
1436            &all_lmbs,
1437        )
1438        .unwrap();
1439        let sparse_common_subspace = SubspaceData::union_in_common_parent(
1440            [&nested_subspace, &disjoint_subspace],
1441            &graph,
1442            &all_lmbs,
1443        )
1444        .unwrap();
1445        assert_eq!(sparse_common_subspace.loopcount(), 2);
1446        assert_eq!(
1447            sparse_common_subspace
1448                .iter_basis_edges(&all_lmbs)
1449                .collect::<std::collections::BTreeSet<_>>(),
1450            requested
1451                .into_iter()
1452                .collect::<std::collections::BTreeSet<_>>(),
1453        );
1454        assert!(
1455            SubspaceData::new_from_parent_basis_edges(
1456                &[requested[0], requested[0]],
1457                &containing_subgraph,
1458                LmbIndex::from(0),
1459                &graph,
1460                &all_lmbs,
1461            )
1462            .unwrap_err()
1463            .to_string()
1464            .contains("repeats basis edge")
1465        );
1466    }
1467}