Skip to main content

gammalooprs/settings/
global.rs

1use std::fmt;
2
3use bincode_trait_derive::{Decode, Encode};
4use eyre::{Result as EyreResult, eyre};
5use linnet::half_edge::involution::{EdgeVec, Orientation};
6use schemars::JsonSchema;
7use serde::{Deserialize, Deserializer, Serialize};
8use symbolica::prelude::*;
9pub use three_dimensional_reps::MediumMode;
10
11use crate::{
12    GammaLoopContext,
13    cff::{expression::OrientationSelector, orientations::GraphOrientation},
14    feyngen::GenerationType,
15    processes::EvaluatorSettings,
16    utils::{
17        DEFAULT_ESURFACE_EXISTENCE_THRESHOLD, GS, W_,
18        serde_utils::{
19            IsDefault, deserialize_nonnegative_finite_f64, is_default_esurface_existence_threshold,
20            is_false, is_float, is_true, is_usize, show_defaults_helper,
21        },
22        symbolica_ext::StringSerializedAtom,
23    },
24    uv::UVgenerationSettings,
25};
26
27#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
28#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
29#[trait_decode(trait = GammaLoopContext)]
30#[serde(default, deny_unknown_fields)]
31#[derive(Default)]
32pub struct GenerationSettings {
33    // Generation Time settings
34    /// Symbolic simplification, contraction, optimization, and compilation controls for evaluators.
35    #[serde(skip_serializing_if = "IsDefault::is_default")]
36    pub evaluator: EvaluatorSettings,
37    /// Feynman-graph generation checks and diagnostics.
38    #[serde(skip_serializing_if = "IsDefault::is_default")]
39    pub feyngen: FeyGenSettings,
40
41    /// Optional causal-orientation pattern applied during generation.
42    #[serde(skip_serializing_if = "IsDefault::is_default")]
43    pub orientation_pattern: OrientationPattern,
44    /// Choose which numerator-energy reconstructions use the shared runtime sampling scale.
45    #[serde(skip_serializing_if = "IsDefault::is_default")]
46    pub uniform_numerator_sampling_scale: UniformNumeratorSamplingScale,
47    /// Code-generation backend, compiler, and optimization flags.
48    #[serde(skip_serializing_if = "IsDefault::is_default")]
49    pub compile: GammaloopCompileOptions,
50    /// Controls construction of tropical subgraph power-counting tables.
51    #[serde(skip_serializing_if = "IsDefault::is_default")]
52    pub tropical_subgraph_table: TropicalSubgraphTableSettings,
53    /// Generation-time selection of local threshold counterterms.
54    #[serde(skip_serializing_if = "IsDefault::is_default")]
55    pub threshold_subtraction: ThresholdSubtractionSettings,
56    /// Gauge used when summing external vector-particle polarizations.
57    #[serde(skip_serializing_if = "IsDefault::is_default")]
58    pub vector_polarization_sum_gauge: VectorPolarizationSumGauge,
59    /// Ultraviolet counterterm generation, prescription, and Vakint backend settings.
60    #[serde(skip_serializing_if = "IsDefault::is_default")]
61    pub uv: UVgenerationSettings,
62    /// Explicit particle-name sets forced to define graph cuts.
63    #[serde(skip_serializing_if = "IsDefault::is_default")]
64    pub force_cuts: Vec<Vec<String>>,
65    /// Use explicitly supplied loop-momentum bases instead of the built-in basis heuristics.
66    #[serde(skip_serializing_if = "is_false")]
67    pub override_lmb_heuristics: bool,
68    /// Sum all generated residue branches explicitly, removing runtime orientation selectors.
69    #[serde(skip_serializing_if = "is_false")]
70    pub explicit_orientation_sum_only: bool,
71    /// Medium for amplitude generation; non-vacuum modes require local 3D UV subtraction
72    /// and disabled threshold subtraction.
73    #[serde(skip_serializing_if = "IsDefault::is_default")]
74    pub medium: MediumSettings,
75}
76
77impl GenerationSettings {
78    pub fn validate_for_process(&self, generation_type: GenerationType) -> EyreResult<()> {
79        if self.medium.mode == MediumMode::Vacuum && self.medium.vacuum_subtraction {
80            return Err(eyre!(
81                "`global.generation.medium.vacuum_subtraction = true` requires a medium mode other than `vacuum`"
82            ));
83        }
84
85        if generation_type == GenerationType::CrossSection && self.medium.mode != MediumMode::Vacuum
86        {
87            return Err(eyre!(
88                "Cross-section (`xs`) generation is not supported in thermal modes or with vacuum subtraction"
89            ));
90        }
91
92        if self.medium.mode != MediumMode::Vacuum && self.threshold_subtraction.enable_thresholds {
93            return Err(eyre!(
94                "Threshold subtraction is not supported in thermal modes; set `global.generation.threshold_subtraction.enable_thresholds = false`"
95            ));
96        }
97
98        self.validate_medium_uv_compatibility()?;
99
100        if self.uv.local_uv_cts_from_expanded_4d_integrands && !self.explicit_orientation_sum_only {
101            return Err(eyre!(
102                "`global.generation.uv.local_uv_cts_from_expanded_4d_integrands = true` requires `global.generation.explicit_orientation_sum_only = true` because projected 4D counterterms have term-local CFF orientation sums"
103            ));
104        }
105
106        if self.explicit_orientation_sum_only && self.orientation_pattern.pat.is_some() {
107            return Err(eyre!(
108                "`global.generation.explicit_orientation_sum_only = true` requires summing all generated orientations; `global.generation.orientation_pattern` must be unset"
109            ));
110        }
111
112        Ok(())
113    }
114
115    pub(crate) fn validate_medium_uv_compatibility(&self) -> EyreResult<()> {
116        if (self.medium.mode != MediumMode::Vacuum || self.medium.vacuum_subtraction)
117            && self.uv.local_uv_cts_from_expanded_4d_integrands
118        {
119            return Err(eyre!(
120                "`global.generation.uv.local_uv_cts_from_expanded_4d_integrands = true` is unsupported for finite-medium modes or vacuum subtraction; use local 3D subtraction instead"
121            ));
122        }
123
124        Ok(())
125    }
126}
127
128#[cfg(test)]
129mod generation_settings_tests {
130    use super::{GenerationSettings, GenerationType, MediumMode, OrientationPattern};
131
132    #[test]
133    fn cross_sections_require_vacuum_mode_without_vacuum_subtraction() {
134        let mut settings = GenerationSettings::default();
135        settings
136            .validate_for_process(GenerationType::CrossSection)
137            .unwrap();
138
139        settings
140            .validate_for_process(GenerationType::Amplitude)
141            .unwrap();
142
143        settings.threshold_subtraction.enable_thresholds = false;
144        for mode in [
145            MediumMode::ThermodynamicEquilibrium,
146            MediumMode::ZeroTemperatureEquilibrium,
147        ] {
148            for vacuum_subtraction in [false, true] {
149                settings.medium.mode = mode;
150                settings.medium.vacuum_subtraction = vacuum_subtraction;
151                settings
152                    .validate_for_process(GenerationType::Amplitude)
153                    .unwrap();
154                let error = settings
155                    .validate_for_process(GenerationType::CrossSection)
156                    .unwrap_err();
157                assert!(error.to_string().contains("`xs`"));
158                assert!(error.to_string().contains("not supported"));
159            }
160        }
161    }
162
163    #[test]
164    fn thermal_modes_require_threshold_subtraction_disabled() {
165        for mode in [
166            MediumMode::ThermodynamicEquilibrium,
167            MediumMode::ZeroTemperatureEquilibrium,
168        ] {
169            for vacuum_subtraction in [false, true] {
170                let mut settings = GenerationSettings::default();
171                settings.medium.mode = mode;
172                settings.medium.vacuum_subtraction = vacuum_subtraction;
173
174                let error = settings
175                    .validate_for_process(GenerationType::Amplitude)
176                    .unwrap_err();
177                assert!(
178                    error
179                        .to_string()
180                        .contains("Threshold subtraction is not supported")
181                );
182                assert!(
183                    error.to_string().contains(
184                        "global.generation.threshold_subtraction.enable_thresholds = false"
185                    )
186                );
187
188                settings.threshold_subtraction.enable_thresholds = false;
189                settings
190                    .validate_for_process(GenerationType::Amplitude)
191                    .unwrap();
192            }
193        }
194    }
195
196    #[test]
197    fn vacuum_subtraction_requires_a_medium_mode() {
198        let mut settings = GenerationSettings::default();
199        settings.medium.vacuum_subtraction = true;
200        for generation_type in [GenerationType::Amplitude, GenerationType::CrossSection] {
201            let error = settings.validate_for_process(generation_type).unwrap_err();
202            assert!(error.to_string().contains("vacuum_subtraction = true"));
203            assert!(error.to_string().contains("other than `vacuum`"));
204        }
205    }
206
207    #[test]
208    fn finite_medium_supports_only_local_3d_subtraction() {
209        for (mode, vacuum_subtraction) in [
210            (MediumMode::ThermodynamicEquilibrium, false),
211            (MediumMode::ZeroTemperatureEquilibrium, false),
212            (MediumMode::ThermodynamicEquilibrium, true),
213            (MediumMode::ZeroTemperatureEquilibrium, true),
214        ] {
215            let mut settings = GenerationSettings::default();
216            settings.medium.mode = mode;
217            settings.medium.vacuum_subtraction = vacuum_subtraction;
218            settings.threshold_subtraction.enable_thresholds = false;
219            settings
220                .validate_for_process(GenerationType::Amplitude)
221                .unwrap();
222
223            settings.explicit_orientation_sum_only = true;
224            settings.uv.local_uv_cts_from_expanded_4d_integrands = true;
225            let error = settings
226                .validate_for_process(GenerationType::Amplitude)
227                .unwrap_err();
228            assert!(
229                error
230                    .to_string()
231                    .contains("use local 3D subtraction instead")
232            );
233        }
234    }
235
236    #[test]
237    fn projected_4d_cff_requires_an_unfiltered_explicit_orientation_sum() {
238        let mut settings = GenerationSettings::default();
239        settings.uv.local_uv_cts_from_expanded_4d_integrands = true;
240
241        let error = settings
242            .validate_for_process(GenerationType::Amplitude)
243            .unwrap_err();
244        assert!(
245            error
246                .to_string()
247                .contains("requires `global.generation.explicit_orientation_sum_only = true`")
248        );
249
250        settings.explicit_orientation_sum_only = true;
251        settings
252            .validate_for_process(GenerationType::Amplitude)
253            .unwrap();
254
255        settings.orientation_pattern = OrientationPattern::from_user_pattern("(+,-)").unwrap();
256        let error = settings
257            .validate_for_process(GenerationType::Amplitude)
258            .unwrap_err();
259        assert!(
260            error
261                .to_string()
262                .contains("`global.generation.orientation_pattern` must be unset")
263        );
264    }
265}
266
267#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
268#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
269#[trait_decode(trait = GammaLoopContext)]
270#[serde(default, deny_unknown_fields)]
271pub struct ThresholdSubtractionSettings {
272    /// Generate local threshold counterterms for eligible E-surfaces.
273    #[serde(skip_serializing_if = "is_true")]
274    pub enable_thresholds: bool,
275    /// Test E-surface existence against the active model while generating counterterms.
276    #[serde(skip_serializing_if = "is_false")]
277    pub check_esurface_at_generation: bool,
278    /// Dimensionless tolerance used to compare the energy-squared E-surface invariant margin
279    /// against `esurface_existence_threshold * E_cm^2` during generation-time checks.
280    #[serde(
281        deserialize_with = "deserialize_nonnegative_finite_f64",
282        skip_serializing_if = "is_default_esurface_existence_threshold"
283    )]
284    #[schemars(range(min = 0.0))]
285    pub esurface_existence_threshold: f64,
286    /// Omit threshold counterterms whose E-surface coincides with a physical cut.
287    #[serde(skip_serializing_if = "is_true")]
288    pub skip_thresholds_that_are_cuts: bool,
289    /// Generate local threshold counterterms without their integrated counterparts.
290    #[serde(skip_serializing_if = "is_false")]
291    pub disable_integrated_ct: bool,
292    /// Simplify existence tests by assuming all external-state energies are positive.
293    #[serde(skip_serializing_if = "is_true")]
294    pub assume_positive_external_energies: bool,
295}
296
297impl Default for ThresholdSubtractionSettings {
298    fn default() -> Self {
299        Self {
300            enable_thresholds: true,
301            check_esurface_at_generation: false,
302            esurface_existence_threshold: DEFAULT_ESURFACE_EXISTENCE_THRESHOLD,
303            skip_thresholds_that_are_cuts: true,
304            disable_integrated_ct: false,
305            assume_positive_external_energies: true,
306        }
307    }
308}
309
310#[derive(
311    Debug, Clone, Copy, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema, Default,
312)]
313#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
314pub enum VectorPolarizationSumGauge {
315    #[serde(rename = "Feynman", alias = "feynman")]
316    Feynman,
317    #[default]
318    #[serde(rename = "LightLikeAxial", alias = "light_like_axial")]
319    LightLikeAxial,
320}
321
322#[derive(
323    Debug, Clone, Copy, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema, Default,
324)]
325#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
326#[serde(rename_all = "snake_case")]
327pub enum UniformNumeratorSamplingScale {
328    #[default]
329    None,
330    BeyondQuadratic,
331    All,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, Copy, JsonSchema)]
335#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
336#[serde(deny_unknown_fields)]
337#[derive(Default)]
338pub enum CompilationOptimizationLevel {
339    O0,
340    O1,
341    #[default]
342    O2,
343    O3,
344}
345
346impl From<CompilationOptimizationLevel> for usize {
347    fn from(value: CompilationOptimizationLevel) -> Self {
348        match value {
349            CompilationOptimizationLevel::O0 => 0,
350            CompilationOptimizationLevel::O1 => 1,
351            CompilationOptimizationLevel::O2 => 2,
352            CompilationOptimizationLevel::O3 => 3,
353        }
354    }
355}
356
357impl fmt::Display for CompilationOptimizationLevel {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        match self {
360            CompilationOptimizationLevel::O0 => f.write_str("O0"),
361            CompilationOptimizationLevel::O1 => f.write_str("O1"),
362            CompilationOptimizationLevel::O2 => f.write_str("O2"),
363            CompilationOptimizationLevel::O3 => f.write_str("O3"),
364        }
365    }
366}
367
368pub const fn yes() -> bool {
369    true
370}
371
372#[cfg(target_vendor = "apple")]
373pub const fn default_external_compiler() -> &'static str {
374    "clang++"
375}
376
377#[cfg(not(target_vendor = "apple"))]
378pub const fn default_external_compiler() -> &'static str {
379    "g++"
380}
381
382pub fn default_external_compiler_owned() -> String {
383    default_external_compiler().to_owned()
384}
385
386pub fn is_default_external_compiler(compiler: &str) -> bool {
387    show_defaults_helper(default_external_compiler() == compiler)
388}
389
390#[derive(Debug, Clone, Copy, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema)]
391#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
392#[derive(Default)]
393pub enum CompilationMode {
394    #[serde(rename = "c++", alias = "cpp")]
395    Cpp,
396    #[serde(rename = "assembly")]
397    Assembly,
398    #[default]
399    #[serde(rename = "symjit")]
400    Symjit,
401}
402
403impl fmt::Display for CompilationMode {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        match self {
406            CompilationMode::Cpp => f.write_str("c++"),
407            CompilationMode::Assembly => f.write_str("assembly"),
408            CompilationMode::Symjit => f.write_str("symjit"),
409        }
410    }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema)]
414#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
415#[serde(default, deny_unknown_fields)]
416pub struct CompilationOptionsSnapshot {
417    /// Optimization level for the evaluator compiler. Indirect JIT translation caps it
418    /// at O2; direct JIT translation always uses O2, regardless of this setting.
419    #[serde(skip_serializing_if = "IsDefault::is_default")]
420    pub optimization_level: CompilationOptimizationLevel,
421    /// Skip Symbolica's tree-based analysis by translating instructions directly to SymJIT IR.
422    /// Enabled by default to reduce JIT preparation work; disable to use the tree-based
423    /// translator when comparing or diagnosing backend behavior. Also applies when JIT
424    /// is the fallback for external artifacts. Direct translation always uses SymJIT O2,
425    /// regardless of `optimization_level`; disable it to use JIT O0 or O1.
426    #[serde(skip_serializing_if = "is_true")]
427    pub jit_direct_translation: bool,
428    #[serde(skip_serializing_if = "is_true")]
429    pub fast_math: bool,
430    #[serde(skip_serializing_if = "is_true")]
431    pub unsafe_math: bool,
432    #[serde(skip_serializing_if = "is_default_external_compiler")]
433    pub compiler: String,
434    #[serde(skip_serializing_if = "IsDefault::is_default")]
435    pub custom: Vec<String>,
436}
437
438impl Default for CompilationOptionsSnapshot {
439    fn default() -> Self {
440        GammaloopCompileOptions::default().options_snapshot()
441    }
442}
443
444impl fmt::Display for CompilationOptionsSnapshot {
445    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446        write!(
447            f,
448            "{} jit_direct_translation={} fast_math={} unsafe_math={} compiler={} custom={}",
449            self.optimization_level,
450            self.jit_direct_translation,
451            self.fast_math,
452            self.unsafe_math,
453            self.compiler,
454            if self.custom.is_empty() {
455                "[]".to_string()
456            } else {
457                format!("[{}]", self.custom.join(", "))
458            }
459        )
460    }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema)]
464pub enum FrozenCompilationMode {
465    Eager,
466    Symjit(CompilationOptionsSnapshot),
467    Cpp(CompilationOptionsSnapshot),
468    Assembly(CompilationOptionsSnapshot),
469}
470
471impl FrozenCompilationMode {
472    pub fn compile_enabled(&self) -> bool {
473        !matches!(self, FrozenCompilationMode::Eager)
474    }
475
476    pub fn active_backend_name(&self) -> &'static str {
477        match self {
478            FrozenCompilationMode::Eager => "eager",
479            FrozenCompilationMode::Symjit(_) => "symjit",
480            FrozenCompilationMode::Cpp(_) => "c++",
481            FrozenCompilationMode::Assembly(_) => "assembly",
482        }
483    }
484
485    pub fn external_options(&self) -> Option<&CompilationOptionsSnapshot> {
486        match self {
487            FrozenCompilationMode::Cpp(options) | FrozenCompilationMode::Assembly(options) => {
488                Some(options)
489            }
490            FrozenCompilationMode::Eager | FrozenCompilationMode::Symjit(_) => None,
491        }
492    }
493
494    pub fn requires_external_compilation(&self) -> bool {
495        matches!(
496            self,
497            FrozenCompilationMode::Cpp(_) | FrozenCompilationMode::Assembly(_)
498        )
499    }
500
501    pub(crate) fn export_settings(&self) -> ExportSettings {
502        ExportSettings::new().inline_asm(match self {
503            FrozenCompilationMode::Assembly(_) => InlineASM::default(),
504            FrozenCompilationMode::Cpp(_)
505            | FrozenCompilationMode::Symjit(_)
506            | FrozenCompilationMode::Eager => InlineASM::None,
507        })
508    }
509
510    pub fn to_symbolica_compile_options(&self) -> Option<CompileOptions> {
511        let options = self.external_options()?;
512        Some(
513            CompileOptions::new()
514                .optimization_level(options.optimization_level.into())
515                .fast_math(options.fast_math)
516                .unsafe_math(options.unsafe_math)
517                .compiler(options.compiler.clone())
518                .args(options.custom.clone()),
519        )
520    }
521}
522
523impl fmt::Display for FrozenCompilationMode {
524    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525        match self {
526            FrozenCompilationMode::Eager => f.write_str("eager"),
527            FrozenCompilationMode::Symjit(options) => write!(
528                f,
529                "symjit ({}, jit_direct_translation={})",
530                options.optimization_level, options.jit_direct_translation,
531            ),
532            FrozenCompilationMode::Cpp(options) => write!(f, "c++ ({options})"),
533            FrozenCompilationMode::Assembly(options) => write!(f, "assembly ({options})"),
534        }
535    }
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
539#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
540#[serde(default, deny_unknown_fields)]
541pub struct GammaloopCompileOptions {
542    /// Evaluator backend: Symbolica JIT, generated C++, or generated assembly.
543    #[serde(skip_serializing_if = "IsDefault::is_default")]
544    pub compilation_mode: CompilationMode,
545
546    /// Optimization level for the evaluator compiler. Indirect JIT translation caps it
547    /// at O2; direct JIT translation always uses O2, regardless of this setting.
548    #[serde(skip_serializing_if = "IsDefault::is_default")]
549    pub optimization_level: CompilationOptimizationLevel,
550
551    /// Skip Symbolica's tree-based analysis by translating instructions directly to SymJIT IR.
552    /// Enabled by default to reduce JIT preparation work; disable to use the tree-based
553    /// translator when comparing or diagnosing backend behavior. Also applies when JIT
554    /// is the fallback for external artifacts. Direct translation always uses SymJIT O2,
555    /// regardless of `optimization_level`; disable it to use JIT O0 or O1.
556    #[serde(skip_serializing_if = "is_true")]
557    pub jit_direct_translation: bool,
558
559    /// Permit algebraic floating-point transformations that ignore strict IEEE ordering.
560    #[serde(skip_serializing_if = "is_true")]
561    pub fast_math: bool,
562
563    /// Permit compiler math optimizations that may change exceptional-value behavior.
564    #[serde(skip_serializing_if = "is_true")] // default true
565    pub unsafe_math: bool,
566
567    /// External C++ compiler executable used by C++ and assembly modes.
568    #[serde(skip_serializing_if = "is_default_external_compiler")] // default compiler
569    pub compiler: String,
570    /// Additional command-line arguments passed verbatim to the external compiler.
571    #[serde(skip_serializing_if = "IsDefault::is_default")]
572    pub custom: Vec<String>,
573}
574
575impl Default for GammaloopCompileOptions {
576    fn default() -> Self {
577        Self {
578            compilation_mode: CompilationMode::Symjit,
579            optimization_level: CompilationOptimizationLevel::O2,
580            jit_direct_translation: true,
581            fast_math: true,
582            unsafe_math: true,
583            compiler: default_external_compiler_owned(),
584            custom: vec![],
585        }
586    }
587}
588
589impl GammaloopCompileOptions {
590    pub fn options_snapshot(&self) -> CompilationOptionsSnapshot {
591        CompilationOptionsSnapshot {
592            optimization_level: self.optimization_level,
593            jit_direct_translation: self.jit_direct_translation,
594            fast_math: self.fast_math,
595            unsafe_math: self.unsafe_math,
596            compiler: self.compiler.clone(),
597            custom: self.custom.clone(),
598        }
599    }
600
601    pub fn requires_external_compilation(&self) -> bool {
602        matches!(
603            self.compilation_mode,
604            CompilationMode::Cpp | CompilationMode::Assembly
605        )
606    }
607
608    pub fn frozen_mode(&self, evaluator_settings: &EvaluatorSettings) -> FrozenCompilationMode {
609        if !evaluator_settings.compile {
610            return FrozenCompilationMode::Eager;
611        }
612
613        let options = self.options_snapshot();
614        match self.compilation_mode {
615            CompilationMode::Cpp => FrozenCompilationMode::Cpp(options),
616            CompilationMode::Assembly => FrozenCompilationMode::Assembly(options),
617            CompilationMode::Symjit => FrozenCompilationMode::Symjit(options),
618        }
619    }
620
621    pub fn to_symbolica_compile_options(&self) -> CompileOptions {
622        CompileOptions::new()
623            .optimization_level(self.optimization_level.into())
624            .fast_math(self.fast_math)
625            .unsafe_math(self.unsafe_math)
626            .compiler(self.compiler.clone())
627            .args(self.custom.clone())
628    }
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
632#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
633#[serde(default, deny_unknown_fields)]
634#[derive(Default)]
635pub struct FeyGenSettings {
636    /// Check that gamma-matrix simplification is closed under the generated rewrite sequence.
637    #[serde(skip_serializing_if = "is_false")]
638    pub gamma_simplification_closure_check: bool,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
642#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
643#[serde(default, deny_unknown_fields)]
644pub struct TropicalSubgraphTableSettings {
645    /// Abort generation instead of reporting and skipping a failed tropical table construction.
646    #[serde(skip_serializing_if = "is_false")]
647    pub panic_on_fail: bool,
648    /// Target superficial degree used when classifying tropical subgraphs.
649    #[serde(skip_serializing_if = "is_float::<1>")] // default 1.0
650    pub target_omega: f64,
651    /// Disable tropical subgraph table construction entirely.
652    #[serde(skip_serializing_if = "is_false")]
653    pub disable_tropical_generation: bool,
654}
655
656impl Default for TropicalSubgraphTableSettings {
657    fn default() -> Self {
658        Self {
659            panic_on_fail: false,
660            target_omega: 1.0,
661            disable_tropical_generation: false,
662        }
663    }
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, Default, PartialEq, JsonSchema)]
667#[trait_decode(trait = GammaLoopContext)]
668#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
669#[serde(default, deny_unknown_fields)]
670pub struct OrientationPattern {
671    /// Symbolica pattern matched against serialized causal orientations.
672    #[serde(
673        default,
674        skip_serializing_if = "IsDefault::is_default",
675        deserialize_with = "deserialize_orientation_pattern_atom"
676    )]
677    pub pat: Option<StringSerializedAtom>,
678}
679
680fn deserialize_orientation_pattern_atom<'de, D>(
681    deserializer: D,
682) -> std::result::Result<Option<StringSerializedAtom>, D::Error>
683where
684    D: Deserializer<'de>,
685{
686    let raw = Option::<String>::deserialize(deserializer)?;
687    raw.map(|value| OrientationPattern::parse_user_pattern(&value))
688        .transpose()
689        .map(|parsed| parsed.map(StringSerializedAtom))
690        .map_err(serde::de::Error::custom)
691}
692
693impl From<Atom> for OrientationPattern {
694    fn from(value: Atom) -> Self {
695        OrientationPattern {
696            pat: Some(StringSerializedAtom(value)),
697        }
698    }
699}
700
701impl OrientationPattern {
702    fn is_orientation_delta_atom(atom: &Atom) -> bool {
703        matches!(
704            atom.as_view(),
705            AtomView::Fun(function)
706                if function.get_symbol().get_stripped_name() == "orientation_delta"
707        )
708    }
709
710    fn split_top_level_args(input: &str) -> EyreResult<Vec<String>> {
711        let mut args = Vec::new();
712        let mut start = 0usize;
713        let mut depth = 0usize;
714
715        for (index, ch) in input.char_indices() {
716            match ch {
717                '(' | '[' | '{' => depth += 1,
718                ')' | ']' | '}' => {
719                    if depth == 0 {
720                        return Err(eyre!(
721                            "Unbalanced delimiter in orientation pattern: {input}"
722                        ));
723                    }
724                    depth -= 1;
725                }
726                ',' if depth == 0 => {
727                    let arg = input[start..index].trim();
728                    if arg.is_empty() {
729                        return Err(eyre!("Empty orientation-pattern entry in pattern: {input}"));
730                    }
731                    args.push(arg.to_string());
732                    start = index + ch.len_utf8();
733                }
734                _ => {}
735            }
736        }
737
738        if depth != 0 {
739            return Err(eyre!(
740                "Unbalanced delimiter in orientation pattern: {input}"
741            ));
742        }
743
744        let tail = input[start..].trim();
745        if tail.is_empty() {
746            if args.is_empty() {
747                return Err(eyre!("Orientation pattern cannot be empty"));
748            }
749            return Err(eyre!("Empty orientation-pattern entry in pattern: {input}"));
750        }
751        args.push(tail.to_string());
752
753        Ok(args)
754    }
755
756    fn normalize_user_pattern(pattern: &str) -> EyreResult<String> {
757        let trimmed = pattern.trim();
758        if trimmed.is_empty() {
759            return Err(eyre!("Orientation pattern cannot be empty"));
760        }
761
762        let args = if let Some(rest) = trimmed.strip_prefix("orientation_delta") {
763            let rest = rest.trim();
764            if !(rest.starts_with('(') && rest.ends_with(')')) {
765                return Err(eyre!(
766                    "orientation_delta patterns must use parentheses, got: {pattern}"
767                ));
768            }
769            Self::split_top_level_args(&rest[1..rest.len() - 1])?
770        } else if trimmed.starts_with('(') && trimmed.ends_with(')') {
771            Self::split_top_level_args(&trimmed[1..trimmed.len() - 1])?
772        } else {
773            Self::split_top_level_args(trimmed)?
774        };
775
776        let normalized_args = args
777            .into_iter()
778            .map(|arg| match arg.as_str() {
779                "+" | "+1" => "1".to_string(),
780                "-" | "-1" => "-1".to_string(),
781                _ => arg,
782            })
783            .collect::<Vec<_>>()
784            .join(",");
785
786        Ok(format!("orientation_delta({normalized_args})"))
787    }
788
789    pub fn parse_user_pattern(pattern: &str) -> EyreResult<Atom> {
790        let trimmed = pattern.trim();
791        if trimmed.is_empty() {
792            return Err(eyre!("Orientation pattern cannot be empty"));
793        }
794
795        if let Ok(parsed) = try_parse!(trimmed)
796            && Self::is_orientation_delta_atom(&parsed)
797        {
798            return Ok(parsed);
799        }
800
801        let normalized = Self::normalize_user_pattern(pattern)?;
802        try_parse!(normalized.as_str())
803            .map_err(|error| eyre!("Symbolica parsing error for orientation pattern: {error}"))
804    }
805
806    pub fn from_user_pattern(pattern: &str) -> EyreResult<Self> {
807        Ok(Self {
808            pat: Some(StringSerializedAtom(Self::parse_user_pattern(pattern)?)),
809        })
810    }
811
812    pub fn from_orientation<O: GraphOrientation>(orientation: &O) -> Self {
813        orientation.orientation_delta().into()
814    }
815
816    pub fn select_pattern(&self, atom: impl AtomCore) -> Option<Atom> {
817        Some(
818            atom.replace(self.pat.as_ref()?.as_view().to_pattern())
819                .with(function!(GS.selected, &self.pat.as_ref()?.0))
820                .replace(function!(GS.orientation_delta, W_.a___))
821                .min_level(0)
822                .max_level(Some(0))
823                .with(Atom::Zero),
824        )
825    }
826
827    pub fn filter<O: GraphOrientation>(&self, orientation: &O) -> bool {
828        if let Some(pat) = &self.pat {
829            let a = orientation.orientation_delta();
830
831            // println!("{a}vs{}", pat.0);
832
833            // println!("{a}");
834            a.pattern_match(&pat.to_pattern(), None, None)
835                .next()
836                .is_some()
837        } else {
838            true
839        }
840    }
841
842    pub fn alt_filter<O: GraphOrientation>(&self, orientation: &O) -> bool {
843        self.filter(orientation)
844    }
845}
846
847impl OrientationSelector for OrientationPattern {
848    fn filter_orientation(&self, orientation: &EdgeVec<Orientation>) -> bool {
849        self.filter(orientation)
850    }
851}
852
853#[cfg(test)]
854mod orientation_pattern_tests {
855    use super::OrientationPattern;
856    use linnet::half_edge::involution::{EdgeVec, Orientation};
857    use symbolica::atom::AtomCore;
858
859    fn orientation(value: i8) -> Orientation {
860        match value {
861            1 => Orientation::Default,
862            -1 => Orientation::Reversed,
863            0 => Orientation::Undirected,
864            _ => panic!("invalid orientation encoding"),
865        }
866    }
867
868    fn edgevec(values: impl IntoIterator<Item = i8>) -> EdgeVec<Orientation> {
869        EdgeVec::from_iter(values.into_iter().map(orientation))
870    }
871
872    #[test]
873    fn orientation_pattern_deserialization_supports_shorthand_and_missing_wrapper() {
874        let wrapped: OrientationPattern =
875            toml::from_str(r#"pat = "orientation_delta(+,-,0)""#).unwrap();
876        let tuple_only: OrientationPattern = toml::from_str(r#"pat = "(+,-,0)""#).unwrap();
877        let bare_args: OrientationPattern = toml::from_str(r#"pat = "+,-,0""#).unwrap();
878
879        assert_eq!(wrapped, tuple_only);
880        assert_eq!(wrapped, bare_args);
881        assert_eq!(
882            wrapped.pat.as_ref().unwrap().to_string(),
883            "orientation_delta(1,-1,0)"
884        );
885        assert!(wrapped.filter(&edgevec([1, -1, 0])));
886        assert!(!wrapped.filter(&edgevec([1, 1, 0])));
887    }
888
889    #[test]
890    fn orientation_pattern_repeated_wildcards_enforce_identical_bindings() {
891        let pattern: OrientationPattern =
892            toml::from_str(r#"pat = "(+,+,-,x_,-,x_,+,0,+,y_,-,+)""#).unwrap();
893
894        assert!(pattern.filter(&edgevec([1, 1, -1, 1, -1, 1, 1, 0, 1, 0, -1, 1])));
895        assert!(pattern.filter(&edgevec([1, 1, -1, 0, -1, 0, 1, 0, 1, -1, -1, 1])));
896        assert!(!pattern.filter(&edgevec([1, 1, -1, 1, -1, 0, 1, 0, 1, 0, -1, 1])));
897        assert!(pattern.alt_filter(&edgevec([1, 1, -1, 1, -1, 1, 1, 0, 1, 0, -1, 1])));
898    }
899
900    #[test]
901    fn orientation_pattern_accepts_canonical_namespaced_roundtrip() {
902        let original = OrientationPattern::from_user_pattern("(+,-,0)").unwrap();
903        let canonical = original.pat.as_ref().unwrap().0.to_canonical_string();
904        let reparsed = OrientationPattern::from_user_pattern(&canonical).unwrap();
905
906        assert_eq!(original, reparsed);
907        assert!(reparsed.filter(&edgevec([1, -1, 0])));
908    }
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
912#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
913#[serde(default, deny_unknown_fields)]
914pub struct Parallelisation {
915    /// Worker threads used by Feynman-graph generation.
916    #[serde(skip_serializing_if = "is_usize::<1>")]
917    pub feyngen: usize,
918    /// Worker threads used to construct symbolic graph evaluators.
919    #[serde(skip_serializing_if = "is_usize::<1>")]
920    pub generate: usize,
921    /// Worker threads used for evaluator compilation.
922    #[serde(skip_serializing_if = "is_usize::<1>")]
923    pub compile: usize,
924    /// Worker threads used by numerical integration.
925    #[serde(skip_serializing_if = "is_usize::<1>")]
926    pub integrate: usize,
927}
928
929impl Default for Parallelisation {
930    fn default() -> Self {
931        Self {
932            feyngen: 1,
933            generate: 1,
934            compile: 1,
935            integrate: 1,
936        }
937    }
938}
939
940#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object, get_all, set_all))]
941#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
942#[trait_decode(trait = GammaLoopContext)]
943#[serde(default, deny_unknown_fields)]
944pub struct MediumSettings {
945    /// `vacuum`, `thermodynamic_equilibrium` at `runtime.general.inverse_temperature`, or its
946    /// `zero_temperature_equilibrium` limit; equilibrium modes use the model's chemical potentials.
947    #[serde(skip_serializing_if = "IsDefault::is_default")]
948    pub mode: MediumMode,
949    /// Subtract each medium weight's vacuum limit, keeping only the medium-dependent part;
950    /// requires a non-vacuum mode.
951    #[serde(skip_serializing_if = "is_false")]
952    pub vacuum_subtraction: bool,
953}
954
955impl Default for MediumSettings {
956    fn default() -> Self {
957        Self {
958            mode: MediumMode::Vacuum,
959            vacuum_subtraction: false,
960        }
961    }
962}