Skip to main content

gammalooprs/observables/
events.rs

1use super::clustering::ClusteringResult;
2use crate::momentum::{FourMomentum, Rotation};
3use crate::utils::{F, FloatLike, into_complex_ff64};
4use colored::Colorize;
5use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};
6use smallvec::SmallVec;
7use spenso::algebra::complex::Complex;
8use std::collections::BTreeMap;
9use std::fmt;
10use std::ops::{Deref, DerefMut};
11use tabled::{
12    Table, Tabled,
13    settings::{Style, style::HorizontalLine},
14};
15
16#[derive(Default, Debug, Clone, Serialize, Deserialize)]
17pub struct CutInfo {
18    pub particle_pdgs: (SmallVec<[isize; 2]>, SmallVec<[isize; 4]>),
19    pub cut_id: usize,
20    pub graph_id: usize,
21    pub graph_group_id: Option<usize>,
22    pub orientation_id: Option<usize>,
23    pub sampling_channel_id: Option<usize>,
24    pub sampling_channel_edge_ids: Option<SmallVec<[usize; 4]>>,
25}
26
27pub type Event = GenericEvent<f64>;
28pub type EventGroup = GenericEventGroup<f64>;
29pub type EventGroupList = GenericEventGroupList<f64>;
30
31#[derive(Default, Debug, Clone)]
32pub struct GenericDerivedEventData<T: FloatLike> {
33    pub clustered_jets: Vec<Option<ClusteringResult<T>>>,
34}
35
36impl<T: FloatLike> GenericDerivedEventData<T> {
37    pub fn to_f64(&self) -> GenericDerivedEventData<f64> {
38        GenericDerivedEventData {
39            clustered_jets: self
40                .clustered_jets
41                .iter()
42                .map(|result| result.as_ref().map(ClusteringResult::to_f64))
43                .collect(),
44        }
45    }
46
47    pub fn from_f64(data: &GenericDerivedEventData<f64>) -> Self {
48        GenericDerivedEventData {
49            clustered_jets: data
50                .clustered_jets
51                .iter()
52                .map(|result| result.as_ref().map(ClusteringResult::from_f64))
53                .collect(),
54        }
55    }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
59pub enum AdditionalWeightKey {
60    FullMultiplicativeFactor,
61    Original,
62    ThresholdCounterterm {
63        subset_index: usize,
64    },
65    AmplitudeThresholdCounterterm {
66        esurface_id: usize,
67        overlap_group: usize,
68    },
69    AmplitudeThresholdCountertermVariant {
70        variant_id: usize,
71        esurface_id: usize,
72        overlap_group: usize,
73    },
74}
75
76#[derive(Default, Debug, Clone, Serialize, Deserialize)]
77pub struct GenericEventGroup<T: FloatLike>(pub Vec<GenericEvent<T>>);
78
79impl<T: FloatLike> Deref for GenericEventGroup<T> {
80    type Target = Vec<GenericEvent<T>>;
81
82    fn deref(&self) -> &Self::Target {
83        &self.0
84    }
85}
86
87impl<T: FloatLike> DerefMut for GenericEventGroup<T> {
88    fn deref_mut(&mut self) -> &mut Self::Target {
89        &mut self.0
90    }
91}
92
93impl<T: FloatLike> GenericEventGroup<T> {
94    pub fn to_f64(&self) -> EventGroup {
95        GenericEventGroup(self.0.iter().map(GenericEvent::to_f64).collect())
96    }
97
98    pub fn from_f64(event_group: &EventGroup) -> Self {
99        GenericEventGroup(event_group.0.iter().map(GenericEvent::from_f64).collect())
100    }
101}
102
103#[derive(Default, Debug, Clone, Serialize, Deserialize)]
104pub struct GenericEventGroupList<T: FloatLike>(pub Vec<GenericEventGroup<T>>);
105
106impl<T: FloatLike> Deref for GenericEventGroupList<T> {
107    type Target = Vec<GenericEventGroup<T>>;
108
109    fn deref(&self) -> &Self::Target {
110        &self.0
111    }
112}
113
114impl<T: FloatLike> DerefMut for GenericEventGroupList<T> {
115    fn deref_mut(&mut self) -> &mut Self::Target {
116        &mut self.0
117    }
118}
119
120impl<T: FloatLike> GenericEventGroupList<T> {
121    pub fn to_f64(&self) -> EventGroupList {
122        GenericEventGroupList(self.0.iter().map(GenericEventGroup::to_f64).collect())
123    }
124
125    pub fn from_f64(event_group_list: &EventGroupList) -> Self {
126        GenericEventGroupList(
127            event_group_list
128                .0
129                .iter()
130                .map(GenericEventGroup::from_f64)
131                .collect(),
132        )
133    }
134
135    pub fn push_singleton(&mut self, event: GenericEvent<T>) {
136        self.0.push(GenericEventGroup(vec![event]));
137    }
138}
139
140#[derive(Default, Debug, Clone, Deserialize)]
141pub struct GenericAdditionalWeightInfo<T: FloatLike> {
142    #[serde(
143        with = "crate::utils::serde_utils::map_as_pairs",
144        bound(deserialize = "T: Deserialize<'de>")
145    )]
146    pub weights: BTreeMap<AdditionalWeightKey, Complex<F<T>>>,
147    /// Detailed, addable threshold-counterterm decomposition. It is absent on the legacy path so
148    /// no-directive human-readable event serialization remains unchanged.
149    #[serde(default)]
150    pub threshold_counterterms: Option<GenericThresholdCountertermEventInfo<T>>,
151}
152
153impl<T: FloatLike> Serialize for GenericAdditionalWeightInfo<T> {
154    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
155        // Positional formats require the None marker; only named fields can be omitted.
156        let include_thresholds =
157            !serializer.is_human_readable() || self.threshold_counterterms.is_some();
158        let mut fields = serializer.serialize_struct(
159            "GenericAdditionalWeightInfo",
160            1 + usize::from(include_thresholds),
161        )?;
162        fields.serialize_field("weights", &self.weights.iter().collect::<Vec<_>>())?;
163        if include_thresholds {
164            fields.serialize_field("threshold_counterterms", &self.threshold_counterterms)?;
165        }
166        fields.end()
167    }
168}
169
170impl<T: FloatLike> GenericAdditionalWeightInfo<T> {
171    pub fn to_f64(&self) -> GenericAdditionalWeightInfo<f64> {
172        GenericAdditionalWeightInfo {
173            weights: self
174                .weights
175                .iter()
176                .map(|(key, weight)| (*key, into_complex_ff64(weight)))
177                .collect(),
178            threshold_counterterms: self
179                .threshold_counterterms
180                .as_ref()
181                .map(GenericThresholdCountertermEventInfo::to_f64),
182        }
183    }
184
185    pub fn from_f64(additional_weights: &GenericAdditionalWeightInfo<f64>) -> Self {
186        GenericAdditionalWeightInfo {
187            weights: additional_weights
188                .weights
189                .iter()
190                .map(|(key, weight)| {
191                    (
192                        *key,
193                        Complex::new(F::from_ff64(weight.re), F::from_ff64(weight.im)),
194                    )
195                })
196                .collect(),
197            threshold_counterterms: additional_weights
198                .threshold_counterterms
199                .as_ref()
200                .map(GenericThresholdCountertermEventInfo::from_f64),
201        }
202    }
203}
204
205/// Runtime provenance distinguishing repeated uses of one static expanded component.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(remote = "Self", tag = "kind", rename_all = "snake_case")]
208pub enum ThresholdCountertermComponentOccurrence {
209    Amplitude {
210        raised_esurface_id: usize,
211        overlap_group: usize,
212    },
213    LocalUnitarity {
214        /// One entry for a single component and left/right entries for an iterated component.
215        overlap_groups: SmallVec<[usize; 2]>,
216        left_threshold_order: Option<usize>,
217        right_threshold_order: Option<usize>,
218        lu_cut_order: Option<usize>,
219    },
220}
221
222// Positional formats use enum discriminants; the public JSON keeps its `kind` tag.
223// Serde's remote derives construct the owning enum directly, without a second runtime type.
224#[derive(Serialize, Deserialize)]
225#[serde(remote = "ThresholdCountertermComponentOccurrence")]
226enum BinaryThresholdCountertermComponentOccurrence {
227    Amplitude {
228        raised_esurface_id: usize,
229        overlap_group: usize,
230    },
231    LocalUnitarity {
232        overlap_groups: SmallVec<[usize; 2]>,
233        left_threshold_order: Option<usize>,
234        right_threshold_order: Option<usize>,
235        lu_cut_order: Option<usize>,
236    },
237}
238
239impl Serialize for ThresholdCountertermComponentOccurrence {
240    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
241        if serializer.is_human_readable() {
242            Self::serialize(self, serializer)
243        } else {
244            BinaryThresholdCountertermComponentOccurrence::serialize(self, serializer)
245        }
246    }
247}
248
249impl<'de> Deserialize<'de> for ThresholdCountertermComponentOccurrence {
250    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
251        if deserializer.is_human_readable() {
252            Self::deserialize(deserializer)
253        } else {
254            BinaryThresholdCountertermComponentOccurrence::deserialize(deserializer)
255        }
256    }
257}
258
259/// One numeric use of a graph-registry component in an event.
260///
261/// `bare` and `weighted` include the counterterm sign and every event normalization. `bare`
262/// excludes only the user multiplier. An exact-zero multiplier may skip the expensive bare
263/// evaluation, in which case `bare` is `None`, `weighted` is exact zero, and
264/// `evaluation_skipped` is true.
265#[derive(Debug, Clone, Deserialize)]
266pub struct GenericThresholdCountertermComponentWeight<T: FloatLike> {
267    pub component_id: usize,
268    pub occurrence: ThresholdCountertermComponentOccurrence,
269    pub multiplier_values: SmallVec<[F<T>; 2]>,
270    pub effective_multiplier: F<T>,
271    #[serde(default)]
272    pub bare: Option<Complex<F<T>>>,
273    pub weighted: Complex<F<T>>,
274    pub evaluation_skipped: bool,
275}
276
277impl<T: FloatLike> Serialize for GenericThresholdCountertermComponentWeight<T> {
278    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
279        let include_bare = !serializer.is_human_readable() || self.bare.is_some();
280        let mut fields = serializer.serialize_struct(
281            "GenericThresholdCountertermComponentWeight",
282            6 + usize::from(include_bare),
283        )?;
284        fields.serialize_field("component_id", &self.component_id)?;
285        fields.serialize_field("occurrence", &self.occurrence)?;
286        fields.serialize_field("multiplier_values", &self.multiplier_values)?;
287        fields.serialize_field("effective_multiplier", &self.effective_multiplier)?;
288        if include_bare {
289            fields.serialize_field("bare", &self.bare)?;
290        }
291        fields.serialize_field("weighted", &self.weighted)?;
292        fields.serialize_field("evaluation_skipped", &self.evaluation_skipped)?;
293        fields.end()
294    }
295}
296
297impl<T: FloatLike> GenericThresholdCountertermComponentWeight<T> {
298    fn to_f64(&self) -> GenericThresholdCountertermComponentWeight<f64> {
299        GenericThresholdCountertermComponentWeight {
300            component_id: self.component_id,
301            occurrence: self.occurrence.clone(),
302            multiplier_values: self.multiplier_values.iter().map(F::into_ff64).collect(),
303            effective_multiplier: self.effective_multiplier.into_ff64(),
304            bare: self.bare.as_ref().map(into_complex_ff64),
305            weighted: into_complex_ff64(&self.weighted),
306            evaluation_skipped: self.evaluation_skipped,
307        }
308    }
309
310    fn from_f64(
311        component: &GenericThresholdCountertermComponentWeight<f64>,
312    ) -> GenericThresholdCountertermComponentWeight<T> {
313        GenericThresholdCountertermComponentWeight {
314            component_id: component.component_id,
315            occurrence: component.occurrence.clone(),
316            multiplier_values: component
317                .multiplier_values
318                .iter()
319                .copied()
320                .map(F::from_ff64)
321                .collect(),
322            effective_multiplier: F::from_ff64(component.effective_multiplier),
323            bare: component
324                .bare
325                .as_ref()
326                .map(|bare| Complex::new(F::from_ff64(bare.re), F::from_ff64(bare.im))),
327            weighted: Complex::new(
328                F::from_ff64(component.weighted.re),
329                F::from_ff64(component.weighted.im),
330            ),
331            evaluation_skipped: component.evaluation_skipped,
332        }
333    }
334}
335
336/// Addable threshold-counterterm decomposition for one event. The original contribution appears
337/// exactly once and is not assigned a synthetic component ID.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct GenericThresholdCountertermEventInfo<T: FloatLike> {
340    pub original: Complex<F<T>>,
341    pub components: Vec<GenericThresholdCountertermComponentWeight<T>>,
342}
343
344impl<T: FloatLike> GenericThresholdCountertermEventInfo<T> {
345    pub fn total(&self) -> Complex<F<T>> {
346        self.components
347            .iter()
348            .fold(self.original.clone(), |total, component| {
349                total + &component.weighted
350            })
351    }
352
353    /// Apply an event-level normalization without changing dimensionless multiplier values.
354    pub fn apply_multiplicative_factor(&mut self, factor: &Complex<F<T>>) {
355        self.original *= factor;
356        for component in &mut self.components {
357            if let Some(bare) = &mut component.bare {
358                *bare *= factor;
359            }
360            component.weighted *= factor;
361        }
362    }
363
364    pub fn to_f64(&self) -> GenericThresholdCountertermEventInfo<f64> {
365        GenericThresholdCountertermEventInfo {
366            original: into_complex_ff64(&self.original),
367            components: self
368                .components
369                .iter()
370                .map(GenericThresholdCountertermComponentWeight::to_f64)
371                .collect(),
372        }
373    }
374
375    pub fn from_f64(info: &GenericThresholdCountertermEventInfo<f64>) -> Self {
376        GenericThresholdCountertermEventInfo {
377            original: Complex::new(
378                F::from_ff64(info.original.re),
379                F::from_ff64(info.original.im),
380            ),
381            components: info
382                .components
383                .iter()
384                .map(GenericThresholdCountertermComponentWeight::from_f64)
385                .collect(),
386        }
387    }
388}
389
390#[derive(Default, Debug, Clone, Serialize, Deserialize)]
391pub struct GenericEvent<T: FloatLike> {
392    #[allow(clippy::type_complexity)]
393    pub kinematic_configuration: (
394        SmallVec<[FourMomentum<F<T>>; 2]>,
395        SmallVec<[FourMomentum<F<T>>; 4]>,
396    ),
397    pub cut_info: CutInfo,
398    // Stores the fully normalized contribution associated with this event.
399    pub weight: Complex<F<T>>,
400    // Contains additional partial weights for future use to do post-processing reweighting.
401    pub additional_weights: GenericAdditionalWeightInfo<T>,
402    #[serde(skip)]
403    pub derived_observable_data: GenericDerivedEventData<T>,
404}
405
406impl<T: FloatLike> GenericEvent<T> {
407    /// Scale the event and its addable threshold decomposition through the same normalization
408    /// boundary. The detailed path is made authoritative so its sum equals the stored event.
409    pub(crate) fn apply_multiplicative_factor(&mut self, factor: &Complex<F<T>>) {
410        self.weight *= factor;
411        if let Some(decomposition) = &mut self.additional_weights.threshold_counterterms {
412            decomposition.apply_multiplicative_factor(factor);
413            self.weight = decomposition.total();
414        }
415    }
416
417    pub(crate) fn inverse_rotate(&mut self, rotation: &Rotation) {
418        if rotation.is_identity() {
419            return;
420        }
421
422        for momentum in self.kinematic_configuration.0.iter_mut() {
423            *momentum = rotation.inverse_rotate_four(momentum);
424        }
425        for momentum in self.kinematic_configuration.1.iter_mut() {
426            *momentum = rotation.inverse_rotate_four(momentum);
427        }
428
429        // Any cached clustering belongs to the rotated frame and must be recomputed.
430        self.derived_observable_data = GenericDerivedEventData::default();
431    }
432
433    pub fn ensure_clustering_slots(&mut self, n_clusterings: usize) {
434        if self.derived_observable_data.clustered_jets.len() < n_clusterings {
435            self.derived_observable_data
436                .clustered_jets
437                .resize_with(n_clusterings, || None);
438        }
439    }
440
441    pub fn cached_clustering(&self, handle: usize) -> Option<&ClusteringResult<T>> {
442        self.derived_observable_data
443            .clustered_jets
444            .get(handle)
445            .and_then(|result| result.as_ref())
446    }
447
448    pub fn to_f64(&self) -> Event {
449        GenericEvent {
450            kinematic_configuration: (
451                self.kinematic_configuration
452                    .0
453                    .iter()
454                    .map(FourMomentum::to_f64)
455                    .collect(),
456                self.kinematic_configuration
457                    .1
458                    .iter()
459                    .map(FourMomentum::to_f64)
460                    .collect(),
461            ),
462            cut_info: self.cut_info.clone(),
463            weight: into_complex_ff64(&self.weight),
464            additional_weights: self.additional_weights.to_f64(),
465            derived_observable_data: self.derived_observable_data.to_f64(),
466        }
467    }
468
469    pub fn from_f64(event: &Event) -> Self {
470        GenericEvent {
471            kinematic_configuration: (
472                event
473                    .kinematic_configuration
474                    .0
475                    .iter()
476                    .map(FourMomentum::from_ff64)
477                    .collect(),
478                event
479                    .kinematic_configuration
480                    .1
481                    .iter()
482                    .map(FourMomentum::from_ff64)
483                    .collect(),
484            ),
485            cut_info: event.cut_info.clone(),
486            weight: Complex::new(F::from_ff64(event.weight.re), F::from_ff64(event.weight.im)),
487            additional_weights: GenericAdditionalWeightInfo::from_f64(&event.additional_weights),
488            derived_observable_data: GenericDerivedEventData::from_f64(
489                &event.derived_observable_data,
490            ),
491        }
492    }
493}
494
495#[derive(Tabled)]
496struct EventSummaryRow {
497    field: String,
498    value: String,
499}
500
501#[derive(Tabled)]
502struct MomentumRow {
503    #[tabled(rename = "state")]
504    state: String,
505    #[tabled(rename = "PDG")]
506    pdg: String,
507    #[tabled(rename = "E")]
508    e: String,
509    px: String,
510    py: String,
511    pz: String,
512    #[tabled(rename = "sqrt(p^2)")]
513    p2: String,
514}
515
516#[derive(Tabled)]
517struct AdditionalWeightRow {
518    key: String,
519    value: String,
520}
521
522#[derive(Tabled)]
523struct ThresholdCountertermWeightRow {
524    #[tabled(rename = "component")]
525    component_id: usize,
526    occurrence: String,
527    multipliers: String,
528    effective: String,
529    bare: String,
530    weighted: String,
531    skipped: bool,
532}
533
534fn format_threshold_counterterm_occurrence(
535    occurrence: &ThresholdCountertermComponentOccurrence,
536) -> String {
537    match occurrence {
538        ThresholdCountertermComponentOccurrence::Amplitude {
539            raised_esurface_id,
540            overlap_group,
541        } => format!("amplitude E{raised_esurface_id}, overlap {overlap_group}"),
542        ThresholdCountertermComponentOccurrence::LocalUnitarity {
543            overlap_groups,
544            left_threshold_order,
545            right_threshold_order,
546            lu_cut_order,
547        } => format!(
548            "LU overlaps {:?}, orders L={left_threshold_order:?} R={right_threshold_order:?} cut={lu_cut_order:?}",
549            overlap_groups,
550        ),
551    }
552}
553
554fn display_decimal_precision<T: FloatLike>(value: &F<T>) -> usize {
555    let precision_bits = value.0.get_precision().max(1) as f64;
556    (precision_bits * std::f64::consts::LOG10_2).ceil().max(1.0) as usize
557}
558
559pub(crate) fn format_real_generic<T: FloatLike>(value: &F<T>) -> String {
560    format!("{:+.*e}", display_decimal_precision(value), value)
561}
562
563fn format_count(value: usize) -> String {
564    if value < 1_000 {
565        return value.to_string();
566    }
567
568    let value = value as f64;
569    for (scale, suffix) in [
570        (1_000_000_000_f64, "B"),
571        (1_000_000_f64, "M"),
572        (1_000_f64, "K"),
573    ] {
574        if value >= scale {
575            let scaled = value / scale;
576            let precision = if scaled >= 100.0 {
577                0
578            } else if scaled >= 10.0 {
579                1
580            } else {
581                2
582            };
583            return format!("{scaled:.precision$}{suffix}");
584        }
585    }
586
587    value.round().to_string()
588}
589
590pub(crate) fn format_optional_real_generic<T: FloatLike>(value: Option<&F<T>>) -> String {
591    value
592        .map(format_real_generic)
593        .unwrap_or_else(|| "None".red().to_string())
594}
595
596pub(crate) fn format_complex_generic<T: FloatLike>(value: &Complex<F<T>>) -> String {
597    let precision = display_decimal_precision(&value.re).max(display_decimal_precision(&value.im));
598    format!("{:+.*e} {:+.*e}i", precision, value.re, precision, value.im)
599}
600
601fn format_pdg(pdg: Option<isize>) -> String {
602    pdg.map(|value| value.to_string())
603        .unwrap_or_else(|| "N/A".red().to_string())
604}
605
606fn format_pdg_with_state_color(pdg: Option<isize>, incoming: bool) -> String {
607    let pdg = format_pdg(pdg);
608    if incoming {
609        pdg.bright_blue().to_string()
610    } else {
611        pdg.bright_green().to_string()
612    }
613}
614
615fn format_sampling_channel_edge_ids(edge_ids: Option<&[usize]>) -> String {
616    edge_ids
617        .map(|edge_ids| {
618            format!(
619                "({})",
620                edge_ids
621                    .iter()
622                    .map(|edge_id| edge_id.to_string())
623                    .collect::<Vec<_>>()
624                    .join(",")
625            )
626        })
627        .unwrap_or_else(|| "None".to_string())
628}
629
630fn momentum_mass_squared<T: FloatLike>(momentum: &FourMomentum<F<T>>) -> F<T> {
631    momentum.temporal.value.clone() * momentum.temporal.value.clone()
632        - momentum.spatial.px.clone() * momentum.spatial.px.clone()
633        - momentum.spatial.py.clone() * momentum.spatial.py.clone()
634        - momentum.spatial.pz.clone() * momentum.spatial.pz.clone()
635}
636
637fn momentum_mass<T: FloatLike>(momentum: &FourMomentum<F<T>>) -> F<T> {
638    momentum_mass_squared(momentum).abs().sqrt()
639}
640
641fn format_momentum_rows<T: FloatLike>(
642    state: String,
643    momenta: &[FourMomentum<F<T>>],
644    pdgs: &[isize],
645    incoming: bool,
646) -> Vec<MomentumRow> {
647    momenta
648        .iter()
649        .enumerate()
650        .map(|(index, momentum)| MomentumRow {
651            state: state.clone(),
652            pdg: format_pdg_with_state_color(pdgs.get(index).copied(), incoming),
653            e: format_real_generic(&momentum.temporal.value),
654            px: format_real_generic(&momentum.spatial.px),
655            py: format_real_generic(&momentum.spatial.py),
656            pz: format_real_generic(&momentum.spatial.pz),
657            p2: format_real_generic(&momentum_mass(momentum))
658                .bright_yellow()
659                .to_string(),
660        })
661        .collect()
662}
663
664fn event_zero<T: FloatLike>(event: &GenericEvent<T>) -> F<T> {
665    event
666        .kinematic_configuration
667        .0
668        .first()
669        .map(|momentum| momentum.temporal.value.zero())
670        .or_else(|| {
671            event
672                .kinematic_configuration
673                .1
674                .first()
675                .map(|momentum| momentum.temporal.value.zero())
676        })
677        .unwrap_or_else(|| F(T::new_zero()))
678}
679
680fn conservation_row<T: FloatLike>(event: &GenericEvent<T>) -> MomentumRow {
681    let zero = event_zero(event);
682    let incoming = event.kinematic_configuration.0.iter().fold(
683        (zero.clone(), zero.clone(), zero.clone(), zero.clone()),
684        |acc, momentum| {
685            (
686                acc.0 + momentum.temporal.value.clone(),
687                acc.1 + momentum.spatial.px.clone(),
688                acc.2 + momentum.spatial.py.clone(),
689                acc.3 + momentum.spatial.pz.clone(),
690            )
691        },
692    );
693    let outgoing = event.kinematic_configuration.1.iter().fold(
694        (zero.clone(), zero.clone(), zero.clone(), zero),
695        |acc, momentum| {
696            (
697                acc.0 + momentum.temporal.value.clone(),
698                acc.1 + momentum.spatial.px.clone(),
699                acc.2 + momentum.spatial.py.clone(),
700                acc.3 + momentum.spatial.pz.clone(),
701            )
702        },
703    );
704    let delta = FourMomentum {
705        temporal: crate::momentum::Energy::new(incoming.0 - outgoing.0),
706        spatial: crate::momentum::ThreeMomentum::new(
707            incoming.1 - outgoing.1,
708            incoming.2 - outgoing.2,
709            incoming.3 - outgoing.3,
710        ),
711    };
712
713    MomentumRow {
714        state: "CHECK".bold().bright_yellow().to_string(),
715        pdg: "N/A".bright_yellow().to_string(),
716        e: format_real_generic(&delta.temporal.value)
717            .bright_yellow()
718            .to_string(),
719        px: format_real_generic(&delta.spatial.px)
720            .bright_yellow()
721            .to_string(),
722        py: format_real_generic(&delta.spatial.py)
723            .bright_yellow()
724            .to_string(),
725        pz: format_real_generic(&delta.spatial.pz)
726            .bright_yellow()
727            .to_string(),
728        p2: String::new().bright_yellow().to_string(),
729    }
730}
731
732fn indent_block(block: &str, prefix: &str) -> String {
733    let mut result = String::new();
734    for (index, line) in block.lines().enumerate() {
735        if index > 0 {
736            result.push('\n');
737        }
738        result.push_str(prefix);
739        result.push_str(line);
740    }
741    result
742}
743
744impl fmt::Display for AdditionalWeightKey {
745    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
746        match self {
747            AdditionalWeightKey::FullMultiplicativeFactor => {
748                write!(f, "full multiplicative factor")
749            }
750            AdditionalWeightKey::Original => write!(f, "original"),
751            AdditionalWeightKey::ThresholdCounterterm { subset_index } => {
752                write!(f, "threshold_counterterm:{subset_index}")
753            }
754            AdditionalWeightKey::AmplitudeThresholdCounterterm {
755                esurface_id,
756                overlap_group,
757            } => {
758                write!(f, "threshold_counterterm:{esurface_id}:{overlap_group}")
759            }
760            AdditionalWeightKey::AmplitudeThresholdCountertermVariant {
761                variant_id,
762                esurface_id,
763                overlap_group,
764            } => write!(
765                f,
766                "threshold_counterterm_variant:{variant_id}:{esurface_id}:{overlap_group}",
767            ),
768        }
769    }
770}
771
772impl fmt::Display for CutInfo {
773    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
774        let rows = vec![
775            EventSummaryRow {
776                field: "graph".to_string(),
777                value: self.graph_id.to_string(),
778            },
779            EventSummaryRow {
780                field: "graph group".to_string(),
781                value: self
782                    .graph_group_id
783                    .map(|value| value.to_string())
784                    .unwrap_or_else(|| "None".to_string()),
785            },
786            EventSummaryRow {
787                field: "orientation".to_string(),
788                value: self
789                    .orientation_id
790                    .map(|value| value.to_string())
791                    .unwrap_or_else(|| "None".to_string()),
792            },
793            EventSummaryRow {
794                field: "cut".to_string(),
795                value: self.cut_id.to_string(),
796            },
797            EventSummaryRow {
798                field: "lmb channel id".to_string(),
799                value: self
800                    .sampling_channel_id
801                    .map(|value| value.to_string())
802                    .unwrap_or_else(|| "None".to_string()),
803            },
804            EventSummaryRow {
805                field: "lmb channel".to_string(),
806                value: format_sampling_channel_edge_ids(self.sampling_channel_edge_ids.as_deref()),
807            },
808        ];
809        write!(f, "{}", Table::new(rows).with(Style::rounded()))
810    }
811}
812
813impl<T: FloatLike> fmt::Display for GenericEvent<T> {
814    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
815        let summary = vec![
816            EventSummaryRow {
817                field: "graph".to_string(),
818                value: self.cut_info.graph_id.to_string(),
819            },
820            EventSummaryRow {
821                field: "graph group".to_string(),
822                value: self
823                    .cut_info
824                    .graph_group_id
825                    .map(|value| value.to_string())
826                    .unwrap_or_else(|| "None".to_string()),
827            },
828            EventSummaryRow {
829                field: "orientation".to_string(),
830                value: self
831                    .cut_info
832                    .orientation_id
833                    .map(|value| value.to_string())
834                    .unwrap_or_else(|| "None".to_string()),
835            },
836            EventSummaryRow {
837                field: "cut".to_string(),
838                value: self.cut_info.cut_id.to_string(),
839            },
840            EventSummaryRow {
841                field: "lmb channel id".to_string(),
842                value: self
843                    .cut_info
844                    .sampling_channel_id
845                    .map(|value| value.to_string())
846                    .unwrap_or_else(|| "None".to_string()),
847            },
848            EventSummaryRow {
849                field: "lmb channel".to_string(),
850                value: format_sampling_channel_edge_ids(
851                    self.cut_info.sampling_channel_edge_ids.as_deref(),
852                ),
853            },
854            EventSummaryRow {
855                field: "weight".to_string(),
856                value: format_complex_generic(&self.weight),
857            },
858        ];
859
860        writeln!(f, "{}", "Event".bold().bright_cyan())?;
861        writeln!(f, "{}", Table::new(summary).with(Style::rounded()))?;
862
863        let mut momentum_rows = format_momentum_rows(
864            "IN".bold().bright_blue().to_string(),
865            &self.kinematic_configuration.0,
866            &self.cut_info.particle_pdgs.0,
867            true,
868        );
869        let incoming_row_count = momentum_rows.len();
870        momentum_rows.extend(format_momentum_rows(
871            "OUT".bold().bright_green().to_string(),
872            &self.kinematic_configuration.1,
873            &self.cut_info.particle_pdgs.1,
874            false,
875        ));
876        if !momentum_rows.is_empty() {
877            momentum_rows.push(conservation_row(self));
878            let check_separator_index = momentum_rows.len();
879            let header_separator = (
880                1,
881                HorizontalLine::new('─')
882                    .intersection('┼')
883                    .left('├')
884                    .right('┤'),
885            );
886            let check_separator = (
887                check_separator_index,
888                HorizontalLine::new('─')
889                    .intersection('┼')
890                    .left('├')
891                    .right('┤'),
892            );
893            writeln!(f)?;
894            writeln!(f, "{}", "Kinematics".bold().bright_blue())?;
895            if incoming_row_count > 0 && incoming_row_count < check_separator_index - 1 {
896                let in_out_separator = (
897                    incoming_row_count + 1,
898                    HorizontalLine::new('─')
899                        .intersection('┼')
900                        .left('├')
901                        .right('┤'),
902                );
903                writeln!(
904                    f,
905                    "{}",
906                    Table::new(momentum_rows).with(Style::rounded().horizontals([
907                        header_separator,
908                        in_out_separator,
909                        check_separator,
910                    ]))
911                )?;
912            } else {
913                writeln!(
914                    f,
915                    "{}",
916                    Table::new(momentum_rows)
917                        .with(Style::rounded().horizontals([header_separator, check_separator]))
918                )?;
919            }
920        }
921
922        if !self.additional_weights.weights.is_empty() {
923            let additional_weights = self
924                .additional_weights
925                .weights
926                .iter()
927                .map(|(key, value)| AdditionalWeightRow {
928                    key: key.to_string(),
929                    value: format_complex_generic(value),
930                })
931                .collect::<Vec<_>>();
932
933            writeln!(f)?;
934            writeln!(f, "{}", "Additional weights".bold().bright_magenta())?;
935            write!(
936                f,
937                "{}",
938                Table::new(additional_weights).with(Style::rounded())
939            )?;
940        }
941
942        if let Some(decomposition) = &self.additional_weights.threshold_counterterms {
943            let summary = [
944                EventSummaryRow {
945                    field: "original".to_string(),
946                    value: format_complex_generic(&decomposition.original),
947                },
948                EventSummaryRow {
949                    field: "total".to_string(),
950                    value: format_complex_generic(&decomposition.total()),
951                },
952            ];
953            let components = decomposition
954                .components
955                .iter()
956                .map(|component| ThresholdCountertermWeightRow {
957                    component_id: component.component_id,
958                    occurrence: format_threshold_counterterm_occurrence(&component.occurrence),
959                    multipliers: component
960                        .multiplier_values
961                        .iter()
962                        .map(format_real_generic)
963                        .collect::<Vec<_>>()
964                        .join(" × "),
965                    effective: format_real_generic(&component.effective_multiplier),
966                    bare: component
967                        .bare
968                        .as_ref()
969                        .map(format_complex_generic)
970                        .unwrap_or_else(|| "—".to_string()),
971                    weighted: format_complex_generic(&component.weighted),
972                    skipped: component.evaluation_skipped,
973                })
974                .collect::<Vec<_>>();
975
976            writeln!(f)?;
977            writeln!(f, "{}", "Threshold counterterms".bold().bright_magenta())?;
978            writeln!(f, "{}", Table::new(summary).with(Style::rounded()))?;
979            if !components.is_empty() {
980                write!(f, "{}", Table::new(components).with(Style::rounded()))?;
981            }
982        }
983
984        Ok(())
985    }
986}
987
988impl<T: FloatLike> fmt::Display for GenericEventGroup<T> {
989    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
990        writeln!(
991            f,
992            "{}",
993            format!("Event group ({} event(s))", format_count(self.len()))
994                .bold()
995                .bright_green()
996        )?;
997        for (index, event) in self.iter().enumerate() {
998            writeln!(f, "  {}", format!("Event {index}:").bold().bright_yellow())?;
999            let event_str = event.to_string();
1000            writeln!(f, "{}", indent_block(&event_str, "    "))?;
1001        }
1002        Ok(())
1003    }
1004}
1005
1006impl<T: FloatLike> fmt::Display for GenericEventGroupList<T> {
1007    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1008        for (index, group) in self.iter().enumerate() {
1009            if index > 0 {
1010                writeln!(f)?;
1011            }
1012            writeln!(
1013                f,
1014                "{}",
1015                format!("Event group {index}:").bold().bright_green()
1016            )?;
1017            writeln!(f, "{}", indent_block(&group.to_string(), "  "))?;
1018        }
1019        Ok(())
1020    }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use serde_json::json;
1026    use spenso::algebra::complex::Complex;
1027
1028    use crate::utils::{ArbPrec, F, f128};
1029
1030    use super::{
1031        GenericAdditionalWeightInfo, GenericEvent, GenericThresholdCountertermComponentWeight,
1032        GenericThresholdCountertermEventInfo, ThresholdCountertermComponentOccurrence,
1033    };
1034
1035    fn decomposition() -> GenericThresholdCountertermEventInfo<f64> {
1036        GenericThresholdCountertermEventInfo {
1037            original: Complex::new(F(3.0), F(0.5)),
1038            components: vec![
1039                GenericThresholdCountertermComponentWeight {
1040                    component_id: 4,
1041                    occurrence: ThresholdCountertermComponentOccurrence::Amplitude {
1042                        raised_esurface_id: 2,
1043                        overlap_group: 1,
1044                    },
1045                    multiplier_values: smallvec::smallvec![F(0.5)],
1046                    effective_multiplier: F(0.5),
1047                    bare: Some(Complex::new(F(-2.0), F(1.0))),
1048                    weighted: Complex::new(F(-1.0), F(0.5)),
1049                    evaluation_skipped: false,
1050                },
1051                GenericThresholdCountertermComponentWeight {
1052                    component_id: 5,
1053                    occurrence: ThresholdCountertermComponentOccurrence::LocalUnitarity {
1054                        overlap_groups: smallvec::smallvec![3],
1055                        left_threshold_order: Some(1),
1056                        right_threshold_order: None,
1057                        lu_cut_order: Some(2),
1058                    },
1059                    multiplier_values: smallvec::smallvec![F(0.0)],
1060                    effective_multiplier: F(0.0),
1061                    bare: None,
1062                    weighted: Complex::new(F(0.0), F(0.0)),
1063                    evaluation_skipped: true,
1064                },
1065            ],
1066        }
1067    }
1068
1069    #[test]
1070    fn absent_decomposition_preserves_legacy_json_shape() {
1071        let info = GenericAdditionalWeightInfo::<f64>::default();
1072        assert_eq!(
1073            serde_json::to_value(&info).unwrap(),
1074            json!({ "weights": [] })
1075        );
1076
1077        let decoded: GenericAdditionalWeightInfo<f64> =
1078            serde_json::from_value(json!({ "weights": [] })).unwrap();
1079        assert!(decoded.threshold_counterterms.is_none());
1080    }
1081
1082    #[test]
1083    fn additional_weights_roundtrip_json_and_binary_with_optional_decomposition() {
1084        let mut iterated = decomposition();
1085        iterated.components[1].occurrence =
1086            ThresholdCountertermComponentOccurrence::LocalUnitarity {
1087                overlap_groups: smallvec::smallvec![3, 7],
1088                left_threshold_order: Some(1),
1089                right_threshold_order: Some(2),
1090                lu_cut_order: Some(2),
1091            };
1092        iterated.components[1].multiplier_values = smallvec::smallvec![F(0.0), F(0.25)];
1093        for threshold_counterterms in [None, Some(iterated)] {
1094            let original = GenericAdditionalWeightInfo::<f64> {
1095                weights: [
1096                    (
1097                        super::AdditionalWeightKey::Original,
1098                        Complex::new(F(3.0), F(0.5)),
1099                    ),
1100                    (
1101                        super::AdditionalWeightKey::AmplitudeThresholdCountertermVariant {
1102                            variant_id: 6,
1103                            esurface_id: 2,
1104                            overlap_group: 1,
1105                        },
1106                        Complex::new(F(-0.25), F(0.125)),
1107                    ),
1108                ]
1109                .into(),
1110                threshold_counterterms,
1111            };
1112            let json = serde_json::to_value(&original).unwrap();
1113            let restored: GenericAdditionalWeightInfo<f64> =
1114                serde_json::from_value(json.clone()).unwrap();
1115            assert_eq!(serde_json::to_value(restored).unwrap(), json);
1116            if original.threshold_counterterms.is_some() {
1117                let components = &json["threshold_counterterms"]["components"];
1118                assert_eq!(components[0]["occurrence"]["kind"], "amplitude");
1119                assert_eq!(components[1]["occurrence"]["kind"], "local_unitarity");
1120                assert_eq!(components[1]["occurrence"]["overlap_groups"], json!([3, 7]));
1121                assert_eq!(components[1]["occurrence"]["right_threshold_order"], 2);
1122                assert_eq!(components[1]["multiplier_values"], json!([0.0, 0.25]));
1123                assert!(components[0].get("bare").is_some());
1124                assert!(components[1].get("bare").is_none());
1125            } else {
1126                assert!(json.get("threshold_counterterms").is_none());
1127            }
1128            let encoded =
1129                bincode::serde::encode_to_vec(&original, bincode::config::standard()).unwrap();
1130            let (restored, consumed): (GenericAdditionalWeightInfo<f64>, _) =
1131                bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
1132            assert_eq!(consumed, encoded.len());
1133            assert_eq!(serde_json::to_value(restored).unwrap(), json);
1134        }
1135    }
1136
1137    #[test]
1138    fn additional_weights_roundtrip_preserves_native_precision() {
1139        let one = F::<f128>::default().one();
1140        let precise_quad = one + one.from_usize(2).powi(-80);
1141        assert_ne!(precise_quad, F::from_ff64(precise_quad.into_ff64()));
1142        let quad = GenericAdditionalWeightInfo::<f128> {
1143            weights: [(
1144                super::AdditionalWeightKey::Original,
1145                Complex::new(precise_quad, -precise_quad),
1146            )]
1147            .into(),
1148            ..Default::default()
1149        };
1150        let json = serde_json::to_vec(&quad).unwrap();
1151        let restored: GenericAdditionalWeightInfo<f128> = serde_json::from_slice(&json).unwrap();
1152        assert_eq!(restored.weights, quad.weights);
1153        let encoded = bincode::serde::encode_to_vec(&quad, bincode::config::standard()).unwrap();
1154        let (restored, consumed): (GenericAdditionalWeightInfo<f128>, _) =
1155            bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
1156        assert_eq!(consumed, encoded.len());
1157        assert_eq!(restored.weights, quad.weights);
1158
1159        // Quad stores a double-double mantissa; Arb additionally preserves exponents
1160        // below binary64's range. Neither boundary may narrow the native weights.
1161        let tiny_arb = F::<ArbPrec>::default().from_usize(2).powi(-1400);
1162        assert!(tiny_arb > tiny_arb.zero());
1163        assert_eq!(tiny_arb.into_ff64(), F(0.0));
1164        let arb = GenericAdditionalWeightInfo::<ArbPrec> {
1165            weights: [(
1166                super::AdditionalWeightKey::Original,
1167                Complex::new(tiny_arb.clone(), -tiny_arb),
1168            )]
1169            .into(),
1170            ..Default::default()
1171        };
1172        let json = serde_json::to_vec(&arb).unwrap();
1173        let restored: GenericAdditionalWeightInfo<ArbPrec> = serde_json::from_slice(&json).unwrap();
1174        assert_eq!(restored.weights, arb.weights);
1175        let encoded = bincode::serde::encode_to_vec(&arb, bincode::config::standard()).unwrap();
1176        let (restored, consumed): (GenericAdditionalWeightInfo<ArbPrec>, _) =
1177            bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
1178        assert_eq!(consumed, encoded.len());
1179        assert_eq!(restored.weights, arb.weights);
1180    }
1181
1182    #[test]
1183    fn decomposition_scaling_preserves_multiplier_values_and_additivity() {
1184        let mut info = decomposition();
1185        assert_eq!(info.total(), Complex::new(F(2.0), F(1.0)));
1186
1187        info.apply_multiplicative_factor(&Complex::new(F(4.0), F(0.0)));
1188
1189        assert_eq!(info.total(), Complex::new(F(8.0), F(4.0)));
1190        assert_eq!(info.components[0].bare, Some(Complex::new(F(-8.0), F(4.0))));
1191        assert_eq!(info.components[0].multiplier_values[0], F(0.5));
1192        assert_eq!(info.components[0].effective_multiplier, F(0.5));
1193        assert!(info.components[1].bare.is_none());
1194        assert_eq!(info.components[1].weighted, Complex::new(F(0.0), F(0.0)));
1195    }
1196
1197    #[test]
1198    fn decomposition_converts_between_active_precision_and_f64() {
1199        let original = decomposition();
1200        let precise = GenericThresholdCountertermEventInfo::<f128>::from_f64(&original);
1201        let roundtrip = precise.to_f64();
1202
1203        assert_eq!(roundtrip.total(), original.total());
1204        assert_eq!(roundtrip.components.len(), 2);
1205        assert_eq!(
1206            roundtrip.components[0].multiplier_values,
1207            original.components[0].multiplier_values
1208        );
1209        assert!(roundtrip.components[1].bare.is_none());
1210        assert!(roundtrip.components[1].evaluation_skipped);
1211    }
1212
1213    #[test]
1214    fn event_scaling_reconciles_weight_with_decomposition_total() {
1215        let mut event = GenericEvent::<f64> {
1216            weight: Complex::new(F(99.0), F(0.0)),
1217            additional_weights: GenericAdditionalWeightInfo {
1218                weights: Default::default(),
1219                threshold_counterterms: Some(decomposition()),
1220            },
1221            ..Default::default()
1222        };
1223
1224        event.apply_multiplicative_factor(&Complex::new(F(4.0), F(0.0)));
1225
1226        assert_eq!(event.weight, Complex::new(F(8.0), F(4.0)));
1227        assert_eq!(
1228            event.weight,
1229            event
1230                .additional_weights
1231                .threshold_counterterms
1232                .as_ref()
1233                .unwrap()
1234                .total()
1235        );
1236    }
1237
1238    #[test]
1239    fn event_display_includes_detailed_threshold_components() {
1240        let event = GenericEvent::<f64> {
1241            weight: decomposition().total(),
1242            additional_weights: GenericAdditionalWeightInfo {
1243                weights: Default::default(),
1244                threshold_counterterms: Some(decomposition()),
1245            },
1246            ..Default::default()
1247        };
1248
1249        let rendered = event.to_string();
1250        assert!(rendered.contains("Threshold counterterms"));
1251        assert!(rendered.contains("amplitude E2, overlap 1"));
1252        assert!(rendered.contains("LU overlaps [3]"));
1253    }
1254}