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::SamplingMapDefinition,
20    integrands::process::evaluators::EvaluatorMethod,
21    momentum::{Helicity, RotationMethod, sample::ExternalIndex, signature::SignatureLike},
22    observables::ObservableFileFormat,
23    settings::runtime::kinematic::{Externals, improvement::generate_default_momenta},
24    utils::{
25        ApproxEq, DEFAULT_ESURFACE_EXISTENCE_THRESHOLD, F, FloatLike, format_uncertainty,
26        serde_utils::{
27            _default_rotation_axis, _default_stability_levels, IsDefault,
28            deserialize_nonnegative_finite_f64, is_default_esurface_existence_threshold,
29            is_default_rotation_axis, is_default_stability_levels, is_false, is_float, is_true,
30            is_u64, is_usize, show_defaults_helper,
31        },
32    },
33};
34use symbolica::domains::{float::Real, integer::Integer, rational::Rational};
35
36use super::{RuntimeSettings, global::OrientationPattern};
37
38#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Encode, Decode, JsonSchema)]
40#[serde(default)]
41pub struct RuntimeModelSettings {
42    #[serde(flatten, skip_serializing_if = "IsDefault::is_default")]
43    pub external_parameters: BTreeMap<String, (F<f64>, F<f64>)>,
44}
45
46#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
48#[serde(default, deny_unknown_fields)]
49pub struct SubtractionSettings {
50    /// Configuration for local ultraviolet counterterms in the numerical integrand.
51    #[serde(skip_serializing_if = "IsDefault::is_default")]
52    pub local_ct_settings: LocalCounterTermSettings,
53    /// Configuration for analytically integrated counterterm contributions.
54    #[serde(skip_serializing_if = "IsDefault::is_default")]
55    pub integrated_ct_settings: IntegratedCounterTermSettings,
56    /// Strategy used to choose centers shared by overlapping threshold surfaces.
57    #[serde(skip_serializing_if = "IsDefault::is_default")]
58    pub overlap_settings: OverlapSettings,
59    /// Dimensionless tolerance used to compare the energy-squared E-surface invariant margin
60    /// against `esurface_existence_threshold * E_cm^2` at runtime.
61    #[serde(
62        deserialize_with = "deserialize_nonnegative_finite_f64",
63        skip_serializing_if = "is_default_esurface_existence_threshold"
64    )]
65    #[schemars(range(min = 0.0))]
66    pub esurface_existence_threshold: f64,
67    /// Multiplier of `epsilon(T) * E_cm` used as the direct residual limit for
68    /// threshold-counterterm radial roots at numerical precision `T`. A residual-limited
69    /// higher-precision root may instead be accepted against the corresponding lower-precision
70    /// limit after satisfying the cross-precision consistency checks.
71    #[serde(
72        deserialize_with = "deserialize_nonnegative_finite_f64",
73        skip_serializing_if = "is_float::<64>"
74    )]
75    #[schemars(range(min = 0.0))]
76    pub radial_root_residual_tolerance: f64,
77    /// Disable local threshold subtraction while leaving the original integrand unchanged.
78    #[serde(skip_serializing_if = "is_false")]
79    pub disable_threshold_subtraction: bool,
80}
81
82impl Default for SubtractionSettings {
83    fn default() -> Self {
84        Self {
85            local_ct_settings: LocalCounterTermSettings::default(),
86            integrated_ct_settings: IntegratedCounterTermSettings::default(),
87            overlap_settings: OverlapSettings::default(),
88            esurface_existence_threshold: DEFAULT_ESURFACE_EXISTENCE_THRESHOLD,
89            radial_root_residual_tolerance: 64.0,
90            disable_threshold_subtraction: false,
91        }
92    }
93}
94
95#[derive(Copy, Clone)]
96pub struct LockedRuntimeSettings<'a>(&'a RuntimeSettings);
97impl<'a> From<&'a RuntimeSettings> for LockedRuntimeSettings<'a> {
98    fn from(value: &'a RuntimeSettings) -> Self {
99        LockedRuntimeSettings(value)
100    }
101}
102
103impl<'a> From<LockedRuntimeSettings<'a>> for RuntimeSettings {
104    fn from(value: LockedRuntimeSettings) -> Self {
105        value.0.clone()
106    }
107}
108
109impl<'a> LockedRuntimeSettings<'a> {
110    pub(crate) fn helicities(&self) -> &[Helicity] {
111        self.0.kinematics.externals.get_helicities()
112    }
113
114    /// If no external momenta are set in the settings, generate a default kinematic point that satisfies the phase-space constraints
115    pub(crate) fn into_with_modified_kinematics(
116        self,
117        external_signature: &SignatureLike<ExternalIndex>,
118        external_masses: &TiVec<ExternalIndex, F<f64>>,
119    ) -> Result<RuntimeSettings> {
120        if external_signature.is_empty() {
121            Ok(self.into())
122        } else {
123            match &self.0.kinematics.externals {
124                Externals::Constant {
125                    momenta,
126                    helicities,
127                    ..
128                } => {
129                    if momenta.is_empty() && helicities.is_empty() {
130                        warn!(
131                            "No external kinematics were provided; using default-generated external momenta and helicities."
132                        );
133                        let new_externals = generate_default_momenta(
134                            external_masses,
135                            external_signature,
136                            &F(self.0.kinematics.e_cm),
137                        )?;
138
139                        let mut new_settigns = self.0.clone();
140                        new_settigns.kinematics.externals = new_externals;
141                        Ok(new_settigns)
142                    } else {
143                        Ok(self.into())
144                    }
145                }
146            }
147        }
148    }
149
150    pub(crate) fn existence_check(
151        &self,
152        esurface: &Esurface,
153        masses: &EdgeVec<F<f64>>,
154        external_signature: &SignatureLike<ExternalIndex>,
155        lmb: &LoopMomentumBasis,
156        esurface_existence_threshold: f64,
157    ) -> bool {
158        let dependent_momenta_constructor =
159            DependentMomentaConstructor::Amplitude(external_signature);
160
161        esurface
162            .classify_existence(
163                &self
164                    .0
165                    .kinematics
166                    .externals
167                    .get_dependent_externals(dependent_momenta_constructor)
168                    .unwrap(),
169                lmb,
170                masses,
171                &F(self.0.kinematics.e_cm),
172                &F(esurface_existence_threshold),
173            )
174            .is_existing()
175    }
176}
177
178#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
179#[derive(
180    Debug, Copy, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, Eq, JsonSchema, Default,
181)]
182#[serde(rename_all = "snake_case")]
183pub enum IntegralUnit {
184    #[default]
185    Auto,
186    Picobarn,
187    Femtobarn,
188    Attobarn,
189    Millibarn,
190    None,
191}
192
193impl IntegralUnit {
194    pub(crate) fn resolve_for_cross_section(self, n_initial_state_particles: usize) -> Self {
195        match self {
196            Self::Auto if n_initial_state_particles > 1 => Self::Picobarn,
197            Self::Auto => Self::None,
198            explicit => explicit,
199        }
200    }
201
202    pub(crate) fn relative_to_picobarn_factor<T: FloatLike>(self, one: &F<T>) -> Option<F<T>> {
203        match self {
204            Self::Picobarn => Some(one.one()),
205            Self::Femtobarn => Some(one.from_i64(1_000)),
206            Self::Attobarn => Some(one.from_i64(1_000_000)),
207            Self::Millibarn => Some(one.one() / one.from_i64(1_000_000_000)),
208            Self::None => None,
209            Self::Auto => unreachable!("integral unit must be resolved before conversion"),
210        }
211    }
212}
213
214#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
215#[derive(Debug, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, JsonSchema)]
216#[trait_decode(trait= GammaLoopContext)]
217#[serde(default, deny_unknown_fields)]
218pub struct GeneralSettings {
219    /// Evaluate through the loop-tree-duality representation where the selected integrand supports it.
220    #[serde(skip_serializing_if = "is_false")]
221    pub use_ltd: bool,
222    /// Evaluator backend used for generated integrands.
223    #[serde(skip_serializing_if = "IsDefault::is_default")]
224    pub evaluator_method: EvaluatorMethod,
225    /// Optional orientation pattern restricting which causal-flow orientations are evaluated.
226    #[serde(skip_serializing_if = "IsDefault::is_default")]
227    pub orientation_pat: OrientationPattern,
228    /// Load a previously compiled causal-flow function instead of rebuilding it for this run.
229    #[serde(skip_serializing_if = "is_false")]
230    pub load_compiled_cff: bool,
231    /// Cache reusable evaluation data between phase-space samples.
232    #[serde(skip_serializing_if = "is_false")]
233    pub enable_cache: bool,
234    /// Emit additional cache consistency diagnostics while evaluating samples.
235    #[serde(skip_serializing_if = "is_false")]
236    pub debug_cache: bool,
237    /// Ultraviolet reference mass used by local counterterms, in the model's energy units.
238    #[serde(skip_serializing_if = "is_float::<1000>")]
239    pub m_uv: f64,
240    /// Localization scale applied to ultraviolet counterterms, in the model's energy units.
241    #[serde(skip_serializing_if = "is_float::<1000>")]
242    pub renormalization_localization_scale: f64,
243    /// Renormalization scale used by running parameters and scale-dependent terms.
244    #[serde(skip_serializing_if = "is_float::<1000>")]
245    pub mu_r: f64,
246    /// Shared energy scale for numerator reconstruction when enabled during generation.
247    #[serde(skip_serializing_if = "is_float::<1>")]
248    pub numerator_sampling_scale: f64,
249    /// Inverse temperature used to evaluate thermal distribution functions.
250    #[serde(skip_serializing_if = "is_float::<1>")]
251    pub inverse_temperature: f64,
252    /// Values assigned, in order, to extra symbolic parameters expected by the evaluator.
253    #[serde(skip_serializing_if = "IsDefault::is_default")]
254    pub additional_param_values: Vec<f64>,
255    /// Unit used to report integrated cross-sections; `auto` selects picobarns for scattering.
256    #[serde(skip_serializing_if = "IsDefault::is_default")]
257    pub integral_unit: IntegralUnit,
258    /// Omit the incoming-state flux factor from the returned integrand value.
259    #[serde(skip_serializing_if = "is_false")]
260    pub disable_flux_factor: bool,
261    /// Return generated events in addition to accumulating integration statistics.
262    #[serde(skip_serializing_if = "is_false")]
263    pub generate_events: bool,
264    /// Persist every configured additional weight on each generated event.
265    #[serde(skip_serializing_if = "is_false")]
266    pub store_additional_weights_in_event: bool,
267}
268
269impl Default for GeneralSettings {
270    fn default() -> Self {
271        Self {
272            evaluator_method: EvaluatorMethod::default(),
273            use_ltd: false,
274            load_compiled_cff: false,
275            enable_cache: false,
276            debug_cache: false,
277            orientation_pat: OrientationPattern::default(),
278            m_uv: 1000.0,
279            renormalization_localization_scale: 1000.0,
280            mu_r: 1000.0,
281            numerator_sampling_scale: 1.0,
282            inverse_temperature: 1.0,
283            additional_param_values: vec![],
284            integral_unit: IntegralUnit::Auto,
285            disable_flux_factor: false,
286            generate_events: false,
287            store_additional_weights_in_event: false,
288        }
289    }
290}
291
292impl GeneralSettings {
293    pub(crate) fn mu_r_sq(&self) -> f64 {
294        self.mu_r * self.mu_r
295    }
296}
297
298#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
299#[derive(
300    Debug, Copy, Clone, PartialEq, Deserialize, Default, Serialize, Encode, Decode, JsonSchema,
301)]
302// #[trait_decode(trait= GammaLoopContext)]
303#[serde(deny_unknown_fields)]
304pub enum IntegratedPhase {
305    #[serde(rename = "real")]
306    #[default]
307    Real,
308    #[serde(rename = "imag")]
309    Imag,
310    #[serde(rename = "both")]
311    Both,
312}
313
314pub mod kinematic;
315
316#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
317#[derive(Debug, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, JsonSchema)]
318#[serde(default, deny_unknown_fields)]
319pub struct ObservablesOutputSettings {
320    /// Output formats written for configured observables; duplicates and `none` are ignored.
321    #[serde(
322        default = "default_observable_output_formats",
323        skip_serializing_if = "is_default_observable_output_formats"
324    )]
325    pub format: Vec<ObservableFileFormat>,
326}
327
328fn default_observable_output_formats() -> Vec<ObservableFileFormat> {
329    vec![ObservableFileFormat::Json]
330}
331
332fn is_default_observable_output_formats(formats: &Vec<ObservableFileFormat>) -> bool {
333    show_defaults_helper(formats.as_slice() == default_observable_output_formats().as_slice())
334}
335
336impl Default for ObservablesOutputSettings {
337    fn default() -> Self {
338        Self {
339            format: default_observable_output_formats(),
340        }
341    }
342}
343
344impl ObservablesOutputSettings {
345    pub(crate) fn resolved_formats(&self) -> Vec<ObservableFileFormat> {
346        let mut resolved_formats = Vec::new();
347        for format in &self.format {
348            if *format == ObservableFileFormat::None || resolved_formats.contains(format) {
349                continue;
350            }
351            resolved_formats.push(*format);
352        }
353        resolved_formats
354    }
355}
356
357#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
358#[derive(Debug, Clone, Deserialize, Serialize, Encode, Decode, PartialEq, JsonSchema)]
359#[serde(default, deny_unknown_fields)]
360pub struct IntegratorSettings {
361    /// Number of bins maintained along each continuous integration dimension.
362    #[serde(skip_serializing_if = "is_usize::<64>")]
363    pub n_bins: usize,
364    /// Optional per-iteration bin counts, overriding the fixed `n_bins` value as training proceeds.
365    #[serde(skip_serializing_if = "IsDefault::is_default")]
366    pub bin_number_evolution: Option<Vec<usize>>,
367    /// Minimum accumulated samples before an adaptive grid or discrete probability is updated.
368    #[serde(skip_serializing_if = "is_usize::<1000>")]
369    pub min_samples_for_update: usize,
370    /// Number of samples in the first Monte Carlo iteration.
371    #[serde(skip_serializing_if = "is_usize::<100000>")]
372    pub n_start: usize,
373    /// Samples added to each successive Monte Carlo iteration.
374    #[serde(skip_serializing_if = "is_usize::<10000>")]
375    pub n_increase: usize,
376    /// Hard upper bound on the total number of integrand evaluations.
377    #[serde(skip_serializing_if = "is_usize::<10000000000>")]
378    pub n_max: usize,
379    /// Stop once the estimated relative uncertainty reaches this value, if configured.
380    #[serde(skip_serializing_if = "IsDefault::is_default")]
381    pub target_relative_accuracy: Option<f64>,
382    /// Stop once the estimated absolute uncertainty reaches this value, if configured.
383    #[serde(skip_serializing_if = "IsDefault::is_default")]
384    pub target_absolute_accuracy: Option<f64>,
385    /// Complex component to integrate: real, imaginary, or both.
386    #[serde(skip_serializing_if = "IsDefault::is_default")]
387    pub integrated_phase: IntegratedPhase,
388    /// Update rate for learned probabilities over discrete sampling choices.
389    #[serde(skip_serializing_if = "is_float::<1>")]
390    pub discrete_dim_learning_rate: f64,
391    /// Update rate for adaptive grids over continuous integration dimensions.
392    #[serde(skip_serializing_if = "is_float::<1>")]
393    pub continuous_dim_learning_rate: f64,
394    /// Train adaptive distributions on iteration averages instead of individual sample weights.
395    #[serde(skip_serializing_if = "is_false")]
396    pub train_on_avg: bool,
397    /// Include maximum-weight diagnostics in integration progress and result data.
398    #[serde(skip_serializing_if = "is_true")]
399    pub show_max_wgt_info: bool,
400    /// Cap the ratio used when turning sampled weights into adaptive probabilities.
401    #[serde(skip_serializing_if = "is_float::<30>")]
402    pub max_prob_ratio: f64,
403    /// Seed for deterministic initialization of Monte Carlo random-number streams.
404    #[serde(skip_serializing_if = "is_u64::<69>")]
405    pub seed: u64,
406    /// File formats used when writing observable histograms and distributions.
407    #[serde(skip_serializing_if = "IsDefault::is_default")]
408    pub observables_output: ObservablesOutputSettings,
409}
410
411impl Default for IntegratorSettings {
412    fn default() -> Self {
413        Self {
414            n_bins: 64,
415            bin_number_evolution: None,
416            min_samples_for_update: 1000,
417            n_start: 100000,
418            n_increase: 10000,
419            n_max: 10000000000,
420            target_relative_accuracy: None,
421            target_absolute_accuracy: None,
422            integrated_phase: IntegratedPhase::default(),
423            discrete_dim_learning_rate: 1.0,
424            continuous_dim_learning_rate: 1.0,
425            train_on_avg: false,
426            show_max_wgt_info: true,
427            max_prob_ratio: 30.0,
428            seed: 69,
429            observables_output: ObservablesOutputSettings::default(),
430        }
431    }
432}
433
434#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
435#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Encode, Decode, JsonSchema)]
436#[serde(default, deny_unknown_fields)]
437pub struct ParameterizationSettings {
438    #[serde(skip_serializing_if = "IsDefault::is_default")]
439    pub mode: ParameterizationMode,
440    #[serde(skip_serializing_if = "IsDefault::is_default")]
441    pub mapping: ParameterizationMapping,
442    #[serde(skip_serializing_if = "is_float::<1>")]
443    pub b: f64,
444    #[serde(skip_serializing_if = "is_float::<1>")]
445    pub power: f64,
446    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
447    pub lmb_basis_ids: BTreeMap<String, Vec<usize>>,
448    /// Graph-aware channels consumed by the production sampler. Keeping their
449    /// selection with the parameterization preserves the canonical catalogue
450    /// through the `SamplingSettings` enum round trip.
451    #[serde(skip_serializing_if = "IsDefault::is_default")]
452    pub sampling_channels: SamplingChannelSelection,
453}
454
455impl Default for ParameterizationSettings {
456    fn default() -> Self {
457        Self {
458            b: 1.0,
459            power: 1.0,
460            mode: ParameterizationMode::default(),
461            mapping: ParameterizationMapping::default(),
462            lmb_basis_ids: BTreeMap::new(),
463            sampling_channels: SamplingChannelSelection::default(),
464        }
465    }
466}
467
468#[derive(Serialize, Deserialize, Default, Debug, Clone)]
469#[serde(default, deny_unknown_fields)]
470pub struct IntegralEstimate {
471    pub neval: usize,
472    pub real_zero: usize,
473    pub im_zero: usize,
474    pub result: Complex<F<f64>>,
475    pub error: Complex<F<f64>>,
476    pub real_chisq: F<f64>,
477    pub im_chisq: F<f64>,
478}
479
480impl Display for IntegralEstimate {
481    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482        // Format real and imaginary parts with proper uncertainty formatting
483        let real_formatted = if self.error.re.0.abs() > 0.0 && f.alternate() {
484            format_uncertainty(self.result.re, self.error.re)
485        } else {
486            // If no error, use formatter precision or default to scientific notation
487            match f.precision() {
488                Some(p) => format!("{:.prec$e}", self.result.re.0, prec = p),
489                None => format!("{:e}", self.result.re.0),
490            }
491        };
492
493        let im_formatted = if self.error.im.0.abs() > 0.0 && f.alternate() {
494            format_uncertainty(self.result.im, self.error.im)
495        } else {
496            // If no error, use formatter precision or default to scientific notation
497            match f.precision() {
498                Some(p) => format!("{:.prec$e}", self.result.im.0, prec = p),
499                None => format!("{:e}", self.result.im.0),
500            }
501        };
502
503        // Display the result in a readable format
504        if self.result.im.0.abs() > 0.0 {
505            if f.alternate() {
506                write!(
507                    f,
508                    "{} + {}i (neval: {}, real_chisq: {:.3}, im_chisq: {:.3})",
509                    real_formatted, im_formatted, self.neval, self.real_chisq.0, self.im_chisq.0
510                )
511            } else {
512                write!(f, "{} + {}i", real_formatted, im_formatted,)
513            }
514        } else if f.alternate() {
515            write!(
516                f,
517                "{} (neval: {}, real_chisq: {:.3})",
518                real_formatted, self.neval, self.real_chisq.0
519            )
520        } else {
521            write!(f, "{}", real_formatted)
522        }
523    }
524}
525
526impl IntegralEstimate {
527    pub fn is_compatible_with_target(&self, target: Complex<F<f64>>, sigma: i8) -> bool {
528        let res_norm = self.result.norm().re;
529        let tolerance = if res_norm.is_zero() {
530            self.error.norm().re
531        } else {
532            (self.error).norm().re * F(sigma as f64) / res_norm
533        };
534
535        if tolerance.0 > 0.1 {
536            warn!("Tolerance larger than 10%! {}", tolerance)
537        }
538        // println!("Tol: {}", tolerance.0);
539        self.result.approx_eq(&target, &tolerance)
540    }
541
542    pub fn is_compatible_with_result(&self, other: &Self, sigma: i8) -> bool {
543        let combined_error = Complex::new(
544            (self.error.re * self.error.re + other.error.re * other.error.re).sqrt(),
545            (self.error.im * self.error.im + other.error.im * other.error.im).sqrt(),
546        );
547        let delta = self.result - other.result;
548        let delta_norm = delta.norm().re;
549        let tolerance = if delta_norm.is_zero() {
550            combined_error.norm().re
551        } else {
552            combined_error.norm().re * F(sigma as f64) / delta_norm
553        };
554
555        if tolerance.0 > 0.1 {
556            warn!("Tolerance larger than 10%! {}", tolerance)
557        }
558
559        self.result.approx_eq(&other.result, &tolerance)
560    }
561}
562
563#[derive(Serialize, Deserialize, Default, Debug, Clone)]
564#[serde(default, deny_unknown_fields)]
565pub struct IntegrationTableComponentResult {
566    pub component: String,
567    pub value: F<f64>,
568    pub error: F<f64>,
569    pub relative_error_percent: Option<f64>,
570    pub chi_sq_per_dof: f64,
571    pub target_delta_sigma: Option<f64>,
572    pub target_delta_percent: Option<f64>,
573    pub max_weight_impact: f64,
574}
575
576#[derive(Serialize, Deserialize, Default, Debug, Clone)]
577#[serde(default, deny_unknown_fields)]
578pub struct IntegrationStatisticsSnapshot {
579    pub num_evals: usize,
580    pub average_total_time_seconds: f64,
581    pub average_parameterization_time_seconds: f64,
582    pub average_integrand_time_seconds: f64,
583    pub average_evaluator_time_seconds: f64,
584    pub average_observable_time_seconds: f64,
585    pub average_integrator_time_seconds: f64,
586    pub f64_percentage: f64,
587    pub f128_percentage: f64,
588    pub arb_percentage: f64,
589    pub nan_percentage: f64,
590    pub nan_or_unstable_percentage: f64,
591    pub generated_event_count: usize,
592    pub accepted_event_count: usize,
593    pub selection_efficiency_percentage: Option<f64>,
594}
595
596#[derive(Serialize, Deserialize, Default, Debug, Clone)]
597#[serde(default, deny_unknown_fields)]
598pub struct MaxWeightInfoEntry {
599    pub component: String,
600    pub sign: String,
601    pub max_eval: F<f64>,
602    pub coordinates: Option<String>,
603}
604
605#[derive(Serialize, Deserialize, Default, Debug, Clone)]
606#[serde(default, deny_unknown_fields)]
607pub struct DiscreteCoordinate {
608    pub axis_label: String,
609    pub bin_index: usize,
610    pub bin_label: Option<String>,
611}
612
613#[derive(Serialize, Deserialize, Default, Debug, Clone)]
614#[serde(default, deny_unknown_fields)]
615pub struct DiscreteBreakdownEntry {
616    pub bin_index: usize,
617    pub bin_label: Option<String>,
618    pub pdf: F<f64>,
619    pub value: F<f64>,
620    pub error: F<f64>,
621    pub chi_sq: F<f64>,
622    pub processed_samples: usize,
623}
624
625#[derive(Serialize, Deserialize, Default, Debug, Clone)]
626#[serde(default, deny_unknown_fields)]
627pub struct DiscreteBreakdown {
628    pub axis_label: String,
629    pub fixed_coordinates: Vec<DiscreteCoordinate>,
630    pub entries: Vec<DiscreteBreakdownEntry>,
631}
632
633#[derive(Serialize, Deserialize, Default, Debug, Clone)]
634#[serde(default, deny_unknown_fields)]
635pub struct ComponentDiscreteBreakdown {
636    pub re: Option<DiscreteBreakdown>,
637    pub im: Option<DiscreteBreakdown>,
638}
639
640#[derive(Serialize, Deserialize, Default, Debug, Clone)]
641#[serde(default, deny_unknown_fields)]
642pub struct AbsoluteIntegrationResult {
643    pub integral: IntegralEstimate,
644    pub table_results: Vec<IntegrationTableComponentResult>,
645    pub max_weight_info: Vec<MaxWeightInfoEntry>,
646    pub grid_breakdown: ComponentDiscreteBreakdown,
647}
648
649#[derive(Serialize, Deserialize, Default, Debug, Clone)]
650#[serde(default, deny_unknown_fields)]
651pub struct SlotIntegrationResult {
652    pub key: String,
653    pub process: String,
654    pub integrand: String,
655    pub target: Option<Complex<F<f64>>>,
656    pub integral: IntegralEstimate,
657    pub table_results: Vec<IntegrationTableComponentResult>,
658    pub integration_statistics: IntegrationStatisticsSnapshot,
659    pub max_weight_info: Vec<MaxWeightInfoEntry>,
660    pub grid_breakdown: ComponentDiscreteBreakdown,
661    pub absolute: AbsoluteIntegrationResult,
662}
663
664#[derive(Serialize, Deserialize, Default, Debug, Clone)]
665#[serde(default, deny_unknown_fields)]
666pub struct IntegrationResult {
667    pub slots: Vec<SlotIntegrationResult>,
668}
669
670impl IntegrationResult {
671    pub fn slot(&self, key: &str) -> Option<&SlotIntegrationResult> {
672        self.slots.iter().find(|slot| slot.key == key)
673    }
674
675    pub fn single_slot(&self) -> Option<&SlotIntegrationResult> {
676        (self.slots.len() == 1).then(|| &self.slots[0])
677    }
678
679    pub fn single_slot_integral(&self) -> Option<&IntegralEstimate> {
680        self.single_slot().map(|slot| &slot.integral)
681    }
682
683    pub fn is_compatible_with_target(&self, target: Complex<F<f64>>, n_sigma: i8) -> bool {
684        self.single_slot_integral()
685            .is_some_and(|integral| integral.is_compatible_with_target(target, n_sigma))
686    }
687}
688
689impl Display for IntegrationResult {
690    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
691        if let Some(slot) = self.single_slot() {
692            return slot.integral.fmt(f);
693        }
694
695        for (index, slot) in self.slots.iter().enumerate() {
696            if index > 0 {
697                writeln!(f)?;
698            }
699            write!(f, "{}: {}", slot.key, slot.integral)?;
700        }
701        Ok(())
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::utils::serde_utils::ShowDefaultsGuard;
709    use spenso::algebra::complex::Complex;
710
711    #[test]
712    fn sampling_lu_h_profile_shorthand_and_table_share_one_definition() {
713        let shorthand: SamplingChannelDefinition =
714            toml::from_str("around='phase_space(cut(2,3))'\nradial_profile='lu_h'").unwrap();
715        let table: SamplingChannelDefinition = toml::from_str("around='phase_space(cut(2,3))'\nradial_profile={kind='lu_h',approximation='log_logistic',broad_fraction=0.02}").unwrap();
716        assert_eq!(shorthand, table);
717        assert_eq!(
718            shorthand.radial_profile,
719            Some(SamplingRadialProfile::default())
720        );
721        let exported = toml::to_string(&shorthand).unwrap();
722        assert_eq!(
723            toml::from_str::<SamplingChannelDefinition>(&exported).unwrap(),
724            shorthand
725        );
726        for invalid in [
727            "{kind='lu_h',broad_faction=0.1}",
728            "'unknown'",
729            "{kind='lu_h',approximation='exact'}",
730        ] {
731            assert!(
732                toml::from_str::<SamplingChannelDefinition>(&format!("radial_profile={invalid}"))
733                    .is_err()
734            );
735        }
736        let _guard = ShowDefaultsGuard::new(true);
737        let json = serde_json::to_value(SamplingRadialProfile::default()).unwrap();
738        assert!(json.get("scale").is_some());
739        assert!(json.get("shape").is_some());
740    }
741
742    #[test]
743    fn h_function_normalizations_match_bessel_integrals_in_native_precision() {
744        use symbolica::{
745            atom::{Atom, AtomView},
746            coefficient::CoefficientView,
747            domains::float::{Float, RealLike},
748            function,
749            transcendental::bessel_k,
750        };
751
752        use crate::utils::{ArbPrec, SamplingFloat, f128};
753
754        const ORACLE_PRECISION: u32 = 1200;
755        // Symbolica's own arbitrary-precision evaluation of e^2 K_nu(2).
756        let scaled_bessel_k = |twice_order: usize| {
757            let value = function!(
758                bessel_k(),
759                Atom::num((twice_order as i64, 2)),
760                Atom::num(Float::with_val(ORACLE_PRECISION, 2))
761            );
762            let AtomView::Num(number) = value.as_view() else {
763                panic!("bessel_k did not evaluate: {value}");
764            };
765            let CoefficientView::Float(real, _) = number.get_coeff_view() else {
766                panic!("bessel_k did not evaluate to a float: {value}");
767            };
768            real.to_float() * Float::with_val(ORACLE_PRECISION, 2).exp()
769        };
770        // The previously tabulated binary64 integrals anchor the Bessel orders.
771        let sqrt_pi_half = std::f64::consts::PI.sqrt() / 2.0;
772        let poly = [
773            (None, sqrt_pi_half),
774            (Some(1), 0.841_568_215_070_771_4),
775            (Some(3), 1.033_476_847_068_688_6),
776            (Some(4), 1.329_340_388_179_137),
777            (Some(6), 2.880_237_507_721_463_7),
778            (Some(7), 4.783_566_971_347_609),
779            (Some(9), 16.225_745_976_182_285),
780            (Some(10), 32.735_007_058_911_25),
781            (Some(12), 155.837_465_922_583_42),
782            (Some(13), 364.658_500_356_566_04),
783            (Some(15), 2_257.637_553_015_473),
784            (Some(16), 5_939.804_418_537_864),
785        ];
786        let left_right = [
787            (Some(0), 2.066_953_694_137_377),
788            (Some(1), 1.683_136_430_141_542_8),
789            (Some(3), 3.750_090_124_278_92),
790            (Some(4), 9.567_133_942_695_218),
791            (Some(6), 139.373_101_752_153_5),
792            (Some(7), 729.317_000_713_132_1),
793            (Some(9), 32_336.242_742_929_753),
794            (Some(10), 263_205.217_049_469),
795            (Some(12), 2.427_503_717_893_097_5e7),
796            (Some(13), 2.694_265_921_644_289e8),
797            (Some(15), 4.261_555_045_314_143e10),
798            (Some(16), 5.998_751_004_871_322e11),
799        ];
800        let tabulated = std::iter::once((HFunction::Exponential, None, sqrt_pi_half))
801            .chain(poly.map(|(power, value)| (HFunction::PolyExponential, power, value)))
802            .chain(
803                left_right
804                    .map(|(power, value)| (HFunction::PolyLeftRightExponential, power, value)),
805            );
806        fn check<T: FloatLike>(sigma: F<T>, settings: &HFunctionSettings, expected: &Float) {
807            // Repeated calls must reuse only the unit integral, not a previous sigma.
808            for multiplier in [1, 2, 3] {
809                let scaled_sigma = &sigma * sigma.from_usize(multiplier);
810                let expected =
811                    expected.clone() * Float::with_val(ORACLE_PRECISION, multiplier as u32);
812                let actual: Float = settings.normalization(&scaled_sigma).0.into();
813                let relative = ((actual - &expected) / &expected).norm();
814                // The K_0 series cancels about four bits before the upward recurrence.
815                let epsilon: Float = sigma.epsilon().0.into();
816                assert!(
817                    relative <= epsilon * 64,
818                    "{settings:?} in {}: relative error {relative}",
819                    std::any::type_name::<T>()
820                );
821            }
822        }
823        for (function, power, binary64) in tabulated {
824            let settings = HFunctionSettings {
825                function: function.clone(),
826                power,
827                ..Default::default()
828            };
829            let p = power.unwrap_or(0);
830            let expected = match function {
831                HFunction::Exponential => scaled_bessel_k(1),
832                HFunction::PolyExponential => scaled_bessel_k(p.abs_diff(1)),
833                _ => scaled_bessel_k(2 * p.abs_diff(1)) * 2,
834            };
835            assert!(
836                ((expected.to_f64() - binary64) / binary64).abs() < 4.0 * f64::EPSILON,
837                "{settings:?}: {expected} != tabulated {binary64}"
838            );
839            // A sigma that is exact in binary keeps every precision comparable.
840            let expected = expected * Float::with_val(ORACLE_PRECISION, 1.25);
841            check(F(1.25_f64), &settings, &expected);
842            check(F(f128::from_f64(1.25)), &settings, &expected);
843            check(F(SamplingFloat::from_f64(1.25)), &settings, &expected);
844            check(F(ArbPrec::from_f64(1.25)), &settings, &expected);
845        }
846        // The default profile keeps its exact sqrt(pi)*sigma/2 normalization.
847        let sigma = F(ArbPrec::from_f64(1.25));
848        assert_eq!(
849            HFunctionSettings::default().normalization(&sigma),
850            sigma.PI().sqrt() * &sigma / sigma.from_usize(2)
851        );
852    }
853
854    #[test]
855    fn test_integration_result_display() {
856        let result = IntegralEstimate {
857            neval: 1000000,
858            real_zero: 0,
859            im_zero: 0,
860            result: Complex::new(F(1.23456789e-3), F(4.56789e-5)),
861            error: Complex::new(F(1.2e-5), F(3.4e-7)),
862            real_chisq: F(1.05),
863            im_chisq: F(0.98),
864        };
865
866        let display_str = format!("{}", result);
867        println!("Integration result display: {}", display_str);
868
869        // Test with only real part
870        let real_only_result = IntegralEstimate {
871            neval: 500000,
872            real_zero: 0,
873            im_zero: 0,
874            result: Complex::new(F(2.345e-2), F(0.0)),
875            error: Complex::new(F(1.5e-4), F(0.0)),
876            real_chisq: F(1.12),
877            im_chisq: F(0.0),
878        };
879
880        let real_display_str = format!("{}", real_only_result);
881        println!("Real-only result display: {}", real_display_str);
882
883        // Test with custom precision
884        let precision_str = format!("{:.2}", result);
885        println!("With precision 2: {}", precision_str);
886    }
887
888    #[test]
889    fn integrator_settings_target_accuracy_defaults_stay_optional() {
890        let settings = IntegratorSettings::default();
891
892        let hidden_defaults = toml::to_string(&settings).expect("serialize integrator defaults");
893        assert!(!hidden_defaults.contains("target_relative_accuracy"));
894        assert!(!hidden_defaults.contains("target_absolute_accuracy"));
895
896        let _guard = ShowDefaultsGuard::new(true);
897        let shown_defaults = toml::to_string(&settings).expect("serialize integrator defaults");
898        assert!(shown_defaults.contains("n_bins"));
899        assert!(!shown_defaults.contains("target_relative_accuracy"));
900        assert!(!shown_defaults.contains("target_absolute_accuracy"));
901    }
902
903    #[test]
904    fn integrator_settings_target_accuracy_serializes_when_configured() {
905        let settings = IntegratorSettings {
906            target_relative_accuracy: Some(0.05),
907            target_absolute_accuracy: Some(1.0e-6),
908            ..IntegratorSettings::default()
909        };
910
911        let serialized = toml::to_string(&settings).expect("serialize configured integrator");
912        assert!(serialized.contains("target_relative_accuracy = 0.05"));
913        assert!(serialized.contains("target_absolute_accuracy = "));
914
915        let deserialized: IntegratorSettings =
916            toml::from_str(&serialized).expect("deserialize configured integrator");
917        assert_eq!(settings, deserialized);
918    }
919
920    #[test]
921    fn stability_ecm_relative_tolerances_default_and_roundtrip() {
922        let _guard = ShowDefaultsGuard::new(false);
923        for settings in [
924            StabilityLevelSetting::default_double(),
925            StabilityLevelSetting::default_quad(),
926            StabilityLevelSetting::default_arb(),
927        ] {
928            let serialized = toml::to_string(&settings).unwrap();
929            assert!(!serialized.contains("ecm_relative_tolerance"));
930            assert_eq!(
931                toml::from_str::<StabilityLevelSetting>(&serialized).unwrap(),
932                settings
933            );
934        }
935
936        let settings = StabilityLevelSetting {
937            ecm_relative_tolerance_for_re: 1e-100,
938            ecm_relative_tolerance_for_im: 2e-90,
939            ..StabilityLevelSetting::default_arb()
940        };
941        let serialized = toml::to_string(&settings).unwrap();
942        assert_eq!(
943            toml::from_str::<StabilityLevelSetting>(&serialized).unwrap(),
944            settings
945        );
946        let schema = serde_json::to_value(schemars::schema_for!(StabilityLevelSetting)).unwrap();
947        for field in [
948            "ecm_relative_tolerance_for_re",
949            "ecm_relative_tolerance_for_im",
950        ] {
951            assert_eq!(schema["properties"][field]["minimum"], 0.0);
952        }
953
954        let defaults: StabilitySettings = toml::from_str("").unwrap();
955        assert_eq!(defaults.integrated_energy_dimension, None);
956        assert!(
957            !toml::to_string(&defaults)
958                .unwrap()
959                .contains("integrated_energy_dimension")
960        );
961        let declared = StabilitySettings {
962            integrated_energy_dimension: Some(-6),
963            levels: vec![settings],
964            ..defaults
965        };
966        assert_eq!(
967            toml::from_str::<StabilitySettings>(&toml::to_string(&declared).unwrap()).unwrap(),
968            declared
969        );
970        drop(_guard);
971        let _guard = ShowDefaultsGuard::new(true);
972        for settings in [
973            StabilityLevelSetting::default_double(),
974            StabilityLevelSetting::default_quad(),
975            StabilityLevelSetting::default_arb(),
976        ] {
977            let shown = serde_json::to_value(settings).unwrap();
978            assert_eq!(shown["ecm_relative_tolerance_for_re"], 0.0);
979            assert_eq!(shown["ecm_relative_tolerance_for_im"], 0.0);
980        }
981        assert_eq!(
982            serde_json::to_value(&declared).unwrap()["integrated_energy_dimension"],
983            -6
984        );
985    }
986
987    #[test]
988    fn stability_ecm_relative_tolerances_reject_invalid_values() {
989        let _guard = ShowDefaultsGuard::new(false);
990        let defaults = toml::to_string(&StabilityLevelSetting::default_arb()).unwrap();
991        for field in [
992            "ecm_relative_tolerance_for_re",
993            "ecm_relative_tolerance_for_im",
994        ] {
995            for invalid in ["-1.0", "nan", "inf", "-inf"] {
996                let text = format!("{defaults}\n{field} = {invalid}\n");
997                assert!(
998                    toml::from_str::<StabilityLevelSetting>(&text).is_err(),
999                    "{field} = {invalid}"
1000                );
1001            }
1002        }
1003    }
1004
1005    #[test]
1006    fn sampling_ose_and_named_weight_roundtrip() {
1007        let _guard = ShowDefaultsGuard::new(false);
1008        for (graphs, channels) in [
1009            ("summed", "summed"),
1010            ("monte_carlo", "summed"),
1011            ("monte_carlo", "monte_carlo"),
1012        ] {
1013            for (global, local) in [("ose", "map_density"), ("map_density", "ose")] {
1014                let text = format!(
1015                    r#"graphs = "{graphs}"
1016sampling_multichanneling = true
1017sampling_channels = "{channels}"
1018sampling_channel_weight = "{global}"
1019alpha = 2.5
1020default_channel_selection = ["auto:optimized_lmb"]
1021[channel_definitions.G.soft]
1022around = "lmb(1,2)"
1023parent_lmb = [1,2]
1024channel_weight = "{local}"
1025"#
1026                );
1027                let settings: SamplingSettings = toml::from_str(&text).unwrap();
1028                let selection = settings
1029                    .get_parameterization_settings()
1030                    .unwrap()
1031                    .sampling_channels;
1032                assert_eq!(selection.alpha, 2.5);
1033                assert_eq!(
1034                    selection.weight == SamplingChannelWeight::Ose,
1035                    global == "ose"
1036                );
1037                assert_eq!(
1038                    selection.channel_definitions["G"]["soft"].channel_weight
1039                        == Some(SamplingChannelWeight::Ose),
1040                    local == "ose"
1041                );
1042                let serialized = toml::to_string(&settings).unwrap();
1043                assert_eq!(
1044                    toml::from_str::<SamplingSettings>(&serialized).unwrap(),
1045                    settings
1046                );
1047            }
1048        }
1049        let settings: SamplingSettings =
1050            toml::from_str("sampling_multichanneling=true\nsampling_channel_weight='ose'").unwrap();
1051        let selection = settings
1052            .get_parameterization_settings()
1053            .unwrap()
1054            .sampling_channels;
1055        assert_eq!(selection.weight, SamplingChannelWeight::Ose);
1056        assert_eq!(selection.alpha, 3.0);
1057        assert_eq!(
1058            selection.default_channel_selection,
1059            vec!["auto:optimized_lmb"]
1060        );
1061        assert!(!toml::to_string(&settings).unwrap().contains("alpha"));
1062        drop(_guard);
1063        let _guard = ShowDefaultsGuard::new(true);
1064        assert_eq!(serde_json::to_value(settings).unwrap()["alpha"], 3.0);
1065    }
1066
1067    #[test]
1068    fn sampling_ose_alpha_validation_and_zero_control() {
1069        for invalid in ["-1.0", "nan", "inf", "-inf"] {
1070            let text = format!("alpha={invalid}");
1071            assert!(toml::from_str::<SamplingSettings>(&text).is_err());
1072            assert!(toml::from_str::<SamplingChannelSelection>(&text).is_err());
1073        }
1074        for alpha in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1075            assert!(
1076                SamplingSettings::from_parser(SamplingSettingsParser {
1077                    alpha,
1078                    ..Default::default()
1079                })
1080                .is_err()
1081            );
1082        }
1083        let settings: SamplingSettings = toml::from_str(
1084            "sampling_multichanneling=true\nsampling_channel_weight='ose'\nalpha=0.0",
1085        )
1086        .unwrap();
1087        assert_eq!(
1088            settings
1089                .get_parameterization_settings()
1090                .unwrap()
1091                .sampling_channels
1092                .alpha,
1093            0.0
1094        );
1095        assert_eq!(
1096            toml::from_str::<SamplingSettings>(&toml::to_string(&settings).unwrap()).unwrap(),
1097            settings
1098        );
1099    }
1100
1101    #[test]
1102    fn uv_localisation_smooth_sliver_defaults_and_roundtrips() {
1103        let defaults: UVLocalisationSettings = toml::from_str("").unwrap();
1104        assert!(!defaults.smooth_sliver);
1105        assert_eq!(defaults, UVLocalisationSettings::default());
1106        {
1107            let _guard = ShowDefaultsGuard::new(false);
1108            assert!(
1109                !toml::to_string(&defaults)
1110                    .unwrap()
1111                    .contains("smooth_sliver")
1112            );
1113            let settings = UVLocalisationSettings {
1114                smooth_sliver: true,
1115                ..defaults.clone()
1116            };
1117            let serialized = toml::to_string(&settings).unwrap();
1118            assert!(serialized.contains("smooth_sliver = true"));
1119            assert_eq!(
1120                toml::from_str::<UVLocalisationSettings>(&serialized).unwrap(),
1121                settings
1122            );
1123        }
1124        let _guard = ShowDefaultsGuard::new(true);
1125        assert!(
1126            toml::to_string(&defaults)
1127                .unwrap()
1128                .contains("smooth_sliver = false")
1129        );
1130    }
1131
1132    #[test]
1133    fn integral_unit_auto_resolves_by_initial_state_count() {
1134        assert_eq!(
1135            IntegralUnit::Auto.resolve_for_cross_section(1),
1136            IntegralUnit::None
1137        );
1138        assert_eq!(
1139            IntegralUnit::Auto.resolve_for_cross_section(2),
1140            IntegralUnit::Picobarn
1141        );
1142    }
1143
1144    #[test]
1145    fn integral_unit_relative_scalings_match_requested_barn_unit() {
1146        let one = F(1.0f64);
1147        assert_eq!(
1148            IntegralUnit::Picobarn.relative_to_picobarn_factor(&one),
1149            Some(F(1.0))
1150        );
1151        assert_eq!(
1152            IntegralUnit::Femtobarn.relative_to_picobarn_factor(&one),
1153            Some(F(1_000.0))
1154        );
1155        assert_eq!(
1156            IntegralUnit::Attobarn.relative_to_picobarn_factor(&one),
1157            Some(F(1_000_000.0))
1158        );
1159        assert_eq!(
1160            IntegralUnit::Millibarn.relative_to_picobarn_factor(&one),
1161            Some(F(1.0e-9))
1162        );
1163        assert_eq!(IntegralUnit::None.relative_to_picobarn_factor(&one), None);
1164    }
1165
1166    #[test]
1167    fn general_settings_store_mu_r_and_compute_mu_r_sq_internally() {
1168        let settings = GeneralSettings {
1169            mu_r: 91.188,
1170            ..GeneralSettings::default()
1171        };
1172
1173        assert_eq!(settings.mu_r, 91.188);
1174        assert!((settings.mu_r_sq() - 8315.251344).abs() < 1.0e-12);
1175
1176        let serialized = toml::to_string(&settings).expect("serialize general settings");
1177        assert!(serialized.contains("mu_r = 91.188"));
1178        assert!(!serialized.contains("mu_r_sq"));
1179    }
1180}
1181
1182#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1183#[derive(Serialize, Deserialize, Debug, Clone, Encode, Decode, PartialEq, JsonSchema)]
1184#[serde(default, deny_unknown_fields)]
1185pub struct StabilitySettings {
1186    /// Energy dimension of the integrated physical quantity after flux
1187    /// normalization and before conversion to output units. Required when an
1188    /// E_cm-relative tolerance is enabled for a physical evaluation; reference
1189    /// functions carry their own known dimensions. Direct momentum evaluations
1190    /// subtract the dimension of their missing spatial integration measure.
1191    #[serde(skip_serializing_if = "IsDefault::is_default")]
1192    pub integrated_energy_dimension: Option<i32>,
1193    /// Rotations applied when comparing an unstable sample with an equivalent phase-space point.
1194    #[serde(skip_serializing_if = "is_default_rotation_axis")]
1195    pub rotation_axis: Vec<RotationSetting>,
1196    /// Ordered numerical-precision levels and tolerances used during stability escalation.
1197    #[serde(skip_serializing_if = "is_default_stability_levels")]
1198    pub levels: Vec<StabilityLevelSetting>,
1199    /// Compare rotated results after normalizing by the result magnitude.
1200    #[serde(skip_serializing_if = "is_true")]
1201    pub check_on_norm: bool,
1202    /// Escalate precision when a result is exactly zero at the current level.
1203    #[serde(skip_serializing_if = "is_false")]
1204    pub escalate_if_exact_zero: bool,
1205    /// Suppress non-final stability escalation for negligible weighted probes.
1206    /// When positive, a probe is allowed to remain at its current precision if
1207    /// its largest absolute weighted rotation result is below this fraction of
1208    /// an absolute-component integral estimate whose relative standard error
1209    /// is at most ten percent. Zero disables this waiver.
1210    #[serde(
1211        default,
1212        deserialize_with = "deserialize_nonnegative_finite_f64",
1213        skip_serializing_if = "is_float::<0>"
1214    )]
1215    #[schemars(range(min = 0.0))]
1216    pub min_abs_wgt_for_escalation: f64,
1217    /// Escalate when the loop-momentum norm exceeds this factor; a negative value disables it.
1218    #[serde(skip_serializing_if = "is_float::<-1>")]
1219    pub loop_momenta_norm_escalation_factor: f64,
1220    /// Optional diagnostics retained for rotated, escalated, and high-precision evaluations.
1221    #[serde(skip_serializing_if = "IsDefault::is_default")]
1222    pub recording: Option<StabilityRecordingSettings>,
1223}
1224
1225impl Default for StabilitySettings {
1226    fn default() -> Self {
1227        Self {
1228            integrated_energy_dimension: None,
1229            rotation_axis: _default_rotation_axis(),
1230            levels: _default_stability_levels(),
1231            check_on_norm: true,
1232            escalate_if_exact_zero: false,
1233            min_abs_wgt_for_escalation: 0.0,
1234            loop_momenta_norm_escalation_factor: -1.0,
1235            recording: None,
1236        }
1237    }
1238}
1239
1240#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1241#[derive(Serialize, Deserialize, Debug, Clone, Copy, Encode, Decode, PartialEq, JsonSchema)]
1242#[serde(default, deny_unknown_fields)]
1243pub struct StabilityRecordingSettings {
1244    /// Record the values obtained at rotated phase-space points.
1245    #[serde(skip_serializing_if = "is_false")]
1246    pub record_rotated_results: bool,
1247    /// Record the result produced at every attempted numerical-precision level.
1248    #[serde(skip_serializing_if = "is_false")]
1249    pub record_all_stability_levels: bool,
1250    /// Record evaluations escalated because of the loop-momentum norm threshold.
1251    #[serde(skip_serializing_if = "is_false")]
1252    pub record_loop_momenta_escalation: bool,
1253}
1254
1255#[allow(clippy::derivable_impls)]
1256impl Default for StabilityRecordingSettings {
1257    fn default() -> Self {
1258        Self {
1259            record_rotated_results: false,
1260            record_all_stability_levels: false,
1261            record_loop_momenta_escalation: false,
1262        }
1263    }
1264}
1265
1266#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1267#[derive(Serialize, Deserialize, Debug, Clone, Copy, Encode, Decode, PartialEq, JsonSchema)]
1268#[serde(deny_unknown_fields)]
1269pub struct StabilityLevelSetting {
1270    /// Numeric precision used for this escalation level.
1271    pub precision: Precision,
1272    /// Maximum accepted relative error in the real component before escalating.
1273    pub required_precision_for_re: f64,
1274    /// Maximum accepted relative error in the imaginary component before escalating.
1275    pub required_precision_for_im: f64,
1276    /// Dimensionless real-component probe disagreement relative to E_cm raised
1277    /// to the observable's declared energy dimension, with its output-unit
1278    /// conversion. Includes every sample factor and the outer grid weight;
1279    /// direct momentum densities account for the missing spatial measure.
1280    /// Zero disables this alternative to relative accuracy. This is not the
1281    /// integrator's target standard error.
1282    #[serde(
1283        default,
1284        deserialize_with = "deserialize_nonnegative_finite_f64",
1285        skip_serializing_if = "is_float::<0>"
1286    )]
1287    #[schemars(range(min = 0.0))]
1288    pub ecm_relative_tolerance_for_re: f64,
1289    /// Independent dimensionless imaginary-component tolerance, with the same
1290    /// E_cm-relative convention as `ecm_relative_tolerance_for_re`.
1291    #[serde(
1292        default,
1293        deserialize_with = "deserialize_nonnegative_finite_f64",
1294        skip_serializing_if = "is_float::<0>"
1295    )]
1296    #[schemars(range(min = 0.0))]
1297    pub ecm_relative_tolerance_for_im: f64,
1298    /// Ratio threshold for the large-weight escalation check; a non-positive value disables it.
1299    pub escalate_for_large_weight_threshold: f64,
1300}
1301
1302impl StabilityLevelSetting {
1303    pub fn default_double() -> Self {
1304        Self {
1305            precision: Precision::Double,
1306            required_precision_for_re: 1e-5,
1307            required_precision_for_im: 1e-5,
1308            ecm_relative_tolerance_for_re: 0.0,
1309            ecm_relative_tolerance_for_im: 0.0,
1310            escalate_for_large_weight_threshold: 0.9,
1311        }
1312    }
1313
1314    pub fn default_quad() -> Self {
1315        Self {
1316            precision: Precision::Quad,
1317            required_precision_for_re: 1e-5,
1318            required_precision_for_im: 1e-5,
1319            ecm_relative_tolerance_for_re: 0.0,
1320            ecm_relative_tolerance_for_im: 0.0,
1321            escalate_for_large_weight_threshold: -1.0,
1322        }
1323    }
1324
1325    pub fn default_arb() -> Self {
1326        Self {
1327            precision: Precision::Arb,
1328            required_precision_for_re: 1e-5,
1329            required_precision_for_im: 1e-5,
1330            ecm_relative_tolerance_for_re: 0.0,
1331            ecm_relative_tolerance_for_im: 0.0,
1332            escalate_for_large_weight_threshold: -1.0,
1333        }
1334    }
1335}
1336
1337#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1338#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Encode, Decode, JsonSchema)]
1339#[serde(tag = "type")]
1340#[serde(deny_unknown_fields)]
1341pub enum RotationSetting {
1342    #[serde(rename = "x")]
1343    Pi2X {},
1344    #[serde(rename = "y")]
1345    Pi2Y {},
1346    #[serde(rename = "z")]
1347    Pi2Z {},
1348    #[serde(rename = "none")]
1349    None {},
1350    #[serde(rename = "euler_angles")]
1351    EulerAngles {
1352        /// First Euler angle, in radians.
1353        alpha: f64,
1354        /// Second Euler angle, in radians.
1355        beta: f64,
1356        /// Third Euler angle, in radians.
1357        gamma: f64,
1358    },
1359}
1360
1361impl Default for RotationSetting {
1362    fn default() -> Self {
1363        Self::Pi2Z {}
1364    }
1365}
1366
1367impl RotationSetting {
1368    pub(crate) fn rotation_method(&self) -> RotationMethod {
1369        match self {
1370            Self::Pi2X {} => RotationMethod::Pi2X,
1371            Self::Pi2Y {} => RotationMethod::Pi2Y,
1372            Self::Pi2Z {} => RotationMethod::Pi2Z,
1373            Self::None {} => RotationMethod::Identity,
1374            Self::EulerAngles { alpha, beta, gamma } => {
1375                RotationMethod::EulerAngles(*alpha, *beta, *gamma)
1376            }
1377        }
1378    }
1379
1380    pub(crate) fn _as_str(&self) -> String {
1381        match self {
1382            Self::Pi2X {} => "x".to_owned(),
1383            Self::Pi2Y {} => "y".to_owned(),
1384            Self::Pi2Z {} => "z".to_owned(),
1385            Self::None {} => "none".to_owned(),
1386            Self::EulerAngles { alpha, beta, gamma } => {
1387                format!("euler {} {} {}", alpha, beta, gamma)
1388            }
1389        }
1390    }
1391}
1392
1393#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
1394#[derive(
1395    Serialize, Deserialize, Debug, Clone, PartialEq, Copy, Hash, Eq, Encode, Decode, JsonSchema,
1396)]
1397#[serde(deny_unknown_fields)]
1398pub enum Precision {
1399    Double,
1400    Quad,
1401    Arb,
1402}
1403
1404impl Display for Precision {
1405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1406        match self {
1407            Self::Double => write!(f, "f64"),
1408            Self::Quad => write!(f, "f128"),
1409            Self::Arb => write!(f, "arb"),
1410        }
1411    }
1412}
1413
1414#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Encode, Decode, JsonSchema)]
1416#[serde(deny_unknown_fields)]
1417pub enum ParameterizationMode {
1418    #[serde(rename = "cartesian")]
1419    Cartesian,
1420    #[serde(rename = "spherical")]
1421    #[default]
1422    Spherical,
1423    #[serde(rename = "hyperspherical")]
1424    HyperSpherical,
1425    #[serde(rename = "hyperspherical_flat")]
1426    HyperSphericalFlat,
1427    #[serde(rename = "momentum_space")]
1428    MomentumSpace,
1429    #[serde(rename = "relative_spherical")]
1430    RelativeSpherical,
1431    #[serde(rename = "spherical_common_radial")]
1432    SphericalCommonRadial,
1433    #[serde(rename = "spherical_product_common_radial")]
1434    SphericalProductCommonRadial,
1435}
1436
1437#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1438#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1439#[serde(default, deny_unknown_fields)]
1440pub struct OutputMetadata {
1441    #[serde(skip_serializing_if = "IsDefault::is_default")]
1442    pub model_name: String,
1443    #[serde(skip_serializing_if = "IsDefault::is_default")]
1444    pub output_type: String,
1445    #[serde(skip_serializing_if = "IsDefault::is_default")]
1446    pub contents: Vec<String>,
1447}
1448
1449impl Display for ParameterizationMode {
1450    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1451        match self {
1452            ParameterizationMode::Cartesian => write!(f, "cartesian"),
1453            ParameterizationMode::Spherical => write!(f, "spherical"),
1454            ParameterizationMode::RelativeSpherical => write!(f, "relative spherical"),
1455            ParameterizationMode::SphericalCommonRadial => {
1456                write!(f, "common-radial spherical")
1457            }
1458            ParameterizationMode::SphericalProductCommonRadial => {
1459                write!(f, "product/common-radial spherical")
1460            }
1461            ParameterizationMode::HyperSpherical => write!(f, "hyperspherical"),
1462            ParameterizationMode::HyperSphericalFlat => write!(f, "flat hyperspherical"),
1463            ParameterizationMode::MomentumSpace => write!(f, "momentum space"),
1464        }
1465    }
1466}
1467
1468#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
1469#[derive(Debug, Clone, Deserialize, PartialEq, Default, Serialize, Encode, Decode, JsonSchema)]
1470#[serde(deny_unknown_fields)]
1471pub enum ParameterizationMapping {
1472    #[serde(rename = "log")]
1473    Log,
1474    #[serde(rename = "power")]
1475    Power,
1476    #[serde(rename = "linear")]
1477    #[default]
1478    Linear,
1479}
1480
1481#[derive(Debug, Clone, PartialEq, Encode, Decode)]
1482#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1483pub enum SamplingSettings {
1484    Default(ParameterizationSettings),
1485    MultiChanneling(MultiChannelingSettings),
1486    DiscreteGraphs(DiscreteGraphSamplingSettings),
1487}
1488
1489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1490#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1491#[serde(default, deny_unknown_fields)]
1492pub struct SamplingSettingsParser {
1493    /// Whether graph contributions are summed deterministically or sampled as a discrete axis.
1494    #[serde(skip_serializing_if = "IsDefault::is_default")]
1495    pub graphs: SumMode,
1496    /// Optional graph-name allow-list for discrete graph sampling.
1497    #[serde(skip_serializing_if = "IsDefault::is_default")]
1498    pub graph_names: Vec<String>,
1499    /// Whether causal orientations are summed deterministically or sampled as a discrete axis.
1500    #[serde(skip_serializing_if = "IsDefault::is_default")]
1501    pub orientations: SumMode,
1502    /// Enable multichannel sampling over the resolved graph-aware channels.
1503    #[serde(skip_serializing_if = "is_false")]
1504    #[serde(rename = "sampling_multichanneling", alias = "lmb_multichanneling")]
1505    pub sampling_multichanneling: bool,
1506    /// Whether resolved sampling channels are summed or sampled discretely.
1507    #[serde(skip_serializing_if = "IsDefault::is_default")]
1508    #[serde(rename = "sampling_channels", alias = "lmb_channels")]
1509    pub sampling_channels: SumMode,
1510    /// Exponent of each selected loop edge's on-shell energy in an OSE channel score.
1511    #[serde(
1512        deserialize_with = "deserialize_nonnegative_finite_f64",
1513        skip_serializing_if = "is_float::<3>"
1514    )]
1515    #[schemars(range(min = 0.0))]
1516    pub alpha: f64,
1517    /// Rule used to construct the relative probability of each resolved sampling channel.
1518    #[serde(skip_serializing_if = "IsDefault::is_default")]
1519    #[serde(rename = "sampling_channel_weight", alias = "lmb_channel_weight")]
1520    pub sampling_channel_weight: SamplingChannelWeight,
1521    /// Coordinate system used to parameterize loop momenta.
1522    #[serde(skip_serializing_if = "IsDefault::is_default")]
1523    pub coordinate_system: CoordinateSystem,
1524    /// Mapping from the unit hypercube to unbounded radial coordinates.
1525    #[serde(skip_serializing_if = "IsDefault::is_default")]
1526    pub mapping: ParameterizationMapping,
1527    /// Scale parameter of the selected radial mapping.
1528    #[serde(skip_serializing_if = "is_float::<1>")]
1529    pub b: f64,
1530    /// Power parameter of the selected radial mapping.
1531    #[serde(skip_serializing_if = "is_float::<1>")]
1532    pub power: f64,
1533    /// Explicit loop-momentum-basis identifiers keyed by graph name.
1534    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
1535    pub lmb_basis_ids: BTreeMap<String, Vec<usize>>,
1536    /// Default channel selectors for graphs without a graph-specific selection.
1537    #[serde(skip_serializing_if = "Vec::is_empty")]
1538    pub default_channel_selection: Vec<String>,
1539    /// Channel selectors keyed by graph name, overriding the default selection.
1540    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
1541    pub channel_selection: BTreeMap<String, Vec<String>>,
1542    /// Named channel definitions keyed first by graph name, then by channel name.
1543    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
1544    pub channel_definitions: BTreeMap<String, BTreeMap<String, SamplingChannelDefinition>>,
1545}
1546
1547// Keep the old Python attribute spellings available while exposing the generic
1548// `sampling_*` names as the canonical fields.  Serde aliases above cover TOML
1549// and JSON cards; these accessors cover callers that construct parser settings
1550// directly through the Python bindings.
1551#[cfg(feature = "python")]
1552#[pyo3::pymethods]
1553impl SamplingSettingsParser {
1554    #[getter(lmb_multichanneling)]
1555    fn legacy_lmb_multichanneling(&self) -> bool {
1556        self.sampling_multichanneling
1557    }
1558
1559    #[setter(lmb_multichanneling)]
1560    fn set_legacy_lmb_multichanneling(&mut self, value: bool) {
1561        self.sampling_multichanneling = value;
1562    }
1563
1564    #[getter(lmb_channels)]
1565    fn legacy_lmb_channels(&self) -> SumMode {
1566        self.sampling_channels.clone()
1567    }
1568
1569    #[setter(lmb_channels)]
1570    fn set_legacy_lmb_channels(&mut self, value: SumMode) {
1571        self.sampling_channels = value;
1572    }
1573
1574    #[getter(lmb_channel_weight)]
1575    fn legacy_lmb_channel_weight(&self) -> SamplingChannelWeight {
1576        self.sampling_channel_weight
1577    }
1578
1579    #[setter(lmb_channel_weight)]
1580    fn set_legacy_lmb_channel_weight(&mut self, value: SamplingChannelWeight) {
1581        self.sampling_channel_weight = value;
1582    }
1583}
1584
1585impl Default for SamplingSettingsParser {
1586    fn default() -> Self {
1587        Self {
1588            graphs: SumMode::Summed,
1589            graph_names: Vec::new(),
1590            orientations: SumMode::Summed,
1591            sampling_multichanneling: false,
1592            sampling_channels: SumMode::Summed,
1593            alpha: 3.0,
1594            sampling_channel_weight: SamplingChannelWeight::default(),
1595            coordinate_system: CoordinateSystem::Spherical,
1596            mapping: ParameterizationMapping::Linear,
1597            b: 1.0,
1598            power: 1.0,
1599            lmb_basis_ids: BTreeMap::new(),
1600            default_channel_selection: Vec::new(),
1601            channel_selection: BTreeMap::new(),
1602            channel_definitions: BTreeMap::new(),
1603        }
1604    }
1605}
1606
1607/// Weighting strategy for the resolved sampling-channel partition.
1608///
1609/// Exact inverse-map densities, on-shell-energy scores, and explicit positive
1610/// proxies share the same channel catalogue. `inverse_jacobian` selects the
1611/// same density score as `map_density`; `ose` is a distinct energy score.
1612#[derive(
1613    Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Encode, Decode, JsonSchema,
1614)]
1615#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1616pub enum SamplingChannelWeight {
1617    #[serde(rename = "map_density")]
1618    #[default]
1619    MapDensity,
1620    #[serde(rename = "ose")]
1621    Ose,
1622    #[serde(rename = "singularity_proxy")]
1623    SingularityProxy,
1624    #[serde(rename = "inverse_jacobian")]
1625    InverseJacobian,
1626}
1627
1628/// Radial proposal family for a physical-cut sampling channel.
1629#[derive(
1630    Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Encode, Decode, JsonSchema,
1631)]
1632#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1633#[serde(rename_all = "snake_case")]
1634pub enum SamplingRadialProfileKind {
1635    #[default]
1636    LuH,
1637}
1638
1639#[derive(
1640    Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Encode, Decode, JsonSchema,
1641)]
1642#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1643#[serde(rename_all = "snake_case")]
1644pub enum SamplingRadialApproximation {
1645    #[default]
1646    LogLogistic,
1647}
1648
1649/// A normalized proposal for the auxiliary LU scale. This changes sampling
1650/// only: the physical LU h-function and its raised-cut derivatives are intact.
1651#[derive(Debug, Clone, Serialize, PartialEq, Encode, Decode, JsonSchema)]
1652#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1653#[schemars(with = "SamplingRadialProfileInput")]
1654pub struct SamplingRadialProfile {
1655    pub kind: SamplingRadialProfileKind,
1656    #[serde(skip_serializing_if = "IsDefault::is_default")]
1657    pub approximation: SamplingRadialApproximation,
1658    pub broad_fraction: f64,
1659    #[serde(skip_serializing_if = "IsDefault::is_default")]
1660    pub scale: Option<f64>,
1661    #[serde(skip_serializing_if = "IsDefault::is_default")]
1662    pub shape: Option<f64>,
1663}
1664
1665impl Default for SamplingRadialProfile {
1666    fn default() -> Self {
1667        Self {
1668            kind: SamplingRadialProfileKind::LuH,
1669            approximation: SamplingRadialApproximation::LogLogistic,
1670            broad_fraction: 0.02,
1671            scale: None,
1672            shape: None,
1673        }
1674    }
1675}
1676
1677// Normalize the compact string and the explicit table to one runtime type.
1678// Export always retains the detailed form, including the proposal overrides.
1679#[derive(Deserialize, JsonSchema)]
1680#[serde(untagged, deny_unknown_fields)]
1681enum SamplingRadialProfileInput {
1682    Name(SamplingRadialProfileKind),
1683    Settings {
1684        kind: SamplingRadialProfileKind,
1685        #[serde(default)]
1686        approximation: SamplingRadialApproximation,
1687        #[serde(default = "SamplingRadialProfile::default_broad_fraction")]
1688        broad_fraction: f64,
1689        #[serde(default)]
1690        scale: Option<f64>,
1691        #[serde(default)]
1692        shape: Option<f64>,
1693    },
1694}
1695
1696impl SamplingRadialProfile {
1697    fn default_broad_fraction() -> f64 {
1698        Self::default().broad_fraction
1699    }
1700}
1701
1702impl<'de> Deserialize<'de> for SamplingRadialProfile {
1703    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1704        Ok(
1705            match SamplingRadialProfileInput::deserialize(deserializer)? {
1706                SamplingRadialProfileInput::Name(kind) => Self {
1707                    kind,
1708                    ..Self::default()
1709                },
1710                SamplingRadialProfileInput::Settings {
1711                    kind,
1712                    approximation,
1713                    broad_fraction,
1714                    scale,
1715                    shape,
1716                } => Self {
1717                    kind,
1718                    approximation,
1719                    broad_fraction,
1720                    scale,
1721                    shape,
1722                },
1723            },
1724        )
1725    }
1726}
1727
1728/// A graph-scoped channel definition.  `parent_lmb` is deliberately explicit:
1729/// resolving a short expression against a graph is a later validation step,
1730/// while exported definitions must never lose the routing frame they use.
1731#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1732#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1733#[serde(default, deny_unknown_fields)]
1734pub struct SamplingChannelDefinition {
1735    pub around: String,
1736    /// Override the global channel score for this named channel. OSE scores
1737    /// require a complete ordinary LMB map; advanced maps keep their actual
1738    /// density unless an explicit compatible proxy is selected.
1739    #[serde(skip_serializing_if = "IsDefault::is_default")]
1740    pub channel_weight: Option<SamplingChannelWeight>,
1741    #[serde(skip_serializing_if = "IsDefault::is_default")]
1742    pub radial_profile: Option<SamplingRadialProfile>,
1743    /// Ordered loop edges spanning the coordinates in which a surface or
1744    /// joint constraint is solved.  This is distinct from the edge set in
1745    /// `around`, which identifies the physical energy constraints.
1746    pub subspace_lmb: Vec<usize>,
1747    pub parent_lmb: Vec<usize>,
1748    pub on_cut: Vec<usize>,
1749    /// Optional positive singularity proxy used when the effective global or
1750    /// per-channel weight is `singularity_proxy`. The expression is
1751    /// parsed by Symbolica and evaluated in the complete master raw frame;
1752    /// its variables are `x0`, `x1`, ... in coordinate order.  A proxy is
1753    /// never inferred from the map, since doing so could hide an incomplete
1754    /// singularity model.
1755    #[serde(skip_serializing_if = "Option::is_none")]
1756    pub singularity_proxy: Option<String>,
1757}
1758
1759/// Selection and user definitions for graph-aware sampling channels.
1760#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1761#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1762#[serde(default, deny_unknown_fields)]
1763pub struct SamplingChannelSelection {
1764    #[serde(skip_serializing_if = "IsDefault::is_default")]
1765    pub weight: SamplingChannelWeight,
1766    /// Exponent of each selected LMB edge's on-shell energy in an OSE score.
1767    /// Zero gives a constant score; this is not a normalized proposal density.
1768    #[serde(
1769        deserialize_with = "deserialize_nonnegative_finite_f64",
1770        skip_serializing_if = "is_float::<3>"
1771    )]
1772    #[schemars(range(min = 0.0))]
1773    pub alpha: f64,
1774    pub default_channel_selection: Vec<String>,
1775    pub channel_selection: BTreeMap<String, Vec<String>>,
1776    pub channel_definitions: BTreeMap<String, BTreeMap<String, SamplingChannelDefinition>>,
1777}
1778
1779impl Default for SamplingChannelSelection {
1780    fn default() -> Self {
1781        Self {
1782            weight: SamplingChannelWeight::default(),
1783            alpha: 3.0,
1784            default_channel_selection: Vec::new(),
1785            channel_selection: BTreeMap::new(),
1786            channel_definitions: BTreeMap::new(),
1787        }
1788    }
1789}
1790
1791#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
1792#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1793pub enum SumMode {
1794    #[serde(rename = "summed")]
1795    #[default]
1796    Summed,
1797    #[serde(rename = "monte_carlo")]
1798    MonteCarlo,
1799}
1800
1801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema, Default)]
1802#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
1803pub enum CoordinateSystem {
1804    #[serde(rename = "cartesian")]
1805    Cartesian,
1806    #[serde(rename = "spherical")]
1807    #[default]
1808    Spherical,
1809    #[serde(rename = "hyperspherical")]
1810    HyperSpherical,
1811    #[serde(rename = "hyperspherical_flat")]
1812    HyperSphericalFlat,
1813    #[serde(rename = "momentum_space")]
1814    MomentumSpace,
1815    #[serde(rename = "tropical")]
1816    MomTrop,
1817    #[serde(rename = "relative_spherical")]
1818    RelativeSpherical,
1819    #[serde(rename = "spherical_common_radial")]
1820    SphericalCommonRadial,
1821    #[serde(rename = "spherical_product_common_radial")]
1822    SphericalProductCommonRadial,
1823}
1824
1825impl Default for SamplingSettings {
1826    fn default() -> Self {
1827        Self::Default(ParameterizationSettings::default())
1828    }
1829}
1830
1831fn validate_lmb_basis_ids(lmb_basis_ids: &BTreeMap<String, Vec<usize>>) -> Result<(), String> {
1832    for (graph_name, basis_ids) in lmb_basis_ids {
1833        if basis_ids.is_empty() {
1834            return Err(format!(
1835                "Invalid sampling settings: lmb_basis_ids entry for graph '{graph_name}' cannot be empty."
1836            ));
1837        }
1838
1839        let mut seen = BTreeSet::new();
1840        for basis_id in basis_ids {
1841            if !seen.insert(*basis_id) {
1842                return Err(format!(
1843                    "Invalid sampling settings: lmb_basis_ids entry for graph '{graph_name}' contains duplicate basis id {basis_id}."
1844                ));
1845            }
1846        }
1847    }
1848
1849    Ok(())
1850}
1851
1852fn validate_sampling_channel_selection(
1853    default_selection: &[String],
1854    selection: &BTreeMap<String, Vec<String>>,
1855    definitions: &BTreeMap<String, BTreeMap<String, SamplingChannelDefinition>>,
1856) -> Result<(), String> {
1857    for (label, entries) in [("default_channel_selection", default_selection)] {
1858        if entries.iter().any(|entry| entry.trim().is_empty()) {
1859            return Err(format!(
1860                "Invalid sampling settings: {label} contains an empty channel selector."
1861            ));
1862        }
1863    }
1864    for (graph, entries) in selection {
1865        if graph.trim().is_empty() {
1866            return Err(
1867                "Invalid sampling settings: channel_selection contains an empty graph name."
1868                    .to_string(),
1869            );
1870        }
1871        if entries.iter().any(|entry| entry.trim().is_empty()) {
1872            return Err(format!(
1873                "Invalid sampling settings: channel_selection entry for graph '{graph}' contains an empty selector."
1874            ));
1875        }
1876    }
1877    for (graph, graph_definitions) in definitions {
1878        if graph.trim().is_empty() {
1879            return Err(
1880                "Invalid sampling settings: channel_definitions contains an empty graph name."
1881                    .to_string(),
1882            );
1883        }
1884        for (name, definition) in graph_definitions {
1885            if name.trim().is_empty() || definition.around.trim().is_empty() {
1886                return Err(format!(
1887                    "Invalid sampling settings: channel definition '{graph}.{name}' requires a non-empty around expression."
1888                ));
1889            }
1890            if definition.parent_lmb.is_empty() {
1891                return Err(format!(
1892                    "Invalid sampling settings: channel definition '{graph}.{name}' requires a non-empty parent_lmb."
1893                ));
1894            }
1895            let mut seen = BTreeSet::new();
1896            if definition.parent_lmb.iter().any(|edge| !seen.insert(edge)) {
1897                return Err(format!(
1898                    "Invalid sampling settings: channel definition '{graph}.{name}' contains duplicate parent_lmb edge ids."
1899                ));
1900            }
1901            let mut seen_subspace = BTreeSet::new();
1902            if definition
1903                .subspace_lmb
1904                .iter()
1905                .any(|edge| !seen_subspace.insert(edge))
1906            {
1907                return Err(format!(
1908                    "Invalid sampling settings: channel definition '{graph}.{name}' contains duplicate subspace_lmb edge ids."
1909                ));
1910            }
1911            let mut seen_on_cut = BTreeSet::new();
1912            if definition
1913                .on_cut
1914                .iter()
1915                .any(|edge| !seen_on_cut.insert(edge))
1916            {
1917                return Err(format!(
1918                    "Invalid sampling settings: channel definition '{graph}.{name}' contains duplicate on_cut edge ids."
1919                ));
1920            }
1921            SamplingMapDefinition::parse(&definition.around).map_err(|error| {
1922                format!(
1923                    "Invalid sampling settings: channel definition '{graph}.{name}' has an invalid around expression: {error}"
1924                )
1925            })?;
1926        }
1927    }
1928    Ok(())
1929}
1930
1931impl SamplingSettings {
1932    pub fn as_parser(&self) -> SamplingSettingsParser {
1933        let mut parser = SamplingSettingsParser::default();
1934        match self {
1935            Self::Default(_) => {}
1936            Self::MultiChanneling(_) => parser.sampling_multichanneling = true,
1937            Self::DiscreteGraphs(settings) => {
1938                parser.graphs = SumMode::MonteCarlo;
1939                parser.graph_names = settings.graph_names.clone();
1940                parser.orientations = if settings.sample_orientations {
1941                    SumMode::MonteCarlo
1942                } else {
1943                    SumMode::Summed
1944                };
1945                parser.sampling_multichanneling = matches!(
1946                    settings.sampling_type,
1947                    DiscreteGraphSamplingType::MultiChanneling(_)
1948                        | DiscreteGraphSamplingType::SamplingMultiChanneling(_)
1949                );
1950                if matches!(
1951                    settings.sampling_type,
1952                    DiscreteGraphSamplingType::SamplingMultiChanneling(_)
1953                ) {
1954                    parser.sampling_channels = SumMode::MonteCarlo;
1955                }
1956            }
1957        }
1958        match self.get_parameterization_settings() {
1959            Some(settings) => SamplingSettingsParser {
1960                alpha: settings.sampling_channels.alpha,
1961                sampling_channel_weight: settings.sampling_channels.weight,
1962                coordinate_system: CoordinateSystem::from_mode(settings.mode),
1963                mapping: settings.mapping,
1964                b: settings.b,
1965                power: settings.power,
1966                lmb_basis_ids: settings.lmb_basis_ids,
1967                default_channel_selection: settings.sampling_channels.default_channel_selection,
1968                channel_selection: settings.sampling_channels.channel_selection,
1969                channel_definitions: settings.sampling_channels.channel_definitions,
1970                ..parser
1971            },
1972            None => SamplingSettingsParser {
1973                coordinate_system: CoordinateSystem::MomTrop,
1974                ..parser
1975            },
1976        }
1977    }
1978
1979    fn from_parser(parser: SamplingSettingsParser) -> Result<Self, String> {
1980        let SamplingSettingsParser {
1981            graphs,
1982            graph_names,
1983            orientations,
1984            sampling_multichanneling,
1985            sampling_channels,
1986            alpha,
1987            sampling_channel_weight,
1988            coordinate_system,
1989            mapping,
1990            b,
1991            power,
1992            lmb_basis_ids,
1993            default_channel_selection,
1994            channel_selection,
1995            channel_definitions,
1996        } = parser;
1997
1998        validate_lmb_basis_ids(&lmb_basis_ids)?;
1999        let default_channel_selection =
2000            if sampling_multichanneling && default_channel_selection.is_empty() {
2001                vec!["auto:optimized_lmb".to_string()]
2002            } else {
2003                default_channel_selection
2004            };
2005        validate_sampling_channel_selection(
2006            &default_channel_selection,
2007            &channel_selection,
2008            &channel_definitions,
2009        )?;
2010        if !alpha.is_finite() || alpha < 0.0 {
2011            return Err(
2012                "Invalid sampling settings: alpha must be finite and nonnegative.".to_string(),
2013            );
2014        }
2015
2016        let mut seen_graph_names = BTreeSet::new();
2017        for graph_name in &graph_names {
2018            if !seen_graph_names.insert(graph_name) {
2019                return Err(format!(
2020                    "Invalid sampling settings: graph_names contains duplicate graph name '{graph_name}'."
2021                ));
2022            }
2023        }
2024        if !graph_names.is_empty() && matches!(graphs, SumMode::Summed) {
2025            return Err(
2026                "Invalid sampling settings: graph_names requires graphs = 'monte_carlo'."
2027                    .to_string(),
2028            );
2029        }
2030
2031        let sample_orientations = match (graphs.clone(), orientations) {
2032            (SumMode::Summed, SumMode::Summed) => false,
2033            (SumMode::Summed, SumMode::MonteCarlo) => {
2034                return Err(
2035                    "Invalid sampling settings: orientations can only be set to 'monte_carlo' when graphs is 'monte_carlo'.".to_string(),
2036                )
2037            }
2038            (SumMode::MonteCarlo, SumMode::Summed) => false,
2039            (SumMode::MonteCarlo, SumMode::MonteCarlo) => true,
2040        };
2041
2042        if matches!(coordinate_system, CoordinateSystem::MomTrop) {
2043            if sampling_channel_weight != SamplingChannelWeight::MapDensity
2044                || !default_channel_selection.is_empty()
2045                || !channel_selection.is_empty()
2046                || !channel_definitions.is_empty()
2047            {
2048                return Err(
2049                    "Invalid sampling settings: advanced sampling channels are incompatible with tropical coordinates."
2050                        .to_string(),
2051                );
2052            }
2053            if !lmb_basis_ids.is_empty() {
2054                return Err(
2055                    "Invalid sampling settings: coordinate_system = 'tropical' is incompatible with lmb_basis_ids."
2056                        .to_string(),
2057                );
2058            }
2059            if !matches!(graphs, SumMode::MonteCarlo) {
2060                return Err(
2061                    "Invalid sampling settings: coordinate_system = 'tropical' requires graphs = 'monte_carlo'."
2062                        .to_string(),
2063                );
2064            }
2065
2066            if sampling_multichanneling {
2067                return Err(
2068                    "Invalid sampling settings: coordinate_system = 'tropical' is incompatible with sampling_multichanneling = true."
2069                        .to_string(),
2070                );
2071            }
2072
2073            return Ok(SamplingSettings::DiscreteGraphs(
2074                DiscreteGraphSamplingSettings {
2075                    graph_names,
2076                    sample_orientations,
2077                    sampling_type: DiscreteGraphSamplingType::TropicalSampling(
2078                        GammaloopTropicalSamplingSettings::default(),
2079                    ),
2080                },
2081            ));
2082        }
2083
2084        let mode = coordinate_system.into_mode();
2085        if matches!(mode, ParameterizationMode::SphericalProductCommonRadial)
2086            && (!sampling_multichanneling
2087                || sampling_channel_weight != SamplingChannelWeight::InverseJacobian)
2088        {
2089            return Err(
2090                "Invalid sampling settings: coordinate_system = 'spherical_product_common_radial' requires sampling_multichanneling = true and sampling_channel_weight = 'inverse_jacobian'."
2091                    .to_string(),
2092            );
2093        }
2094        if sampling_channel_weight == SamplingChannelWeight::InverseJacobian
2095            && matches!(mode, ParameterizationMode::HyperSphericalFlat)
2096        {
2097            return Err(
2098                "Invalid sampling settings: sampling_channel_weight = 'inverse_jacobian' is incompatible with coordinate_system = 'hyperspherical_flat' because the inverse map is not available."
2099                    .to_string(),
2100            );
2101        }
2102        if matches!(mapping, ParameterizationMapping::Power) {
2103            if !matches!(
2104                mode,
2105                ParameterizationMode::Spherical
2106                    | ParameterizationMode::RelativeSpherical
2107                    | ParameterizationMode::SphericalCommonRadial
2108                    | ParameterizationMode::SphericalProductCommonRadial
2109                    | ParameterizationMode::HyperSpherical
2110                    | ParameterizationMode::HyperSphericalFlat
2111            ) {
2112                return Err(
2113                    "Invalid sampling settings: mapping = 'power' requires a spherical coordinate system."
2114                        .to_string(),
2115                );
2116            }
2117            if !power.is_finite() || power < 1.0 {
2118                return Err(
2119                    "Invalid sampling settings: mapping = 'power' requires power >= 1.".to_string(),
2120                );
2121            }
2122        }
2123        let parameterization_settings = ParameterizationSettings {
2124            mode,
2125            mapping,
2126            b,
2127            power,
2128            lmb_basis_ids,
2129            sampling_channels: SamplingChannelSelection {
2130                weight: sampling_channel_weight,
2131                alpha,
2132                default_channel_selection,
2133                channel_selection,
2134                channel_definitions,
2135            },
2136        };
2137
2138        match graphs {
2139            SumMode::Summed => {
2140                if !matches!(sampling_channels, SumMode::Summed) {
2141                    return Err(
2142                        "Invalid sampling settings: sampling_channels = 'monte_carlo' requires graphs = 'monte_carlo'."
2143                            .to_string(),
2144                    );
2145                }
2146
2147                if sample_orientations {
2148                    return Err(
2149                        "Invalid sampling settings: orientations can only be Monte Carlo sampled when graphs are Monte Carlo sampled."
2150                            .to_string(),
2151                    );
2152                }
2153
2154                if sampling_multichanneling {
2155                    Ok(SamplingSettings::MultiChanneling(MultiChannelingSettings {
2156                        parameterization_settings,
2157                    }))
2158                } else {
2159                    Ok(SamplingSettings::Default(parameterization_settings))
2160                }
2161            }
2162            SumMode::MonteCarlo => {
2163                let sampling_type = if sampling_multichanneling {
2164                    let settings = MultiChannelingSettings {
2165                        parameterization_settings,
2166                    };
2167
2168                    match sampling_channels {
2169                        SumMode::Summed => DiscreteGraphSamplingType::MultiChanneling(settings),
2170                        SumMode::MonteCarlo => {
2171                            DiscreteGraphSamplingType::SamplingMultiChanneling(settings)
2172                        }
2173                    }
2174                } else {
2175                    DiscreteGraphSamplingType::Default(parameterization_settings)
2176                };
2177
2178                Ok(SamplingSettings::DiscreteGraphs(
2179                    DiscreteGraphSamplingSettings {
2180                        graph_names,
2181                        sample_orientations,
2182                        sampling_type,
2183                    },
2184                ))
2185            }
2186        }
2187    }
2188}
2189
2190impl Serialize for SamplingSettings {
2191    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2192    where
2193        S: Serializer,
2194    {
2195        self.as_parser().serialize(serializer)
2196    }
2197}
2198
2199impl<'de> Deserialize<'de> for SamplingSettings {
2200    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2201    where
2202        D: Deserializer<'de>,
2203    {
2204        let parser = SamplingSettingsParser::deserialize(deserializer)?;
2205        SamplingSettings::from_parser(parser).map_err(serde::de::Error::custom)
2206    }
2207}
2208
2209impl JsonSchema for SamplingSettings {
2210    fn schema_name() -> std::borrow::Cow<'static, str> {
2211        "SamplingSettings".into()
2212    }
2213
2214    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
2215        SamplingSettingsParser::json_schema(generator)
2216    }
2217}
2218
2219impl CoordinateSystem {
2220    fn from_mode(mode: ParameterizationMode) -> Self {
2221        match mode {
2222            ParameterizationMode::Cartesian => Self::Cartesian,
2223            ParameterizationMode::Spherical => Self::Spherical,
2224            ParameterizationMode::RelativeSpherical => Self::RelativeSpherical,
2225            ParameterizationMode::SphericalCommonRadial => Self::SphericalCommonRadial,
2226            ParameterizationMode::SphericalProductCommonRadial => {
2227                Self::SphericalProductCommonRadial
2228            }
2229            ParameterizationMode::HyperSpherical => Self::HyperSpherical,
2230            ParameterizationMode::HyperSphericalFlat => Self::HyperSphericalFlat,
2231            ParameterizationMode::MomentumSpace => Self::MomentumSpace,
2232        }
2233    }
2234
2235    fn into_mode(self) -> ParameterizationMode {
2236        match self {
2237            CoordinateSystem::Cartesian => ParameterizationMode::Cartesian,
2238            CoordinateSystem::Spherical => ParameterizationMode::Spherical,
2239            CoordinateSystem::RelativeSpherical => ParameterizationMode::RelativeSpherical,
2240            CoordinateSystem::SphericalCommonRadial => ParameterizationMode::SphericalCommonRadial,
2241            CoordinateSystem::SphericalProductCommonRadial => {
2242                ParameterizationMode::SphericalProductCommonRadial
2243            }
2244            CoordinateSystem::HyperSpherical => ParameterizationMode::HyperSpherical,
2245            CoordinateSystem::HyperSphericalFlat => ParameterizationMode::HyperSphericalFlat,
2246            CoordinateSystem::MomentumSpace => ParameterizationMode::MomentumSpace,
2247            CoordinateSystem::MomTrop => {
2248                unreachable!("tropical coordinate system has no ParameterizationMode equivalent")
2249            }
2250        }
2251    }
2252}
2253
2254impl SamplingSettings {
2255    /// Whether the selected runtime mode maps canonical sampling channels.
2256    /// Both top-level and discrete graph sampling use the same channel owner;
2257    /// keeping the predicate central also guards cut-dependent maps in both forms.
2258    pub(crate) fn uses_sampling_channels(&self) -> bool {
2259        match self {
2260            Self::MultiChanneling(_) => true,
2261            Self::DiscreteGraphs(settings) => matches!(
2262                &settings.sampling_type,
2263                DiscreteGraphSamplingType::MultiChanneling(_)
2264                    | DiscreteGraphSamplingType::SamplingMultiChanneling(_)
2265            ),
2266            Self::Default(_) => false,
2267        }
2268    }
2269
2270    pub fn selected_graph_names(&self) -> &[String] {
2271        match self {
2272            SamplingSettings::DiscreteGraphs(settings) => &settings.graph_names,
2273            SamplingSettings::Default(_) | SamplingSettings::MultiChanneling(_) => &[],
2274        }
2275    }
2276
2277    pub fn get_parameterization_settings(&self) -> Option<ParameterizationSettings> {
2278        match self {
2279            SamplingSettings::Default(settings) => Some(settings.clone()),
2280            SamplingSettings::MultiChanneling(settings) => {
2281                Some(settings.parameterization_settings.clone())
2282            }
2283            SamplingSettings::DiscreteGraphs(settings) => match &settings.sampling_type {
2284                DiscreteGraphSamplingType::Default(settings) => Some(settings.clone()),
2285                DiscreteGraphSamplingType::MultiChanneling(settings) => {
2286                    Some(settings.parameterization_settings.clone())
2287                }
2288                DiscreteGraphSamplingType::SamplingMultiChanneling(settings) => {
2289                    Some(settings.parameterization_settings.clone())
2290                }
2291                DiscreteGraphSamplingType::TropicalSampling(_) => None,
2292            },
2293        }
2294    }
2295
2296    pub(crate) fn discrete_depth(&self) -> usize {
2297        match self {
2298            SamplingSettings::Default(_) => 0,
2299            SamplingSettings::MultiChanneling(_) => 0,
2300            SamplingSettings::DiscreteGraphs(settings) => {
2301                let depth_from_orientations = settings.sample_orientations as usize;
2302
2303                match &settings.sampling_type {
2304                    DiscreteGraphSamplingType::Default(_) => 1 + depth_from_orientations,
2305                    DiscreteGraphSamplingType::MultiChanneling(_) => 1 + depth_from_orientations,
2306                    DiscreteGraphSamplingType::SamplingMultiChanneling(_) => {
2307                        2 + depth_from_orientations
2308                    }
2309                    DiscreteGraphSamplingType::TropicalSampling(_) => 1 + depth_from_orientations,
2310                }
2311            }
2312        }
2313    }
2314
2315    pub(crate) fn describe_settings(&self) -> String {
2316        match self {
2317            SamplingSettings::Default(settings) => {
2318                format!("{} coordinates", settings.mode)
2319            }
2320            SamplingSettings::MultiChanneling(settings) => {
2321                format!(
2322                    "lmb multichanneling in {} coordinates",
2323                    settings.parameterization_settings.mode
2324                )
2325            }
2326            SamplingSettings::DiscreteGraphs(settings) => {
2327                let discrete_graph_string = if settings.graph_names.is_empty() {
2328                    "Monte Carlo over graphs".to_string()
2329                } else {
2330                    format!(
2331                        "Monte Carlo over selected graph groups [{}]",
2332                        settings.graph_names.join(", ")
2333                    )
2334                };
2335                let orientation_sampling_string = if settings.sample_orientations {
2336                    "and Monte Carlo over orientations"
2337                } else {
2338                    ""
2339                };
2340
2341                match &settings.sampling_type {
2342                    DiscreteGraphSamplingType::Default(settings) => {
2343                        format!(
2344                            "{} {} in {} coordinates",
2345                            discrete_graph_string, orientation_sampling_string, settings.mode
2346                        )
2347                    }
2348                    DiscreteGraphSamplingType::MultiChanneling(settings) => {
2349                        format!(
2350                            "{}, lmb multichanneling in {} coordinates {}",
2351                            discrete_graph_string,
2352                            settings.parameterization_settings.mode,
2353                            orientation_sampling_string,
2354                        )
2355                    }
2356                    DiscreteGraphSamplingType::SamplingMultiChanneling(settings) => {
2357                        format!(
2358                            "{}, {} and monte carlo over lmbs in {} coordinates",
2359                            discrete_graph_string,
2360                            orientation_sampling_string,
2361                            settings.parameterization_settings.mode
2362                        )
2363                    }
2364                    DiscreteGraphSamplingType::TropicalSampling(_) => {
2365                        format!(
2366                            "{} {} using 🌴🥥 tropical sampling 🥥🌴",
2367                            discrete_graph_string, orientation_sampling_string,
2368                        )
2369                    }
2370                }
2371            }
2372        }
2373    }
2374}
2375
2376#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2377#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2378#[serde(deny_unknown_fields, default)]
2379pub struct MultiChannelingSettings {
2380    /// Continuous loop-momentum parameterization used inside each channel.
2381    #[serde(skip_serializing_if = "IsDefault::is_default")]
2382    pub parameterization_settings: ParameterizationSettings,
2383}
2384
2385#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2387#[serde(deny_unknown_fields, default)]
2388pub struct GammaloopTropicalSamplingSettings {
2389    /// Retry unstable tropical matrix operations at a higher numerical precision.
2390    #[serde(skip_serializing_if = "is_true")]
2391    pub upcast_on_failure: bool,
2392    /// Optional condition threshold used to reject an unstable tropical sampling matrix.
2393    #[serde(skip_serializing_if = "IsDefault::is_default")]
2394    pub matrix_stability_test: Option<f64>,
2395}
2396
2397impl Default for GammaloopTropicalSamplingSettings {
2398    fn default() -> Self {
2399        Self {
2400            upcast_on_failure: true,
2401            matrix_stability_test: None,
2402        }
2403    }
2404}
2405
2406impl GammaloopTropicalSamplingSettings {
2407    pub fn into_tropical_sampling_settings(&self) -> momtrop::TropicalSamplingSettings {
2408        momtrop::TropicalSamplingSettings {
2409            matrix_stability_test: self.matrix_stability_test,
2410            print_debug_info: false,
2411            return_metadata: false,
2412        }
2413    }
2414}
2415
2416#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2418#[serde(tag = "subtype", deny_unknown_fields)]
2419pub enum DiscreteGraphSamplingType {
2420    #[serde(rename = "default")]
2421    Default(ParameterizationSettings),
2422    #[serde(rename = "multi_channeling")]
2423    MultiChanneling(MultiChannelingSettings),
2424    #[serde(rename = "discrete_multi_channeling")]
2425    SamplingMultiChanneling(MultiChannelingSettings),
2426    #[serde(rename = "tropical")]
2427    TropicalSampling(GammaloopTropicalSamplingSettings),
2428}
2429
2430impl Default for DiscreteGraphSamplingType {
2431    fn default() -> Self {
2432        DiscreteGraphSamplingType::Default(ParameterizationSettings::default())
2433    }
2434}
2435
2436#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema, Default)]
2438#[serde(deny_unknown_fields, default)]
2439pub struct DiscreteGraphSamplingSettings {
2440    /// Optional graph-name allow-list; an empty list includes all generated graphs.
2441    #[serde(skip_serializing_if = "IsDefault::is_default")]
2442    pub graph_names: Vec<String>,
2443    /// Sample graph orientations as an additional discrete integration coordinate.
2444    #[serde(skip_serializing_if = "is_false")]
2445    pub sample_orientations: bool,
2446    /// Continuous and multichannel strategy used inside each selected graph.
2447    #[serde(skip_serializing_if = "IsDefault::is_default")]
2448    pub sampling_type: DiscreteGraphSamplingType,
2449}
2450
2451#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2452#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2453#[serde(deny_unknown_fields, default)]
2454pub struct LocalCounterTermSettings {
2455    /// Widths and override controls for ultraviolet-counterterm localization dampers.
2456    #[serde(skip_serializing_if = "IsDefault::is_default")]
2457    pub uv_localisation: UVLocalisationSettings,
2458}
2459
2460#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2462#[serde(default, deny_unknown_fields)]
2463pub struct UVLocalisationSettings {
2464    /// Width of the transition region used by the ultraviolet sliver damper.
2465    #[serde(skip_serializing_if = "is_float::<10>")]
2466    pub sliver_width: f64,
2467    /// Make the compact sliver profile smooth at its support boundary.
2468    #[serde(skip_serializing_if = "is_false")]
2469    pub smooth_sliver: bool,
2470    /// Derive the localization width dynamically from the sampled ultraviolet configuration.
2471    #[serde(skip_serializing_if = "is_false")]
2472    pub dynamic_width: bool,
2473    /// Width of the Gaussian ultraviolet localization factor.
2474    #[serde(skip_serializing_if = "is_float::<1>")]
2475    pub gaussian_width: f64,
2476    /// Replace ultraviolet localization dampers by one for diagnostic comparisons.
2477    #[serde(skip_serializing_if = "is_false")]
2478    pub force_uv_dampers_to_one: bool,
2479}
2480
2481impl Default for UVLocalisationSettings {
2482    fn default() -> Self {
2483        Self {
2484            sliver_width: 10.0,
2485            smooth_sliver: false,
2486            dynamic_width: false,
2487            gaussian_width: 1.0,
2488            force_uv_dampers_to_one: false,
2489        }
2490    }
2491}
2492
2493#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2494#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2495#[serde(deny_unknown_fields, default)]
2496pub struct IntegratedCounterTermSettings {
2497    /// Integration domain and damping function used for integrated counterterms.
2498    #[serde(skip_serializing_if = "IsDefault::is_default")]
2499    pub range: IntegratedCounterTermRange,
2500}
2501
2502#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2503#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2504#[serde(tag = "type")]
2505#[serde(deny_unknown_fields)]
2506pub enum IntegratedCounterTermRange {
2507    /// Integrate over the full radial domain and regulate it with an H-function profile.
2508    #[serde(rename = "infinite")]
2509    Infinite {
2510        /// Damping profile used to make the infinite-domain counterterm integral convergent.
2511        h_function_settings: HFunctionSettings,
2512    },
2513    /// Integrate over the compact radial domain without an infinite-tail damping profile.
2514    #[serde(rename = "compact")]
2515    Compact {},
2516}
2517
2518impl Default for IntegratedCounterTermRange {
2519    fn default() -> Self {
2520        Self::Infinite {
2521            h_function_settings: HFunctionSettings::default(),
2522        }
2523    }
2524}
2525
2526#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
2527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2528#[serde(deny_unknown_fields)]
2529#[serde(default)]
2530pub struct OverlapSettings {
2531    //v
2532    /// Explicit three-vector center used for every threshold overlap, when provided.
2533    #[serde(skip_serializing_if = "IsDefault::is_default")]
2534    pub force_global_center: Option<Vec<[f64; 3]>>,
2535    /// Validate that the selected global center lies inside every required E-surface.
2536    #[serde(skip_serializing_if = "is_true")]
2537    pub check_global_center: bool,
2538    /// Try the loop-momentum origin as the global center before solving for another point.
2539    #[serde(skip_serializing_if = "is_true")]
2540    pub try_origin: bool,
2541    /// Try the origin independently for every available loop-momentum basis.
2542    #[serde(skip_serializing_if = "is_false")]
2543    pub try_origin_all_lmbs: bool,
2544}
2545
2546impl Default for OverlapSettings {
2547    fn default() -> Self {
2548        Self {
2549            force_global_center: None,
2550            check_global_center: true,
2551            try_origin: true,
2552            try_origin_all_lmbs: false,
2553        }
2554    }
2555}
2556
2557#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
2558#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2559#[serde(deny_unknown_fields)]
2560// #[trait_decode(trait= GammaLoopContext)]
2561pub enum HFunction {
2562    #[default]
2563    #[serde(rename = "poly_exponential")]
2564    PolyExponential,
2565    #[serde(rename = "exponential")]
2566    Exponential,
2567    #[serde(rename = "poly_left_right_exponential")]
2568    PolyLeftRightExponential,
2569    #[serde(rename = "exponential_ct")]
2570    ExponentialCT,
2571}
2572
2573#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
2574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
2575#[serde(deny_unknown_fields)]
2576#[serde(default)]
2577pub struct HFunctionSettings {
2578    /// Functional form of the counterterm damping profile.
2579    #[serde(skip_serializing_if = "IsDefault::is_default")]
2580    pub function: HFunction,
2581    /// Characteristic width of the damping profile.
2582    #[serde(skip_serializing_if = "is_float::<1>")]
2583    pub sigma: f64,
2584    /// Apply the damping profile; disabling it leaves the corresponding factor equal to one.
2585    #[serde(skip_serializing_if = "is_true")]
2586    pub enabled_dampening: bool,
2587    /// Optional polynomial power overriding the profile's built-in choice.
2588    #[serde(skip_serializing_if = "IsDefault::is_default")]
2589    pub power: Option<usize>,
2590}
2591
2592impl Default for HFunctionSettings {
2593    fn default() -> Self {
2594        Self {
2595            sigma: 1.0,
2596            function: HFunction::default(),
2597            enabled_dampening: true,
2598            power: None,
2599        }
2600    }
2601}
2602
2603impl HFunctionSettings {
2604    /// Supported powers of the polynomial profiles.
2605    const NORMALIZED_POWERS: [usize; 12] = [0, 1, 3, 4, 6, 7, 9, 10, 12, 13, 15, 16];
2606
2607    /// Integral of the unnormalized profile over `t > 0`, in the precision of
2608    /// `sigma`. With `x = t/sigma`, `exponential` integrates `exp(-x^2)` to
2609    /// `sqrt(pi)/2`, while the polynomial profiles are Bessel integrals:
2610    /// `x^-p exp(2 - x^2 - x^-2)` gives `e^2 K_{(1-p)/2}(2)` and
2611    /// `x^-p exp(2 - x - 1/x)` gives `2 e^2 K_{1-p}(2)`.
2612    pub(crate) fn normalization<T: FloatLike>(&self, sigma: &F<T>) -> F<T> {
2613        let one = sigma.one();
2614        let power = self.power.unwrap_or(0);
2615        if self.function != HFunction::Exponential && !Self::NORMALIZED_POWERS.contains(&power) {
2616            panic!("Value {power} of power in poly exponential h function not supported");
2617        }
2618        let unit = match self.function {
2619            HFunction::Exponential => Self::scaled_bessel_k_at_two(1, &one),
2620            HFunction::PolyExponential => Self::scaled_bessel_k_at_two(power.abs_diff(1), &one),
2621            HFunction::PolyLeftRightExponential => {
2622                one.from_usize(2) * Self::scaled_bessel_k_at_two(2 * power.abs_diff(1), &one)
2623            }
2624            HFunction::ExponentialCT => {
2625                unreachable!("exponential_ct is a threshold localization without a normalization")
2626            }
2627        };
2628        unit * sigma
2629    }
2630
2631    /// `e^2 K_nu(2)` for `nu = twice_order/2`, in the precision of `one`.
2632    ///
2633    /// Half-integer orders are elementary, `e^2 K_{n+1/2}(2) = sqrt(pi)/2 *
2634    /// sum_k (n+k)!/(k! (n-k)! 4^k)`, an exact rational times `sqrt(pi)/2`.
2635    /// Integer orders start from the `K_0` series, where `log(z/2)` vanishes at
2636    /// `z = 2` and leaves Euler's constant as the only transcendental input.
2637    /// The Wronskian `I_0 K_1 + I_1 K_0 = 1/2` gives `K_1`, and the upward
2638    /// recurrence `K_{n+1}(2) = K_{n-1}(2) + n K_n(2)` is stable for `K`.
2639    fn scaled_bessel_k_at_two<T: FloatLike>(twice_order: usize, one: &F<T>) -> F<T> {
2640        use std::{
2641            any::{Any, TypeId},
2642            cell::RefCell,
2643            collections::HashMap,
2644        };
2645        use symbolica::domains::float::SingleFloat;
2646
2647        if twice_order % 2 == 1 {
2648            let n = twice_order / 2;
2649            let sum = (0..=n).fold(Rational::zero(), |sum, k| {
2650                sum + Rational::from((
2651                    Integer::factorial((n + k) as u32),
2652                    Integer::factorial(k as u32)
2653                        * Integer::factorial((n - k) as u32)
2654                        * Integer::from(4).pow(k as u64),
2655                ))
2656            });
2657            return one.PI().sqrt() * one.from_rational(&(sum / Rational::from(2)));
2658        }
2659        // Cache native unit values per worker, like the decimal-promotion caches.
2660        // Include both the numeric representation and its precision; sigma is
2661        // applied by normalization() and must never become part of this value.
2662        type NativeValues = HashMap<(TypeId, u32, usize), Box<dyn Any>>;
2663        thread_local! {
2664            static CACHE: RefCell<NativeValues> = RefCell::default();
2665        }
2666        let key = (TypeId::of::<T>(), one.0.get_precision(), twice_order);
2667        if let Some(value) = CACHE.with_borrow(|cache| {
2668            cache.get(&key).map(|value| {
2669                value
2670                    .downcast_ref::<F<T>>()
2671                    .expect("cache key includes the numeric type")
2672                    .clone()
2673            })
2674        }) {
2675            return value;
2676        }
2677
2678        // Each series term carries (z^2/4)^k/(k!)^2 = 1/(k!)^2 at z = 2.
2679        let (mut i0, mut i1, mut harmonic_sum) = (one.zero(), one.zero(), one.zero());
2680        let (mut inverse_square, mut harmonic) = (one.clone(), one.zero());
2681        for k in 1.. {
2682            i0 += &inverse_square;
2683            i1 += &inverse_square / one.from_usize(k);
2684            inverse_square = &inverse_square / one.from_usize(k * k);
2685            harmonic += one.from_usize(k).inv();
2686            // H_k >= 1 bounds every remaining I_0 and I_1 term as well.
2687            let term = &harmonic * &inverse_square;
2688            harmonic_sum += &term;
2689            if term <= &harmonic_sum * one.epsilon() {
2690                break;
2691            }
2692        }
2693        let k0 = harmonic_sum - one.euler() * &i0;
2694        let k1 = (one.from_usize(2).inv() - &i1 * &k0) / &i0;
2695        let order = twice_order / 2;
2696        let (mut lower, mut upper) = (k0, k1);
2697        for n in 1..order {
2698            (lower, upper) = (upper.clone(), lower + one.from_usize(n) * &upper);
2699        }
2700        let value = one.from_usize(2).exp() * if order == 0 { lower } else { upper };
2701        CACHE.with_borrow_mut(|cache| {
2702            // Bound retention for callers using many numeric types or precisions.
2703            if cache.len() == 1024 {
2704                cache.clear();
2705            }
2706            cache.insert(key, Box::new(value.clone()));
2707        });
2708        value
2709    }
2710
2711    /// Require a profile normalized to one over positive scales `t`. LU-h
2712    /// sampling and Fermi-surface localization both insert it as a unit integral.
2713    pub(crate) fn validate_normalized_profile(&self) -> Result<()> {
2714        if !self.sigma.is_finite() || self.sigma <= 0.0 {
2715            return Err(eyre::eyre!(
2716                "A normalized h_function profile requires a positive finite h_function.sigma; got {}",
2717                self.sigma
2718            ));
2719        }
2720        match self.function {
2721            HFunction::Exponential => Ok(()),
2722            HFunction::PolyExponential | HFunction::PolyLeftRightExponential => {
2723                let power = self.power.unwrap_or(0);
2724                if Self::NORMALIZED_POWERS.contains(&power) {
2725                    Ok(())
2726                } else {
2727                    Err(eyre::eyre!(
2728                        "Unsupported h_function.power {power}; supported powers are {:?}",
2729                        Self::NORMALIZED_POWERS
2730                    ))
2731                }
2732            }
2733            HFunction::ExponentialCT => Err(eyre::eyre!(
2734                "exponential_ct is a local threshold localization, not a normalized h_function profile"
2735            )),
2736        }
2737    }
2738}