Skip to main content

gammalooprs/settings/
runtime.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt::Display,
4};
5
6use bincode_trait_derive::{Decode, Encode};
7use eyre::Result;
8use linnet::half_edge::involution::EdgeVec;
9use schemars::JsonSchema;
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11use spenso::algebra::{algebraic_traits::IsZero, complex::Complex};
12use tracing::warn;
13use typed_index_collections::TiVec;
14
15use crate::{
16    DependentMomentaConstructor, GammaLoopContext,
17    cff::esurface::Esurface,
18    graph::LoopMomentumBasis,
19    integrands::process::evaluators::EvaluatorMethod,
20    momentum::{Helicity, RotationMethod, sample::ExternalIndex, signature::SignatureLike},
21    observables::ObservableFileFormat,
22    settings::runtime::kinematic::{Externals, improvement::generate_default_momenta},
23    utils::{
24        ApproxEq, DEFAULT_ESURFACE_EXISTENCE_THRESHOLD, F, FloatLike, format_uncertainty,
25        serde_utils::{
26            _default_rotation_axis, _default_stability_levels, IsDefault,
27            deserialize_nonnegative_finite_f64, is_default_esurface_existence_threshold,
28            is_default_rotation_axis, is_default_stability_levels, is_false, is_float, is_true,
29            is_u64, is_usize, show_defaults_helper,
30        },
31    },
32};
33use symbolica::domains::float::Real;
34
35use super::{RuntimeSettings, global::OrientationPattern};
36
37#[cfg_attr(
38    feature = "python_api",
39    pyo3::pyclass(from_py_object, get_all, set_all)
40)]
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Encode, Decode, JsonSchema)]
42#[serde(default)]
43pub struct RuntimeModelSettings {
44    #[serde(flatten, skip_serializing_if = "IsDefault::is_default")]
45    pub external_parameters: BTreeMap<String, (F<f64>, F<f64>)>,
46}
47
48#[cfg_attr(
49    feature = "python_api",
50    pyo3::pyclass(from_py_object, get_all, set_all)
51)]
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
53#[serde(default, deny_unknown_fields)]
54pub struct SubtractionSettings {
55    /// Configuration for local ultraviolet counterterms in the numerical integrand.
56    #[serde(skip_serializing_if = "IsDefault::is_default")]
57    pub local_ct_settings: LocalCounterTermSettings,
58    /// Configuration for analytically integrated counterterm contributions.
59    #[serde(skip_serializing_if = "IsDefault::is_default")]
60    pub integrated_ct_settings: IntegratedCounterTermSettings,
61    /// Strategy used to choose centers shared by overlapping threshold surfaces.
62    #[serde(skip_serializing_if = "IsDefault::is_default")]
63    pub overlap_settings: OverlapSettings,
64    /// Dimensionless tolerance used to compare the energy-squared E-surface invariant margin
65    /// against `esurface_existence_threshold * E_cm^2` at runtime.
66    #[serde(
67        deserialize_with = "deserialize_nonnegative_finite_f64",
68        skip_serializing_if = "is_default_esurface_existence_threshold"
69    )]
70    #[schemars(range(min = 0.0))]
71    pub esurface_existence_threshold: f64,
72    /// Multiplier of `epsilon(T) * E_cm` used as the direct residual limit for
73    /// threshold-counterterm radial roots at numerical precision `T`. A residual-limited
74    /// higher-precision root may instead be accepted against the corresponding lower-precision
75    /// limit after satisfying the cross-precision consistency checks.
76    #[serde(
77        deserialize_with = "deserialize_nonnegative_finite_f64",
78        skip_serializing_if = "is_float::<64>"
79    )]
80    #[schemars(range(min = 0.0))]
81    pub radial_root_residual_tolerance: f64,
82    /// Disable local threshold subtraction while leaving the original integrand unchanged.
83    #[serde(skip_serializing_if = "is_false")]
84    pub disable_threshold_subtraction: bool,
85}
86
87impl Default for SubtractionSettings {
88    fn default() -> Self {
89        Self {
90            local_ct_settings: LocalCounterTermSettings::default(),
91            integrated_ct_settings: IntegratedCounterTermSettings::default(),
92            overlap_settings: OverlapSettings::default(),
93            esurface_existence_threshold: DEFAULT_ESURFACE_EXISTENCE_THRESHOLD,
94            radial_root_residual_tolerance: 64.0,
95            disable_threshold_subtraction: false,
96        }
97    }
98}
99
100#[derive(Copy, Clone)]
101pub struct LockedRuntimeSettings<'a>(&'a RuntimeSettings);
102impl<'a> From<&'a RuntimeSettings> for LockedRuntimeSettings<'a> {
103    fn from(value: &'a RuntimeSettings) -> Self {
104        LockedRuntimeSettings(value)
105    }
106}
107
108impl<'a> From<LockedRuntimeSettings<'a>> for RuntimeSettings {
109    fn from(value: LockedRuntimeSettings) -> Self {
110        value.0.clone()
111    }
112}
113
114impl<'a> LockedRuntimeSettings<'a> {
115    pub(crate) fn helicities(&self) -> &[Helicity] {
116        self.0.kinematics.externals.get_helicities()
117    }
118
119    /// If no external momenta are set in the settings, generate a default kinematic point that satisfies the phase-space constraints
120    pub(crate) fn into_with_modified_kinematics(
121        self,
122        external_signature: &SignatureLike<ExternalIndex>,
123        external_masses: &TiVec<ExternalIndex, F<f64>>,
124    ) -> Result<RuntimeSettings> {
125        if external_signature.is_empty() {
126            Ok(self.into())
127        } else {
128            match &self.0.kinematics.externals {
129                Externals::Constant {
130                    momenta,
131                    helicities,
132                    ..
133                } => {
134                    if momenta.is_empty() && helicities.is_empty() {
135                        warn!(
136                            "No external kinematics were provided; using default-generated external momenta and helicities."
137                        );
138                        let new_externals = generate_default_momenta(
139                            external_masses,
140                            external_signature,
141                            &F(self.0.kinematics.e_cm),
142                        )?;
143
144                        let mut new_settigns = self.0.clone();
145                        new_settigns.kinematics.externals = new_externals;
146                        Ok(new_settigns)
147                    } else {
148                        Ok(self.into())
149                    }
150                }
151            }
152        }
153    }
154
155    pub(crate) fn existence_check(
156        &self,
157        esurface: &Esurface,
158        masses: &EdgeVec<F<f64>>,
159        external_signature: &SignatureLike<ExternalIndex>,
160        lmb: &LoopMomentumBasis,
161        esurface_existence_threshold: f64,
162    ) -> bool {
163        let dependent_momenta_constructor =
164            DependentMomentaConstructor::Amplitude(external_signature);
165
166        esurface
167            .classify_existence(
168                &self
169                    .0
170                    .kinematics
171                    .externals
172                    .get_dependent_externals(dependent_momenta_constructor)
173                    .unwrap(),
174                lmb,
175                masses,
176                &F(self.0.kinematics.e_cm),
177                &F(esurface_existence_threshold),
178            )
179            .is_existing()
180    }
181}
182
183#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
184#[derive(
185    Debug, Copy, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, Eq, JsonSchema, Default,
186)]
187#[serde(rename_all = "snake_case")]
188pub enum IntegralUnit {
189    #[default]
190    Auto,
191    Picobarn,
192    Femtobarn,
193    Attobarn,
194    Millibarn,
195    None,
196}
197
198impl IntegralUnit {
199    pub(crate) fn resolve_for_cross_section(self, n_initial_state_particles: usize) -> Self {
200        match self {
201            Self::Auto if n_initial_state_particles > 1 => Self::Picobarn,
202            Self::Auto => Self::None,
203            explicit => explicit,
204        }
205    }
206
207    pub(crate) fn relative_to_picobarn_factor<T: FloatLike>(self, one: &F<T>) -> Option<F<T>> {
208        match self {
209            Self::Picobarn => Some(one.one()),
210            Self::Femtobarn => Some(one.from_i64(1_000)),
211            Self::Attobarn => Some(one.from_i64(1_000_000)),
212            Self::Millibarn => Some(one.one() / one.from_i64(1_000_000_000)),
213            Self::None => None,
214            Self::Auto => unreachable!("integral unit must be resolved before conversion"),
215        }
216    }
217}
218
219#[cfg_attr(
220    feature = "python_api",
221    pyo3::pyclass(from_py_object, get_all, set_all)
222)]
223#[derive(Debug, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, JsonSchema)]
224#[trait_decode(trait= GammaLoopContext)]
225#[serde(default, deny_unknown_fields)]
226pub struct GeneralSettings {
227    /// Evaluate through the loop-tree-duality representation where the selected integrand supports it.
228    #[serde(skip_serializing_if = "is_false")]
229    pub use_ltd: bool,
230    /// Evaluator backend used for generated integrands.
231    #[serde(skip_serializing_if = "IsDefault::is_default")]
232    pub evaluator_method: EvaluatorMethod,
233    /// Optional orientation pattern restricting which causal-flow orientations are evaluated.
234    #[serde(skip_serializing_if = "IsDefault::is_default")]
235    pub orientation_pat: OrientationPattern,
236    /// Load a previously compiled causal-flow function instead of rebuilding it for this run.
237    #[serde(skip_serializing_if = "is_false")]
238    pub load_compiled_cff: bool,
239    /// Cache reusable evaluation data between phase-space samples.
240    #[serde(skip_serializing_if = "is_false")]
241    pub enable_cache: bool,
242    /// Emit additional cache consistency diagnostics while evaluating samples.
243    #[serde(skip_serializing_if = "is_false")]
244    pub debug_cache: bool,
245    /// Ultraviolet reference mass used by local counterterms, in the model's energy units.
246    #[serde(skip_serializing_if = "is_float::<1000>")]
247    pub m_uv: f64,
248    /// Localization scale applied to ultraviolet counterterms, in the model's energy units.
249    #[serde(skip_serializing_if = "is_float::<1000>")]
250    pub renormalization_localization_scale: f64,
251    /// Renormalization scale used by running parameters and scale-dependent terms.
252    #[serde(skip_serializing_if = "is_float::<1000>")]
253    pub mu_r: f64,
254    /// Values assigned, in order, to extra symbolic parameters expected by the evaluator.
255    #[serde(skip_serializing_if = "IsDefault::is_default")]
256    pub additional_param_values: Vec<f64>,
257    /// Unit used to report integrated cross-sections; `auto` selects picobarns for scattering.
258    #[serde(skip_serializing_if = "IsDefault::is_default")]
259    pub integral_unit: IntegralUnit,
260    /// Omit the incoming-state flux factor from the returned integrand value.
261    #[serde(skip_serializing_if = "is_false")]
262    pub disable_flux_factor: bool,
263    /// Return generated events in addition to accumulating integration statistics.
264    #[serde(skip_serializing_if = "is_false")]
265    pub generate_events: bool,
266    /// Persist every configured additional weight on each generated event.
267    #[serde(skip_serializing_if = "is_false")]
268    pub store_additional_weights_in_event: bool,
269}
270
271impl Default for GeneralSettings {
272    fn default() -> Self {
273        Self {
274            evaluator_method: EvaluatorMethod::default(),
275            use_ltd: false,
276            load_compiled_cff: false,
277            enable_cache: false,
278            debug_cache: false,
279            orientation_pat: OrientationPattern::default(),
280            m_uv: 1000.0,
281            renormalization_localization_scale: 1000.0,
282            mu_r: 1000.0,
283
284            additional_param_values: vec![],
285            integral_unit: IntegralUnit::Auto,
286            disable_flux_factor: false,
287            generate_events: false,
288            store_additional_weights_in_event: false,
289        }
290    }
291}
292
293impl GeneralSettings {
294    pub(crate) fn mu_r_sq(&self) -> f64 {
295        self.mu_r * self.mu_r
296    }
297}
298
299#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
300#[derive(
301    Debug, Copy, Clone, PartialEq, Deserialize, Default, Serialize, Encode, Decode, JsonSchema,
302)]
303// #[trait_decode(trait= GammaLoopContext)]
304#[serde(deny_unknown_fields)]
305pub enum IntegratedPhase {
306    #[serde(rename = "real")]
307    #[default]
308    Real,
309    #[serde(rename = "imag")]
310    Imag,
311    #[serde(rename = "both")]
312    Both,
313}
314
315pub mod kinematic;
316
317#[cfg_attr(
318    feature = "python_api",
319    pyo3::pyclass(from_py_object, get_all, set_all)
320)]
321#[derive(Debug, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, JsonSchema)]
322#[serde(default, deny_unknown_fields)]
323pub struct ObservablesOutputSettings {
324    /// Output formats written for configured observables; duplicates and `none` are ignored.
325    #[serde(
326        default = "default_observable_output_formats",
327        skip_serializing_if = "is_default_observable_output_formats"
328    )]
329    pub format: Vec<ObservableFileFormat>,
330}
331
332fn default_observable_output_formats() -> Vec<ObservableFileFormat> {
333    vec![ObservableFileFormat::Json]
334}
335
336fn is_default_observable_output_formats(formats: &Vec<ObservableFileFormat>) -> bool {
337    show_defaults_helper(formats.as_slice() == default_observable_output_formats().as_slice())
338}
339
340impl Default for ObservablesOutputSettings {
341    fn default() -> Self {
342        Self {
343            format: default_observable_output_formats(),
344        }
345    }
346}
347
348impl ObservablesOutputSettings {
349    pub(crate) fn resolved_formats(&self) -> Vec<ObservableFileFormat> {
350        let mut resolved_formats = Vec::new();
351        for format in &self.format {
352            if *format == ObservableFileFormat::None || resolved_formats.contains(format) {
353                continue;
354            }
355            resolved_formats.push(*format);
356        }
357        resolved_formats
358    }
359}
360
361#[cfg_attr(
362    feature = "python_api",
363    pyo3::pyclass(from_py_object, get_all, set_all)
364)]
365#[derive(Debug, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, JsonSchema)]
366#[serde(default, deny_unknown_fields)]
367pub struct IntegratorSettings {
368    /// Number of bins maintained along each continuous integration dimension.
369    #[serde(skip_serializing_if = "is_usize::<64>")]
370    pub n_bins: usize,
371    /// Optional per-iteration bin counts, overriding the fixed `n_bins` value as training proceeds.
372    #[serde(skip_serializing_if = "IsDefault::is_default")]
373    pub bin_number_evolution: Option<Vec<usize>>,
374    /// Minimum accumulated samples before an adaptive grid or discrete probability is updated.
375    #[serde(skip_serializing_if = "is_usize::<1000>")]
376    pub min_samples_for_update: usize,
377    /// Number of samples in the first Monte Carlo iteration.
378    #[serde(skip_serializing_if = "is_usize::<100000>")]
379    pub n_start: usize,
380    /// Samples added to each successive Monte Carlo iteration.
381    #[serde(skip_serializing_if = "is_usize::<10000>")]
382    pub n_increase: usize,
383    /// Hard upper bound on the total number of integrand evaluations.
384    #[serde(skip_serializing_if = "is_usize::<10000000000>")]
385    pub n_max: usize,
386    /// Stop once the estimated relative uncertainty reaches this value, if configured.
387    #[serde(skip_serializing_if = "IsDefault::is_default")]
388    pub target_relative_accuracy: Option<f64>,
389    /// Stop once the estimated absolute uncertainty reaches this value, if configured.
390    #[serde(skip_serializing_if = "IsDefault::is_default")]
391    pub target_absolute_accuracy: Option<f64>,
392    /// Complex component to integrate: real, imaginary, or both.
393    #[serde(skip_serializing_if = "IsDefault::is_default")]
394    pub integrated_phase: IntegratedPhase,
395    /// Update rate for learned probabilities over discrete sampling choices.
396    #[serde(skip_serializing_if = "is_float::<1>")]
397    pub discrete_dim_learning_rate: f64,
398    /// Update rate for adaptive grids over continuous integration dimensions.
399    #[serde(skip_serializing_if = "is_float::<1>")]
400    pub continuous_dim_learning_rate: f64,
401    /// Train adaptive distributions on iteration averages instead of individual sample weights.
402    #[serde(skip_serializing_if = "is_false")]
403    pub train_on_avg: bool,
404    /// Include maximum-weight diagnostics in integration progress and result data.
405    #[serde(skip_serializing_if = "is_true")]
406    pub show_max_wgt_info: bool,
407    /// Cap the ratio used when turning sampled weights into adaptive probabilities.
408    #[serde(skip_serializing_if = "is_float::<30>")]
409    pub max_prob_ratio: f64,
410    /// Seed for deterministic initialization of Monte Carlo random-number streams.
411    #[serde(skip_serializing_if = "is_u64::<69>")]
412    pub seed: u64,
413    /// File formats used when writing observable histograms and distributions.
414    #[serde(skip_serializing_if = "IsDefault::is_default")]
415    pub observables_output: ObservablesOutputSettings,
416}
417
418impl Default for IntegratorSettings {
419    fn default() -> Self {
420        Self {
421            n_bins: 64,
422            bin_number_evolution: None,
423            min_samples_for_update: 1000,
424            n_start: 100000,
425            n_increase: 10000,
426            n_max: 10000000000,
427            target_relative_accuracy: None,
428            target_absolute_accuracy: None,
429            integrated_phase: IntegratedPhase::default(),
430            discrete_dim_learning_rate: 1.0,
431            continuous_dim_learning_rate: 1.0,
432            train_on_avg: false,
433            show_max_wgt_info: true,
434            max_prob_ratio: 30.0,
435            seed: 69,
436            observables_output: ObservablesOutputSettings::default(),
437        }
438    }
439}
440
441#[cfg_attr(
442    feature = "python_api",
443    pyo3::pyclass(from_py_object, get_all, set_all)
444)]
445#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Encode, Decode, JsonSchema)]
446#[serde(default, deny_unknown_fields)]
447pub struct ParameterizationSettings {
448    #[serde(skip_serializing_if = "IsDefault::is_default")]
449    pub mode: ParameterizationMode,
450    #[serde(skip_serializing_if = "IsDefault::is_default")]
451    pub mapping: ParameterizationMapping,
452    #[serde(skip_serializing_if = "is_float::<1>")]
453    pub b: f64,
454    #[serde(skip_serializing_if = "is_float::<1>")]
455    pub power: f64,
456    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
457    pub lmb_basis_ids: BTreeMap<String, Vec<usize>>,
458}
459
460impl Default for ParameterizationSettings {
461    fn default() -> Self {
462        Self {
463            b: 1.0,
464            power: 1.0,
465            mode: ParameterizationMode::default(),
466            mapping: ParameterizationMapping::default(),
467            lmb_basis_ids: BTreeMap::new(),
468        }
469    }
470}
471
472#[derive(Serialize, Deserialize, Default, Debug, Clone)]
473#[serde(default, deny_unknown_fields)]
474pub struct IntegralEstimate {
475    pub neval: usize,
476    pub real_zero: usize,
477    pub im_zero: usize,
478    pub result: Complex<F<f64>>,
479    pub error: Complex<F<f64>>,
480    pub real_chisq: F<f64>,
481    pub im_chisq: F<f64>,
482}
483
484impl Display for IntegralEstimate {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        // Format real and imaginary parts with proper uncertainty formatting
487        let real_formatted = if self.error.re.0.abs() > 0.0 && f.alternate() {
488            format_uncertainty(self.result.re, self.error.re)
489        } else {
490            // If no error, use formatter precision or default to scientific notation
491            match f.precision() {
492                Some(p) => format!("{:.prec$e}", self.result.re.0, prec = p),
493                None => format!("{:e}", self.result.re.0),
494            }
495        };
496
497        let im_formatted = if self.error.im.0.abs() > 0.0 && f.alternate() {
498            format_uncertainty(self.result.im, self.error.im)
499        } else {
500            // If no error, use formatter precision or default to scientific notation
501            match f.precision() {
502                Some(p) => format!("{:.prec$e}", self.result.im.0, prec = p),
503                None => format!("{:e}", self.result.im.0),
504            }
505        };
506
507        // Display the result in a readable format
508        if self.result.im.0.abs() > 0.0 {
509            if f.alternate() {
510                write!(
511                    f,
512                    "{} + {}i (neval: {}, real_chisq: {:.3}, im_chisq: {:.3})",
513                    real_formatted, im_formatted, self.neval, self.real_chisq.0, self.im_chisq.0
514                )
515            } else {
516                write!(f, "{} + {}i", real_formatted, im_formatted,)
517            }
518        } else if f.alternate() {
519            write!(
520                f,
521                "{} (neval: {}, real_chisq: {:.3})",
522                real_formatted, self.neval, self.real_chisq.0
523            )
524        } else {
525            write!(f, "{}", real_formatted)
526        }
527    }
528}
529
530impl IntegralEstimate {
531    pub fn is_compatible_with_target(&self, target: Complex<F<f64>>, sigma: i8) -> bool {
532        let res_norm = self.result.norm().re;
533        let tolerance = if res_norm.is_zero() {
534            self.error.norm().re
535        } else {
536            (self.error).norm().re * F(sigma as f64) / res_norm
537        };
538
539        if tolerance.0 > 0.1 {
540            warn!("Tolerance larger than 10%! {}", tolerance)
541        }
542        // println!("Tol: {}", tolerance.0);
543        self.result.approx_eq(&target, &tolerance)
544    }
545
546    pub fn is_compatible_with_result(&self, other: &Self, sigma: i8) -> bool {
547        let combined_error = Complex::new(
548            (self.error.re * self.error.re + other.error.re * other.error.re).sqrt(),
549            (self.error.im * self.error.im + other.error.im * other.error.im).sqrt(),
550        );
551        let delta = self.result - other.result;
552        let delta_norm = delta.norm().re;
553        let tolerance = if delta_norm.is_zero() {
554            combined_error.norm().re
555        } else {
556            combined_error.norm().re * F(sigma as f64) / delta_norm
557        };
558
559        if tolerance.0 > 0.1 {
560            warn!("Tolerance larger than 10%! {}", tolerance)
561        }
562
563        self.result.approx_eq(&other.result, &tolerance)
564    }
565}
566
567#[derive(Serialize, Deserialize, Default, Debug, Clone)]
568#[serde(default, deny_unknown_fields)]
569pub struct IntegrationTableComponentResult {
570    pub component: String,
571    pub value: F<f64>,
572    pub error: F<f64>,
573    pub relative_error_percent: Option<f64>,
574    pub chi_sq_per_dof: f64,
575    pub target_delta_sigma: Option<f64>,
576    pub target_delta_percent: Option<f64>,
577    pub max_weight_impact: f64,
578}
579
580#[derive(Serialize, Deserialize, Default, Debug, Clone)]
581#[serde(default, deny_unknown_fields)]
582pub struct IntegrationStatisticsSnapshot {
583    pub num_evals: usize,
584    pub average_total_time_seconds: f64,
585    pub average_parameterization_time_seconds: f64,
586    pub average_integrand_time_seconds: f64,
587    pub average_evaluator_time_seconds: f64,
588    pub average_observable_time_seconds: f64,
589    pub average_integrator_time_seconds: f64,
590    pub f64_percentage: f64,
591    pub f128_percentage: f64,
592    pub arb_percentage: f64,
593    pub nan_percentage: f64,
594    pub nan_or_unstable_percentage: f64,
595    pub generated_event_count: usize,
596    pub accepted_event_count: usize,
597    pub selection_efficiency_percentage: Option<f64>,
598}
599
600#[derive(Serialize, Deserialize, Default, Debug, Clone)]
601#[serde(default, deny_unknown_fields)]
602pub struct MaxWeightInfoEntry {
603    pub component: String,
604    pub sign: String,
605    pub max_eval: F<f64>,
606    pub coordinates: Option<String>,
607}
608
609#[derive(Serialize, Deserialize, Default, Debug, Clone)]
610#[serde(default, deny_unknown_fields)]
611pub struct DiscreteCoordinate {
612    pub axis_label: String,
613    pub bin_index: usize,
614    pub bin_label: Option<String>,
615}
616
617#[derive(Serialize, Deserialize, Default, Debug, Clone)]
618#[serde(default, deny_unknown_fields)]
619pub struct DiscreteBreakdownEntry {
620    pub bin_index: usize,
621    pub bin_label: Option<String>,
622    pub pdf: F<f64>,
623    pub value: F<f64>,
624    pub error: F<f64>,
625    pub chi_sq: F<f64>,
626    pub processed_samples: usize,
627}
628
629#[derive(Serialize, Deserialize, Default, Debug, Clone)]
630#[serde(default, deny_unknown_fields)]
631pub struct DiscreteBreakdown {
632    pub axis_label: String,
633    pub fixed_coordinates: Vec<DiscreteCoordinate>,
634    pub entries: Vec<DiscreteBreakdownEntry>,
635}
636
637#[derive(Serialize, Deserialize, Default, Debug, Clone)]
638#[serde(default, deny_unknown_fields)]
639pub struct ComponentDiscreteBreakdown {
640    pub re: Option<DiscreteBreakdown>,
641    pub im: Option<DiscreteBreakdown>,
642}
643
644#[derive(Serialize, Deserialize, Default, Debug, Clone)]
645#[serde(default, deny_unknown_fields)]
646pub struct SlotIntegrationResult {
647    pub key: String,
648    pub process: String,
649    pub integrand: String,
650    pub target: Option<Complex<F<f64>>>,
651    pub integral: IntegralEstimate,
652    pub table_results: Vec<IntegrationTableComponentResult>,
653    pub integration_statistics: IntegrationStatisticsSnapshot,
654    pub max_weight_info: Vec<MaxWeightInfoEntry>,
655    pub grid_breakdown: ComponentDiscreteBreakdown,
656}
657
658#[derive(Serialize, Deserialize, Default, Debug, Clone)]
659#[serde(default, deny_unknown_fields)]
660pub struct IntegrationResult {
661    pub slots: Vec<SlotIntegrationResult>,
662}
663
664impl IntegrationResult {
665    pub fn slot(&self, key: &str) -> Option<&SlotIntegrationResult> {
666        self.slots.iter().find(|slot| slot.key == key)
667    }
668
669    pub fn single_slot(&self) -> Option<&SlotIntegrationResult> {
670        (self.slots.len() == 1).then(|| &self.slots[0])
671    }
672
673    pub fn single_slot_integral(&self) -> Option<&IntegralEstimate> {
674        self.single_slot().map(|slot| &slot.integral)
675    }
676
677    pub fn is_compatible_with_target(&self, target: Complex<F<f64>>, n_sigma: i8) -> bool {
678        self.single_slot_integral()
679            .is_some_and(|integral| integral.is_compatible_with_target(target, n_sigma))
680    }
681}
682
683impl Display for IntegrationResult {
684    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
685        if let Some(slot) = self.single_slot() {
686            return slot.integral.fmt(f);
687        }
688
689        for (index, slot) in self.slots.iter().enumerate() {
690            if index > 0 {
691                writeln!(f)?;
692            }
693            write!(f, "{}: {}", slot.key, slot.integral)?;
694        }
695        Ok(())
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use crate::utils::serde_utils::ShowDefaultsGuard;
703    use spenso::algebra::complex::Complex;
704
705    #[test]
706    fn test_integration_result_display() {
707        let result = IntegralEstimate {
708            neval: 1000000,
709            real_zero: 0,
710            im_zero: 0,
711            result: Complex::new(F(1.23456789e-3), F(4.56789e-5)),
712            error: Complex::new(F(1.2e-5), F(3.4e-7)),
713            real_chisq: F(1.05),
714            im_chisq: F(0.98),
715        };
716
717        let display_str = format!("{}", result);
718        println!("Integration result display: {}", display_str);
719
720        // Test with only real part
721        let real_only_result = IntegralEstimate {
722            neval: 500000,
723            real_zero: 0,
724            im_zero: 0,
725            result: Complex::new(F(2.345e-2), F(0.0)),
726            error: Complex::new(F(1.5e-4), F(0.0)),
727            real_chisq: F(1.12),
728            im_chisq: F(0.0),
729        };
730
731        let real_display_str = format!("{}", real_only_result);
732        println!("Real-only result display: {}", real_display_str);
733
734        // Test with custom precision
735        let precision_str = format!("{:.2}", result);
736        println!("With precision 2: {}", precision_str);
737    }
738
739    #[test]
740    fn integrator_settings_target_accuracy_defaults_stay_optional() {
741        let settings = IntegratorSettings::default();
742
743        let hidden_defaults = toml::to_string(&settings).expect("serialize integrator defaults");
744        assert!(!hidden_defaults.contains("target_relative_accuracy"));
745        assert!(!hidden_defaults.contains("target_absolute_accuracy"));
746
747        let _guard = ShowDefaultsGuard::new(true);
748        let shown_defaults = toml::to_string(&settings).expect("serialize integrator defaults");
749        assert!(shown_defaults.contains("n_bins"));
750        assert!(!shown_defaults.contains("target_relative_accuracy"));
751        assert!(!shown_defaults.contains("target_absolute_accuracy"));
752    }
753
754    #[test]
755    fn integrator_settings_target_accuracy_serializes_when_configured() {
756        let settings = IntegratorSettings {
757            target_relative_accuracy: Some(0.05),
758            target_absolute_accuracy: Some(1.0e-6),
759            ..IntegratorSettings::default()
760        };
761
762        let serialized = toml::to_string(&settings).expect("serialize configured integrator");
763        assert!(serialized.contains("target_relative_accuracy = 0.05"));
764        assert!(serialized.contains("target_absolute_accuracy = "));
765
766        let deserialized: IntegratorSettings =
767            toml::from_str(&serialized).expect("deserialize configured integrator");
768        assert_eq!(settings, deserialized);
769    }
770
771    #[test]
772    fn integral_unit_auto_resolves_by_initial_state_count() {
773        assert_eq!(
774            IntegralUnit::Auto.resolve_for_cross_section(1),
775            IntegralUnit::None
776        );
777        assert_eq!(
778            IntegralUnit::Auto.resolve_for_cross_section(2),
779            IntegralUnit::Picobarn
780        );
781    }
782
783    #[test]
784    fn integral_unit_relative_scalings_match_requested_barn_unit() {
785        let one = F(1.0f64);
786        assert_eq!(
787            IntegralUnit::Picobarn.relative_to_picobarn_factor(&one),
788            Some(F(1.0))
789        );
790        assert_eq!(
791            IntegralUnit::Femtobarn.relative_to_picobarn_factor(&one),
792            Some(F(1_000.0))
793        );
794        assert_eq!(
795            IntegralUnit::Attobarn.relative_to_picobarn_factor(&one),
796            Some(F(1_000_000.0))
797        );
798        assert_eq!(
799            IntegralUnit::Millibarn.relative_to_picobarn_factor(&one),
800            Some(F(1.0e-9))
801        );
802        assert_eq!(IntegralUnit::None.relative_to_picobarn_factor(&one), None);
803    }
804
805    #[test]
806    fn general_settings_store_mu_r_and_compute_mu_r_sq_internally() {
807        let settings = GeneralSettings {
808            mu_r: 91.188,
809            ..GeneralSettings::default()
810        };
811
812        assert_eq!(settings.mu_r, 91.188);
813        assert!((settings.mu_r_sq() - 8315.251344).abs() < 1.0e-12);
814
815        let serialized = toml::to_string(&settings).expect("serialize general settings");
816        assert!(serialized.contains("mu_r = 91.188"));
817        assert!(!serialized.contains("mu_r_sq"));
818    }
819}
820
821#[cfg_attr(
822    feature = "python_api",
823    pyo3::pyclass(from_py_object, get_all, set_all)
824)]
825#[derive(Serialize, Deserialize, Debug, Clone, Encode, Decode, PartialEq, JsonSchema)]
826#[serde(default, deny_unknown_fields)]
827pub struct StabilitySettings {
828    /// Rotations applied when comparing an unstable sample with an equivalent phase-space point.
829    #[serde(skip_serializing_if = "is_default_rotation_axis")]
830    pub rotation_axis: Vec<RotationSetting>,
831    /// Ordered numerical-precision levels and tolerances used during stability escalation.
832    #[serde(skip_serializing_if = "is_default_stability_levels")]
833    pub levels: Vec<StabilityLevelSetting>,
834    /// Compare rotated results after normalizing by the result magnitude.
835    #[serde(skip_serializing_if = "is_true")]
836    pub check_on_norm: bool,
837    /// Escalate precision when a result is exactly zero at the current level.
838    #[serde(skip_serializing_if = "is_true")]
839    pub escalate_if_exact_zero: bool,
840    /// Escalate when the loop-momentum norm exceeds this factor; a negative value disables it.
841    #[serde(skip_serializing_if = "is_float::<-1>")]
842    pub loop_momenta_norm_escalation_factor: f64,
843    /// Optional diagnostics retained for rotated, escalated, and high-precision evaluations.
844    #[serde(skip_serializing_if = "IsDefault::is_default")]
845    pub recording: Option<StabilityRecordingSettings>,
846}
847
848impl Default for StabilitySettings {
849    fn default() -> Self {
850        Self {
851            rotation_axis: _default_rotation_axis(),
852            levels: _default_stability_levels(),
853            check_on_norm: true,
854            escalate_if_exact_zero: false,
855            loop_momenta_norm_escalation_factor: -1.0,
856            recording: None,
857        }
858    }
859}
860
861#[cfg_attr(
862    feature = "python_api",
863    pyo3::pyclass(from_py_object, get_all, set_all)
864)]
865#[derive(Serialize, Deserialize, Debug, Clone, Copy, Encode, Decode, PartialEq, JsonSchema)]
866#[serde(default, deny_unknown_fields)]
867pub struct StabilityRecordingSettings {
868    /// Record the values obtained at rotated phase-space points.
869    #[serde(skip_serializing_if = "is_false")]
870    pub record_rotated_results: bool,
871    /// Record the result produced at every attempted numerical-precision level.
872    #[serde(skip_serializing_if = "is_false")]
873    pub record_all_stability_levels: bool,
874    /// Record evaluations escalated because of the loop-momentum norm threshold.
875    #[serde(skip_serializing_if = "is_false")]
876    pub record_loop_momenta_escalation: bool,
877}
878
879#[allow(clippy::derivable_impls)]
880impl Default for StabilityRecordingSettings {
881    fn default() -> Self {
882        Self {
883            record_rotated_results: false,
884            record_all_stability_levels: false,
885            record_loop_momenta_escalation: false,
886        }
887    }
888}
889
890#[cfg_attr(
891    feature = "python_api",
892    pyo3::pyclass(from_py_object, get_all, set_all)
893)]
894#[derive(Serialize, Deserialize, Debug, Clone, Copy, Encode, Decode, PartialEq, JsonSchema)]
895#[serde(deny_unknown_fields)]
896pub struct StabilityLevelSetting {
897    /// Numeric precision used for this escalation level.
898    pub precision: Precision,
899    /// Maximum accepted relative error in the real component before escalating.
900    pub required_precision_for_re: f64,
901    /// Maximum accepted relative error in the imaginary component before escalating.
902    pub required_precision_for_im: f64,
903    /// Ratio threshold for the large-weight escalation check; a non-positive value disables it.
904    pub escalate_for_large_weight_threshold: f64,
905}
906
907impl StabilityLevelSetting {
908    pub fn default_double() -> Self {
909        Self {
910            precision: Precision::Double,
911            required_precision_for_re: 1e-5,
912            required_precision_for_im: 1e-5,
913            escalate_for_large_weight_threshold: 0.9,
914        }
915    }
916
917    pub fn default_quad() -> Self {
918        Self {
919            precision: Precision::Quad,
920            required_precision_for_re: 1e-5,
921            required_precision_for_im: 1e-5,
922            escalate_for_large_weight_threshold: -1.0,
923        }
924    }
925
926    pub fn default_arb() -> Self {
927        Self {
928            precision: Precision::Arb,
929            required_precision_for_re: 1e-5,
930            required_precision_for_im: 1e-5,
931            escalate_for_large_weight_threshold: -1.0,
932        }
933    }
934}
935
936#[cfg_attr(
937    feature = "python_api",
938    pyo3::pyclass(from_py_object, get_all, set_all)
939)]
940#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Encode, Decode, JsonSchema)]
941#[serde(tag = "type")]
942#[serde(deny_unknown_fields)]
943pub enum RotationSetting {
944    #[serde(rename = "x")]
945    Pi2X {},
946    #[serde(rename = "y")]
947    Pi2Y {},
948    #[serde(rename = "z")]
949    Pi2Z {},
950    #[serde(rename = "none")]
951    None {},
952    #[serde(rename = "euler_angles")]
953    EulerAngles {
954        /// First Euler angle, in radians.
955        alpha: f64,
956        /// Second Euler angle, in radians.
957        beta: f64,
958        /// Third Euler angle, in radians.
959        gamma: f64,
960    },
961}
962
963impl Default for RotationSetting {
964    fn default() -> Self {
965        Self::Pi2Z {}
966    }
967}
968
969impl RotationSetting {
970    pub(crate) fn rotation_method(&self) -> RotationMethod {
971        match self {
972            Self::Pi2X {} => RotationMethod::Pi2X,
973            Self::Pi2Y {} => RotationMethod::Pi2Y,
974            Self::Pi2Z {} => RotationMethod::Pi2Z,
975            Self::None {} => RotationMethod::Identity,
976            Self::EulerAngles { alpha, beta, gamma } => {
977                RotationMethod::EulerAngles(*alpha, *beta, *gamma)
978            }
979        }
980    }
981
982    pub(crate) fn _as_str(&self) -> String {
983        match self {
984            Self::Pi2X {} => "x".to_owned(),
985            Self::Pi2Y {} => "y".to_owned(),
986            Self::Pi2Z {} => "z".to_owned(),
987            Self::None {} => "none".to_owned(),
988            Self::EulerAngles { alpha, beta, gamma } => {
989                format!("euler {} {} {}", alpha, beta, gamma)
990            }
991        }
992    }
993}
994
995#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
996#[derive(
997    Serialize, Deserialize, Debug, Clone, PartialEq, Copy, Hash, Eq, Encode, Decode, JsonSchema,
998)]
999#[serde(deny_unknown_fields)]
1000pub enum Precision {
1001    Double,
1002    Quad,
1003    Arb,
1004}
1005
1006impl Display for Precision {
1007    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1008        match self {
1009            Self::Double => write!(f, "f64"),
1010            Self::Quad => write!(f, "f128"),
1011            Self::Arb => write!(f, "arb"),
1012        }
1013    }
1014}
1015
1016#[cfg_attr(
1017    feature = "python_api",
1018    pyo3::pyclass(from_py_object, get_all, set_all)
1019)]
1020#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Encode, Decode, JsonSchema)]
1021#[serde(deny_unknown_fields)]
1022pub enum ParameterizationMode {
1023    #[serde(rename = "cartesian")]
1024    Cartesian,
1025    #[serde(rename = "spherical")]
1026    #[default]
1027    Spherical,
1028    #[serde(rename = "hyperspherical")]
1029    HyperSpherical,
1030    #[serde(rename = "hyperspherical_flat")]
1031    HyperSphericalFlat,
1032    #[serde(rename = "momentum_space")]
1033    MomentumSpace,
1034    #[serde(rename = "relative_spherical")]
1035    RelativeSpherical,
1036    #[serde(rename = "spherical_common_radial")]
1037    SphericalCommonRadial,
1038    #[serde(rename = "spherical_product_common_radial")]
1039    SphericalProductCommonRadial,
1040}
1041
1042#[cfg_attr(
1043    feature = "python_api",
1044    pyo3::pyclass(from_py_object, get_all, set_all)
1045)]
1046#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1047#[serde(default, deny_unknown_fields)]
1048pub struct OutputMetadata {
1049    #[serde(skip_serializing_if = "IsDefault::is_default")]
1050    pub model_name: String,
1051    #[serde(skip_serializing_if = "IsDefault::is_default")]
1052    pub output_type: String,
1053    #[serde(skip_serializing_if = "IsDefault::is_default")]
1054    pub contents: Vec<String>,
1055}
1056
1057impl Display for ParameterizationMode {
1058    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1059        match self {
1060            ParameterizationMode::Cartesian => write!(f, "cartesian"),
1061            ParameterizationMode::Spherical => write!(f, "spherical"),
1062            ParameterizationMode::RelativeSpherical => write!(f, "relative spherical"),
1063            ParameterizationMode::SphericalCommonRadial => {
1064                write!(f, "common-radial spherical")
1065            }
1066            ParameterizationMode::SphericalProductCommonRadial => {
1067                write!(f, "product/common-radial spherical")
1068            }
1069            ParameterizationMode::HyperSpherical => write!(f, "hyperspherical"),
1070            ParameterizationMode::HyperSphericalFlat => write!(f, "flat hyperspherical"),
1071            ParameterizationMode::MomentumSpace => write!(f, "momentum space"),
1072        }
1073    }
1074}
1075
1076#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
1077#[derive(Debug, Clone, Deserialize, PartialEq, Default, Serialize, Encode, Decode, JsonSchema)]
1078#[serde(deny_unknown_fields)]
1079pub enum ParameterizationMapping {
1080    #[serde(rename = "log")]
1081    Log,
1082    #[serde(rename = "power")]
1083    Power,
1084    #[serde(rename = "linear")]
1085    #[default]
1086    Linear,
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Encode, Decode)]
1090#[cfg_attr(
1091    feature = "python_api",
1092    pyo3::pyclass(from_py_object, get_all, set_all)
1093)]
1094pub enum SamplingSettings {
1095    Default(ParameterizationSettings),
1096    MultiChanneling(MultiChannelingSettings),
1097    DiscreteGraphs(DiscreteGraphSamplingSettings),
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1101#[cfg_attr(
1102    feature = "python_api",
1103    pyo3::pyclass(from_py_object, get_all, set_all)
1104)]
1105#[serde(default, deny_unknown_fields)]
1106pub struct SamplingSettingsParser {
1107    /// Whether graph contributions are summed deterministically or sampled as a discrete axis.
1108    #[serde(skip_serializing_if = "IsDefault::is_default")]
1109    pub graphs: SumMode,
1110    /// Optional graph-name allow-list for discrete graph sampling.
1111    #[serde(skip_serializing_if = "IsDefault::is_default")]
1112    pub graph_names: Vec<String>,
1113    /// Whether causal orientations are summed deterministically or sampled as a discrete axis.
1114    #[serde(skip_serializing_if = "IsDefault::is_default")]
1115    pub orientations: SumMode,
1116    /// Enable multichannel sampling over loop-momentum bases.
1117    #[serde(skip_serializing_if = "is_false")]
1118    pub lmb_multichanneling: bool,
1119    /// Whether loop-momentum-basis channels are summed or sampled discretely.
1120    #[serde(skip_serializing_if = "IsDefault::is_default")]
1121    pub lmb_channels: SumMode,
1122    /// Exponent controlling the sharpness of multichannel weights.
1123    #[serde(skip_serializing_if = "is_float::<3>")]
1124    pub alpha: f64,
1125    /// Rule used to construct the relative probability of each loop-momentum-basis channel.
1126    #[serde(skip_serializing_if = "IsDefault::is_default")]
1127    pub lmb_channel_weight: LmbChannelWeight,
1128    /// Coordinate system used to parameterize loop momenta.
1129    #[serde(skip_serializing_if = "IsDefault::is_default")]
1130    pub coordinate_system: CoordinateSystem,
1131    /// Mapping from the unit hypercube to unbounded radial coordinates.
1132    #[serde(skip_serializing_if = "IsDefault::is_default")]
1133    pub mapping: ParameterizationMapping,
1134    /// Scale parameter of the selected radial mapping.
1135    #[serde(skip_serializing_if = "is_float::<1>")]
1136    pub b: f64,
1137    /// Power parameter of the selected radial mapping.
1138    #[serde(skip_serializing_if = "is_float::<1>")]
1139    pub power: f64,
1140    /// Explicit loop-momentum-basis identifiers keyed by graph name.
1141    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
1142    pub lmb_basis_ids: BTreeMap<String, Vec<usize>>,
1143}
1144
1145impl Default for SamplingSettingsParser {
1146    fn default() -> Self {
1147        Self {
1148            graphs: SumMode::Summed,
1149            graph_names: Vec::new(),
1150            orientations: SumMode::Summed,
1151            lmb_multichanneling: false,
1152            lmb_channels: SumMode::Summed,
1153            alpha: 3.0,
1154            lmb_channel_weight: LmbChannelWeight::default(),
1155            coordinate_system: CoordinateSystem::Spherical,
1156            mapping: ParameterizationMapping::Linear,
1157            b: 1.0,
1158            power: 1.0,
1159            lmb_basis_ids: BTreeMap::new(),
1160        }
1161    }
1162}
1163
1164#[derive(
1165    Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Encode, Decode, JsonSchema,
1166)]
1167#[cfg_attr(
1168    feature = "python_api",
1169    pyo3::pyclass(from_py_object, get_all, set_all)
1170)]
1171pub enum LmbChannelWeight {
1172    #[serde(rename = "ose")]
1173    #[default]
1174    Ose,
1175    #[serde(rename = "inverse_jacobian")]
1176    InverseJacobian,
1177}
1178
1179#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1180#[cfg_attr(
1181    feature = "python_api",
1182    pyo3::pyclass(from_py_object, get_all, set_all)
1183)]
1184pub enum SumMode {
1185    #[serde(rename = "summed")]
1186    #[default]
1187    Summed,
1188    #[serde(rename = "monte_carlo")]
1189    MonteCarlo,
1190}
1191
1192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema, Default)]
1193#[cfg_attr(
1194    feature = "python_api",
1195    pyo3::pyclass(from_py_object, get_all, set_all)
1196)]
1197pub enum CoordinateSystem {
1198    #[serde(rename = "cartesian")]
1199    Cartesian,
1200    #[serde(rename = "spherical")]
1201    #[default]
1202    Spherical,
1203    #[serde(rename = "hyperspherical")]
1204    HyperSpherical,
1205    #[serde(rename = "hyperspherical_flat")]
1206    HyperSphericalFlat,
1207    #[serde(rename = "momentum_space")]
1208    MomentumSpace,
1209    #[serde(rename = "tropical")]
1210    MomTrop,
1211    #[serde(rename = "relative_spherical")]
1212    RelativeSpherical,
1213    #[serde(rename = "spherical_common_radial")]
1214    SphericalCommonRadial,
1215    #[serde(rename = "spherical_product_common_radial")]
1216    SphericalProductCommonRadial,
1217}
1218
1219impl Default for SamplingSettings {
1220    fn default() -> Self {
1221        Self::Default(ParameterizationSettings::default())
1222    }
1223}
1224
1225fn validate_lmb_basis_ids(lmb_basis_ids: &BTreeMap<String, Vec<usize>>) -> Result<(), String> {
1226    for (graph_name, basis_ids) in lmb_basis_ids {
1227        if basis_ids.is_empty() {
1228            return Err(format!(
1229                "Invalid sampling settings: lmb_basis_ids entry for graph '{graph_name}' cannot be empty."
1230            ));
1231        }
1232
1233        let mut seen = BTreeSet::new();
1234        for basis_id in basis_ids {
1235            if !seen.insert(*basis_id) {
1236                return Err(format!(
1237                    "Invalid sampling settings: lmb_basis_ids entry for graph '{graph_name}' contains duplicate basis id {basis_id}."
1238                ));
1239            }
1240        }
1241    }
1242
1243    Ok(())
1244}
1245
1246impl SamplingSettings {
1247    fn as_parser(&self) -> SamplingSettingsParser {
1248        match self {
1249            SamplingSettings::Default(settings) => SamplingSettingsParser {
1250                graphs: SumMode::Summed,
1251                graph_names: Vec::new(),
1252                orientations: SumMode::Summed,
1253                lmb_multichanneling: false,
1254                lmb_channels: SumMode::Summed,
1255                alpha: 3.0,
1256                lmb_channel_weight: LmbChannelWeight::default(),
1257                coordinate_system: CoordinateSystem::from_mode(settings.mode.clone()),
1258                mapping: settings.mapping.clone(),
1259                b: settings.b,
1260                power: settings.power,
1261                lmb_basis_ids: settings.lmb_basis_ids.clone(),
1262            },
1263            SamplingSettings::MultiChanneling(settings) => SamplingSettingsParser {
1264                graphs: SumMode::Summed,
1265                graph_names: Vec::new(),
1266                orientations: SumMode::Summed,
1267                lmb_multichanneling: true,
1268                lmb_channels: SumMode::Summed,
1269                alpha: settings.alpha,
1270                lmb_channel_weight: settings.channel_weight,
1271                coordinate_system: CoordinateSystem::from_mode(
1272                    settings.parameterization_settings.mode.clone(),
1273                ),
1274                mapping: settings.parameterization_settings.mapping.clone(),
1275                b: settings.parameterization_settings.b,
1276                power: settings.parameterization_settings.power,
1277                lmb_basis_ids: settings.parameterization_settings.lmb_basis_ids.clone(),
1278            },
1279            SamplingSettings::DiscreteGraphs(settings) => {
1280                let orientations = if settings.sample_orientations {
1281                    SumMode::MonteCarlo
1282                } else {
1283                    SumMode::Summed
1284                };
1285
1286                match &settings.sampling_type {
1287                    DiscreteGraphSamplingType::Default(parameterization_settings) => {
1288                        SamplingSettingsParser {
1289                            graphs: SumMode::MonteCarlo,
1290                            graph_names: settings.graph_names.clone(),
1291                            orientations,
1292                            lmb_multichanneling: false,
1293                            lmb_channels: SumMode::Summed,
1294                            alpha: 3.0,
1295                            lmb_channel_weight: LmbChannelWeight::default(),
1296                            coordinate_system: CoordinateSystem::from_mode(
1297                                parameterization_settings.mode.clone(),
1298                            ),
1299                            mapping: parameterization_settings.mapping.clone(),
1300                            b: parameterization_settings.b,
1301                            power: parameterization_settings.power,
1302                            lmb_basis_ids: parameterization_settings.lmb_basis_ids.clone(),
1303                        }
1304                    }
1305                    DiscreteGraphSamplingType::MultiChanneling(multichanneling_settings) => {
1306                        SamplingSettingsParser {
1307                            graphs: SumMode::MonteCarlo,
1308                            graph_names: settings.graph_names.clone(),
1309                            orientations,
1310                            lmb_multichanneling: true,
1311                            lmb_channels: SumMode::Summed,
1312                            alpha: multichanneling_settings.alpha,
1313                            lmb_channel_weight: multichanneling_settings.channel_weight,
1314                            coordinate_system: CoordinateSystem::from_mode(
1315                                multichanneling_settings
1316                                    .parameterization_settings
1317                                    .mode
1318                                    .clone(),
1319                            ),
1320                            mapping: multichanneling_settings
1321                                .parameterization_settings
1322                                .mapping
1323                                .clone(),
1324                            b: multichanneling_settings.parameterization_settings.b,
1325                            power: multichanneling_settings.parameterization_settings.power,
1326                            lmb_basis_ids: multichanneling_settings
1327                                .parameterization_settings
1328                                .lmb_basis_ids
1329                                .clone(),
1330                        }
1331                    }
1332                    DiscreteGraphSamplingType::DiscreteMultiChanneling(
1333                        multichanneling_settings,
1334                    ) => SamplingSettingsParser {
1335                        graphs: SumMode::MonteCarlo,
1336                        graph_names: settings.graph_names.clone(),
1337                        orientations,
1338                        lmb_multichanneling: true,
1339                        lmb_channels: SumMode::MonteCarlo,
1340                        alpha: multichanneling_settings.alpha,
1341                        lmb_channel_weight: multichanneling_settings.channel_weight,
1342                        coordinate_system: CoordinateSystem::from_mode(
1343                            multichanneling_settings
1344                                .parameterization_settings
1345                                .mode
1346                                .clone(),
1347                        ),
1348                        mapping: multichanneling_settings
1349                            .parameterization_settings
1350                            .mapping
1351                            .clone(),
1352                        b: multichanneling_settings.parameterization_settings.b,
1353                        power: multichanneling_settings.parameterization_settings.power,
1354                        lmb_basis_ids: multichanneling_settings
1355                            .parameterization_settings
1356                            .lmb_basis_ids
1357                            .clone(),
1358                    },
1359                    DiscreteGraphSamplingType::TropicalSampling(_) => SamplingSettingsParser {
1360                        graphs: SumMode::MonteCarlo,
1361                        graph_names: settings.graph_names.clone(),
1362                        orientations,
1363                        lmb_multichanneling: false,
1364                        lmb_channels: SumMode::Summed,
1365                        alpha: 3.0,
1366                        lmb_channel_weight: LmbChannelWeight::default(),
1367                        coordinate_system: CoordinateSystem::MomTrop,
1368                        mapping: ParameterizationMapping::default(),
1369                        b: 1.0,
1370                        power: 1.0,
1371                        lmb_basis_ids: BTreeMap::new(),
1372                    },
1373                }
1374            }
1375        }
1376    }
1377
1378    fn from_parser(parser: SamplingSettingsParser) -> Result<Self, String> {
1379        let SamplingSettingsParser {
1380            graphs,
1381            graph_names,
1382            orientations,
1383            lmb_multichanneling,
1384            lmb_channels,
1385            alpha,
1386            lmb_channel_weight,
1387            coordinate_system,
1388            mapping,
1389            b,
1390            power,
1391            lmb_basis_ids,
1392        } = parser;
1393
1394        validate_lmb_basis_ids(&lmb_basis_ids)?;
1395
1396        let mut seen_graph_names = BTreeSet::new();
1397        for graph_name in &graph_names {
1398            if !seen_graph_names.insert(graph_name) {
1399                return Err(format!(
1400                    "Invalid sampling settings: graph_names contains duplicate graph name '{graph_name}'."
1401                ));
1402            }
1403        }
1404        if !graph_names.is_empty() && matches!(graphs, SumMode::Summed) {
1405            return Err(
1406                "Invalid sampling settings: graph_names requires graphs = 'monte_carlo'."
1407                    .to_string(),
1408            );
1409        }
1410
1411        let sample_orientations = match (graphs.clone(), orientations) {
1412            (SumMode::Summed, SumMode::Summed) => false,
1413            (SumMode::Summed, SumMode::MonteCarlo) => {
1414                return Err(
1415                    "Invalid sampling settings: orientations can only be set to 'monte_carlo' when graphs is 'monte_carlo'.".to_string(),
1416                )
1417            }
1418            (SumMode::MonteCarlo, SumMode::Summed) => false,
1419            (SumMode::MonteCarlo, SumMode::MonteCarlo) => true,
1420        };
1421
1422        if matches!(coordinate_system, CoordinateSystem::MomTrop) {
1423            if !lmb_basis_ids.is_empty() {
1424                return Err(
1425                    "Invalid sampling settings: coordinate_system = 'tropical' is incompatible with lmb_basis_ids."
1426                        .to_string(),
1427                );
1428            }
1429            if !matches!(graphs, SumMode::MonteCarlo) {
1430                return Err(
1431                    "Invalid sampling settings: coordinate_system = 'tropical' requires graphs = 'monte_carlo'."
1432                        .to_string(),
1433                );
1434            }
1435
1436            if lmb_multichanneling {
1437                return Err(
1438                    "Invalid sampling settings: coordinate_system = 'tropical' is incompatible with lmb_multichanneling = true."
1439                        .to_string(),
1440                );
1441            }
1442
1443            return Ok(SamplingSettings::DiscreteGraphs(
1444                DiscreteGraphSamplingSettings {
1445                    graph_names,
1446                    sample_orientations,
1447                    sampling_type: DiscreteGraphSamplingType::TropicalSampling(
1448                        GammaloopTropicalSamplingSettings::default(),
1449                    ),
1450                },
1451            ));
1452        }
1453
1454        let mode = coordinate_system.into_mode();
1455        if matches!(mode, ParameterizationMode::SphericalProductCommonRadial)
1456            && (!lmb_multichanneling || lmb_channel_weight != LmbChannelWeight::InverseJacobian)
1457        {
1458            return Err(
1459                "Invalid sampling settings: coordinate_system = 'spherical_product_common_radial' requires lmb_multichanneling = true and lmb_channel_weight = 'inverse_jacobian'."
1460                    .to_string(),
1461            );
1462        }
1463        if lmb_channel_weight == LmbChannelWeight::InverseJacobian
1464            && matches!(mode, ParameterizationMode::HyperSphericalFlat)
1465        {
1466            return Err(
1467                "Invalid sampling settings: lmb_channel_weight = 'inverse_jacobian' is incompatible with coordinate_system = 'hyperspherical_flat' because the inverse map is not available."
1468                    .to_string(),
1469            );
1470        }
1471        if matches!(mapping, ParameterizationMapping::Power) {
1472            if !matches!(
1473                mode,
1474                ParameterizationMode::Spherical
1475                    | ParameterizationMode::RelativeSpherical
1476                    | ParameterizationMode::SphericalCommonRadial
1477                    | ParameterizationMode::SphericalProductCommonRadial
1478                    | ParameterizationMode::HyperSpherical
1479                    | ParameterizationMode::HyperSphericalFlat
1480            ) {
1481                return Err(
1482                    "Invalid sampling settings: mapping = 'power' requires a spherical coordinate system."
1483                        .to_string(),
1484                );
1485            }
1486            if !power.is_finite() || power < 1.0 {
1487                return Err(
1488                    "Invalid sampling settings: mapping = 'power' requires power >= 1.".to_string(),
1489                );
1490            }
1491        }
1492        let parameterization_settings = ParameterizationSettings {
1493            mode,
1494            mapping,
1495            b,
1496            power,
1497            lmb_basis_ids,
1498        };
1499
1500        match graphs {
1501            SumMode::Summed => {
1502                if !matches!(lmb_channels, SumMode::Summed) {
1503                    return Err(
1504                        "Invalid sampling settings: lmb_channels = 'monte_carlo' requires graphs = 'monte_carlo'."
1505                            .to_string(),
1506                    );
1507                }
1508
1509                if sample_orientations {
1510                    return Err(
1511                        "Invalid sampling settings: orientations can only be Monte Carlo sampled when graphs are Monte Carlo sampled."
1512                            .to_string(),
1513                    );
1514                }
1515
1516                if lmb_multichanneling {
1517                    Ok(SamplingSettings::MultiChanneling(MultiChannelingSettings {
1518                        alpha,
1519                        channel_weight: lmb_channel_weight,
1520                        parameterization_settings,
1521                    }))
1522                } else {
1523                    Ok(SamplingSettings::Default(parameterization_settings))
1524                }
1525            }
1526            SumMode::MonteCarlo => {
1527                let sampling_type = if lmb_multichanneling {
1528                    let settings = MultiChannelingSettings {
1529                        alpha,
1530                        channel_weight: lmb_channel_weight,
1531                        parameterization_settings,
1532                    };
1533
1534                    match lmb_channels {
1535                        SumMode::Summed => DiscreteGraphSamplingType::MultiChanneling(settings),
1536                        SumMode::MonteCarlo => {
1537                            DiscreteGraphSamplingType::DiscreteMultiChanneling(settings)
1538                        }
1539                    }
1540                } else {
1541                    DiscreteGraphSamplingType::Default(parameterization_settings)
1542                };
1543
1544                Ok(SamplingSettings::DiscreteGraphs(
1545                    DiscreteGraphSamplingSettings {
1546                        graph_names,
1547                        sample_orientations,
1548                        sampling_type,
1549                    },
1550                ))
1551            }
1552        }
1553    }
1554}
1555
1556impl Serialize for SamplingSettings {
1557    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1558    where
1559        S: Serializer,
1560    {
1561        self.as_parser().serialize(serializer)
1562    }
1563}
1564
1565impl<'de> Deserialize<'de> for SamplingSettings {
1566    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1567    where
1568        D: Deserializer<'de>,
1569    {
1570        let parser = SamplingSettingsParser::deserialize(deserializer)?;
1571        SamplingSettings::from_parser(parser).map_err(serde::de::Error::custom)
1572    }
1573}
1574
1575impl JsonSchema for SamplingSettings {
1576    fn schema_name() -> std::borrow::Cow<'static, str> {
1577        "SamplingSettings".into()
1578    }
1579
1580    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1581        SamplingSettingsParser::json_schema(generator)
1582    }
1583}
1584
1585impl CoordinateSystem {
1586    fn from_mode(mode: ParameterizationMode) -> Self {
1587        match mode {
1588            ParameterizationMode::Cartesian => Self::Cartesian,
1589            ParameterizationMode::Spherical => Self::Spherical,
1590            ParameterizationMode::RelativeSpherical => Self::RelativeSpherical,
1591            ParameterizationMode::SphericalCommonRadial => Self::SphericalCommonRadial,
1592            ParameterizationMode::SphericalProductCommonRadial => {
1593                Self::SphericalProductCommonRadial
1594            }
1595            ParameterizationMode::HyperSpherical => Self::HyperSpherical,
1596            ParameterizationMode::HyperSphericalFlat => Self::HyperSphericalFlat,
1597            ParameterizationMode::MomentumSpace => Self::MomentumSpace,
1598        }
1599    }
1600
1601    fn into_mode(self) -> ParameterizationMode {
1602        match self {
1603            CoordinateSystem::Cartesian => ParameterizationMode::Cartesian,
1604            CoordinateSystem::Spherical => ParameterizationMode::Spherical,
1605            CoordinateSystem::RelativeSpherical => ParameterizationMode::RelativeSpherical,
1606            CoordinateSystem::SphericalCommonRadial => ParameterizationMode::SphericalCommonRadial,
1607            CoordinateSystem::SphericalProductCommonRadial => {
1608                ParameterizationMode::SphericalProductCommonRadial
1609            }
1610            CoordinateSystem::HyperSpherical => ParameterizationMode::HyperSpherical,
1611            CoordinateSystem::HyperSphericalFlat => ParameterizationMode::HyperSphericalFlat,
1612            CoordinateSystem::MomentumSpace => ParameterizationMode::MomentumSpace,
1613            CoordinateSystem::MomTrop => {
1614                unreachable!("tropical coordinate system has no ParameterizationMode equivalent")
1615            }
1616        }
1617    }
1618}
1619
1620impl SamplingSettings {
1621    pub fn selected_graph_names(&self) -> &[String] {
1622        match self {
1623            SamplingSettings::DiscreteGraphs(settings) => &settings.graph_names,
1624            SamplingSettings::Default(_) | SamplingSettings::MultiChanneling(_) => &[],
1625        }
1626    }
1627
1628    pub fn get_parameterization_settings(&self) -> Option<ParameterizationSettings> {
1629        match self {
1630            SamplingSettings::Default(settings) => Some(settings.clone()),
1631            SamplingSettings::MultiChanneling(settings) => {
1632                Some(settings.parameterization_settings.clone())
1633            }
1634            SamplingSettings::DiscreteGraphs(settings) => match &settings.sampling_type {
1635                DiscreteGraphSamplingType::Default(settings) => Some(settings.clone()),
1636                DiscreteGraphSamplingType::MultiChanneling(settings) => {
1637                    Some(settings.parameterization_settings.clone())
1638                }
1639                DiscreteGraphSamplingType::DiscreteMultiChanneling(settings) => {
1640                    Some(settings.parameterization_settings.clone())
1641                }
1642                DiscreteGraphSamplingType::TropicalSampling(_) => None,
1643            },
1644        }
1645    }
1646
1647    pub(crate) fn discrete_depth(&self) -> usize {
1648        match self {
1649            SamplingSettings::Default(_) => 0,
1650            SamplingSettings::MultiChanneling(_) => 0,
1651            SamplingSettings::DiscreteGraphs(settings) => {
1652                let depth_from_orientations = settings.sample_orientations as usize;
1653
1654                match &settings.sampling_type {
1655                    DiscreteGraphSamplingType::Default(_) => 1 + depth_from_orientations,
1656                    DiscreteGraphSamplingType::MultiChanneling(_) => 1 + depth_from_orientations,
1657                    DiscreteGraphSamplingType::DiscreteMultiChanneling(_) => {
1658                        2 + depth_from_orientations
1659                    }
1660                    DiscreteGraphSamplingType::TropicalSampling(_) => 1 + depth_from_orientations,
1661                }
1662            }
1663        }
1664    }
1665
1666    pub(crate) fn describe_settings(&self) -> String {
1667        match self {
1668            SamplingSettings::Default(settings) => {
1669                format!("{} coordinates", settings.mode)
1670            }
1671            SamplingSettings::MultiChanneling(settings) => {
1672                format!(
1673                    "lmb multichanneling in {} coordinates",
1674                    settings.parameterization_settings.mode
1675                )
1676            }
1677            SamplingSettings::DiscreteGraphs(settings) => {
1678                let discrete_graph_string = if settings.graph_names.is_empty() {
1679                    "Monte Carlo over graphs".to_string()
1680                } else {
1681                    format!(
1682                        "Monte Carlo over selected graph groups [{}]",
1683                        settings.graph_names.join(", ")
1684                    )
1685                };
1686                let orientation_sampling_string = if settings.sample_orientations {
1687                    "and Monte Carlo over orientations"
1688                } else {
1689                    ""
1690                };
1691
1692                match &settings.sampling_type {
1693                    DiscreteGraphSamplingType::Default(settings) => {
1694                        format!(
1695                            "{} {} in {} coordinates",
1696                            discrete_graph_string, orientation_sampling_string, settings.mode
1697                        )
1698                    }
1699                    DiscreteGraphSamplingType::MultiChanneling(settings) => {
1700                        format!(
1701                            "{}, lmb multichanneling in {} coordinates {}",
1702                            discrete_graph_string,
1703                            settings.parameterization_settings.mode,
1704                            orientation_sampling_string,
1705                        )
1706                    }
1707                    DiscreteGraphSamplingType::DiscreteMultiChanneling(settings) => {
1708                        format!(
1709                            "{}, {} and monte carlo over lmbs in {} coordinates",
1710                            discrete_graph_string,
1711                            orientation_sampling_string,
1712                            settings.parameterization_settings.mode
1713                        )
1714                    }
1715                    DiscreteGraphSamplingType::TropicalSampling(_) => {
1716                        format!(
1717                            "{} {} using 🌴🥥 tropical sampling 🥥🌴",
1718                            discrete_graph_string, orientation_sampling_string,
1719                        )
1720                    }
1721                }
1722            }
1723        }
1724    }
1725}
1726
1727#[cfg_attr(
1728    feature = "python_api",
1729    pyo3::pyclass(from_py_object, get_all, set_all)
1730)]
1731#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1732#[serde(deny_unknown_fields, default)]
1733pub struct MultiChannelingSettings {
1734    /// Exponent controlling the sharpness of loop-momentum-basis channel weights.
1735    #[serde(skip_serializing_if = "is_float::<3>")]
1736    pub alpha: f64,
1737    /// Rule used to assign relative loop-momentum-basis channel weights.
1738    #[serde(skip_serializing_if = "IsDefault::is_default")]
1739    pub channel_weight: LmbChannelWeight,
1740    /// Continuous loop-momentum parameterization used inside each channel.
1741    #[serde(skip_serializing_if = "IsDefault::is_default")]
1742    pub parameterization_settings: ParameterizationSettings,
1743}
1744
1745impl Default for MultiChannelingSettings {
1746    fn default() -> Self {
1747        Self {
1748            alpha: 3.0,
1749            channel_weight: LmbChannelWeight::default(),
1750            parameterization_settings: ParameterizationSettings::default(),
1751        }
1752    }
1753}
1754
1755#[cfg_attr(
1756    feature = "python_api",
1757    pyo3::pyclass(from_py_object, get_all, set_all)
1758)]
1759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1760#[serde(deny_unknown_fields, default)]
1761pub struct GammaloopTropicalSamplingSettings {
1762    /// Retry unstable tropical matrix operations at a higher numerical precision.
1763    #[serde(skip_serializing_if = "is_true")]
1764    pub upcast_on_failure: bool,
1765    /// Optional condition threshold used to reject an unstable tropical sampling matrix.
1766    #[serde(skip_serializing_if = "IsDefault::is_default")]
1767    pub matrix_stability_test: Option<f64>,
1768}
1769
1770impl Default for GammaloopTropicalSamplingSettings {
1771    fn default() -> Self {
1772        Self {
1773            upcast_on_failure: true,
1774            matrix_stability_test: None,
1775        }
1776    }
1777}
1778
1779impl GammaloopTropicalSamplingSettings {
1780    pub fn into_tropical_sampling_settings(&self) -> momtrop::TropicalSamplingSettings {
1781        momtrop::TropicalSamplingSettings {
1782            matrix_stability_test: self.matrix_stability_test,
1783            print_debug_info: false,
1784            return_metadata: false,
1785        }
1786    }
1787}
1788
1789#[cfg_attr(
1790    feature = "python_api",
1791    pyo3::pyclass(from_py_object, get_all, set_all)
1792)]
1793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1794#[serde(tag = "subtype", deny_unknown_fields)]
1795pub enum DiscreteGraphSamplingType {
1796    #[serde(rename = "default")]
1797    Default(ParameterizationSettings),
1798    #[serde(rename = "multi_channeling")]
1799    MultiChanneling(MultiChannelingSettings),
1800    #[serde(rename = "discrete_multi_channeling")]
1801    DiscreteMultiChanneling(MultiChannelingSettings),
1802    #[serde(rename = "tropical")]
1803    TropicalSampling(GammaloopTropicalSamplingSettings),
1804}
1805
1806impl Default for DiscreteGraphSamplingType {
1807    fn default() -> Self {
1808        DiscreteGraphSamplingType::Default(ParameterizationSettings::default())
1809    }
1810}
1811
1812#[cfg_attr(
1813    feature = "python_api",
1814    pyo3::pyclass(from_py_object, get_all, set_all)
1815)]
1816#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema, Default)]
1817#[serde(deny_unknown_fields, default)]
1818pub struct DiscreteGraphSamplingSettings {
1819    /// Optional graph-name allow-list; an empty list includes all generated graphs.
1820    #[serde(skip_serializing_if = "IsDefault::is_default")]
1821    pub graph_names: Vec<String>,
1822    /// Sample graph orientations as an additional discrete integration coordinate.
1823    #[serde(skip_serializing_if = "is_false")]
1824    pub sample_orientations: bool,
1825    /// Continuous and multichannel strategy used inside each selected graph.
1826    #[serde(skip_serializing_if = "IsDefault::is_default")]
1827    pub sampling_type: DiscreteGraphSamplingType,
1828}
1829
1830#[cfg_attr(
1831    feature = "python_api",
1832    pyo3::pyclass(from_py_object, get_all, set_all)
1833)]
1834#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1835#[serde(deny_unknown_fields, default)]
1836pub struct LocalCounterTermSettings {
1837    /// Widths and override controls for ultraviolet-counterterm localization dampers.
1838    #[serde(skip_serializing_if = "IsDefault::is_default")]
1839    pub uv_localisation: UVLocalisationSettings,
1840}
1841
1842#[cfg_attr(
1843    feature = "python_api",
1844    pyo3::pyclass(from_py_object, get_all, set_all)
1845)]
1846#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1847#[serde(default, deny_unknown_fields)]
1848pub struct UVLocalisationSettings {
1849    /// Width of the transition region used by the ultraviolet sliver damper.
1850    #[serde(skip_serializing_if = "is_float::<10>")]
1851    pub sliver_width: f64,
1852    /// Derive the localization width dynamically from the sampled ultraviolet configuration.
1853    #[serde(skip_serializing_if = "is_false")]
1854    pub dynamic_width: bool,
1855    /// Width of the Gaussian ultraviolet localization factor.
1856    #[serde(skip_serializing_if = "is_float::<1>")]
1857    pub gaussian_width: f64,
1858    /// Replace ultraviolet localization dampers by one for diagnostic comparisons.
1859    #[serde(skip_serializing_if = "is_false")]
1860    pub force_uv_dampers_to_one: bool,
1861}
1862
1863impl Default for UVLocalisationSettings {
1864    fn default() -> Self {
1865        Self {
1866            sliver_width: 10.0,
1867            dynamic_width: false,
1868            gaussian_width: 1.0,
1869            force_uv_dampers_to_one: false,
1870        }
1871    }
1872}
1873
1874#[cfg_attr(
1875    feature = "python_api",
1876    pyo3::pyclass(from_py_object, get_all, set_all)
1877)]
1878#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1879#[serde(deny_unknown_fields, default)]
1880pub struct IntegratedCounterTermSettings {
1881    /// Integration domain and damping function used for integrated counterterms.
1882    #[serde(skip_serializing_if = "IsDefault::is_default")]
1883    pub range: IntegratedCounterTermRange,
1884}
1885
1886#[cfg_attr(
1887    feature = "python_api",
1888    pyo3::pyclass(from_py_object, get_all, set_all)
1889)]
1890#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1891#[serde(tag = "type")]
1892#[serde(deny_unknown_fields)]
1893pub enum IntegratedCounterTermRange {
1894    /// Integrate over the full radial domain and regulate it with an H-function profile.
1895    #[serde(rename = "infinite")]
1896    Infinite {
1897        /// Damping profile used to make the infinite-domain counterterm integral convergent.
1898        h_function_settings: HFunctionSettings,
1899    },
1900    /// Integrate over the compact radial domain without an infinite-tail damping profile.
1901    #[serde(rename = "compact")]
1902    Compact {},
1903}
1904
1905impl Default for IntegratedCounterTermRange {
1906    fn default() -> Self {
1907        Self::Infinite {
1908            h_function_settings: HFunctionSettings::default(),
1909        }
1910    }
1911}
1912
1913#[cfg_attr(
1914    feature = "python_api",
1915    pyo3::pyclass(from_py_object, get_all, set_all)
1916)]
1917#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1918#[serde(deny_unknown_fields)]
1919#[serde(default)]
1920pub struct OverlapSettings {
1921    //v
1922    /// Explicit three-vector center used for every threshold overlap, when provided.
1923    #[serde(skip_serializing_if = "IsDefault::is_default")]
1924    pub force_global_center: Option<Vec<[f64; 3]>>,
1925    /// Validate that the selected global center lies inside every required E-surface.
1926    #[serde(skip_serializing_if = "is_true")]
1927    pub check_global_center: bool,
1928    /// Try the loop-momentum origin as the global center before solving for another point.
1929    #[serde(skip_serializing_if = "is_true")]
1930    pub try_origin: bool,
1931    /// Try the origin independently for every available loop-momentum basis.
1932    #[serde(skip_serializing_if = "is_false")]
1933    pub try_origin_all_lmbs: bool,
1934}
1935
1936impl Default for OverlapSettings {
1937    fn default() -> Self {
1938        Self {
1939            force_global_center: None,
1940            check_global_center: true,
1941            try_origin: true,
1942            try_origin_all_lmbs: false,
1943        }
1944    }
1945}
1946
1947#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
1948#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1949#[serde(deny_unknown_fields)]
1950// #[trait_decode(trait= GammaLoopContext)]
1951pub enum HFunction {
1952    #[default]
1953    #[serde(rename = "poly_exponential")]
1954    PolyExponential,
1955    #[serde(rename = "exponential")]
1956    Exponential,
1957    #[serde(rename = "poly_left_right_exponential")]
1958    PolyLeftRightExponential,
1959    #[serde(rename = "exponential_ct")]
1960    ExponentialCT,
1961}
1962
1963#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
1964#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1965#[serde(deny_unknown_fields)]
1966#[serde(default)]
1967pub struct HFunctionSettings {
1968    /// Functional form of the counterterm damping profile.
1969    #[serde(skip_serializing_if = "IsDefault::is_default")]
1970    pub function: HFunction,
1971    /// Characteristic width of the damping profile.
1972    #[serde(skip_serializing_if = "is_float::<1>")]
1973    pub sigma: f64,
1974    /// Apply the damping profile; disabling it leaves the corresponding factor equal to one.
1975    #[serde(skip_serializing_if = "is_true")]
1976    pub enabled_dampening: bool,
1977    /// Optional polynomial power overriding the profile's built-in choice.
1978    #[serde(skip_serializing_if = "IsDefault::is_default")]
1979    pub power: Option<usize>,
1980}
1981
1982impl Default for HFunctionSettings {
1983    fn default() -> Self {
1984        Self {
1985            sigma: 1.0,
1986            function: HFunction::default(),
1987            enabled_dampening: true,
1988            power: None,
1989        }
1990    }
1991}