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 schemars::JsonSchema;
6use serde::{Deserialize, Deserializer, Serialize};
7use symbolica::prelude::*;
8
9use crate::{
10    GammaLoopContext,
11    cff::orientations::GraphOrientation,
12    processes::EvaluatorSettings,
13    utils::{
14        DEFAULT_ESURFACE_EXISTENCE_THRESHOLD, GS, W_,
15        serde_utils::{
16            IsDefault, deserialize_nonnegative_finite_f64, is_default_esurface_existence_threshold,
17            is_false, is_float, is_true, is_usize, show_defaults_helper,
18        },
19        symbolica_ext::StringSerializedAtom,
20    },
21    uv::UVgenerationSettings,
22};
23
24#[cfg_attr(
25    feature = "python_api",
26    pyo3::pyclass(from_py_object, get_all, set_all)
27)]
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    /// Code-generation backend, compiler, and optimization flags.
45    #[serde(skip_serializing_if = "IsDefault::is_default")]
46    pub compile: GammaloopCompileOptions,
47    /// Controls construction of tropical subgraph power-counting tables.
48    #[serde(skip_serializing_if = "IsDefault::is_default")]
49    pub tropical_subgraph_table: TropicalSubgraphTableSettings,
50    /// Generation-time selection of local threshold counterterms.
51    #[serde(skip_serializing_if = "IsDefault::is_default")]
52    pub threshold_subtraction: ThresholdSubtractionSettings,
53    /// Gauge used when summing external vector-particle polarizations.
54    #[serde(skip_serializing_if = "IsDefault::is_default")]
55    pub vector_polarization_sum_gauge: VectorPolarizationSumGauge,
56    /// Ultraviolet counterterm generation, prescription, and Vakint backend settings.
57    #[serde(skip_serializing_if = "IsDefault::is_default")]
58    pub uv: UVgenerationSettings,
59    /// Explicit particle-name sets forced to define graph cuts.
60    #[serde(skip_serializing_if = "IsDefault::is_default")]
61    pub force_cuts: Vec<Vec<String>>,
62    /// Use explicitly supplied loop-momentum bases instead of the built-in basis heuristics.
63    #[serde(skip_serializing_if = "is_false")]
64    pub override_lmb_heuristics: bool,
65}
66
67#[cfg_attr(
68    feature = "python_api",
69    pyo3::pyclass(from_py_object, get_all, set_all)
70)]
71#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
72#[trait_decode(trait = GammaLoopContext)]
73#[serde(default, deny_unknown_fields)]
74pub struct ThresholdSubtractionSettings {
75    /// Generate local threshold counterterms for eligible E-surfaces.
76    #[serde(skip_serializing_if = "is_true")]
77    pub enable_thresholds: bool,
78    /// Test E-surface existence against the active model while generating counterterms.
79    #[serde(skip_serializing_if = "is_false")]
80    pub check_esurface_at_generation: bool,
81    /// Dimensionless tolerance used to compare the energy-squared E-surface invariant margin
82    /// against `esurface_existence_threshold * E_cm^2` during generation-time checks.
83    #[serde(
84        deserialize_with = "deserialize_nonnegative_finite_f64",
85        skip_serializing_if = "is_default_esurface_existence_threshold"
86    )]
87    #[schemars(range(min = 0.0))]
88    pub esurface_existence_threshold: f64,
89    /// Omit threshold counterterms whose E-surface coincides with a physical cut.
90    #[serde(skip_serializing_if = "is_true")]
91    pub skip_thresholds_that_are_cuts: bool,
92    /// Generate local threshold counterterms without their integrated counterparts.
93    #[serde(skip_serializing_if = "is_false")]
94    pub disable_integrated_ct: bool,
95    /// Simplify existence tests by assuming all external-state energies are positive.
96    #[serde(skip_serializing_if = "is_true")]
97    pub assume_positive_external_energies: bool,
98}
99
100impl Default for ThresholdSubtractionSettings {
101    fn default() -> Self {
102        Self {
103            enable_thresholds: true,
104            check_esurface_at_generation: false,
105            esurface_existence_threshold: DEFAULT_ESURFACE_EXISTENCE_THRESHOLD,
106            skip_thresholds_that_are_cuts: true,
107            disable_integrated_ct: false,
108            assume_positive_external_energies: true,
109        }
110    }
111}
112
113#[derive(
114    Debug, Clone, Copy, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema, Default,
115)]
116#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
117pub enum VectorPolarizationSumGauge {
118    #[serde(rename = "Feynman", alias = "feynman")]
119    Feynman,
120    #[default]
121    #[serde(rename = "LightLikeAxial", alias = "light_like_axial")]
122    LightLikeAxial,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, Copy, JsonSchema)]
126#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
127#[serde(deny_unknown_fields)]
128#[derive(Default)]
129pub enum CompilationOptimizationLevel {
130    O0,
131    O1,
132    #[default]
133    O2,
134    O3,
135}
136
137impl From<CompilationOptimizationLevel> for usize {
138    fn from(value: CompilationOptimizationLevel) -> Self {
139        match value {
140            CompilationOptimizationLevel::O0 => 0,
141            CompilationOptimizationLevel::O1 => 1,
142            CompilationOptimizationLevel::O2 => 2,
143            CompilationOptimizationLevel::O3 => 3,
144        }
145    }
146}
147
148impl fmt::Display for CompilationOptimizationLevel {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            CompilationOptimizationLevel::O0 => f.write_str("O0"),
152            CompilationOptimizationLevel::O1 => f.write_str("O1"),
153            CompilationOptimizationLevel::O2 => f.write_str("O2"),
154            CompilationOptimizationLevel::O3 => f.write_str("O3"),
155        }
156    }
157}
158
159pub const fn yes() -> bool {
160    true
161}
162
163#[cfg(target_vendor = "apple")]
164pub const fn default_external_compiler() -> &'static str {
165    "clang++"
166}
167
168#[cfg(not(target_vendor = "apple"))]
169pub const fn default_external_compiler() -> &'static str {
170    "g++"
171}
172
173pub fn default_external_compiler_owned() -> String {
174    default_external_compiler().to_owned()
175}
176
177pub fn is_default_external_compiler(compiler: &str) -> bool {
178    show_defaults_helper(default_external_compiler() == compiler)
179}
180
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema)]
182#[cfg_attr(
183    feature = "python_api",
184    pyo3::pyclass(from_py_object, get_all, set_all)
185)]
186#[derive(Default)]
187pub enum CompilationMode {
188    #[serde(rename = "c++", alias = "cpp")]
189    Cpp,
190    #[serde(rename = "assembly")]
191    Assembly,
192    #[default]
193    #[serde(rename = "symjit")]
194    Symjit,
195}
196
197impl fmt::Display for CompilationMode {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        match self {
200            CompilationMode::Cpp => f.write_str("c++"),
201            CompilationMode::Assembly => f.write_str("assembly"),
202            CompilationMode::Symjit => f.write_str("symjit"),
203        }
204    }
205}
206
207#[derive(
208    Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema, Default,
209)]
210#[cfg_attr(
211    feature = "python_api",
212    pyo3::pyclass(from_py_object, get_all, set_all)
213)]
214#[serde(default, deny_unknown_fields)]
215pub struct ExternalCompilationOptionsSnapshot {
216    #[serde(skip_serializing_if = "IsDefault::is_default")]
217    pub optimization_level: CompilationOptimizationLevel,
218    #[serde(skip_serializing_if = "is_true")]
219    pub fast_math: bool,
220    #[serde(skip_serializing_if = "is_true")]
221    pub unsafe_math: bool,
222    #[serde(skip_serializing_if = "is_default_external_compiler")]
223    pub compiler: String,
224    #[serde(skip_serializing_if = "IsDefault::is_default")]
225    pub custom: Vec<String>,
226}
227
228impl fmt::Display for ExternalCompilationOptionsSnapshot {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        write!(
231            f,
232            "{} fast_math={} unsafe_math={} compiler={} custom={}",
233            self.optimization_level,
234            self.fast_math,
235            self.unsafe_math,
236            self.compiler,
237            if self.custom.is_empty() {
238                "[]".to_string()
239            } else {
240                format!("[{}]", self.custom.join(", "))
241            }
242        )
243    }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema)]
247pub enum FrozenCompilationMode {
248    Eager,
249    Symjit(CompilationOptimizationLevel),
250    Cpp(ExternalCompilationOptionsSnapshot),
251    Assembly(ExternalCompilationOptionsSnapshot),
252}
253
254impl FrozenCompilationMode {
255    pub fn compile_enabled(&self) -> bool {
256        !matches!(self, FrozenCompilationMode::Eager)
257    }
258
259    pub fn active_backend_name(&self) -> &'static str {
260        match self {
261            FrozenCompilationMode::Eager => "eager",
262            FrozenCompilationMode::Symjit(_) => "symjit",
263            FrozenCompilationMode::Cpp(_) => "c++",
264            FrozenCompilationMode::Assembly(_) => "assembly",
265        }
266    }
267
268    pub fn external_options(&self) -> Option<&ExternalCompilationOptionsSnapshot> {
269        match self {
270            FrozenCompilationMode::Cpp(options) | FrozenCompilationMode::Assembly(options) => {
271                Some(options)
272            }
273            FrozenCompilationMode::Eager | FrozenCompilationMode::Symjit(_) => None,
274        }
275    }
276
277    pub fn requires_external_compilation(&self) -> bool {
278        matches!(
279            self,
280            FrozenCompilationMode::Cpp(_) | FrozenCompilationMode::Assembly(_)
281        )
282    }
283
284    pub(crate) fn export_settings(&self) -> ExportSettings {
285        ExportSettings::new().inline_asm(match self {
286            FrozenCompilationMode::Assembly(_) => InlineASM::default(),
287            FrozenCompilationMode::Cpp(_)
288            | FrozenCompilationMode::Symjit(_)
289            | FrozenCompilationMode::Eager => InlineASM::None,
290        })
291    }
292
293    pub fn to_symbolica_compile_options(&self) -> Option<CompileOptions> {
294        let options = self.external_options()?;
295        Some(
296            CompileOptions::new()
297                .optimization_level(options.optimization_level.into())
298                .fast_math(options.fast_math)
299                .unsafe_math(options.unsafe_math)
300                .compiler(options.compiler.clone())
301                .args(options.custom.clone()),
302        )
303    }
304}
305
306impl fmt::Display for FrozenCompilationMode {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        match self {
309            FrozenCompilationMode::Eager => f.write_str("eager"),
310            FrozenCompilationMode::Symjit(level) => write!(f, "symjit ({level})"),
311            FrozenCompilationMode::Cpp(options) => write!(f, "c++ ({options})"),
312            FrozenCompilationMode::Assembly(options) => write!(f, "assembly ({options})"),
313        }
314    }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
318#[cfg_attr(
319    feature = "python_api",
320    pyo3::pyclass(from_py_object, get_all, set_all)
321)]
322#[serde(default, deny_unknown_fields)]
323pub struct GammaloopCompileOptions {
324    /// Evaluator backend: Symbolica JIT, generated C++, or generated assembly.
325    #[serde(skip_serializing_if = "IsDefault::is_default")]
326    pub compilation_mode: CompilationMode,
327
328    /// Optimization level passed to the selected evaluator compiler.
329    #[serde(skip_serializing_if = "IsDefault::is_default")]
330    pub optimization_level: CompilationOptimizationLevel,
331
332    /// Permit algebraic floating-point transformations that ignore strict IEEE ordering.
333    #[serde(skip_serializing_if = "is_true")]
334    pub fast_math: bool,
335
336    /// Permit compiler math optimizations that may change exceptional-value behavior.
337    #[serde(skip_serializing_if = "is_true")] // default true
338    pub unsafe_math: bool,
339
340    /// External C++ compiler executable used by C++ and assembly modes.
341    #[serde(skip_serializing_if = "is_default_external_compiler")] // default compiler
342    pub compiler: String,
343    /// Additional command-line arguments passed verbatim to the external compiler.
344    #[serde(skip_serializing_if = "IsDefault::is_default")]
345    pub custom: Vec<String>,
346}
347
348impl Default for GammaloopCompileOptions {
349    fn default() -> Self {
350        Self {
351            compilation_mode: CompilationMode::Symjit,
352            optimization_level: CompilationOptimizationLevel::O2,
353            fast_math: true,
354            unsafe_math: true,
355            compiler: default_external_compiler_owned(),
356            custom: vec![],
357        }
358    }
359}
360
361impl GammaloopCompileOptions {
362    pub fn external_options_snapshot(&self) -> ExternalCompilationOptionsSnapshot {
363        ExternalCompilationOptionsSnapshot {
364            optimization_level: self.optimization_level,
365            fast_math: self.fast_math,
366            unsafe_math: self.unsafe_math,
367            compiler: self.compiler.clone(),
368            custom: self.custom.clone(),
369        }
370    }
371
372    pub fn requires_external_compilation(&self) -> bool {
373        matches!(
374            self.compilation_mode,
375            CompilationMode::Cpp | CompilationMode::Assembly
376        )
377    }
378
379    pub fn frozen_mode(&self, evaluator_settings: &EvaluatorSettings) -> FrozenCompilationMode {
380        if !evaluator_settings.compile {
381            return FrozenCompilationMode::Eager;
382        }
383
384        match self.compilation_mode {
385            CompilationMode::Cpp => FrozenCompilationMode::Cpp(self.external_options_snapshot()),
386            CompilationMode::Assembly => {
387                FrozenCompilationMode::Assembly(self.external_options_snapshot())
388            }
389            CompilationMode::Symjit => FrozenCompilationMode::Symjit(self.optimization_level),
390        }
391    }
392
393    pub fn to_symbolica_compile_options(&self) -> CompileOptions {
394        CompileOptions::new()
395            .optimization_level(self.optimization_level.into())
396            .fast_math(self.fast_math)
397            .unsafe_math(self.unsafe_math)
398            .compiler(self.compiler.clone())
399            .args(self.custom.clone())
400    }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
404#[cfg_attr(
405    feature = "python_api",
406    pyo3::pyclass(from_py_object, get_all, set_all)
407)]
408#[serde(default, deny_unknown_fields)]
409#[derive(Default)]
410pub struct FeyGenSettings {
411    /// Check that gamma-matrix simplification is closed under the generated rewrite sequence.
412    #[serde(skip_serializing_if = "is_false")]
413    pub gamma_simplification_closure_check: bool,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
417#[cfg_attr(
418    feature = "python_api",
419    pyo3::pyclass(from_py_object, get_all, set_all)
420)]
421#[serde(default, deny_unknown_fields)]
422pub struct TropicalSubgraphTableSettings {
423    /// Abort generation instead of reporting and skipping a failed tropical table construction.
424    #[serde(skip_serializing_if = "is_false")]
425    pub panic_on_fail: bool,
426    /// Target superficial degree used when classifying tropical subgraphs.
427    #[serde(skip_serializing_if = "is_float::<1>")] // default 1.0
428    pub target_omega: f64,
429    /// Disable tropical subgraph table construction entirely.
430    #[serde(skip_serializing_if = "is_false")]
431    pub disable_tropical_generation: bool,
432}
433
434impl Default for TropicalSubgraphTableSettings {
435    fn default() -> Self {
436        Self {
437            panic_on_fail: false,
438            target_omega: 1.0,
439            disable_tropical_generation: false,
440        }
441    }
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, Default, PartialEq, JsonSchema)]
445#[trait_decode(trait = GammaLoopContext)]
446#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
447#[serde(default, deny_unknown_fields)]
448pub struct OrientationPattern {
449    /// Symbolica pattern matched against serialized causal orientations.
450    #[serde(
451        default,
452        skip_serializing_if = "IsDefault::is_default",
453        deserialize_with = "deserialize_orientation_pattern_atom"
454    )]
455    pub pat: Option<StringSerializedAtom>,
456}
457
458fn deserialize_orientation_pattern_atom<'de, D>(
459    deserializer: D,
460) -> std::result::Result<Option<StringSerializedAtom>, D::Error>
461where
462    D: Deserializer<'de>,
463{
464    let raw = Option::<String>::deserialize(deserializer)?;
465    raw.map(|value| OrientationPattern::parse_user_pattern(&value))
466        .transpose()
467        .map(|parsed| parsed.map(StringSerializedAtom))
468        .map_err(serde::de::Error::custom)
469}
470
471impl From<Atom> for OrientationPattern {
472    fn from(value: Atom) -> Self {
473        OrientationPattern {
474            pat: Some(StringSerializedAtom(value)),
475        }
476    }
477}
478
479impl OrientationPattern {
480    fn is_orientation_delta_atom(atom: &Atom) -> bool {
481        matches!(
482            atom.as_view(),
483            AtomView::Fun(function)
484                if function.get_symbol().get_stripped_name() == "orientation_delta"
485        )
486    }
487
488    fn split_top_level_args(input: &str) -> EyreResult<Vec<String>> {
489        let mut args = Vec::new();
490        let mut start = 0usize;
491        let mut depth = 0usize;
492
493        for (index, ch) in input.char_indices() {
494            match ch {
495                '(' | '[' | '{' => depth += 1,
496                ')' | ']' | '}' => {
497                    if depth == 0 {
498                        return Err(eyre!(
499                            "Unbalanced delimiter in orientation pattern: {input}"
500                        ));
501                    }
502                    depth -= 1;
503                }
504                ',' if depth == 0 => {
505                    let arg = input[start..index].trim();
506                    if arg.is_empty() {
507                        return Err(eyre!("Empty orientation-pattern entry in pattern: {input}"));
508                    }
509                    args.push(arg.to_string());
510                    start = index + ch.len_utf8();
511                }
512                _ => {}
513            }
514        }
515
516        if depth != 0 {
517            return Err(eyre!(
518                "Unbalanced delimiter in orientation pattern: {input}"
519            ));
520        }
521
522        let tail = input[start..].trim();
523        if tail.is_empty() {
524            if args.is_empty() {
525                return Err(eyre!("Orientation pattern cannot be empty"));
526            }
527            return Err(eyre!("Empty orientation-pattern entry in pattern: {input}"));
528        }
529        args.push(tail.to_string());
530
531        Ok(args)
532    }
533
534    fn normalize_user_pattern(pattern: &str) -> EyreResult<String> {
535        let trimmed = pattern.trim();
536        if trimmed.is_empty() {
537            return Err(eyre!("Orientation pattern cannot be empty"));
538        }
539
540        let args = if let Some(rest) = trimmed.strip_prefix("orientation_delta") {
541            let rest = rest.trim();
542            if !(rest.starts_with('(') && rest.ends_with(')')) {
543                return Err(eyre!(
544                    "orientation_delta patterns must use parentheses, got: {pattern}"
545                ));
546            }
547            Self::split_top_level_args(&rest[1..rest.len() - 1])?
548        } else if trimmed.starts_with('(') && trimmed.ends_with(')') {
549            Self::split_top_level_args(&trimmed[1..trimmed.len() - 1])?
550        } else {
551            Self::split_top_level_args(trimmed)?
552        };
553
554        let normalized_args = args
555            .into_iter()
556            .map(|arg| match arg.as_str() {
557                "+" | "+1" => "1".to_string(),
558                "-" | "-1" => "-1".to_string(),
559                _ => arg,
560            })
561            .collect::<Vec<_>>()
562            .join(",");
563
564        Ok(format!("orientation_delta({normalized_args})"))
565    }
566
567    pub fn parse_user_pattern(pattern: &str) -> EyreResult<Atom> {
568        let trimmed = pattern.trim();
569        if trimmed.is_empty() {
570            return Err(eyre!("Orientation pattern cannot be empty"));
571        }
572
573        if let Ok(parsed) = try_parse!(trimmed)
574            && Self::is_orientation_delta_atom(&parsed)
575        {
576            return Ok(parsed);
577        }
578
579        let normalized = Self::normalize_user_pattern(pattern)?;
580        try_parse!(normalized.as_str())
581            .map_err(|error| eyre!("Symbolica parsing error for orientation pattern: {error}"))
582    }
583
584    pub fn from_user_pattern(pattern: &str) -> EyreResult<Self> {
585        Ok(Self {
586            pat: Some(StringSerializedAtom(Self::parse_user_pattern(pattern)?)),
587        })
588    }
589
590    pub fn from_orientation<O: GraphOrientation>(orientation: &O) -> Self {
591        orientation.orientation_delta().into()
592    }
593
594    pub fn select_pattern(&self, atom: impl AtomCore) -> Option<Atom> {
595        Some(
596            atom.replace(self.pat.as_ref()?.as_view().to_pattern())
597                .with(function!(GS.selected, &self.pat.as_ref()?.0))
598                .replace(function!(GS.orientation_delta, W_.a___))
599                .level_range((0, Some(0)))
600                .with(Atom::Zero),
601        )
602    }
603
604    pub fn filter<O: GraphOrientation>(&self, orientation: &O) -> bool {
605        if let Some(pat) = &self.pat {
606            let a = orientation.orientation_delta();
607
608            // println!("{a}vs{}", pat.0);
609
610            // println!("{a}");
611            a.pattern_match(&pat.to_pattern(), None, None)
612                .next()
613                .is_some()
614        } else {
615            true
616        }
617    }
618
619    pub fn alt_filter<O: GraphOrientation>(&self, orientation: &O) -> bool {
620        self.filter(orientation)
621    }
622}
623
624#[cfg(test)]
625mod orientation_pattern_tests {
626    use super::OrientationPattern;
627    use linnet::half_edge::involution::{EdgeVec, Orientation};
628    use symbolica::atom::AtomCore;
629
630    fn orientation(value: i8) -> Orientation {
631        match value {
632            1 => Orientation::Default,
633            -1 => Orientation::Reversed,
634            0 => Orientation::Undirected,
635            _ => panic!("invalid orientation encoding"),
636        }
637    }
638
639    fn edgevec(values: impl IntoIterator<Item = i8>) -> EdgeVec<Orientation> {
640        EdgeVec::from_iter(values.into_iter().map(orientation))
641    }
642
643    #[test]
644    fn orientation_pattern_deserialization_supports_shorthand_and_missing_wrapper() {
645        let wrapped: OrientationPattern =
646            toml::from_str(r#"pat = "orientation_delta(+,-,0)""#).unwrap();
647        let tuple_only: OrientationPattern = toml::from_str(r#"pat = "(+,-,0)""#).unwrap();
648        let bare_args: OrientationPattern = toml::from_str(r#"pat = "+,-,0""#).unwrap();
649
650        assert_eq!(wrapped, tuple_only);
651        assert_eq!(wrapped, bare_args);
652        assert_eq!(
653            wrapped.pat.as_ref().unwrap().to_string(),
654            "orientation_delta(1,-1,0)"
655        );
656        assert!(wrapped.filter(&edgevec([1, -1, 0])));
657        assert!(!wrapped.filter(&edgevec([1, 1, 0])));
658    }
659
660    #[test]
661    fn orientation_pattern_repeated_wildcards_enforce_identical_bindings() {
662        let pattern: OrientationPattern =
663            toml::from_str(r#"pat = "(+,+,-,x_,-,x_,+,0,+,y_,-,+)""#).unwrap();
664
665        assert!(pattern.filter(&edgevec([1, 1, -1, 1, -1, 1, 1, 0, 1, 0, -1, 1])));
666        assert!(pattern.filter(&edgevec([1, 1, -1, 0, -1, 0, 1, 0, 1, -1, -1, 1])));
667        assert!(!pattern.filter(&edgevec([1, 1, -1, 1, -1, 0, 1, 0, 1, 0, -1, 1])));
668        assert!(pattern.alt_filter(&edgevec([1, 1, -1, 1, -1, 1, 1, 0, 1, 0, -1, 1])));
669    }
670
671    #[test]
672    fn orientation_pattern_accepts_canonical_namespaced_roundtrip() {
673        let original = OrientationPattern::from_user_pattern("(+,-,0)").unwrap();
674        let canonical = original.pat.as_ref().unwrap().0.to_canonical_string();
675        let reparsed = OrientationPattern::from_user_pattern(&canonical).unwrap();
676
677        assert_eq!(original, reparsed);
678        assert!(reparsed.filter(&edgevec([1, -1, 0])));
679    }
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
683#[cfg_attr(
684    feature = "python_api",
685    pyo3::pyclass(from_py_object, get_all, set_all)
686)]
687#[serde(default, deny_unknown_fields)]
688pub struct Parallelisation {
689    /// Worker threads used by Feynman-graph generation.
690    #[serde(skip_serializing_if = "is_usize::<1>")]
691    pub feyngen: usize,
692    /// Worker threads used to construct symbolic graph evaluators.
693    #[serde(skip_serializing_if = "is_usize::<1>")]
694    pub generate: usize,
695    /// Worker threads used for evaluator compilation.
696    #[serde(skip_serializing_if = "is_usize::<1>")]
697    pub compile: usize,
698    /// Worker threads used by numerical integration.
699    #[serde(skip_serializing_if = "is_usize::<1>")]
700    pub integrate: usize,
701}
702
703impl Default for Parallelisation {
704    fn default() -> Self {
705        Self {
706            feyngen: 1,
707            generate: 1,
708            compile: 1,
709            integrate: 1,
710        }
711    }
712}