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 #[serde(skip_serializing_if = "IsDefault::is_default")]
36 pub evaluator: EvaluatorSettings,
37 #[serde(skip_serializing_if = "IsDefault::is_default")]
39 pub feyngen: FeyGenSettings,
40
41 #[serde(skip_serializing_if = "IsDefault::is_default")]
43 pub orientation_pattern: OrientationPattern,
44 #[serde(skip_serializing_if = "IsDefault::is_default")]
46 pub uniform_numerator_sampling_scale: UniformNumeratorSamplingScale,
47 #[serde(skip_serializing_if = "IsDefault::is_default")]
49 pub compile: GammaloopCompileOptions,
50 #[serde(skip_serializing_if = "IsDefault::is_default")]
52 pub tropical_subgraph_table: TropicalSubgraphTableSettings,
53 #[serde(skip_serializing_if = "IsDefault::is_default")]
55 pub threshold_subtraction: ThresholdSubtractionSettings,
56 #[serde(skip_serializing_if = "IsDefault::is_default")]
58 pub vector_polarization_sum_gauge: VectorPolarizationSumGauge,
59 #[serde(skip_serializing_if = "IsDefault::is_default")]
61 pub uv: UVgenerationSettings,
62 #[serde(skip_serializing_if = "IsDefault::is_default")]
64 pub force_cuts: Vec<Vec<String>>,
65 #[serde(skip_serializing_if = "is_false")]
67 pub override_lmb_heuristics: bool,
68 #[serde(skip_serializing_if = "is_false")]
70 pub explicit_orientation_sum_only: bool,
71 #[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 #[serde(skip_serializing_if = "is_true")]
274 pub enable_thresholds: bool,
275 #[serde(skip_serializing_if = "is_false")]
277 pub check_esurface_at_generation: bool,
278 #[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 #[serde(skip_serializing_if = "is_true")]
288 pub skip_thresholds_that_are_cuts: bool,
289 #[serde(skip_serializing_if = "is_false")]
291 pub disable_integrated_ct: bool,
292 #[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 #[serde(skip_serializing_if = "IsDefault::is_default")]
420 pub optimization_level: CompilationOptimizationLevel,
421 #[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 #[serde(skip_serializing_if = "IsDefault::is_default")]
544 pub compilation_mode: CompilationMode,
545
546 #[serde(skip_serializing_if = "IsDefault::is_default")]
549 pub optimization_level: CompilationOptimizationLevel,
550
551 #[serde(skip_serializing_if = "is_true")]
557 pub jit_direct_translation: bool,
558
559 #[serde(skip_serializing_if = "is_true")]
561 pub fast_math: bool,
562
563 #[serde(skip_serializing_if = "is_true")] pub unsafe_math: bool,
566
567 #[serde(skip_serializing_if = "is_default_external_compiler")] pub compiler: String,
570 #[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 #[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 #[serde(skip_serializing_if = "is_false")]
647 pub panic_on_fail: bool,
648 #[serde(skip_serializing_if = "is_float::<1>")] pub target_omega: f64,
651 #[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 #[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 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 #[serde(skip_serializing_if = "is_usize::<1>")]
917 pub feyngen: usize,
918 #[serde(skip_serializing_if = "is_usize::<1>")]
920 pub generate: usize,
921 #[serde(skip_serializing_if = "is_usize::<1>")]
923 pub compile: usize,
924 #[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 #[serde(skip_serializing_if = "IsDefault::is_default")]
948 pub mode: MediumMode,
949 #[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}