Skip to main content

gammaloop_api/commands/
generate.rs

1// file: src/cmd/generate.rs
2#![allow(clippy::too_many_arguments)]
3
4use ahash::HashMap;
5use color_eyre::owo_colors::OwoColorize;
6use tracing::info;
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::ffi::OsStr;
10use std::fs;
11use std::num::ParseIntError;
12use std::ops::RangeInclusive;
13use std::path::Path;
14use std::str::FromStr;
15
16use clap::{Args, Parser, Subcommand, ValueEnum};
17use color_eyre::Result;
18use regex::Regex;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use symbolica::parse;
22use tabled::{
23    builder::Builder,
24    settings::{style::HorizontalLine, themes::Theme, Style},
25};
26use thiserror::Error;
27use walkdir::WalkDir;
28
29use eyre::{eyre, Context};
30use gammalooprs::feyngen::{
31    FeynGenFilter, FeynGenFilters, GenerationType, GraphGroupingOptions,
32    NumeratorAwareGraphGroupingOption, SelfEnergyFilterOptions, SewedFilterOptions,
33    SnailFilterOptions, TadpolesFilterOptions,
34};
35use gammalooprs::model::Model;
36use gammalooprs::numerator::GlobalPrefactor;
37use gammalooprs::processes::amplitude::Amplitude;
38use gammalooprs::processes::{
39    merge_generated_graph_reports, CrossSection, GeneratedGraphReport, GraphGenerationStats,
40    Process, ProcessDefinition, ProcessList,
41};
42use gammalooprs::settings::{GlobalSettings, RuntimeSettings};
43
44use crate::commands::set::KvPair;
45use crate::completion::CompletionArgExt;
46use crate::state::{GenerationResourceSummary, ProcessRef, State};
47
48// =================== CLI containers (kept close to your structure) ===================
49
50#[derive(Debug, Parser, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
51/// Generate cross-section or amplitude integrands from a process specification.
52pub struct Generate {
53    /// Keep generated C++ source files after external compilation
54    #[arg(long = "keep-sources", default_value_t = false, global = true)]
55    #[serde(default)]
56    pub keep_sources: bool,
57    /// Selects cross-section, amplitude, or existing-process generation; when
58    /// omitted, generates all integrands in the active state.
59    #[command(subcommand)]
60    pub mode: Option<GenerateCmd>,
61}
62
63#[derive(Debug, Subcommand, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
64/// Select cross-section, amplitude, or existing-process integrand generation.
65pub enum GenerateCmd {
66    /// Generate a cross-section integrand through the forward-scattering construction.
67    Xs(SpecArgs),
68
69    /// Generate an amplitude integrand for the supplied process specification.
70    #[command(alias = "amplitude")]
71    Amp(SpecArgs),
72
73    /// Create another integrand from an existing process without regenerating its graphs.
74    Existing(ProcessArgs),
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, ValueEnum)]
78pub enum GroupingChoice {
79    #[clap(name = "no_grouping")]
80    NoGrouping,
81    #[clap(name = "only_detect_zeroes")]
82    OnlyDetectZeroes,
83    #[clap(name = "group_identical_graphs_up_to_sign")]
84    GroupIdenticalGraphsUpToSign,
85    #[clap(name = "group_identical_graphs_up_to_scalar_rescaling")]
86    GroupIdenticalGraphsUpToScalarRescaling,
87}
88
89impl GroupingChoice {
90    fn to_strategy(
91        self,
92        seed: Option<u16>,
93        num_samples: Option<usize>,
94        differentiate_particle_masses_only: Option<bool>,
95        fully_numerical_substitution_when_comparing_numerators: Option<bool>,
96        test_canonized_numerator: Option<bool>,
97        symmetric_polarizations: Option<bool>,
98    ) -> NumeratorAwareGraphGroupingOption {
99        let mut graph_grouping_options = GraphGroupingOptions::default();
100        if let Some(seed) = seed {
101            graph_grouping_options.numerical_sample_seed = seed;
102        }
103        if let Some(num_samples) = num_samples {
104            graph_grouping_options.number_of_numerical_samples = num_samples;
105        }
106        if let Some(differentiate_particle_masses_only) = differentiate_particle_masses_only {
107            graph_grouping_options.differentiate_particle_masses_only =
108                differentiate_particle_masses_only;
109        }
110        if let Some(test_canonized_numerator) = test_canonized_numerator {
111            graph_grouping_options.test_canonized_numerator = test_canonized_numerator;
112        }
113        if let Some(fully_numerical_substitution_when_comparing_numerators) =
114            fully_numerical_substitution_when_comparing_numerators
115        {
116            graph_grouping_options.fully_numerical_substitution_when_comparing_numerators =
117                fully_numerical_substitution_when_comparing_numerators;
118        }
119        if let Some(symmetric_polarizations) = symmetric_polarizations {
120            graph_grouping_options.symmetric_polarizations = symmetric_polarizations;
121        }
122        match self {
123            GroupingChoice::GroupIdenticalGraphsUpToScalarRescaling => {
124                NumeratorAwareGraphGroupingOption::GroupIdenticalGraphUpToScalarRescaling(
125                    graph_grouping_options,
126                )
127            }
128            GroupingChoice::GroupIdenticalGraphsUpToSign => {
129                NumeratorAwareGraphGroupingOption::GroupIdenticalGraphUpToSign(
130                    graph_grouping_options,
131                )
132            }
133            GroupingChoice::NoGrouping => NumeratorAwareGraphGroupingOption::NoGrouping,
134            GroupingChoice::OnlyDetectZeroes => NumeratorAwareGraphGroupingOption::OnlyDetectZeroes,
135        }
136    }
137}
138
139#[derive(Args, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
140pub struct SpecArgs {
141    /// Quote-free spec.
142    ///
143    /// Grammar (unquoted): `INITIAL to FINAL [process_options] [generation_options]`
144    /// Also accepts: `INITIAL > FINAL [ ... ]`
145    ///
146    /// Examples:
147    ///   e+ e- to d d~ g veto u d xs QED^2==2 QCD^2<=4 pert loops=1 fsloops=2 QCD=2
148    ///   e+ e- > { z z, a a } [ {1} {{2}} QCD=2 QED=1 ] / u d | g ghG QED==2 QCD>=2 QCD<=4
149    ///
150    /// Notes:
151    /// - `veto` and `only` are synonyms for `/` and `|`.
152    /// - The `[ ... ]` block holds perturbative spec: `{n}`, `{{n}}`, and orders `QCD=n` with shorthand `QCD`≡1.
153    #[arg(value_name = "TOKENS", num_args = 1..)]
154    pub tokens: Vec<String>,
155
156    // --------- Generation options that influence FeynGenOptions ---------
157    // Number of threads
158    //#[arg(short = 'n', long = "num-threads")]
159    //pub num_threads: Option<u32>, deprectaed in favor of global parallelisation settings
160    /// Append the generated process instead of replacing the current process selection.
161    #[arg(short = 'a', default_value_t = false)]
162    pub append: bool,
163
164    /// Clear pre-existing processes
165    #[arg(
166        long = "clear-existing-processes",
167        short = 'c',
168        alias = "clear",
169        default_value_t = false
170    )]
171    pub clear_existing_processes: bool,
172
173    /// Optional human-readable process name
174    #[arg(long = "process-name", short = 'p')]
175    pub process_name: Option<String>,
176
177    /// Name assigned to the generated integrand; defaults to the generated process name.
178    #[arg(
179        long = "integrand-name",
180        short = 'i',
181        completion_disable_special_value()
182    )]
183    pub integrand_name: Option<String>,
184
185    /// Stop after diagram generation without constructing an evaluable integrand.
186    #[arg(long = "only-diagrams", short = 'o', default_value_t = false)]
187    pub only_diagrams: bool,
188
189    /// Include or exclude self-energy topologies; omission uses the generation-mode default.
190    #[arg(long = "filter-selfenergies")]
191    pub filter_selfenergies: Option<bool>,
192    /// Include or exclude snail topologies; omission uses the generation-mode default.
193    #[arg(long = "filter-snails")]
194    pub filter_snails: Option<bool>,
195    /// Include or exclude tadpole topologies; omission uses the generation-mode default.
196    #[arg(long = "filter-tadpoles")]
197    pub filter_tadpoles: Option<bool>,
198
199    /// Exclude graphs containing any listed vertex-interaction name
200    #[arg(long = "veto-vertex-interactions", num_args = 0..)]
201    pub veto_vertex_interactions: Option<Vec<String>>,
202
203    /// Restrict generation to an allow-list of vertex interactions
204    #[arg(long = "allowed-vertex-interactions", num_args = 0..)]
205    pub allowed_vertex_interactions: Option<Vec<String>>,
206
207    /// Filter tadpoles formed while sewing amplitudes into a cross-section graph.
208    #[arg(long = "filter-cross-section-tadpoles")]
209    pub filter_cross_section_tadpoles: Option<bool>,
210
211    /// Veto tadpoles attached to massive propagators.
212    #[arg(long = "veto-tadpoles-attached-to-massive-lines")]
213    pub veto_tadpoles_attached_to_massive_lines: Option<bool>,
214    /// Veto tadpoles attached to massless propagators.
215    #[arg(long = "veto-tadpoles-attached-to-massless-lines")]
216    pub veto_tadpoles_attached_to_massless_lines: Option<bool>,
217    /// Veto only tadpoles that are scaleless.
218    #[arg(long = "veto-only-scaleless-tadpoles")]
219    pub veto_only_scaleless_tadpoles: Option<bool>,
220
221    /// Veto snail insertions attached to massive propagators.
222    #[arg(long = "veto-snails-attached-to-massive-lines")]
223    pub veto_snails_attached_to_massive_lines: Option<bool>,
224    /// Veto snail insertions attached to massless propagators.
225    #[arg(long = "veto-snails-attached-to-massless-lines")]
226    pub veto_snails_attached_to_massless_lines: Option<bool>,
227    /// Veto only snail insertions that are scaleless.
228    #[arg(long = "veto-only-scaleless-snails")]
229    pub veto_only_scaleless_snails: Option<bool>,
230
231    /// Veto self-energy insertions on massive propagators.
232    #[arg(long = "veto-self-energy-of-massive-lines")]
233    pub veto_self_energy_of_massive_lines: Option<bool>,
234    /// Veto self-energy insertions on massless propagators.
235    #[arg(long = "veto-self-energy-of-massless-lines")]
236    pub veto_self_energy_of_massless_lines: Option<bool>,
237    /// Veto only self-energy insertions that are scaleless.
238    #[arg(long = "veto-only-scaleless-self-energy")]
239    pub veto_only_scaleless_self_energy: Option<bool>,
240
241    /// Maximum number of bridges allowed in a generated graph; negative values disable the bound.
242    #[arg(long = "max-n-bridges", short = 'b', allow_negative_numbers = true)]
243    pub max_n_bridges: Option<i32>,
244    /// Inclusive minimum and maximum number of factorized loop subtopologies.
245    #[arg(
246        long = "number-of-factorized-loop-subtopologies",
247        short = 'f',
248        alias = "nfactl",
249        num_args = 2,
250        allow_negative_numbers = true
251    )]
252    pub number_of_factorized_loop_subtopologies: Option<Vec<i32>>,
253    /// Number of closed fermion loops; negative disables
254    #[arg(
255        long = "number-of-fermion-loops",
256        short = 'L',
257        num_args = 2,
258        allow_negative_numbers = true
259    )]
260    pub number_of_fermion_loops: Option<Vec<i32>>,
261
262    /// Inclusive minimum and maximum number of cut blobs on either side of a cross-section cut.
263    #[arg(long = "n-cut-blobs", short = 'B', num_args = 2)]
264    pub n_cut_blobs: Option<Vec<usize>>,
265    /// Inclusive minimum and maximum number of spectators crossing a cross-section cut.
266    #[arg(long = "n-cut-spectators", short = 'S', num_args = 2)]
267    pub n_cut_spectators: Option<Vec<usize>>,
268
269    /// Permit external fermion permutations when identifying equivalent amplitudes.
270    #[arg(
271        long = "allow-symmetrization-of-external-fermions-in-amplitudes",
272        alias = "symferm"
273    )]
274    pub allow_symmetrization_of_external_fermions_in_amplitudes: Option<bool>,
275    /// Identify graphs related by permutations of identical initial-state particles.
276    #[arg(long = "symmetrize-initial-states")]
277    pub symmetrize_initial_states: Option<bool>,
278    /// Identify graphs related by permutations of identical final-state particles.
279    #[arg(long = "symmetrize-final-states")]
280    pub symmetrize_final_states: Option<bool>,
281    /// Identify cross-section graphs related by exchanging the left and right amplitudes.
282    #[arg(long = "symmetrize-left-right-states")]
283    pub symmetrize_left_right_states: Option<bool>,
284
285    /// Numerator-aware grouping choice
286    #[arg(long = "numerator-grouping", short = 'G', value_enum)]
287    pub numerator_aware_isomorphism_grouping: Option<GroupingChoice>,
288
289    /// Deterministic random seed used for numerical numerator comparisons.
290    #[arg(long = "numerical-samples-seed")]
291    pub numerical_samples_seed: Option<u16>,
292    /// Number of phase-space samples used to decide whether two numerators are equivalent.
293    #[arg(long = "number-of-samples-for-numerator-comparisons")]
294    pub number_of_samples_for_numerator_comparisons: Option<usize>,
295    /// Distinguish mass parameters while ignoring other model-parameter labels during grouping.
296    #[arg(long = "consider-internal-masses-only-in-numerator-isomorphisms")]
297    pub consider_internal_masses_only_in_numerator_isomorphisms: Option<bool>,
298    /// Numerically substitute every parameter when comparing candidate numerator isomorphisms.
299    #[arg(long = "fully-numerical-substitution-when-comparing-numerators")]
300    pub fully_numerical_substitution_when_comparing_numerators: Option<bool>,
301    /// Compare canonicalized numerators in addition to graph topology when grouping.
302    #[arg(long = "compare-canonized-numerator")]
303    pub compare_canonized_numerator: Option<bool>,
304    /// Treat left- and right-side polarization factors symmetrically during numerator grouping.
305    #[arg(long = "symmetric-left-right-polarizations")]
306    pub symmetric_left_right_polarizations: Option<bool>,
307
308    /// Graph processing toggles
309    ///
310    /// Format:
311    ///   --loop-momentum-bases "GL_12=7,10 GL_77=4,2"
312    ///   --select-graphs "GL_12 GL_13"
313    ///   --veto-graphs "GL_11 GL_15"
314    #[arg(long = "loop-momentum-bases", value_name = "KEY=VALUE", num_args = 0.., value_parser = KvPair::from_str)]
315    pub loop_momentum_bases: Option<Vec<KvPair>>,
316    /// Generate only graphs whose names appear in this allow-list.
317    #[arg(long = "select-graphs", num_args = 0..)]
318    pub select_graphs: Option<Vec<String>>,
319    /// Exclude graphs whose names appear in this deny-list.
320    #[arg(long = "veto-graphs", num_args = 0..)]
321    pub veto_graphs: Option<Vec<String>>,
322    /// Prefix assigned to generated graph names; defaults to `GL`
323    #[arg(long = "graph-prefix", short = 'g')]
324    pub graph_prefix: Option<String>,
325
326    /// Global prefactor projector (Symbolica atom string)
327    #[arg(
328        long = "global-prefactor-projector",
329        value_name = "ATOM",
330        allow_hyphen_values = true
331    )]
332    pub global_prefactor_projector: Option<String>,
333
334    /// Global prefactor numerator (Symbolica atom string)
335    #[arg(
336        long = "global-prefactor-num",
337        value_name = "ATOM",
338        allow_hyphen_values = true
339    )]
340    pub global_prefactor_num: Option<String>,
341
342    /// Fast cut filter switch multiplicity
343    #[arg(
344        long = "max-multiplicity-for-fast-cut-filter",
345        short = 'M',
346        default_value_t = 6usize
347    )]
348    pub max_multiplicity_for_fast_cut_filter: usize,
349
350    /// Filter graph-theoretic self-loops explicitly; omission keeps them.
351    #[arg(long = "filter-self-loop")]
352    pub filter_self_loop: Option<bool>,
353
354    /// Filter edges that carry zero momentum flow in the selected routing.
355    #[arg(long = "filter-zero-flow-edges")]
356    pub filter_zero_flow_edges: Option<bool>,
357}
358
359// =================== Runner ===================
360
361fn format_generation_duration(duration: std::time::Duration) -> String {
362    if duration.as_secs() >= 60 {
363        let minutes = duration.as_secs() / 60;
364        let seconds = duration.as_secs_f64() - (minutes * 60) as f64;
365        format!("{minutes}m {seconds:.1}s")
366    } else if duration.as_secs_f64() >= 1.0 {
367        format!("{:.2}s", duration.as_secs_f64())
368    } else {
369        format!("{}ms", duration.as_millis())
370    }
371}
372
373fn format_generation_fraction(
374    numerator: std::time::Duration,
375    total: std::time::Duration,
376) -> String {
377    let percent = if total.is_zero() {
378        0.0
379    } else {
380        (numerator.as_secs_f64() / total.as_secs_f64()) * 100.0
381    };
382
383    if percent >= 10.0 {
384        format!("{percent:.0}%")
385    } else if percent >= 1.0 {
386        format!("{percent:.1}%")
387    } else {
388        format!("{percent:.2}%")
389    }
390}
391
392fn format_generation_memory(bytes: u64) -> String {
393    const KIB: f64 = 1024.0;
394    const MIB: f64 = KIB * 1024.0;
395    const GIB: f64 = MIB * 1024.0;
396    const TIB: f64 = GIB * 1024.0;
397
398    let value = bytes as f64;
399    if value < KIB {
400        format!("{bytes} B")
401    } else if value < MIB {
402        format!("{:.2} KiB", value / KIB)
403    } else if value < GIB {
404        format!("{:.2} MiB", value / MIB)
405    } else if value < TIB {
406        format!("{:.2} GiB", value / GIB)
407    } else {
408        format!("{:.2} TiB", value / TIB)
409    }
410}
411
412pub(crate) fn render_generation_summary(
413    reports: &[GeneratedGraphReport],
414    peak_ram_bytes: u64,
415    generation_cores: Option<usize>,
416    title: Option<&str>,
417) -> Option<String> {
418    if reports.is_empty() {
419        return None;
420    }
421
422    let mut sorted_reports = reports.to_vec();
423    sorted_reports.sort_by(|left, right| {
424        (
425            left.process_id,
426            left.integrand_name.as_str(),
427            left.graph_name.as_str(),
428        )
429            .cmp(&(
430                right.process_id,
431                right.integrand_name.as_str(),
432                right.graph_name.as_str(),
433            ))
434    });
435
436    let mut builder = Builder::new();
437    builder.push_record([
438        "integrand".bold().blue().to_string(),
439        "graph".bold().blue().to_string(),
440        "# evals".bold().blue().to_string(),
441        "expr build".bold().blue().to_string(),
442        "spenso".bold().blue().to_string(),
443        "symbolica eval".bold().blue().to_string(),
444        "compile".bold().blue().to_string(),
445    ]);
446
447    let mut total_stats = GraphGenerationStats::default();
448    for report in &sorted_reports {
449        total_stats.merge_in_place(&report.stats);
450        let expr_time = report.stats.expression_build_time();
451        let total_time = report.stats.total_time;
452        let expr_value = format!(
453            "{} ({})",
454            format_generation_duration(expr_time).magenta(),
455            format_generation_fraction(expr_time, total_time).cyan()
456        );
457        let spenso_value = format!(
458            "{} ({})",
459            format_generation_duration(report.stats.evaluator_spenso_time).magenta(),
460            format_generation_fraction(report.stats.evaluator_spenso_time, total_time).cyan()
461        );
462        let symbolica_value = format!(
463            "{} ({})",
464            format_generation_duration(report.stats.evaluator_symbolica_time).magenta(),
465            format_generation_fraction(report.stats.evaluator_symbolica_time, total_time).cyan()
466        );
467        let compile_value = format!(
468            "{} ({})",
469            format_generation_duration(report.stats.evaluator_compile_time).magenta(),
470            format_generation_fraction(report.stats.evaluator_compile_time, total_time).cyan()
471        );
472
473        builder.push_record([
474            report.integrand_name.yellow().to_string(),
475            report.graph_name.yellow().to_string(),
476            report
477                .stats
478                .evaluator_count
479                .to_string()
480                .yellow()
481                .to_string(),
482            expr_value,
483            spenso_value,
484            symbolica_value,
485            compile_value,
486        ]);
487    }
488
489    let total_time = total_stats.total_time;
490    let total_expr_time = total_stats.expression_build_time();
491    let total_expr_value = format!(
492        "{} ({})",
493        format_generation_duration(total_expr_time).magenta(),
494        format_generation_fraction(total_expr_time, total_time).cyan()
495    );
496    let total_spenso_value = format!(
497        "{} ({})",
498        format_generation_duration(total_stats.evaluator_spenso_time).magenta(),
499        format_generation_fraction(total_stats.evaluator_spenso_time, total_time).cyan()
500    );
501    let total_symbolica_value = format!(
502        "{} ({})",
503        format_generation_duration(total_stats.evaluator_symbolica_time).magenta(),
504        format_generation_fraction(total_stats.evaluator_symbolica_time, total_time).cyan()
505    );
506    let total_compile_value = format!(
507        "{} ({})",
508        format_generation_duration(total_stats.evaluator_compile_time).magenta(),
509        format_generation_fraction(total_stats.evaluator_compile_time, total_time).cyan()
510    );
511    builder.push_record([
512        "Total".bold().yellow().to_string(),
513        String::new(),
514        total_stats
515            .evaluator_count
516            .to_string()
517            .bold()
518            .yellow()
519            .to_string(),
520        total_expr_value,
521        total_spenso_value,
522        total_symbolica_value,
523        total_compile_value,
524    ]);
525
526    let mut table = builder.build();
527    let mut style = Theme::from_style(Style::rounded());
528    style.insert_horizontal_line(
529        sorted_reports.len() + 1,
530        HorizontalLine::inherit(Style::modern()),
531    );
532    table.with(style);
533    let mut sections = Vec::new();
534    if let Some(title) = title {
535        sections.push(title.bold().blue().to_string());
536    }
537    let resources = match generation_cores {
538        Some(cores) => {
539            format!(
540                "{} {} | {} {}",
541                "peak RAM".bold().blue(),
542                format_generation_memory(peak_ram_bytes).yellow(),
543                "cores".bold().blue(),
544                cores.to_string().yellow(),
545            )
546        }
547        None => format!(
548            "{} {}",
549            "peak RAM".bold().blue(),
550            format_generation_memory(peak_ram_bytes).yellow(),
551        ),
552    };
553    sections.push(resources);
554    sections.push(table.to_string());
555    Some(sections.join("\n"))
556}
557
558fn log_generation_summary_table(
559    reports: &[GeneratedGraphReport],
560    resources: GenerationResourceSummary,
561) {
562    if let Some(summary) = render_generation_summary(
563        reports,
564        resources.peak_ram_bytes,
565        Some(resources.generation_cores),
566        Some("Integrand generation summary"),
567    ) {
568        info!("\n{summary}");
569    }
570}
571
572fn remove_compiled_cpp_sources(root: &Path) -> Result<usize> {
573    let mut removed_count = 0usize;
574    for entry in WalkDir::new(root) {
575        let entry = entry?;
576        if !entry.file_type().is_file() {
577            continue;
578        }
579
580        let source_path = entry.path();
581        if source_path.extension() != Some(OsStr::new("cpp")) {
582            continue;
583        }
584
585        let library_path = source_path.with_extension("so");
586        if !library_path.is_file() {
587            continue;
588        }
589
590        fs::remove_file(source_path).with_context(|| {
591            format!(
592                "Trying to remove generated C++ source {} after compiling {}",
593                source_path.display(),
594                library_path.display()
595            )
596        })?;
597        removed_count += 1;
598    }
599
600    Ok(removed_count)
601}
602
603fn compile_integrands_for_generation(
604    state: &mut State,
605    compile_folder: &Path,
606    override_existing_compiled: bool,
607    global_settings: &GlobalSettings,
608    process_id: Option<usize>,
609    integrand_name: Option<String>,
610    keep_sources: bool,
611) -> Result<Vec<GeneratedGraphReport>> {
612    let reports = state.compile_integrands(
613        compile_folder,
614        override_existing_compiled,
615        global_settings,
616        process_id,
617        integrand_name,
618    )?;
619
620    if !keep_sources {
621        let removed_count = remove_compiled_cpp_sources(compile_folder)?;
622        if removed_count > 0 {
623            info!(
624                "Removed {} generated C++ source file(s). Use '{}' to keep them.",
625                removed_count,
626                "--keep-sources".green()
627            );
628        }
629    }
630
631    Ok(reports)
632}
633
634fn finish_generation(
635    state: &mut State,
636    reports: &[GeneratedGraphReport],
637    resources: GenerationResourceSummary,
638) {
639    state.record_generation_summary(reports, resources);
640    log_generation_summary_table(reports, resources);
641}
642
643impl Generate {
644    pub fn run(
645        &self,
646        state: &mut State,
647        compile_folder: impl AsRef<Path>,
648        override_existing_compiled: bool,
649        global_settings: &GlobalSettings,
650        runtime_settings: &RuntimeSettings,
651    ) -> Result<()> {
652        let compile_folder = compile_folder.as_ref();
653        let generation_mode = match &self.mode {
654            Some(GenerateCmd::Xs(a)) => Some((GenerationType::CrossSection, a)),
655            Some(GenerateCmd::Amp(a)) => Some((GenerationType::Amplitude, a)),
656            _ => None,
657        };
658        if let Some((_, args)) = generation_mode.as_ref() {
659            if !state.process_list.processes.is_empty() && args.clear_existing_processes {
660                info!(
661                    "Clearing all {} existing processes as requested.",
662                    state.process_list.processes.len()
663                );
664                state.process_list = ProcessList::default();
665            }
666        }
667        let generation_info = if let Some((gen_mode, args)) = generation_mode {
668            let mut spec = parse_spec_with_model(args, gen_mode, &state.model)?;
669            spec.process_definition.process_id = state.process_list.processes.len();
670
671            let mut existing_process = None;
672            if let Some(ep) = state
673                .process_list
674                .processes
675                .iter_mut()
676                .find(|p| p.definition.folder_name == spec.process_definition.folder_name)
677            {
678                if ep.definition != spec.process_definition {
679                    if !args.append {
680                        return Err(eyre!(
681                            "Process with name '{}' already exists.\n> Use 'existing' subcommand to continue generation of this process.\n> Use '--clear-existing-processes' to remove all existing ones.\n> Or specify a different process name with '--process-name <chosen_process_name>'.",
682                            spec.process_definition.folder_name
683                        ));
684                    }
685                } else {
686                    info!(
687                        "Identical process definition, with name '{}', already exists. Gammaloop will recycle it.",
688                        spec.process_definition.folder_name
689                    );
690                    return Ok(());
691                }
692                spec.process_definition.process_id = ep.definition.process_id;
693                existing_process = Some(ep);
694            }
695            Some((spec, existing_process))
696        } else {
697            None
698        };
699        match &self.mode {
700            Some(GenerateCmd::Amp(args)) | Some(GenerateCmd::Xs(args)) => {
701                let generation_type = generation_mode.as_ref().unwrap().0;
702                let model: &Model = &state.model;
703                let (spec, existing_process) = generation_info.unwrap();
704                let this_process_id = spec.process_definition.process_id;
705                // TODO handle existing process and continue
706                let graphs = spec.process_definition.generate(model, global_settings)?;
707                info!(
708                    "Generated {} {} graphs.",
709                    if matches!(self.mode, Some(GenerateCmd::Amp(_))) {
710                        "amplitude"
711                    } else {
712                        "cross-section"
713                    },
714                    graphs.len()
715                );
716                // Keep the possibility of changing default name for the two modes
717                let integrand_base_name = matches!(self.mode, Some(GenerateCmd::Amp(_)))
718                    .then(|| args.integrand_name.clone().unwrap_or("default".to_string()))
719                    .unwrap_or_else(|| {
720                        args.integrand_name.clone().unwrap_or("default".to_string())
721                    });
722                let generated_integrand_name = if let Some(p) = existing_process {
723                    let existing_names = p.collection.get_integrand_names();
724                    let integrand_name = if existing_names.contains(&integrand_base_name.as_str()) {
725                        let mut integrand_i = 0;
726                        while existing_names
727                            .iter()
728                            .any(|ce| *ce == format!("{}_{}", integrand_base_name, integrand_i))
729                        {
730                            integrand_i += 1;
731                        }
732                        format!("{}_{}", integrand_base_name, integrand_i)
733                    } else {
734                        integrand_base_name
735                    };
736                    match &self.mode {
737                        Some(GenerateCmd::Amp(_)) => {
738                            p.collection.add_amplitude(Amplitude::from_graph_list(
739                                integrand_name.clone(),
740                                graphs,
741                            )?);
742                        }
743                        Some(GenerateCmd::Xs(_)) => {
744                            p.collection
745                                .add_cross_section(CrossSection::from_graph_list(
746                                    integrand_name.clone(),
747                                    graphs,
748                                    model,
749                                )?);
750                        }
751                        _ => unreachable!(),
752                    }
753                    integrand_name
754                } else {
755                    let process = Process::from_graph_list(
756                        spec.process_definition.folder_name.clone(),
757                        integrand_base_name.clone(),
758                        graphs,
759                        generation_type,
760                        Some(spec.process_definition),
761                        None,
762                        model,
763                    )?;
764                    state.process_list.add_process(process);
765                    integrand_base_name
766                };
767                if !args.only_diagrams {
768                    let generated_integrand_name_for_compile = generated_integrand_name.clone();
769                    let generation = state.generate_integrand(
770                        global_settings,
771                        runtime_settings.into(),
772                        this_process_id,
773                        Some(generated_integrand_name),
774                    )?;
775                    let mut reports = generation.reports;
776                    if global_settings.generation.evaluator.compile
777                        && global_settings
778                            .generation
779                            .compile
780                            .requires_external_compilation()
781                    {
782                        merge_generated_graph_reports(
783                            &mut reports,
784                            compile_integrands_for_generation(
785                                state,
786                                compile_folder,
787                                override_existing_compiled,
788                                global_settings,
789                                Some(this_process_id),
790                                Some(generated_integrand_name_for_compile),
791                                self.keep_sources,
792                            )?,
793                        );
794                    }
795                    finish_generation(state, &reports, generation.resources);
796                    Ok(())
797                } else {
798                    info!(
799                        "Only diagram generation was requested, skipping integrand generation. You can generate integrands later using the '{}' command.",
800                        "generate existing <options>".green()
801                    );
802                    Ok(())
803                }
804            }
805            Some(GenerateCmd::Existing(process_args)) => {
806                let process_id = state.resolve_process_ref(process_args.process.as_ref())?;
807
808                let generation = state.generate_integrand(
809                    global_settings,
810                    runtime_settings.into(),
811                    process_id,
812                    process_args.integrand_name.clone(),
813                )?;
814                let mut reports = generation.reports;
815                if global_settings.generation.evaluator.compile
816                    && global_settings
817                        .generation
818                        .compile
819                        .requires_external_compilation()
820                {
821                    merge_generated_graph_reports(
822                        &mut reports,
823                        compile_integrands_for_generation(
824                            state,
825                            compile_folder,
826                            override_existing_compiled,
827                            global_settings,
828                            Some(process_id),
829                            process_args.integrand_name.clone(),
830                            self.keep_sources,
831                        )?,
832                    );
833                }
834                finish_generation(state, &reports, generation.resources);
835                Ok(())
836            }
837            None => {
838                let generation =
839                    state.generate_integrands(global_settings, runtime_settings.into())?;
840                let mut reports = generation.reports;
841                if global_settings.generation.evaluator.compile
842                    && global_settings
843                        .generation
844                        .compile
845                        .requires_external_compilation()
846                {
847                    merge_generated_graph_reports(
848                        &mut reports,
849                        compile_integrands_for_generation(
850                            state,
851                            compile_folder,
852                            override_existing_compiled,
853                            global_settings,
854                            None,
855                            None,
856                            self.keep_sources,
857                        )?,
858                    );
859                }
860                finish_generation(state, &reports, generation.resources);
861                Ok(())
862            }
863        }
864    }
865}
866
867#[derive(Args, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
868pub struct ProcessArgs {
869    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
870    #[arg(
871        long = "process",
872        short = 'p',
873        value_name = "PROCESS",
874        completion_process_selector(crate::completion::SelectorKind::Any)
875    )]
876    pub process: Option<ProcessRef>,
877
878    /// Optional human-readable integrand name used to disambiguate the process
879    #[arg(
880        long = "integrand-name",
881        short = 'i',
882        completion_integrand_selector(crate::completion::SelectorKind::Any)
883    )]
884    pub integrand_name: Option<String>,
885}
886
887// =================== Domain structs ===================
888
889#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
890pub struct OrderRange {
891    pub eq: Option<u32>,
892    pub min: Option<u32>,
893    pub max: Option<u32>,
894}
895impl OrderRange {
896    fn set(&mut self, op: &str, v: u32) {
897        match op {
898            "==" => self.eq = Some(v),
899            ">=" => self.min = Some(v),
900            "<=" => self.max = Some(v),
901            _ => {}
902        }
903    }
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)]
907pub struct CouplingKey {
908    pub name: String,
909    pub power: u32, // parsed, but dropped when building FeynGenFilter::CouplingOrders
910}
911
912#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
913pub struct Perturbative {
914    /// {n}
915    pub loops_sum_amp_or_sum: Option<u32>,
916    /// {{n}}
917    pub loops_forward_graph: Option<u32>,
918    /// QCD=2, QED=1; shorthand "QCD"≡1
919    pub orders: BTreeMap<String, u32>,
920}
921
922#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
923pub struct ProcessSpec {
924    pub initial: Vec<String>,
925    pub final_: Vec<String>,
926    pub empty_initial: bool,
927    pub empty_final: bool,
928
929    /// `/ ...`
930    pub veto: BTreeSet<String>,
931    /// `| ...`
932    pub only: Option<BTreeSet<String>>,
933
934    /// AMP amplitude coupling ranges: QED==2, QCD>=2, QCD<=4
935    pub amp_couplings: BTreeMap<String, OrderRange>,
936    /// XS graph-level coupling ranges: QED^2==2, QCD^2<=4  (power is dropped when building filters)
937    pub xs_couplings: BTreeMap<CouplingKey, OrderRange>,
938
939    /// For example, `[{1} {{2}} QCD=2 QED=1]` or `[QCD]`.
940    pub pert: Perturbative,
941
942    /// XS-only: final-state alternative sets `{ A, B, ... }`
943    pub final_sets: Vec<Vec<String>>,
944
945    /// Generation options captured alongside the spec
946    pub process_definition: ProcessDefinition,
947
948    /// Parsed numerator-aware grouping mode (not part of FeynGenOptions)
949    pub numerator_grouping: NumeratorAwareGraphGroupingOption,
950}
951
952impl ProcessSpec {
953    /// Canonical shell-friendly name. Deterministic and lexicographically ordered.
954    pub fn process_shell_name(&self, short: bool) -> String {
955        // sanitize within a piece so '-' separators between pieces are preserved
956        let sanitize_piece = |s: &str| s.replace('~', "x").replace('+', "p").replace('-', "m");
957        let lower = |s: &str| s.to_ascii_lowercase();
958
959        // base slug: "<init>_<final or sets joined by _or_>"
960        let init_slug = if self.initial.is_empty() {
961            "empty".to_string()
962        } else {
963            self.initial.iter().map(|p| lower(p)).collect::<String>()
964        };
965
966        let finals_lists: Vec<Vec<String>> = if self.final_sets.is_empty() {
967            vec![self.final_.clone()]
968        } else {
969            self.final_sets.clone()
970        };
971        let finals_slugs: Vec<String> = finals_lists
972            .iter()
973            .map(|lst| {
974                if lst.is_empty() {
975                    "empty".to_string()
976                } else {
977                    lst.iter().map(|p| lower(p)).collect::<String>()
978                }
979            })
980            .collect();
981
982        let mut pieces: Vec<String> = vec![sanitize_piece(&format!(
983            "{}_{}",
984            init_slug,
985            finals_slugs.join("_or_")
986        ))];
987
988        if !short {
989            // particle vetoes (sorted)
990            if !self.veto.is_empty() {
991                let mut veto_list: Vec<String> = self.veto.iter().map(|v| lower(v)).collect();
992                veto_list.sort();
993                pieces.push(sanitize_piece(&format!("no__{}", veto_list.join("_"))));
994            }
995
996            // amplitude coupling orders (sorted by coupling name)
997            if !self.amp_couplings.is_empty() {
998                let mut keys: Vec<&String> = self.amp_couplings.keys().collect();
999                keys.sort();
1000                let mut parts: Vec<String> = Vec::new();
1001                for k in keys {
1002                    let r = &self.amp_couplings[k];
1003                    if let Some(eq) = r.eq {
1004                        parts.push(format!("{}_eq_{}", k, eq));
1005                    } else {
1006                        if let Some(min) = r.min {
1007                            if min > 0 {
1008                                parts.push(format!("{}_ge_{}", k, min));
1009                            }
1010                        }
1011                        if let Some(max) = r.max {
1012                            parts.push(format!("{}_le_{}", k, max));
1013                        }
1014                    }
1015                }
1016                if !parts.is_empty() {
1017                    pieces.push(parts.join("__"));
1018                }
1019            }
1020
1021            // cross-section coupling orders (sorted by name, then power)
1022            if !self.xs_couplings.is_empty() {
1023                let mut entries: Vec<(&CouplingKey, &OrderRange)> =
1024                    self.xs_couplings.iter().collect();
1025                entries.sort_by(|(ka, _), (kb, _)| {
1026                    use std::cmp::Ordering;
1027                    match ka.name.cmp(&kb.name) {
1028                        Ordering::Equal => ka.power.cmp(&kb.power),
1029                        other => other,
1030                    }
1031                });
1032                let mut parts: Vec<String> = Vec::new();
1033                for (k, r) in entries {
1034                    let base = format!("{}sq", k.name);
1035                    if let Some(eq) = r.eq {
1036                        parts.push(format!("{}_eq_{}", base, eq));
1037                    } else {
1038                        if let Some(min) = r.min {
1039                            if min > 0 {
1040                                parts.push(format!("{}_ge_{}", base, min));
1041                            }
1042                        }
1043                        if let Some(max) = r.max {
1044                            parts.push(format!("{}_le_{}", base, max));
1045                        }
1046                    }
1047                }
1048                if !parts.is_empty() {
1049                    pieces.push(parts.join("__"));
1050                }
1051            }
1052
1053            // perturbative coupling orders (sorted by key)
1054            if !self.pert.orders.is_empty() {
1055                let mut ords: Vec<(&String, &u32)> = self.pert.orders.iter().collect();
1056                ords.sort_by(|a, b| a.0.cmp(b.0));
1057                let parts: Vec<String> = ords
1058                    .into_iter()
1059                    .map(|(k, v)| format!("{}loop_eq_{}", k, v))
1060                    .collect();
1061                if !parts.is_empty() {
1062                    pieces.push(parts.join("__"));
1063                }
1064            }
1065        }
1066
1067        pieces.join("-")
1068    }
1069
1070    /// Canonical human-readable representation. Deterministic and lexicographically ordered.
1071    pub fn repr_str(&self) -> String {
1072        let lower = |s: &str| s.to_ascii_lowercase();
1073
1074        let mut out: Vec<String> = Vec::new();
1075
1076        // initial states
1077        if self.initial.is_empty() {
1078            out.push("{}".to_string());
1079        } else {
1080            for p in &self.initial {
1081                out.push(lower(p));
1082            }
1083        }
1084
1085        // arrow
1086        out.push(">".to_string());
1087
1088        // final states or sets
1089        if self.final_sets.is_empty() {
1090            if self.final_.is_empty() {
1091                out.push("{}".to_string());
1092            } else {
1093                for p in &self.final_ {
1094                    out.push(lower(p));
1095                }
1096            }
1097        } else {
1098            // sets with deterministic inner ordering as given; outer order is as parsed
1099            let sets_str = self
1100                .final_sets
1101                .iter()
1102                .map(|set| {
1103                    if set.is_empty() {
1104                        "empty".to_string()
1105                    } else {
1106                        set.iter().map(|p| lower(p)).collect::<Vec<_>>().join(" ")
1107                    }
1108                })
1109                .collect::<Vec<_>>()
1110                .join(", ");
1111            out.push(format!("{{ {} }}", sets_str));
1112        }
1113
1114        // particle vetoes (sorted)
1115        if !self.veto.is_empty() {
1116            out.push("/".to_string());
1117            let mut veto_list: Vec<String> = self.veto.iter().map(|v| lower(v)).collect();
1118            veto_list.sort();
1119            out.extend(veto_list);
1120        }
1121
1122        // amplitude coupling orders (sorted by coupling name)
1123        if !self.amp_couplings.is_empty() {
1124            let mut keys: Vec<&String> = self.amp_couplings.keys().collect();
1125            keys.sort();
1126            for k in keys {
1127                let r = &self.amp_couplings[k];
1128                if let Some(eq) = r.eq {
1129                    out.push(format!("{}=={}", k, eq));
1130                } else {
1131                    if let Some(min) = r.min {
1132                        if min > 0 {
1133                            out.push(format!("{}>={}", k, min));
1134                        }
1135                    }
1136                    if let Some(max) = r.max {
1137                        out.push(format!("{}<={}", k, max));
1138                    }
1139                }
1140            }
1141        }
1142
1143        // perturbative block if any
1144        let has_pert_block = self.pert.loops_sum_amp_or_sum.is_some()
1145            || self.pert.loops_forward_graph.is_some()
1146            || !self.pert.orders.is_empty();
1147        if has_pert_block {
1148            out.push("[".to_string());
1149            if let Some(n) = self.pert.loops_sum_amp_or_sum {
1150                out.push(format!("{{{}}}", n));
1151            }
1152            if let Some(n) = self.pert.loops_forward_graph {
1153                out.push(format!("{{{{{}}}}}", n));
1154            }
1155            if !self.pert.orders.is_empty() {
1156                let mut ords: Vec<(&String, &u32)> = self.pert.orders.iter().collect();
1157                ords.sort_by(|a, b| a.0.cmp(b.0));
1158                for (k, v) in ords {
1159                    if *v > 0 {
1160                        if *v == 1 {
1161                            out.push(k.clone());
1162                        } else {
1163                            out.push(format!("{}={}", k, v));
1164                        }
1165                    }
1166                }
1167            }
1168            out.push("]".to_string());
1169        }
1170
1171        // cross-section coupling orders (sorted by name, then power)
1172        if !self.xs_couplings.is_empty() {
1173            let mut entries: Vec<(&CouplingKey, &OrderRange)> = self.xs_couplings.iter().collect();
1174            entries.sort_by(|(ka, _), (kb, _)| {
1175                use std::cmp::Ordering;
1176                match ka.name.cmp(&kb.name) {
1177                    Ordering::Equal => ka.power.cmp(&kb.power),
1178                    other => other,
1179                }
1180            });
1181            for (k, r) in entries {
1182                if let Some(eq) = r.eq {
1183                    out.push(format!("{}^{}=={}", k.name, k.power, eq));
1184                } else {
1185                    if let Some(min) = r.min {
1186                        if min > 0 {
1187                            out.push(format!("{}^{}>={}", k.name, k.power, min));
1188                        }
1189                    }
1190                    if let Some(max) = r.max {
1191                        out.push(format!("{}^{}<={}", k.name, k.power, max));
1192                    }
1193                }
1194            }
1195        }
1196
1197        out.join(" ")
1198    }
1199}
1200
1201// =================== Parsing ===================
1202
1203#[derive(Debug, Error, PartialEq, Eq)]
1204pub enum ParseError {
1205    #[error("expected 'to' or '>' between initial and final states")]
1206    MissingArrow,
1207    #[error("unbalanced brackets/braces")]
1208    Unbalanced,
1209    #[error("invalid token '{0}'")]
1210    InvalidToken(String),
1211    #[error("unknown particle '{name}'. Valid choices: {choices}")]
1212    UnknownParticle { name: String, choices: String },
1213    #[error("unknown coupling '{name}'. Valid choices: {choices}")]
1214    UnknownCoupling { name: String, choices: String },
1215    #[error("invalid lmb specification: {0}")]
1216    InvalidLmbSpec(ParseIntError),
1217}
1218
1219pub fn parse_spec_with_model(
1220    args: &SpecArgs,
1221    generation_type: GenerationType,
1222    model: &Model,
1223) -> std::result::Result<ProcessSpec, ParseError> {
1224    let raw = args.tokens.join(" ");
1225    let (lhs, rhs0) = split_top_level_arrow(&raw).ok_or(ParseError::MissingArrow)?;
1226    let (rhs_wo_bracket, pert) = extract_perturbative(rhs0.trim())?;
1227
1228    // LHS initial states
1229    let lhs = lhs.trim();
1230    let (initial_names, empty_initial) = if lhs == "{}" || lhs.eq_ignore_ascii_case("empty-initial")
1231    {
1232        (Vec::new(), true)
1233    } else {
1234        (split_ws(lhs), false)
1235    };
1236
1237    // Tokenize RHS outside the perturbative block
1238    let tokens = tokenize(rhs_wo_bracket)?;
1239    let (final_spec, empty_final, rest) = parse_final_states(tokens)?;
1240    let (veto_names, only, amp_couplings, xs_couplings) = parse_process_options(rest)?;
1241
1242    // Validate coupling orders against model
1243    validate_coupling_names(model, &amp_couplings, &xs_couplings, &pert)?;
1244
1245    // Convert names → PDGs using the model
1246    let initial_pdgs = resolve_pdgs(model, &initial_names)?;
1247    let primary_final_pdgs = resolve_pdgs(model, &final_spec.primary)?;
1248    let final_sets_pdgs = final_spec
1249        .sets
1250        .iter()
1251        .map(|set| resolve_pdgs(model, set))
1252        .collect::<Result<Vec<_>, _>>()?;
1253
1254    // Resolve vetoed particles to PDGs and validate they exist
1255    let mut veto_pdgs = resolve_pdgs(model, &veto_names.iter().cloned().collect::<Vec<_>>())?;
1256    if let Some(o) = only.clone() {
1257        let only_vec = o.iter().cloned().collect::<Vec<_>>();
1258        if !only_vec.is_empty() {
1259            let only_pdgs = resolve_pdgs(model, &only_vec)?
1260                .iter()
1261                .map(|pdg| pdg.abs())
1262                .collect::<Vec<_>>();
1263
1264            let all_pdgs = model
1265                .particles
1266                .iter()
1267                .map(|p| p.pdg_code.abs() as i64)
1268                .filter(|pdg| !only_pdgs.contains(pdg))
1269                .collect::<Vec<_>>();
1270
1271            veto_pdgs.extend(all_pdgs);
1272            veto_pdgs.sort_unstable();
1273            veto_pdgs.dedup();
1274        }
1275    }
1276
1277    // Build FeynGenOptions with filters and PDGs
1278    let numerator_grouping = build_grouping_option(args);
1279    let process_definition = feyngen_from_spec_args(
1280        args,
1281        generation_type,
1282        &pert,
1283        &amp_couplings,
1284        &xs_couplings,
1285        &initial_pdgs,
1286        &primary_final_pdgs,
1287        &final_sets_pdgs,
1288        &veto_pdgs,
1289    );
1290
1291    let mut spec = ProcessSpec {
1292        initial: initial_names,
1293        final_: final_spec.primary.clone(),
1294        empty_initial,
1295        empty_final,
1296        veto: veto_names,
1297        only,
1298        amp_couplings,
1299        xs_couplings,
1300        pert,
1301        final_sets: final_spec.sets,
1302        process_definition,
1303        numerator_grouping,
1304    };
1305
1306    if let Some(process_name) = &args.process_name {
1307        spec.process_definition.folder_name = process_name.clone();
1308    } else {
1309        spec.process_definition.folder_name = spec.process_shell_name(true);
1310    }
1311    spec.process_definition.numerator_grouping = spec.numerator_grouping.clone();
1312    spec.process_definition.filter_self_loop = args.filter_self_loop.unwrap_or(false);
1313    spec.process_definition.filter_zero_flow_edges = args.filter_zero_flow_edges.unwrap_or(true);
1314
1315    spec.process_definition.graph_prefix = args
1316        .graph_prefix
1317        .clone()
1318        .unwrap_or_else(|| "GL".to_string());
1319    // spec.process_definition.selected_graphs =
1320    //     args.select_graphs.as_ref().map(|s| parse_csv_list(s));
1321    // spec.process_definition.vetoed_graphs = args.veto_graphs.as_ref().map(|s| parse_csv_list(s));
1322    spec.process_definition.selected_graphs = args.select_graphs.clone();
1323    spec.process_definition.vetoed_graphs = args.veto_graphs.clone();
1324    spec.process_definition.loop_momentum_bases = args
1325        .loop_momentum_bases
1326        .as_deref()
1327        .map(|kvs| {
1328            kvs.iter()
1329                .map(|kv| {
1330                    parse_csv_list(&kv.value)
1331                        .into_iter()
1332                        .map(|s| s.parse::<usize>())
1333                        .collect::<core::result::Result<Vec<_>, _>>()
1334                        .map(|lst| (kv.key.clone(), lst))
1335                })
1336                .collect::<core::result::Result<HashMap<_, _>, _>>()
1337        })
1338        .transpose()
1339        .map_err(ParseError::InvalidLmbSpec)?;
1340    let mut prefactor = GlobalPrefactor::default();
1341    if let Some(projector) = &args.global_prefactor_projector {
1342        prefactor.projector = parse!(projector);
1343    }
1344    if let Some(num) = &args.global_prefactor_num {
1345        prefactor.num = parse!(num);
1346    }
1347    spec.process_definition.prefactor = prefactor;
1348
1349    Ok(spec)
1350}
1351
1352// ---- helpers ----
1353
1354fn split_top_level_arrow(s: &str) -> Option<(&str, &str)> {
1355    // Accept either "to" token or '>' outside any [...] or {...}
1356    let mut depth_brace = 0i32;
1357    let mut depth_bracket = 0i32;
1358    let bytes = s.as_bytes();
1359    let mut i = 0usize;
1360    while i < bytes.len() {
1361        let c = bytes[i] as char;
1362        match c {
1363            '{' => depth_brace += 1,
1364            '}' => depth_brace -= 1,
1365            '[' => depth_bracket += 1,
1366            ']' => depth_bracket -= 1,
1367            '>' if depth_brace == 0 && depth_bracket == 0 => {
1368                return Some((&s[..i], &s[i + 1..]));
1369            }
1370            _ => {
1371                if depth_brace == 0
1372                    && depth_bracket == 0
1373                    && i + 2 < bytes.len()
1374                    && (c == 't' || c == 'T')
1375                    && &s[i..].to_ascii_lowercase().as_str()[..2] == "to"
1376                {
1377                    let before = if i == 0 {
1378                        ' '
1379                    } else {
1380                        s.as_bytes()[i - 1] as char
1381                    };
1382                    let after = if i + 2 >= s.len() {
1383                        ' '
1384                    } else {
1385                        s.as_bytes()[i + 2] as char
1386                    };
1387                    if before.is_whitespace() && after.is_whitespace() {
1388                        return Some((&s[..i], &s[i + 2..]));
1389                    }
1390                }
1391            }
1392        }
1393        i += 1;
1394    }
1395    None
1396}
1397
1398fn extract_perturbative(rhs: &str) -> Result<(String, Perturbative), ParseError> {
1399    let mut out = rhs.to_string();
1400    let mut spec = Perturbative::default();
1401    if let Some((start, end)) = find_top_level_block(rhs, '[', ']') {
1402        let inside = &rhs[start + 1..end];
1403        spec = parse_perturbative_block(inside.trim())?;
1404        out.replace_range(start..=end, "");
1405    }
1406    Ok((out, spec))
1407}
1408
1409fn parse_perturbative_block(s: &str) -> Result<Perturbative, ParseError> {
1410    if s.is_empty() {
1411        return Ok(Perturbative::default());
1412    }
1413    let re_amp = Regex::new(r"^\{(\d+)\}$").unwrap();
1414    let re_fwd = Regex::new(r"^\{\{(\d+)\}\}$").unwrap();
1415    let re_order = Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*)=(\d+)$").unwrap();
1416
1417    let mut spec = Perturbative::default();
1418    for tok in split_ws(s) {
1419        if let Some(c) = re_amp.captures(&tok) {
1420            spec.loops_sum_amp_or_sum = Some(c[1].parse().unwrap());
1421            continue;
1422        }
1423        if let Some(c) = re_fwd.captures(&tok) {
1424            spec.loops_forward_graph = Some(c[1].parse().unwrap());
1425            continue;
1426        }
1427        if let Some(c) = re_order.captures(&tok) {
1428            let n: u32 = c[2].parse().unwrap();
1429            spec.orders.insert(c[1].to_string(), n);
1430            continue;
1431        }
1432        if tok.chars().all(|ch| ch.is_ascii_alphabetic()) {
1433            spec.orders.insert(tok, 1);
1434            continue;
1435        }
1436        return Err(ParseError::InvalidToken(tok));
1437    }
1438    Ok(spec)
1439}
1440
1441fn tokenize(s: String) -> Result<Vec<String>, ParseError> {
1442    let mut out = Vec::new();
1443    let mut cur = String::new();
1444    let mut depth = 0i32;
1445    for ch in s.chars() {
1446        match ch {
1447            '{' => {
1448                depth += 1;
1449                cur.push(ch);
1450            }
1451            '}' => {
1452                depth -= 1;
1453                cur.push(ch);
1454                if depth < 0 {
1455                    return Err(ParseError::Unbalanced);
1456                }
1457            }
1458            c if c.is_whitespace() && depth == 0 => {
1459                if !cur.trim().is_empty() {
1460                    out.push(cur.trim().to_string());
1461                }
1462                cur.clear();
1463            }
1464            _ => cur.push(ch),
1465        }
1466    }
1467    if !cur.trim().is_empty() {
1468        out.push(cur.trim().to_string());
1469    }
1470    if depth != 0 {
1471        return Err(ParseError::Unbalanced);
1472    }
1473    Ok(out)
1474}
1475
1476fn parse_final_states(tokens: Vec<String>) -> Result<(FinalSpec, bool, Vec<String>), ParseError> {
1477    if tokens.is_empty() {
1478        return Ok((FinalSpec::default(), false, vec![]));
1479    }
1480    let mut t = tokens;
1481    let mut finals = Vec::<String>::new();
1482    let mut empty_final = false;
1483
1484    if t[0].eq_ignore_ascii_case("empty-final") || t[0] == "{}" {
1485        empty_final = true;
1486        t.remove(0);
1487        return Ok((FinalSpec::default(), empty_final, t));
1488    }
1489
1490    if t[0].starts_with('{') {
1491        let raw = t.remove(0);
1492        let sets = parse_alt_sets(&raw)?;
1493        return Ok((
1494            FinalSpec {
1495                primary: vec![],
1496                sets,
1497            },
1498            empty_final,
1499            t,
1500        ));
1501    }
1502
1503    while !t.is_empty() {
1504        if is_option_token(&t[0]) {
1505            break;
1506        }
1507        finals.push(t.remove(0));
1508    }
1509    Ok((
1510        FinalSpec {
1511            primary: finals,
1512            sets: vec![],
1513        },
1514        empty_final,
1515        t,
1516    ))
1517}
1518
1519#[derive(Default)]
1520struct FinalSpec {
1521    primary: Vec<String>,
1522    sets: Vec<Vec<String>>,
1523}
1524
1525fn parse_alt_sets(s: &str) -> Result<Vec<Vec<String>>, ParseError> {
1526    let trimmed = s.trim();
1527    if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
1528        return Err(ParseError::InvalidToken(s.to_string()));
1529    }
1530    let inner = &trimmed[1..trimmed.len() - 1];
1531    let mut out: Vec<Vec<String>> = Vec::new();
1532    let mut cur = String::new();
1533    let mut depth = 0i32;
1534    for ch in inner.chars() {
1535        match ch {
1536            '{' => {
1537                depth += 1;
1538                cur.push(ch);
1539            }
1540            '}' => {
1541                depth -= 1;
1542                if depth < 0 {
1543                    return Err(ParseError::Unbalanced);
1544                }
1545                cur.push(ch);
1546            }
1547            ',' if depth == 0 => {
1548                let items = split_ws(cur.trim());
1549                if !items.is_empty() {
1550                    out.push(items);
1551                } else {
1552                    out.push(vec![]);
1553                }
1554                cur.clear();
1555            }
1556            _ => cur.push(ch),
1557        }
1558    }
1559    if !cur.trim().is_empty() {
1560        out.push(split_ws(cur.trim()));
1561    }
1562    Ok(out)
1563}
1564
1565fn is_option_token(tok: &str) -> bool {
1566    matches!(
1567        tok,
1568        "/" | "|" | "veto" | "only" | "amp" | "xs" | "pert" | "sets" | "empty-final"
1569    ) || tok.starts_with('[')
1570        || Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*(?:\^\d+)?(==|>=|<=)\d+$")
1571            .unwrap()
1572            .is_match(tok)
1573}
1574
1575#[allow(clippy::type_complexity)]
1576fn parse_process_options(
1577    tokens: Vec<String>,
1578) -> Result<
1579    (
1580        BTreeSet<String>,
1581        Option<BTreeSet<String>>,
1582        BTreeMap<String, OrderRange>,
1583        BTreeMap<CouplingKey, OrderRange>,
1584    ),
1585    ParseError,
1586> {
1587    let mut veto = BTreeSet::<String>::new();
1588    let mut only: Option<BTreeSet<String>> = None;
1589    let mut amp = BTreeMap::<String, OrderRange>::new();
1590    let mut xs = BTreeMap::<CouplingKey, OrderRange>::new();
1591
1592    let re = Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*)(?:\^(\d+))?(==|>=|<=)(\d+)$").unwrap();
1593
1594    let mut i = 0usize;
1595    while i < tokens.len() {
1596        match tokens[i].as_str() {
1597            "/" | "veto" => {
1598                i += 1;
1599                while i < tokens.len() && !is_option_token(&tokens[i]) {
1600                    veto.insert(tokens[i].clone());
1601                    i += 1;
1602                }
1603            }
1604            "|" | "only" => {
1605                i += 1;
1606                let mut s = only.take().unwrap_or_default();
1607                while i < tokens.len() && !is_option_token(&tokens[i]) {
1608                    s.insert(tokens[i].clone());
1609                    i += 1;
1610                }
1611                only = Some(s);
1612            }
1613            "amp" | "xs" | "pert" | "sets" | "empty-final" => {
1614                return Err(ParseError::InvalidToken(tokens[i].clone()));
1615            }
1616            other => {
1617                if let Some(c) = re.captures(other) {
1618                    let name = c[1].to_string();
1619                    let power: u32 = c.get(2).map(|m| m.as_str().parse().unwrap()).unwrap_or(1);
1620                    let op = c.get(3).unwrap().as_str();
1621                    let val: u32 = c.get(4).unwrap().as_str().parse().unwrap();
1622                    if power == 1 {
1623                        let e = amp.entry(name).or_default();
1624                        e.set(op, val);
1625                    } else {
1626                        let key = CouplingKey { name, power };
1627                        let e = xs.entry(key).or_default();
1628                        e.set(op, val);
1629                    }
1630                } else {
1631                    return Err(ParseError::InvalidToken(other.to_string()));
1632                }
1633                i += 1;
1634            }
1635        }
1636    }
1637    Ok((veto, only, amp, xs))
1638}
1639
1640fn find_top_level_block(s: &str, open: char, close: char) -> Option<(usize, usize)> {
1641    let mut depth = 0i32;
1642    let mut start: Option<usize> = None;
1643    for (i, ch) in s.char_indices() {
1644        if ch == open {
1645            if depth == 0 {
1646                start = Some(i);
1647            }
1648            depth += 1;
1649        } else if ch == close {
1650            depth -= 1;
1651            if depth == 0 {
1652                return start.map(|st| (st, i));
1653            }
1654        }
1655    }
1656    None
1657}
1658
1659fn split_ws(s: &str) -> Vec<String> {
1660    s.split_whitespace().map(|t| t.to_string()).collect()
1661}
1662
1663// =================== Build Feyngen options and filters ===================
1664
1665fn feyngen_from_spec_args(
1666    a: &SpecArgs,
1667    generation_type: GenerationType,
1668    pert: &Perturbative,
1669    amp_couplings: &BTreeMap<String, OrderRange>,
1670    xs_couplings: &BTreeMap<CouplingKey, OrderRange>,
1671    initial_pdgs: &[i64],
1672    primary_final_pdgs: &[i64],
1673    final_sets_pdgs: &[Vec<i64>],
1674    veto_pdgs: &[i64],
1675) -> ProcessDefinition {
1676    // Decide vacuum-like topology from PDGs
1677    let is_vacuum = if initial_pdgs.is_empty() {
1678        match generation_type {
1679            GenerationType::CrossSection => true,
1680            GenerationType::Amplitude => {
1681                primary_final_pdgs.is_empty()
1682                    && (final_sets_pdgs
1683                        .first()
1684                        .map(|v| v.is_empty())
1685                        .unwrap_or(true))
1686            }
1687        }
1688    } else {
1689        false
1690    };
1691
1692    // Smart defaults influenced by vacuum
1693    let filter_tadpoles_default = !is_vacuum;
1694    let filter_snails_default = !is_vacuum;
1695    let filter_selfenergies_default = !is_vacuum;
1696
1697    // Normalize filter toggles
1698    let filter_tadpoles = a.filter_tadpoles.unwrap_or(filter_tadpoles_default);
1699    let filter_snails = a.filter_snails.unwrap_or(filter_snails_default);
1700    let filter_selfenergies = a.filter_selfenergies.unwrap_or(filter_selfenergies_default);
1701
1702    // Normalize numeric toggles with the vacuum defaults
1703    let number_of_factorized_loop_subtopologies = a
1704        .number_of_factorized_loop_subtopologies
1705        .clone()
1706        .or(if is_vacuum {
1707            Some(vec![1_i32, 1_i32])
1708        } else {
1709            None
1710        })
1711        .and_then(|v| {
1712            if v[0] < 0 && v[1] < 0 {
1713                None
1714            } else {
1715                Some((v[0].max(0) as usize, v[1].max(0) as usize))
1716            }
1717        });
1718
1719    let max_n_bridges = a
1720        .max_n_bridges
1721        .or(if is_vacuum { Some(0) } else { None })
1722        .and_then(|n| if n < 0 { None } else { Some(n as usize) });
1723
1724    let number_of_fermion_loops = a
1725        .number_of_fermion_loops
1726        .as_ref()
1727        .map(|v| (v[0].max(0) as usize, v[1].max(0) as usize));
1728
1729    // Cut ranges (XS)
1730    let blob_range: RangeInclusive<usize> = {
1731        let (lo, hi) = a
1732            .n_cut_blobs
1733            .as_ref()
1734            .map(|v| (v[0], v[1]))
1735            .unwrap_or((1, 1));
1736        lo..=hi
1737    };
1738    let spectator_range: RangeInclusive<usize> = {
1739        let (lo, hi) = a
1740            .n_cut_spectators
1741            .as_ref()
1742            .map(|v| (v[0], v[1]))
1743            .unwrap_or((0, 0));
1744        lo..=hi
1745    };
1746
1747    // Symmetrization defaults depend on generation type (mirror python logic)
1748    let sym_left_right = a.symmetrize_left_right_states.unwrap_or(false);
1749    let (sym_init, sym_final) = match generation_type {
1750        GenerationType::Amplitude => {
1751            let s_init = a
1752                .symmetrize_initial_states
1753                .unwrap_or(sym_left_right /* default equal to LR */);
1754            let s_final = a
1755                .symmetrize_final_states
1756                .unwrap_or(sym_left_right /* default equal to LR */);
1757            (s_init, s_final)
1758        }
1759        GenerationType::CrossSection => {
1760            let s_init = a.symmetrize_initial_states.unwrap_or(false);
1761            let s_final = a.symmetrize_final_states.unwrap_or(true);
1762            (s_init, s_final)
1763        }
1764    };
1765    let allow_symferm = a
1766        .allow_symmetrization_of_external_fermions_in_amplitudes
1767        .unwrap_or(false);
1768
1769    // Base options
1770    let mut fg = ProcessDefinition {
1771        generation_type,
1772        initial_pdgs: initial_pdgs.to_vec(),
1773        final_pdgs_lists: if final_sets_pdgs.is_empty() {
1774            vec![primary_final_pdgs.to_vec()]
1775        } else if !primary_final_pdgs.is_empty() {
1776            let mut sets = vec![primary_final_pdgs.to_vec()];
1777            sets.extend_from_slice(final_sets_pdgs);
1778            sets
1779        } else {
1780            final_sets_pdgs.to_vec()
1781        },
1782        loop_count_range: (1, 1), // may be overridden below
1783        // (Blob/Spectator ranges are expressed through filters below)
1784        symmetrize_initial_states: sym_init,
1785        symmetrize_final_states: sym_final,
1786        symmetrize_left_right_states: sym_left_right,
1787        allow_symmetrization_of_external_fermions_in_amplitudes: allow_symferm,
1788        max_multiplicity_for_fast_cut_filter: a.max_multiplicity_for_fast_cut_filter,
1789        amplitude_filters: FeynGenFilters(vec![]),
1790        cross_section_filters: FeynGenFilters(vec![]),
1791        ..Default::default()
1792    };
1793
1794    // Build filters
1795    let mut amp_filters: Vec<FeynGenFilter> = Vec::new();
1796    let mut xs_filters: Vec<FeynGenFilter> = Vec::new();
1797
1798    // Loop counts
1799    if let Some(n) = pert.loops_sum_amp_or_sum {
1800        amp_filters.push(FeynGenFilter::LoopCountRange((n as usize, n as usize)));
1801        if fg.generation_type == GenerationType::Amplitude {
1802            fg.loop_count_range = (n as usize, n as usize);
1803        }
1804    }
1805    if let Some(n) = pert.loops_forward_graph {
1806        xs_filters.push(FeynGenFilter::LoopCountRange((n as usize, n as usize)));
1807        if fg.generation_type == GenerationType::CrossSection {
1808            fg.loop_count_range = (n as usize, n as usize);
1809        }
1810    }
1811
1812    // Perturbative orders (only to matching container)
1813    if !pert.orders.is_empty() {
1814        let map: HashMap<String, usize> = pert
1815            .orders
1816            .iter()
1817            .map(|(k, v)| (k.clone(), *v as usize))
1818            .collect();
1819        if fg.generation_type == GenerationType::Amplitude {
1820            amp_filters.push(FeynGenFilter::PerturbativeOrders(map));
1821        } else {
1822            xs_filters.push(FeynGenFilter::PerturbativeOrders(map));
1823        }
1824    }
1825
1826    // CouplingOrders
1827    if !amp_couplings.is_empty() {
1828        let cmap = coupling_orders_from_order_ranges_amp(amp_couplings);
1829        amp_filters.push(FeynGenFilter::CouplingOrders(cmap));
1830    }
1831    if !xs_couplings.is_empty() {
1832        let cmap = coupling_orders_from_order_ranges_xs(xs_couplings);
1833        xs_filters.push(FeynGenFilter::CouplingOrders(cmap));
1834    }
1835
1836    // Factorized loop topologies → FactorizedLoopTopologiesCountRange((n,n))
1837    if let Some((n_min, n_max)) = number_of_factorized_loop_subtopologies {
1838        let filt = FeynGenFilter::FactorizedLoopTopologiesCountRange((n_min, n_max));
1839        if fg.generation_type == GenerationType::Amplitude {
1840            amp_filters.push(filt);
1841        } else {
1842            xs_filters.push(filt);
1843        }
1844    }
1845
1846    // Fermion loop count → FermionLoopCountRange((n,n))
1847    if let Some((n_min, n_max)) = number_of_fermion_loops {
1848        let filt = FeynGenFilter::FermionLoopCountRange((n_min, n_max));
1849        if fg.generation_type == GenerationType::Amplitude {
1850            amp_filters.push(filt);
1851        } else {
1852            xs_filters.push(filt);
1853        }
1854    }
1855
1856    // MaxNumberOfBridges
1857    if let Some(n) = max_n_bridges {
1858        let filt = FeynGenFilter::MaxNumberOfBridges(n);
1859        if fg.generation_type == GenerationType::Amplitude {
1860            amp_filters.push(filt);
1861        } else {
1862            xs_filters.push(filt);
1863        }
1864    }
1865
1866    // Particle vetoes
1867    if !veto_pdgs.is_empty() {
1868        let filt = FeynGenFilter::ParticleVeto(veto_pdgs.to_vec());
1869        if fg.generation_type == GenerationType::Amplitude {
1870            amp_filters.push(filt);
1871        } else {
1872            xs_filters.push(filt);
1873        }
1874    }
1875
1876    // XS cut ranges
1877    if fg.generation_type == GenerationType::CrossSection {
1878        xs_filters.push(FeynGenFilter::BlobRange(blob_range));
1879        xs_filters.push(FeynGenFilter::SpectatorRange(spectator_range));
1880    }
1881
1882    // Self-energy / snails / tadpoles with detailed veto flags:
1883    if filter_selfenergies {
1884        let mut se = SelfEnergyFilterOptions::default();
1885        if let Some(opt) = a.veto_self_energy_of_massive_lines {
1886            se.veto_self_energy_of_massive_lines = opt;
1887        }
1888        if let Some(opt) = a.veto_self_energy_of_massless_lines {
1889            se.veto_self_energy_of_massless_lines = opt;
1890        }
1891        if let Some(opt) = a.veto_only_scaleless_self_energy {
1892            se.veto_only_scaleless_self_energy = opt;
1893        }
1894        let filt = FeynGenFilter::SelfEnergyFilter(se);
1895        if fg.generation_type == GenerationType::Amplitude {
1896            amp_filters.push(filt);
1897        } else {
1898            xs_filters.push(filt);
1899        }
1900    }
1901    if filter_snails {
1902        let mut sn = SnailFilterOptions::default();
1903        if let Some(opt) = a.veto_snails_attached_to_massive_lines {
1904            sn.veto_snails_attached_to_massive_lines = opt;
1905        }
1906        if let Some(opt) = a.veto_snails_attached_to_massless_lines {
1907            sn.veto_snails_attached_to_massless_lines = opt;
1908        }
1909        if let Some(opt) = a.veto_only_scaleless_snails {
1910            sn.veto_only_scaleless_snails = opt;
1911        }
1912        let filt = FeynGenFilter::ZeroSnailsFilter(sn);
1913        if fg.generation_type == GenerationType::Amplitude {
1914            amp_filters.push(filt);
1915        } else {
1916            xs_filters.push(filt);
1917        }
1918    }
1919    if filter_tadpoles {
1920        let mut td = TadpolesFilterOptions::default();
1921        if let Some(opt) = a.veto_tadpoles_attached_to_massive_lines {
1922            td.veto_tadpoles_attached_to_massive_lines = opt;
1923        }
1924        if let Some(opt) = a.veto_tadpoles_attached_to_massless_lines {
1925            td.veto_tadpoles_attached_to_massless_lines = opt;
1926        }
1927        if let Some(opt) = a.veto_only_scaleless_tadpoles {
1928            td.veto_only_scaleless_tadpoles = opt;
1929        }
1930        let filt = FeynGenFilter::TadpolesFilter(td);
1931        if fg.generation_type == GenerationType::Amplitude {
1932            amp_filters.push(filt);
1933        } else {
1934            xs_filters.push(filt);
1935        }
1936    }
1937
1938    // XS-only: sewed filter controlled separately
1939    if fg.generation_type == GenerationType::CrossSection
1940        && a.filter_cross_section_tadpoles.unwrap_or(false)
1941    {
1942        if let Some(opt) = a.filter_cross_section_tadpoles {
1943            xs_filters.push(FeynGenFilter::SewedFilter(SewedFilterOptions {
1944                filter_tadpoles: opt,
1945            }));
1946        }
1947    }
1948
1949    if let Some(vertex_interactions_allowed) = a.allowed_vertex_interactions.as_ref() {
1950        if !vertex_interactions_allowed.is_empty() {
1951            let filt = FeynGenFilter::VertexAllow(vertex_interactions_allowed.clone());
1952            if fg.generation_type == GenerationType::Amplitude {
1953                amp_filters.push(filt);
1954            } else {
1955                xs_filters.push(filt);
1956            }
1957        }
1958    }
1959    if let Some(vertex_interactions_vetoed) = a.veto_vertex_interactions.as_ref() {
1960        let filt = FeynGenFilter::VertexVeto(vertex_interactions_vetoed.clone());
1961        if fg.generation_type == GenerationType::Amplitude {
1962            amp_filters.push(filt);
1963        } else {
1964            xs_filters.push(filt);
1965        }
1966    }
1967
1968    fg.amplitude_filters = FeynGenFilters(amp_filters);
1969    fg.cross_section_filters = FeynGenFilters(xs_filters);
1970
1971    fg
1972}
1973
1974fn coupling_orders_from_order_ranges_amp(
1975    orders: &BTreeMap<String, OrderRange>,
1976) -> HashMap<String, (usize, Option<usize>)> {
1977    let mut out = HashMap::default();
1978    for (name, r) in orders {
1979        if let Some(eq) = r.eq {
1980            out.insert(name.clone(), (eq as usize, Some(eq as usize)));
1981        } else {
1982            let minv = r.min.unwrap_or(0) as usize;
1983            let maxv = r.max.map(|m| m as usize);
1984            out.insert(name.clone(), (minv, maxv));
1985        }
1986    }
1987    out
1988}
1989
1990fn coupling_orders_from_order_ranges_xs(
1991    orders: &BTreeMap<CouplingKey, OrderRange>,
1992) -> HashMap<String, (usize, Option<usize>)> {
1993    // Drop the power and merge constraints per name.
1994    let mut merged: BTreeMap<String, OrderRange> = BTreeMap::new();
1995    for (k, r) in orders {
1996        let e = merged.entry(k.name.clone()).or_default();
1997        if let Some(eq) = r.eq {
1998            e.eq = Some(eq);
1999        }
2000        if let Some(min) = r.min {
2001            e.min = Some(e.min.map_or(min, |m| m.max(min)));
2002        }
2003        if let Some(max) = r.max {
2004            e.max = Some(e.max.map_or(max, |m| m.min(max)));
2005        }
2006    }
2007    coupling_orders_from_order_ranges_amp(&merged)
2008}
2009
2010fn build_grouping_option(a: &SpecArgs) -> NumeratorAwareGraphGroupingOption {
2011    a.numerator_aware_isomorphism_grouping
2012        .unwrap_or(GroupingChoice::GroupIdenticalGraphsUpToScalarRescaling)
2013        .to_strategy(
2014            a.numerical_samples_seed,
2015            a.number_of_samples_for_numerator_comparisons,
2016            a.consider_internal_masses_only_in_numerator_isomorphisms,
2017            a.fully_numerical_substitution_when_comparing_numerators,
2018            a.compare_canonized_numerator,
2019            a.symmetric_left_right_polarizations,
2020        )
2021}
2022
2023// ---- Model-backed validation and resolution ----
2024
2025fn all_particle_names(model: &Model) -> Vec<String> {
2026    model
2027        .particles
2028        .iter()
2029        .map(|p| p.name.clone().to_string())
2030        .collect::<Vec<_>>()
2031}
2032
2033fn resolve_pdgs(model: &Model, names: &[String]) -> Result<Vec<i64>, ParseError> {
2034    let all_particles = all_particle_names(model);
2035    let mut out = Vec::with_capacity(names.len());
2036    'outer: for n in names {
2037        // Accept numeric PDG directly
2038        if let Ok(p) = n.parse::<i64>() {
2039            out.push(p);
2040            continue;
2041        }
2042        // Try exact name lookup via model
2043        if let Ok(p) = model.try_get_particle(n) {
2044            out.push(p.pdg_code as i64);
2045            continue;
2046        }
2047        // Case-insensitive fallback against both names and antinames
2048        for part in model.particles.iter() {
2049            if part.name.eq_ignore_ascii_case(n) || part.antiname.eq_ignore_ascii_case(n) {
2050                out.push(part.pdg_code as i64);
2051                continue 'outer;
2052            }
2053        }
2054        // Unknown
2055        return Err(ParseError::UnknownParticle {
2056            name: n.clone(),
2057            choices: all_particles.join(", "),
2058        });
2059    }
2060    Ok(out)
2061}
2062
2063fn validate_coupling_names(
2064    model: &Model,
2065    amp: &BTreeMap<String, OrderRange>,
2066    xs: &BTreeMap<CouplingKey, OrderRange>,
2067    pert: &Perturbative,
2068) -> Result<(), ParseError> {
2069    // If the model has no coupling metadata, skip validation entirely.
2070    let has_orders_info = !model.orders.is_empty();
2071
2072    if !has_orders_info {
2073        return Ok(());
2074    }
2075
2076    // Collect all mentioned coupling names
2077    let mut names = BTreeSet::<String>::new();
2078    for k in amp.keys() {
2079        names.insert(k.clone());
2080    }
2081    for k in xs.keys() {
2082        names.insert(k.name.clone());
2083    }
2084    for k in pert.orders.keys() {
2085        names.insert(k.clone());
2086    }
2087
2088    if names.is_empty() {
2089        return Ok(());
2090    }
2091
2092    // Known couplings (case-insensitive compare)
2093    let known: Vec<String> = model
2094        .orders
2095        .iter()
2096        .map(|o| o.name.to_string())
2097        .collect::<Vec<_>>();
2098    let known_lower: BTreeSet<String> = known.iter().map(|s| s.to_ascii_lowercase()).collect();
2099
2100    for n in names {
2101        if !known_lower.contains(&n.to_ascii_lowercase()) {
2102            return Err(ParseError::UnknownCoupling {
2103                name: n,
2104                choices: known.join(", "),
2105            });
2106        }
2107    }
2108    Ok(())
2109}
2110
2111// ---- parsing of structured CLI fields ----
2112
2113fn parse_csv_list(s: &str) -> Vec<String> {
2114    s.split(',')
2115        .flat_map(|x| x.split_whitespace())
2116        .filter(|t| !t.is_empty())
2117        .map(|t| t.to_string())
2118        .collect()
2119}
2120
2121#[allow(dead_code)]
2122fn parse_loop_momentum_bases(s: &str) -> Option<HashMap<String, Vec<String>>> {
2123    // Format: "B1=p1,p2;B2=q1,q2"
2124    let mut out: HashMap<String, Vec<String>> = HashMap::default();
2125    for kv in s.split(';') {
2126        let kv = kv.trim();
2127        if kv.is_empty() {
2128            continue;
2129        }
2130        if let Some((k, v)) = kv.split_once('=') {
2131            let key = k.trim().to_string();
2132            let vals = v
2133                .split(',')
2134                .map(|x| x.trim().to_string())
2135                .filter(|x| !x.is_empty())
2136                .collect::<Vec<_>>();
2137            if !key.is_empty() {
2138                out.insert(key, vals);
2139            }
2140        } else {
2141            // single name without '=' is allowed but ignored
2142        }
2143    }
2144    if out.is_empty() {
2145        None
2146    } else {
2147        Some(out)
2148    }
2149}
2150
2151// =================== Tests ===================
2152
2153#[cfg(test)]
2154mod tests {
2155    use super::*;
2156    use crate::{commands::Commands, Repl};
2157    use clap::Parser;
2158    use gammalooprs::utils::load_generic_model;
2159    use gammalooprs::{feyngen::GenerationType, initialisation::test_initialise};
2160    use std::time::Duration;
2161
2162    #[test]
2163    fn remove_compiled_cpp_sources_only_removes_sources_with_matching_libraries() -> Result<()> {
2164        let temp = tempfile::tempdir()?;
2165        let compiled_source = temp.path().join("integrand.cpp");
2166        let compiled_library = temp.path().join("integrand.so");
2167        let uncompiled_source = temp.path().join("orphan.cpp");
2168        std::fs::write(&compiled_source, "compiled source")?;
2169        std::fs::write(&compiled_library, "compiled library")?;
2170        std::fs::write(&uncompiled_source, "orphan source")?;
2171
2172        let removed_count = remove_compiled_cpp_sources(temp.path())?;
2173
2174        assert_eq!(removed_count, 1);
2175        assert!(!compiled_source.exists());
2176        assert!(compiled_library.is_file());
2177        assert!(uncompiled_source.is_file());
2178        Ok(())
2179    }
2180
2181    #[test]
2182    fn parse_generate_keep_sources_after_subcommand() {
2183        let repl =
2184            Repl::try_parse_from(["gammaloop", "generate", "existing", "--keep-sources"]).unwrap();
2185
2186        match repl.command {
2187            Commands::Generate(generate) => {
2188                assert!(generate.keep_sources);
2189                assert!(matches!(generate.mode, Some(GenerateCmd::Existing(_))));
2190            }
2191            other => panic!("Expected generate command, got {other:?}"),
2192        }
2193    }
2194
2195    #[test]
2196    fn generation_summary_includes_separator_and_total_row() {
2197        let reports = vec![
2198            GeneratedGraphReport {
2199                process_id: 0,
2200                integrand_name: "itg".to_string(),
2201                graph_name: "GL01".to_string(),
2202                stats: GraphGenerationStats {
2203                    evaluator_count: 2,
2204                    total_time: Duration::from_secs(4),
2205                    evaluator_spenso_time: Duration::from_secs(1),
2206                    evaluator_symbolica_time: Duration::from_secs(1),
2207                    evaluator_compile_time: Duration::from_secs(1),
2208                },
2209            },
2210            GeneratedGraphReport {
2211                process_id: 0,
2212                integrand_name: "itg".to_string(),
2213                graph_name: "GL02".to_string(),
2214                stats: GraphGenerationStats {
2215                    evaluator_count: 3,
2216                    total_time: Duration::from_secs(6),
2217                    evaluator_spenso_time: Duration::from_secs(2),
2218                    evaluator_symbolica_time: Duration::ZERO,
2219                    evaluator_compile_time: Duration::from_secs(3),
2220                },
2221            },
2222        ];
2223
2224        let summary = render_generation_summary(&reports, 0, Some(4), None).unwrap();
2225        let plain = Regex::new(r"\x1b\[[0-9;]*m")
2226            .unwrap()
2227            .replace_all(&summary, "")
2228            .into_owned();
2229
2230        assert!(plain.contains("Total"));
2231        assert!(plain.contains("5"));
2232        assert!(plain.contains("20%"));
2233        assert!(plain.contains("30%"));
2234        assert!(plain.contains("10%"));
2235        assert!(plain.contains("40%"));
2236
2237        let lines = plain.lines().collect::<Vec<_>>();
2238        let total_line_index = lines
2239            .iter()
2240            .position(|line| line.contains("Total"))
2241            .expect("missing Total row");
2242        let separator_line = lines[..total_line_index]
2243            .iter()
2244            .rev()
2245            .find(|line| !line.trim().is_empty())
2246            .expect("missing line before Total row");
2247        assert!(
2248            separator_line.chars().any(|ch| "┼╪╫┿├┤".contains(ch)),
2249            "missing horizontal separator before Total row: {separator_line}"
2250        );
2251    }
2252
2253    fn base_args(tokens: &str) -> SpecArgs {
2254        SpecArgs {
2255            tokens: tokens.split_whitespace().map(|x| x.to_string()).collect(),
2256            clear_existing_processes: false,
2257            filter_selfenergies: None,
2258            filter_snails: None,
2259            filter_tadpoles: None,
2260            filter_cross_section_tadpoles: None,
2261            veto_tadpoles_attached_to_massive_lines: None,
2262            veto_tadpoles_attached_to_massless_lines: None,
2263            veto_only_scaleless_tadpoles: None,
2264            veto_snails_attached_to_massive_lines: None,
2265            veto_snails_attached_to_massless_lines: None,
2266            veto_only_scaleless_snails: None,
2267            veto_self_energy_of_massive_lines: None,
2268            veto_self_energy_of_massless_lines: None,
2269            veto_only_scaleless_self_energy: None,
2270            max_n_bridges: None,
2271            number_of_factorized_loop_subtopologies: None,
2272            number_of_fermion_loops: None,
2273            symmetric_left_right_polarizations: None,
2274            n_cut_blobs: None,
2275            n_cut_spectators: None,
2276            allow_symmetrization_of_external_fermions_in_amplitudes: None,
2277            symmetrize_initial_states: None,
2278            symmetrize_final_states: None,
2279            symmetrize_left_right_states: None,
2280            numerator_aware_isomorphism_grouping: None,
2281            numerical_samples_seed: None,
2282            number_of_samples_for_numerator_comparisons: None,
2283            consider_internal_masses_only_in_numerator_isomorphisms: None,
2284            fully_numerical_substitution_when_comparing_numerators: None,
2285            compare_canonized_numerator: None,
2286            loop_momentum_bases: None,
2287            select_graphs: None,
2288            veto_graphs: None,
2289            graph_prefix: None,
2290            max_multiplicity_for_fast_cut_filter: 6,
2291            filter_self_loop: None,
2292            filter_zero_flow_edges: None,
2293            process_name: None,
2294            append: false,
2295            integrand_name: None,
2296            only_diagrams: true,
2297            allowed_vertex_interactions: None,
2298            veto_vertex_interactions: None,
2299            global_prefactor_num: None,
2300            global_prefactor_projector: None,
2301        }
2302    }
2303
2304    // Test helpers using a real generic model shipped with the crate
2305    fn parse_ok_amp(s: &str) -> ProcessSpec {
2306        let model = &load_generic_model("sm");
2307        let a = base_args(s);
2308        parse_spec_with_model(&a, GenerationType::Amplitude, model).unwrap()
2309    }
2310
2311    fn parse_ok_xs(s: &str) -> ProcessSpec {
2312        let model = &load_generic_model("sm");
2313        let a = base_args(s);
2314        parse_spec_with_model(&a, GenerationType::CrossSection, model).unwrap()
2315    }
2316
2317    #[test]
2318    fn allowed_vertex_interactions_are_added_to_generation_filters() {
2319        let model = &load_generic_model("sm");
2320        let mut args = base_args("e+ e- > d d~");
2321        args.allowed_vertex_interactions = Some(vec!["V_6".to_string(), "V_9".to_string()]);
2322
2323        let spec = parse_spec_with_model(&args, GenerationType::Amplitude, model).unwrap();
2324
2325        assert!(spec
2326            .process_definition
2327            .amplitude_filters
2328            .0
2329            .iter()
2330            .any(|filter| matches!(
2331                filter,
2332                FeynGenFilter::VertexAllow(vertex_names)
2333                    if vertex_names == &vec!["V_6".to_string(), "V_9".to_string()]
2334            )));
2335    }
2336
2337    #[test]
2338    fn basic_process_list_arrow() {
2339        test_initialise().unwrap();
2340        let ps = parse_ok_amp("e+ e- > d d~ g");
2341        assert_eq!(ps.initial, vec!["e+", "e-"]);
2342        assert_eq!(ps.final_, vec!["d", "d~", "g"]);
2343        assert!(!ps.empty_initial && !ps.empty_final);
2344    }
2345
2346    #[test]
2347    fn basic_process_list_to() {
2348        test_initialise().unwrap();
2349        let ps = parse_ok_amp("e+ e- to d d~ g");
2350        assert_eq!(ps.initial, vec!["e+", "e-"]);
2351        assert_eq!(ps.final_, vec!["d", "d~", "g"]);
2352    }
2353
2354    #[test]
2355    fn empty_sets_allowed() {
2356        test_initialise().unwrap();
2357        let ps = parse_ok_xs("{} to {}");
2358        assert!(ps.empty_initial);
2359        assert!(ps.empty_final);
2360    }
2361
2362    #[test]
2363    fn alternatives_in_final_states() {
2364        test_initialise().unwrap();
2365        let ps = parse_ok_xs("e+ e- > { Z Z, a a }");
2366        assert!(ps.final_.is_empty());
2367        assert_eq!(ps.final_sets.len(), 2);
2368        assert_eq!(ps.final_sets[0], vec!["Z", "Z"]);
2369        assert_eq!(ps.final_sets[1], vec!["a", "a"]);
2370    }
2371
2372    #[test]
2373    fn veto_and_only() {
2374        test_initialise().unwrap();
2375        let ps = parse_ok_xs("e+ e- > mu+ mu- / u d c g ghG e- | u g ghG");
2376        assert!(ps.veto.contains("u"));
2377        assert!(ps.veto.contains("e-"));
2378        let sel = ps.only.as_ref().unwrap();
2379        assert!(sel.contains("u"));
2380        assert!(sel.contains("g"));
2381        assert!(sel.contains("ghG"));
2382    }
2383
2384    #[test]
2385    fn amp_order_constraints() {
2386        test_initialise().unwrap();
2387        let ps = parse_ok_amp("e+ e- > mu+ mu- QED==2 QCD>=2 QCD<=4");
2388        let qcd = ps.amp_couplings.get("QCD").unwrap();
2389        assert_eq!(qcd.min, Some(2));
2390        assert_eq!(qcd.max, Some(4));
2391        let qed = ps.amp_couplings.get("QED").unwrap();
2392        assert_eq!(qed.eq, Some(2));
2393    }
2394
2395    #[test]
2396    fn xs_order_constraints_powered() {
2397        test_initialise().unwrap();
2398        let ps = parse_ok_xs("e+ e- > mu+ mu- QED^2==2 QCD^2>=2 QCD^2<=4");
2399        let key = CouplingKey {
2400            name: "QCD".into(),
2401            power: 2,
2402        };
2403        let qcd2 = ps.xs_couplings.get(&key).unwrap();
2404        assert_eq!(qcd2.min, Some(2));
2405        assert_eq!(qcd2.max, Some(4));
2406        let keyq = CouplingKey {
2407            name: "QED".into(),
2408            power: 2,
2409        };
2410        let qed2 = ps.xs_couplings.get(&keyq).unwrap();
2411        assert_eq!(qed2.eq, Some(2));
2412    }
2413
2414    #[test]
2415    fn perturbative_block_all_forms() {
2416        test_initialise().unwrap();
2417        let ps = parse_ok_xs("e+ e- > z [ {1} {{2}} QCD=2 QED=1 ]");
2418        assert_eq!(ps.pert.loops_sum_amp_or_sum, Some(1));
2419        assert_eq!(ps.pert.loops_forward_graph, Some(2));
2420        assert_eq!(ps.pert.orders.get("QCD"), Some(&2));
2421        assert_eq!(ps.pert.orders.get("QED"), Some(&1));
2422    }
2423
2424    #[test]
2425    fn perturbative_block_shorthand() {
2426        test_initialise().unwrap();
2427        let ps = parse_ok_xs("e+ e- > z [ QCD ]");
2428        assert_eq!(ps.pert.orders.get("QCD"), Some(&1));
2429    }
2430
2431    #[test]
2432    fn amplitude_generation_type_is_set() {
2433        test_initialise().unwrap();
2434        let ps = parse_ok_amp("e+ e- > mu+ mu-");
2435        assert_eq!(
2436            ps.process_definition.generation_type,
2437            GenerationType::Amplitude
2438        );
2439    }
2440
2441    #[test]
2442    fn rejects_malformed_tokens() {
2443        test_initialise().unwrap();
2444        let model = &load_generic_model("sm");
2445
2446        let args = base_args("e+ > z [ {{}} ]");
2447        let err = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap_err();
2448        assert_eq!(err, ParseError::InvalidToken("{{}}".into()));
2449    }
2450
2451    #[test]
2452    fn missing_arrow_detected() {
2453        test_initialise().unwrap();
2454        let model = &load_generic_model("sm");
2455
2456        let args = base_args("e+ e-  z z");
2457        let err = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap_err();
2458        assert!(matches!(err, ParseError::MissingArrow));
2459    }
2460
2461    #[test]
2462    fn vacuum_defaults_and_filters_xs() {
2463        test_initialise().unwrap();
2464        let ps = parse_ok_xs("{} to {}");
2465        assert_eq!(
2466            ps.process_definition.generation_type,
2467            GenerationType::CrossSection
2468        );
2469
2470        let xs_filters = &ps.process_definition.cross_section_filters.0;
2471
2472        // Smart defaults for vacuum-like graphs
2473        assert!(xs_filters
2474            .iter()
2475            .any(|f| matches!(f, FeynGenFilter::MaxNumberOfBridges(0))));
2476        assert!(xs_filters
2477            .iter()
2478            .any(|f| matches!(f, FeynGenFilter::FactorizedLoopTopologiesCountRange((1, 1)))));
2479
2480        // Default cut ranges present
2481        assert!(xs_filters
2482            .iter()
2483            .any(|f| matches!(f, FeynGenFilter::BlobRange(r) if r.clone()==(1..=1))));
2484        assert!(xs_filters
2485            .iter()
2486            .any(|f| matches!(f, FeynGenFilter::SpectatorRange(r) if r.clone()==(0..=0))));
2487    }
2488
2489    #[test]
2490    fn particle_veto_resolution_cross_section() {
2491        use std::collections::BTreeSet;
2492        test_initialise().unwrap();
2493        // Veto both particles and anti-particles, plus a charged lepton
2494        let ps = parse_ok_xs("e+ e- > mu+ mu- / u d u~ e+");
2495        let xs_filters = &ps.process_definition.cross_section_filters.0;
2496
2497        let veto_pdgs = xs_filters
2498            .iter()
2499            .find_map(|f| match f {
2500                FeynGenFilter::ParticleVeto(v) => Some(v.clone()),
2501                _ => None,
2502            })
2503            .expect("ParticleVeto filter not found");
2504        let set: BTreeSet<i64> = veto_pdgs.into_iter().collect();
2505
2506        // u=2, d=1, u~=-2, e+=-11
2507        for pdg in [2_i64, 1_i64, -2_i64, -11_i64] {
2508            assert!(set.contains(&pdg), "missing PDG {pdg} in veto");
2509        }
2510    }
2511
2512    #[test]
2513    fn particle_inclusion_resolution_cross_section() {
2514        use std::collections::BTreeSet;
2515        test_initialise().unwrap();
2516        // Veto both particles and anti-particles, plus a charged lepton
2517        let ps = parse_ok_xs("e+ e- > mu+ mu- | u d u~ e+");
2518        let xs_filters = &ps.process_definition.cross_section_filters.0;
2519
2520        let veto_pdgs = xs_filters
2521            .iter()
2522            .find_map(|f| match f {
2523                FeynGenFilter::ParticleVeto(v) => Some(v.clone()),
2524                _ => None,
2525            })
2526            .expect("ParticleVeto filter not found");
2527        let set: BTreeSet<i64> = veto_pdgs.into_iter().collect();
2528
2529        for pdg in [
2530            3, 4, 5, 6, 12, 13, 14, 15, 16, 21, 22, 23, 24, 25, 250, 251, 9000001, 9000002,
2531            9000003, 9000004, 9000005,
2532        ] {
2533            assert!(set.contains(&pdg), "missing PDG {pdg} in veto");
2534        }
2535    }
2536
2537    #[test]
2538    fn final_sets_and_orders_and_loops_cross_section() {
2539        test_initialise().unwrap();
2540        // Cross-section with final-state alternatives and perturbative block
2541        let ps = parse_ok_xs("e+ e- > { Z Z, a a, H H } [ {{3}} QCD=2 QED=1 ]");
2542
2543        // Final-state alternatives captured
2544        assert_eq!(ps.final_sets.len(), 3);
2545        assert_eq!(
2546            ps.process_definition.generation_type,
2547            GenerationType::CrossSection
2548        );
2549
2550        // XS loop count from {{3}}
2551        assert_eq!(ps.process_definition.loop_count_range, (3, 3));
2552
2553        // Perturbative orders end up in XS filters
2554        let xs_filters = &ps.process_definition.cross_section_filters.0;
2555        assert!(xs_filters.iter().any(|f| {
2556            if let FeynGenFilter::PerturbativeOrders(m) = f {
2557                m.get("QCD") == Some(&2usize) && m.get("QED") == Some(&1usize)
2558            } else {
2559                false
2560            }
2561        }));
2562    }
2563
2564    #[test]
2565    fn amplitude_with_powered_xs_constraints_and_only() {
2566        test_initialise().unwrap();
2567        // AMP spec including "only" and XS-style powered coupling constraints
2568        let ps = parse_ok_amp("e+ e- > mu+ mu- | g ghG QED^2==2 QCD^2>=2 QCD^2<=4 [ {1} QCD ]");
2569
2570        // AMP-side perturbative bits
2571        assert_eq!(ps.pert.loops_sum_amp_or_sum, Some(1));
2572        assert_eq!(ps.pert.orders.get("QCD"), Some(&1));
2573
2574        // XS-style powered constraints recorded
2575        let key_qed2 = CouplingKey {
2576            name: "QED".into(),
2577            power: 2,
2578        };
2579        let key_qcd2 = CouplingKey {
2580            name: "QCD".into(),
2581            power: 2,
2582        };
2583        let qed2 = ps.xs_couplings.get(&key_qed2).unwrap();
2584        assert_eq!(qed2.eq, Some(2));
2585        let qcd2 = ps.xs_couplings.get(&key_qcd2).unwrap();
2586        assert_eq!(qcd2.min, Some(2));
2587        assert_eq!(qcd2.max, Some(4));
2588
2589        // "only" selection recorded
2590        let only = ps.only.as_ref().expect("missing only-set");
2591        assert!(only.contains("g"));
2592        assert!(only.contains("ghG"));
2593    }
2594
2595    #[test]
2596    fn amplitude_case_insensitive_and_anti_in_veto() {
2597        use std::collections::BTreeSet;
2598        test_initialise().unwrap();
2599        // Mixed case names and anti-particles in veto; ensure AMP-side veto filter present
2600        let ps = parse_ok_amp("E+ e- to Z Z / u~ d~ A");
2601
2602        let amp_filters = &ps.process_definition.amplitude_filters.0;
2603        let veto_pdgs = amp_filters
2604            .iter()
2605            .find_map(|f| match f {
2606                FeynGenFilter::ParticleVeto(v) => Some(v.clone()),
2607                _ => None,
2608            })
2609            .expect("AMP ParticleVeto filter not found");
2610
2611        let set: BTreeSet<i64> = veto_pdgs.into_iter().collect();
2612        // u~=-2, d~=-1, A(=a)=22
2613        for pdg in [-2_i64, -1_i64, 22_i64] {
2614            assert!(set.contains(&pdg), "missing PDG {pdg} in AMP veto");
2615        }
2616    }
2617
2618    #[test]
2619    fn shell_name_long_with_sets_veto_orders_pert_and_xs() {
2620        test_initialise().unwrap();
2621        let model = &load_generic_model("sm");
2622
2623        // Intentionally scrambled input order; output must be canonical.
2624        let args =
2625            base_args("e+ e- > { Z Z, a a } / u d QED==2 QCD>=1 QED^2<=4 QCD^2>=2 [ QED=1 QCD=2 ]");
2626        let ps = parse_spec_with_model(&args, GenerationType::Amplitude, model).unwrap();
2627
2628        let long = ps.process_shell_name(false);
2629
2630        let expected = "epem_zz_or_aa-\
2631                    no__d_u-\
2632                    QCD_ge_1__QED_eq_2-\
2633                    QCDsq_ge_2__QEDsq_le_4-\
2634                    QCDloop_eq_2__QEDloop_eq_1";
2635
2636        assert_eq!(long, expected);
2637    }
2638
2639    #[test]
2640    fn repr_str_canonical_with_amp_xs_veto_loops_orders() {
2641        test_initialise().unwrap();
2642        let model = &load_generic_model("sm");
2643
2644        // Mixed options with non-canonical input ordering.
2645        let args = base_args(
2646            "mu+ mu- > g g / ghg gha QED==1 QCD<=3 [ {2} QED=0 QCD=1 ] QED^2==0 QCD^2>=2",
2647        );
2648        let ps = parse_spec_with_model(&args, GenerationType::Amplitude, model).unwrap();
2649
2650        let r = ps.repr_str();
2651
2652        let expected = "mu+ mu- > g g / gha ghg QCD<=3 QED==1 [ {2} QCD ] QCD^2>=2 QED^2==0";
2653
2654        assert_eq!(r, expected);
2655    }
2656
2657    #[test]
2658    fn repr_str_with_both_loop_counts_and_sorted_orders() {
2659        test_initialise().unwrap();
2660        let model = &load_generic_model("sm");
2661
2662        let args = base_args("e+ e- > z z [ {{2}} QED=2 QCD=1 {1} ]");
2663        let ps = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap();
2664
2665        let r = ps.repr_str();
2666
2667        let expected = "e+ e- > z z [ {1} {{2}} QCD QED=2 ]";
2668
2669        assert_eq!(r, expected);
2670    }
2671
2672    #[test]
2673    fn shell_name_short_canonical_and_sanitization() {
2674        test_initialise().unwrap();
2675        let model = &load_generic_model("sm");
2676
2677        let args = base_args("W+ W- > {}");
2678        let ps = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap();
2679
2680        // Short form uses only the base slug and sanitizes +/- and ~.
2681        let short = ps.process_shell_name(true);
2682        let expected = "wpwm_empty";
2683
2684        assert_eq!(short, expected);
2685    }
2686}