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, excluding ghosts; 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 under CP-based left/right exchange; requires CP symmetry.
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
359impl SpecArgs {
360    pub(crate) fn from_process_spec_string(spec: impl Into<String>) -> Self {
361        Self {
362            tokens: vec![spec.into()],
363            ..Self::default()
364        }
365    }
366}
367
368impl Default for SpecArgs {
369    fn default() -> Self {
370        Self {
371            tokens: Vec::new(),
372            append: false,
373            clear_existing_processes: false,
374            process_name: None,
375            integrand_name: None,
376            only_diagrams: false,
377            filter_selfenergies: None,
378            filter_snails: None,
379            filter_tadpoles: None,
380            veto_vertex_interactions: None,
381            allowed_vertex_interactions: None,
382            filter_cross_section_tadpoles: None,
383            veto_tadpoles_attached_to_massive_lines: None,
384            veto_tadpoles_attached_to_massless_lines: None,
385            veto_only_scaleless_tadpoles: None,
386            veto_snails_attached_to_massive_lines: None,
387            veto_snails_attached_to_massless_lines: None,
388            veto_only_scaleless_snails: None,
389            veto_self_energy_of_massive_lines: None,
390            veto_self_energy_of_massless_lines: None,
391            veto_only_scaleless_self_energy: None,
392            max_n_bridges: None,
393            number_of_factorized_loop_subtopologies: None,
394            number_of_fermion_loops: None,
395            n_cut_blobs: None,
396            n_cut_spectators: None,
397            allow_symmetrization_of_external_fermions_in_amplitudes: None,
398            symmetrize_initial_states: None,
399            symmetrize_final_states: None,
400            symmetrize_left_right_states: None,
401            numerator_aware_isomorphism_grouping: None,
402            numerical_samples_seed: None,
403            number_of_samples_for_numerator_comparisons: None,
404            consider_internal_masses_only_in_numerator_isomorphisms: None,
405            fully_numerical_substitution_when_comparing_numerators: None,
406            compare_canonized_numerator: None,
407            symmetric_left_right_polarizations: None,
408            loop_momentum_bases: None,
409            select_graphs: None,
410            veto_graphs: None,
411            graph_prefix: None,
412            global_prefactor_projector: None,
413            global_prefactor_num: None,
414            max_multiplicity_for_fast_cut_filter: 6,
415            filter_self_loop: None,
416            filter_zero_flow_edges: None,
417        }
418    }
419}
420
421// =================== Runner ===================
422
423fn format_generation_duration(duration: std::time::Duration) -> String {
424    if duration.as_secs() >= 60 {
425        let minutes = duration.as_secs() / 60;
426        let seconds = duration.as_secs_f64() - (minutes * 60) as f64;
427        format!("{minutes}m {seconds:.1}s")
428    } else if duration.as_secs_f64() >= 1.0 {
429        format!("{:.2}s", duration.as_secs_f64())
430    } else {
431        format!("{}ms", duration.as_millis())
432    }
433}
434
435fn format_generation_fraction(
436    numerator: std::time::Duration,
437    total: std::time::Duration,
438) -> String {
439    let percent = if total.is_zero() {
440        0.0
441    } else {
442        (numerator.as_secs_f64() / total.as_secs_f64()) * 100.0
443    };
444
445    if percent >= 10.0 {
446        format!("{percent:.0}%")
447    } else if percent >= 1.0 {
448        format!("{percent:.1}%")
449    } else {
450        format!("{percent:.2}%")
451    }
452}
453
454fn format_generation_memory(bytes: u64) -> String {
455    const KIB: f64 = 1024.0;
456    const MIB: f64 = KIB * 1024.0;
457    const GIB: f64 = MIB * 1024.0;
458    const TIB: f64 = GIB * 1024.0;
459
460    let value = bytes as f64;
461    if value < KIB {
462        format!("{bytes} B")
463    } else if value < MIB {
464        format!("{:.2} KiB", value / KIB)
465    } else if value < GIB {
466        format!("{:.2} MiB", value / MIB)
467    } else if value < TIB {
468        format!("{:.2} GiB", value / GIB)
469    } else {
470        format!("{:.2} TiB", value / TIB)
471    }
472}
473
474pub(crate) fn render_generation_summary(
475    reports: &[GeneratedGraphReport],
476    peak_ram_bytes: u64,
477    generation_cores: Option<usize>,
478    title: Option<&str>,
479) -> Option<String> {
480    if reports.is_empty() {
481        return None;
482    }
483
484    let mut sorted_reports = reports.to_vec();
485    sorted_reports.sort_by(|left, right| {
486        (
487            left.process_id,
488            left.integrand_name.as_str(),
489            left.graph_name.as_str(),
490        )
491            .cmp(&(
492                right.process_id,
493                right.integrand_name.as_str(),
494                right.graph_name.as_str(),
495            ))
496    });
497
498    let mut builder = Builder::new();
499    builder.push_record([
500        "integrand".bold().blue().to_string(),
501        "graph".bold().blue().to_string(),
502        "# evals".bold().blue().to_string(),
503        "expr build".bold().blue().to_string(),
504        "spenso".bold().blue().to_string(),
505        "symbolica eval".bold().blue().to_string(),
506        "compile".bold().blue().to_string(),
507    ]);
508
509    let mut total_stats = GraphGenerationStats::default();
510    for report in &sorted_reports {
511        total_stats.merge_in_place(&report.stats);
512        let expr_time = report.stats.expression_build_time();
513        let total_time = report.stats.total_time;
514        let expr_value = format!(
515            "{} ({})",
516            format_generation_duration(expr_time).magenta(),
517            format_generation_fraction(expr_time, total_time).cyan()
518        );
519        let spenso_value = format!(
520            "{} ({})",
521            format_generation_duration(report.stats.evaluator_spenso_time).magenta(),
522            format_generation_fraction(report.stats.evaluator_spenso_time, total_time).cyan()
523        );
524        let symbolica_value = format!(
525            "{} ({})",
526            format_generation_duration(report.stats.evaluator_symbolica_time).magenta(),
527            format_generation_fraction(report.stats.evaluator_symbolica_time, total_time).cyan()
528        );
529        let compile_value = format!(
530            "{} ({})",
531            format_generation_duration(report.stats.evaluator_compile_time).magenta(),
532            format_generation_fraction(report.stats.evaluator_compile_time, total_time).cyan()
533        );
534
535        builder.push_record([
536            report.integrand_name.yellow().to_string(),
537            report.graph_name.yellow().to_string(),
538            report
539                .stats
540                .evaluator_count
541                .to_string()
542                .yellow()
543                .to_string(),
544            expr_value,
545            spenso_value,
546            symbolica_value,
547            compile_value,
548        ]);
549    }
550
551    let total_time = total_stats.total_time;
552    let total_expr_time = total_stats.expression_build_time();
553    let total_expr_value = format!(
554        "{} ({})",
555        format_generation_duration(total_expr_time).magenta(),
556        format_generation_fraction(total_expr_time, total_time).cyan()
557    );
558    let total_spenso_value = format!(
559        "{} ({})",
560        format_generation_duration(total_stats.evaluator_spenso_time).magenta(),
561        format_generation_fraction(total_stats.evaluator_spenso_time, total_time).cyan()
562    );
563    let total_symbolica_value = format!(
564        "{} ({})",
565        format_generation_duration(total_stats.evaluator_symbolica_time).magenta(),
566        format_generation_fraction(total_stats.evaluator_symbolica_time, total_time).cyan()
567    );
568    let total_compile_value = format!(
569        "{} ({})",
570        format_generation_duration(total_stats.evaluator_compile_time).magenta(),
571        format_generation_fraction(total_stats.evaluator_compile_time, total_time).cyan()
572    );
573    builder.push_record([
574        "Total".bold().yellow().to_string(),
575        String::new(),
576        total_stats
577            .evaluator_count
578            .to_string()
579            .bold()
580            .yellow()
581            .to_string(),
582        total_expr_value,
583        total_spenso_value,
584        total_symbolica_value,
585        total_compile_value,
586    ]);
587
588    let mut table = builder.build();
589    let mut style = Theme::from_style(Style::rounded());
590    style.insert_horizontal_line(
591        sorted_reports.len() + 1,
592        HorizontalLine::inherit(Style::modern()),
593    );
594    table.with(style);
595    let mut sections = Vec::new();
596    if let Some(title) = title {
597        sections.push(title.bold().blue().to_string());
598    }
599    let resources = match generation_cores {
600        Some(cores) => {
601            format!(
602                "{} {} | {} {}",
603                "peak RAM".bold().blue(),
604                format_generation_memory(peak_ram_bytes).yellow(),
605                "cores".bold().blue(),
606                cores.to_string().yellow(),
607            )
608        }
609        None => format!(
610            "{} {}",
611            "peak RAM".bold().blue(),
612            format_generation_memory(peak_ram_bytes).yellow(),
613        ),
614    };
615    sections.push(resources);
616    sections.push(table.to_string());
617    Some(sections.join("\n"))
618}
619
620fn log_generation_summary_table(
621    reports: &[GeneratedGraphReport],
622    resources: GenerationResourceSummary,
623) {
624    if let Some(summary) = render_generation_summary(
625        reports,
626        resources.peak_ram_bytes,
627        Some(resources.generation_cores),
628        Some("Integrand generation summary"),
629    ) {
630        info!("\n{summary}");
631    }
632}
633
634fn remove_compiled_cpp_sources(root: &Path) -> Result<usize> {
635    let mut removed_count = 0usize;
636    for entry in WalkDir::new(root) {
637        let entry = entry?;
638        if !entry.file_type().is_file() {
639            continue;
640        }
641
642        let source_path = entry.path();
643        if source_path.extension() != Some(OsStr::new("cpp")) {
644            continue;
645        }
646
647        let library_path = source_path.with_extension("so");
648        if !library_path.is_file() {
649            continue;
650        }
651
652        fs::remove_file(source_path).with_context(|| {
653            format!(
654                "Trying to remove generated C++ source {} after compiling {}",
655                source_path.display(),
656                library_path.display()
657            )
658        })?;
659        removed_count += 1;
660    }
661
662    Ok(removed_count)
663}
664
665fn compile_integrands_for_generation(
666    state: &mut State,
667    compile_folder: &Path,
668    override_existing_compiled: bool,
669    global_settings: &GlobalSettings,
670    process_id: Option<usize>,
671    integrand_name: Option<String>,
672    keep_sources: bool,
673) -> Result<Vec<GeneratedGraphReport>> {
674    let reports = state.compile_integrands(
675        compile_folder,
676        override_existing_compiled,
677        global_settings,
678        process_id,
679        integrand_name,
680    )?;
681
682    if !keep_sources {
683        let removed_count = remove_compiled_cpp_sources(compile_folder)?;
684        if removed_count > 0 {
685            info!(
686                "Removed {} generated C++ source file(s). Use '{}' to keep them.",
687                removed_count,
688                "--keep-sources".green()
689            );
690        }
691    }
692
693    Ok(reports)
694}
695
696fn finish_generation(
697    state: &mut State,
698    reports: &[GeneratedGraphReport],
699    resources: GenerationResourceSummary,
700) {
701    state.record_generation_summary(reports, resources);
702    log_generation_summary_table(reports, resources);
703}
704
705impl Generate {
706    pub fn run(
707        &self,
708        state: &mut State,
709        compile_folder: impl AsRef<Path>,
710        override_existing_compiled: bool,
711        global_settings: &GlobalSettings,
712        runtime_settings: &RuntimeSettings,
713    ) -> Result<()> {
714        let compile_folder = compile_folder.as_ref();
715        let generation_mode = match &self.mode {
716            Some(GenerateCmd::Xs(a)) => Some((GenerationType::CrossSection, a)),
717            Some(GenerateCmd::Amp(a)) => Some((GenerationType::Amplitude, a)),
718            _ => None,
719        };
720        if let Some((generation_type, args)) = generation_mode.as_ref() {
721            global_settings
722                .generation
723                .validate_for_process(*generation_type)?;
724            if !state.process_list.processes.is_empty() && args.clear_existing_processes {
725                info!(
726                    "Clearing all {} existing processes as requested.",
727                    state.process_list.processes.len()
728                );
729                state.process_list = ProcessList::default();
730            }
731        }
732        let generation_info = if let Some((gen_mode, args)) = generation_mode {
733            let mut spec = parse_spec_with_model(args, gen_mode, &state.model)?;
734            spec.process_definition.process_id = state.process_list.processes.len();
735
736            let mut existing_process = None;
737            if let Some(ep) = state
738                .process_list
739                .processes
740                .iter_mut()
741                .find(|p| p.definition.folder_name == spec.process_definition.folder_name)
742            {
743                if ep.definition != spec.process_definition {
744                    if !args.append {
745                        return Err(eyre!(
746                            "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>'.",
747                            spec.process_definition.folder_name
748                        ));
749                    }
750                } else {
751                    info!(
752                        "Identical process definition, with name '{}', already exists. Gammaloop will recycle it.",
753                        spec.process_definition.folder_name
754                    );
755                    return Ok(());
756                }
757                spec.process_definition.process_id = ep.definition.process_id;
758                existing_process = Some(ep);
759            }
760            Some((spec, existing_process))
761        } else {
762            None
763        };
764        match &self.mode {
765            Some(GenerateCmd::Amp(args)) | Some(GenerateCmd::Xs(args)) => {
766                let generation_type = generation_mode.as_ref().unwrap().0;
767                let model: &Model = &state.model;
768                let (spec, existing_process) = generation_info.unwrap();
769                let this_process_id = spec.process_definition.process_id;
770                // TODO handle existing process and continue
771                let graphs = spec.process_definition.generate(model, global_settings)?;
772                info!(
773                    "Generated {} {} graphs.",
774                    if matches!(self.mode, Some(GenerateCmd::Amp(_))) {
775                        "amplitude"
776                    } else {
777                        "cross-section"
778                    },
779                    graphs.len()
780                );
781                // Keep the possibility of changing default name for the two modes
782                let integrand_base_name = matches!(self.mode, Some(GenerateCmd::Amp(_)))
783                    .then(|| args.integrand_name.clone().unwrap_or("default".to_string()))
784                    .unwrap_or_else(|| {
785                        args.integrand_name.clone().unwrap_or("default".to_string())
786                    });
787                let generated_integrand_name = if let Some(p) = existing_process {
788                    let existing_names = p.collection.get_integrand_names();
789                    let integrand_name = if existing_names.contains(&integrand_base_name.as_str()) {
790                        let mut integrand_i = 0;
791                        while existing_names
792                            .iter()
793                            .any(|ce| *ce == format!("{}_{}", integrand_base_name, integrand_i))
794                        {
795                            integrand_i += 1;
796                        }
797                        format!("{}_{}", integrand_base_name, integrand_i)
798                    } else {
799                        integrand_base_name
800                    };
801                    match &self.mode {
802                        Some(GenerateCmd::Amp(_)) => {
803                            p.collection.add_amplitude(Amplitude::from_graph_list(
804                                integrand_name.clone(),
805                                graphs,
806                            )?);
807                        }
808                        Some(GenerateCmd::Xs(_)) => {
809                            p.collection
810                                .add_cross_section(CrossSection::from_graph_list(
811                                    integrand_name.clone(),
812                                    graphs,
813                                    model,
814                                )?);
815                        }
816                        _ => unreachable!(),
817                    }
818                    integrand_name
819                } else {
820                    let process = Process::from_graph_list(
821                        spec.process_definition.folder_name.clone(),
822                        integrand_base_name.clone(),
823                        graphs,
824                        generation_type,
825                        Some(spec.process_definition),
826                        None,
827                        model,
828                    )?;
829                    state.process_list.add_process(process);
830                    integrand_base_name
831                };
832                if !args.only_diagrams {
833                    let generated_integrand_name_for_compile = generated_integrand_name.clone();
834                    let generation = state.generate_integrand(
835                        global_settings,
836                        runtime_settings.into(),
837                        this_process_id,
838                        Some(generated_integrand_name),
839                    )?;
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                                Some(this_process_id),
855                                Some(generated_integrand_name_for_compile),
856                                self.keep_sources,
857                            )?,
858                        );
859                    }
860                    finish_generation(state, &reports, generation.resources);
861                    Ok(())
862                } else {
863                    info!(
864                        "Only diagram generation was requested, skipping integrand generation. You can generate integrands later using the '{}' command.",
865                        "generate existing <options>".green()
866                    );
867                    Ok(())
868                }
869            }
870            Some(GenerateCmd::Existing(process_args)) => {
871                let process_id = state.resolve_process_ref(process_args.process.as_ref())?;
872
873                let generation = state.generate_integrand(
874                    global_settings,
875                    runtime_settings.into(),
876                    process_id,
877                    process_args.integrand_name.clone(),
878                )?;
879                let mut reports = generation.reports;
880                if global_settings.generation.evaluator.compile
881                    && global_settings
882                        .generation
883                        .compile
884                        .requires_external_compilation()
885                {
886                    merge_generated_graph_reports(
887                        &mut reports,
888                        compile_integrands_for_generation(
889                            state,
890                            compile_folder,
891                            override_existing_compiled,
892                            global_settings,
893                            Some(process_id),
894                            process_args.integrand_name.clone(),
895                            self.keep_sources,
896                        )?,
897                    );
898                }
899                finish_generation(state, &reports, generation.resources);
900                Ok(())
901            }
902            None => {
903                let generation =
904                    state.generate_integrands(global_settings, runtime_settings.into())?;
905                let mut reports = generation.reports;
906                if global_settings.generation.evaluator.compile
907                    && global_settings
908                        .generation
909                        .compile
910                        .requires_external_compilation()
911                {
912                    merge_generated_graph_reports(
913                        &mut reports,
914                        compile_integrands_for_generation(
915                            state,
916                            compile_folder,
917                            override_existing_compiled,
918                            global_settings,
919                            None,
920                            None,
921                            self.keep_sources,
922                        )?,
923                    );
924                }
925                finish_generation(state, &reports, generation.resources);
926                Ok(())
927            }
928        }
929    }
930}
931
932#[derive(Args, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
933pub struct ProcessArgs {
934    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
935    #[arg(
936        long = "process",
937        short = 'p',
938        value_name = "PROCESS",
939        completion_process_selector(crate::completion::SelectorKind::Any)
940    )]
941    pub process: Option<ProcessRef>,
942
943    /// Optional human-readable integrand name used to disambiguate the process
944    #[arg(
945        long = "integrand-name",
946        short = 'i',
947        completion_integrand_selector(crate::completion::SelectorKind::Any)
948    )]
949    pub integrand_name: Option<String>,
950}
951
952// =================== Domain structs ===================
953
954#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
955pub struct OrderRange {
956    pub eq: Option<u32>,
957    pub min: Option<u32>,
958    pub max: Option<u32>,
959}
960impl OrderRange {
961    fn set(&mut self, op: &str, v: u32) {
962        match op {
963            "==" => self.eq = Some(v),
964            ">=" => self.min = Some(v),
965            "<=" => self.max = Some(v),
966            _ => {}
967        }
968    }
969}
970
971#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)]
972pub struct CouplingKey {
973    pub name: String,
974    pub power: u32, // parsed, but dropped when building FeynGenFilter::CouplingOrders
975}
976
977#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
978pub struct Perturbative {
979    /// {n}
980    pub loops_sum_amp_or_sum: Option<u32>,
981    /// {{n}}
982    pub loops_forward_graph: Option<u32>,
983    /// QCD=2, QED=1; shorthand "QCD"≡1
984    pub orders: BTreeMap<String, u32>,
985}
986
987#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
988pub struct ProcessSpec {
989    pub initial: Vec<String>,
990    pub final_: Vec<String>,
991    pub empty_initial: bool,
992    pub empty_final: bool,
993
994    /// `/ ...`
995    pub veto: BTreeSet<String>,
996    /// `| ...`
997    pub only: Option<BTreeSet<String>>,
998
999    /// AMP amplitude coupling ranges: QED==2, QCD>=2, QCD<=4
1000    pub amp_couplings: BTreeMap<String, OrderRange>,
1001    /// XS graph-level coupling ranges: QED^2==2, QCD^2<=4  (power is dropped when building filters)
1002    pub xs_couplings: BTreeMap<CouplingKey, OrderRange>,
1003
1004    /// For example, `[{1} {{2}} QCD=2 QED=1]` or `[QCD]`.
1005    pub pert: Perturbative,
1006
1007    /// XS-only: final-state alternative sets `{ A, B, ... }`
1008    pub final_sets: Vec<Vec<String>>,
1009
1010    /// Generation options captured alongside the spec
1011    pub process_definition: ProcessDefinition,
1012
1013    /// Parsed numerator-aware grouping mode (not part of FeynGenOptions)
1014    pub numerator_grouping: NumeratorAwareGraphGroupingOption,
1015}
1016
1017impl ProcessSpec {
1018    /// Canonical shell-friendly name. Deterministic and lexicographically ordered.
1019    pub fn process_shell_name(&self, short: bool) -> String {
1020        // sanitize within a piece so '-' separators between pieces are preserved
1021        let sanitize_piece = |s: &str| s.replace('~', "x").replace('+', "p").replace('-', "m");
1022        let lower = |s: &str| s.to_ascii_lowercase();
1023
1024        // base slug: "<init>_<final or sets joined by _or_>"
1025        let init_slug = if self.initial.is_empty() {
1026            "empty".to_string()
1027        } else {
1028            self.initial.iter().map(|p| lower(p)).collect::<String>()
1029        };
1030
1031        let finals_lists: Vec<Vec<String>> = if self.final_sets.is_empty() {
1032            vec![self.final_.clone()]
1033        } else {
1034            self.final_sets.clone()
1035        };
1036        let finals_slugs: Vec<String> = finals_lists
1037            .iter()
1038            .map(|lst| {
1039                if lst.is_empty() {
1040                    "empty".to_string()
1041                } else {
1042                    lst.iter().map(|p| lower(p)).collect::<String>()
1043                }
1044            })
1045            .collect();
1046
1047        let mut pieces: Vec<String> = vec![sanitize_piece(&format!(
1048            "{}_{}",
1049            init_slug,
1050            finals_slugs.join("_or_")
1051        ))];
1052
1053        if !short {
1054            // particle vetoes (sorted)
1055            if !self.veto.is_empty() {
1056                let mut veto_list: Vec<String> = self.veto.iter().map(|v| lower(v)).collect();
1057                veto_list.sort();
1058                pieces.push(sanitize_piece(&format!("no__{}", veto_list.join("_"))));
1059            }
1060
1061            // amplitude coupling orders (sorted by coupling name)
1062            if !self.amp_couplings.is_empty() {
1063                let mut keys: Vec<&String> = self.amp_couplings.keys().collect();
1064                keys.sort();
1065                let mut parts: Vec<String> = Vec::new();
1066                for k in keys {
1067                    let r = &self.amp_couplings[k];
1068                    if let Some(eq) = r.eq {
1069                        parts.push(format!("{}_eq_{}", k, eq));
1070                    } else {
1071                        if let Some(min) = r.min {
1072                            if min > 0 {
1073                                parts.push(format!("{}_ge_{}", k, min));
1074                            }
1075                        }
1076                        if let Some(max) = r.max {
1077                            parts.push(format!("{}_le_{}", k, max));
1078                        }
1079                    }
1080                }
1081                if !parts.is_empty() {
1082                    pieces.push(parts.join("__"));
1083                }
1084            }
1085
1086            // cross-section coupling orders (sorted by name, then power)
1087            if !self.xs_couplings.is_empty() {
1088                let mut entries: Vec<(&CouplingKey, &OrderRange)> =
1089                    self.xs_couplings.iter().collect();
1090                entries.sort_by(|(ka, _), (kb, _)| {
1091                    use std::cmp::Ordering;
1092                    match ka.name.cmp(&kb.name) {
1093                        Ordering::Equal => ka.power.cmp(&kb.power),
1094                        other => other,
1095                    }
1096                });
1097                let mut parts: Vec<String> = Vec::new();
1098                for (k, r) in entries {
1099                    let base = format!("{}sq", k.name);
1100                    if let Some(eq) = r.eq {
1101                        parts.push(format!("{}_eq_{}", base, eq));
1102                    } else {
1103                        if let Some(min) = r.min {
1104                            if min > 0 {
1105                                parts.push(format!("{}_ge_{}", base, min));
1106                            }
1107                        }
1108                        if let Some(max) = r.max {
1109                            parts.push(format!("{}_le_{}", base, max));
1110                        }
1111                    }
1112                }
1113                if !parts.is_empty() {
1114                    pieces.push(parts.join("__"));
1115                }
1116            }
1117
1118            // perturbative coupling orders (sorted by key)
1119            if !self.pert.orders.is_empty() {
1120                let mut ords: Vec<(&String, &u32)> = self.pert.orders.iter().collect();
1121                ords.sort_by(|a, b| a.0.cmp(b.0));
1122                let parts: Vec<String> = ords
1123                    .into_iter()
1124                    .map(|(k, v)| format!("{}loop_eq_{}", k, v))
1125                    .collect();
1126                if !parts.is_empty() {
1127                    pieces.push(parts.join("__"));
1128                }
1129            }
1130        }
1131
1132        pieces.join("-")
1133    }
1134
1135    /// Canonical human-readable representation. Deterministic and lexicographically ordered.
1136    pub fn repr_str(&self) -> String {
1137        let lower = |s: &str| s.to_ascii_lowercase();
1138
1139        let mut out: Vec<String> = Vec::new();
1140
1141        // initial states
1142        if self.initial.is_empty() {
1143            out.push("{}".to_string());
1144        } else {
1145            for p in &self.initial {
1146                out.push(lower(p));
1147            }
1148        }
1149
1150        // arrow
1151        out.push(">".to_string());
1152
1153        // final states or sets
1154        if self.final_sets.is_empty() {
1155            if self.final_.is_empty() {
1156                out.push("{}".to_string());
1157            } else {
1158                for p in &self.final_ {
1159                    out.push(lower(p));
1160                }
1161            }
1162        } else {
1163            // sets with deterministic inner ordering as given; outer order is as parsed
1164            let sets_str = self
1165                .final_sets
1166                .iter()
1167                .map(|set| {
1168                    if set.is_empty() {
1169                        "empty".to_string()
1170                    } else {
1171                        set.iter().map(|p| lower(p)).collect::<Vec<_>>().join(" ")
1172                    }
1173                })
1174                .collect::<Vec<_>>()
1175                .join(", ");
1176            out.push(format!("{{ {} }}", sets_str));
1177        }
1178
1179        // particle vetoes (sorted)
1180        if !self.veto.is_empty() {
1181            out.push("/".to_string());
1182            let mut veto_list: Vec<String> = self.veto.iter().map(|v| lower(v)).collect();
1183            veto_list.sort();
1184            out.extend(veto_list);
1185        }
1186
1187        // amplitude coupling orders (sorted by coupling name)
1188        if !self.amp_couplings.is_empty() {
1189            let mut keys: Vec<&String> = self.amp_couplings.keys().collect();
1190            keys.sort();
1191            for k in keys {
1192                let r = &self.amp_couplings[k];
1193                if let Some(eq) = r.eq {
1194                    out.push(format!("{}=={}", k, eq));
1195                } else {
1196                    if let Some(min) = r.min {
1197                        if min > 0 {
1198                            out.push(format!("{}>={}", k, min));
1199                        }
1200                    }
1201                    if let Some(max) = r.max {
1202                        out.push(format!("{}<={}", k, max));
1203                    }
1204                }
1205            }
1206        }
1207
1208        // perturbative block if any
1209        let has_pert_block = self.pert.loops_sum_amp_or_sum.is_some()
1210            || self.pert.loops_forward_graph.is_some()
1211            || !self.pert.orders.is_empty();
1212        if has_pert_block {
1213            out.push("[".to_string());
1214            if let Some(n) = self.pert.loops_sum_amp_or_sum {
1215                out.push(format!("{{{}}}", n));
1216            }
1217            if let Some(n) = self.pert.loops_forward_graph {
1218                out.push(format!("{{{{{}}}}}", n));
1219            }
1220            if !self.pert.orders.is_empty() {
1221                let mut ords: Vec<(&String, &u32)> = self.pert.orders.iter().collect();
1222                ords.sort_by(|a, b| a.0.cmp(b.0));
1223                for (k, v) in ords {
1224                    if *v > 0 {
1225                        if *v == 1 {
1226                            out.push(k.clone());
1227                        } else {
1228                            out.push(format!("{}={}", k, v));
1229                        }
1230                    }
1231                }
1232            }
1233            out.push("]".to_string());
1234        }
1235
1236        // cross-section coupling orders (sorted by name, then power)
1237        if !self.xs_couplings.is_empty() {
1238            let mut entries: Vec<(&CouplingKey, &OrderRange)> = self.xs_couplings.iter().collect();
1239            entries.sort_by(|(ka, _), (kb, _)| {
1240                use std::cmp::Ordering;
1241                match ka.name.cmp(&kb.name) {
1242                    Ordering::Equal => ka.power.cmp(&kb.power),
1243                    other => other,
1244                }
1245            });
1246            for (k, r) in entries {
1247                if let Some(eq) = r.eq {
1248                    out.push(format!("{}^{}=={}", k.name, k.power, eq));
1249                } else {
1250                    if let Some(min) = r.min {
1251                        if min > 0 {
1252                            out.push(format!("{}^{}>={}", k.name, k.power, min));
1253                        }
1254                    }
1255                    if let Some(max) = r.max {
1256                        out.push(format!("{}^{}<={}", k.name, k.power, max));
1257                    }
1258                }
1259            }
1260        }
1261
1262        out.join(" ")
1263    }
1264}
1265
1266// =================== Parsing ===================
1267
1268#[derive(Debug, Error, PartialEq, Eq)]
1269pub enum ParseError {
1270    #[error("expected 'to' or '>' between initial and final states")]
1271    MissingArrow,
1272    #[error("unbalanced brackets/braces")]
1273    Unbalanced,
1274    #[error("invalid token '{0}'")]
1275    InvalidToken(String),
1276    #[error("unknown particle '{name}'. Valid choices: {choices}")]
1277    UnknownParticle { name: String, choices: String },
1278    #[error("unknown coupling '{name}'. Valid choices: {choices}")]
1279    UnknownCoupling { name: String, choices: String },
1280    #[error("invalid lmb specification: {0}")]
1281    InvalidLmbSpec(ParseIntError),
1282}
1283
1284pub fn parse_spec_with_model(
1285    args: &SpecArgs,
1286    generation_type: GenerationType,
1287    model: &Model,
1288) -> std::result::Result<ProcessSpec, ParseError> {
1289    let raw = args.tokens.join(" ");
1290    let (lhs, rhs0) = split_top_level_arrow(&raw).ok_or(ParseError::MissingArrow)?;
1291    let (rhs_wo_bracket, pert) = extract_perturbative(rhs0.trim())?;
1292
1293    // LHS initial states
1294    let lhs = lhs.trim();
1295    let (initial_names, empty_initial) = if lhs == "{}" || lhs.eq_ignore_ascii_case("empty-initial")
1296    {
1297        (Vec::new(), true)
1298    } else {
1299        (split_ws(lhs), false)
1300    };
1301
1302    // Tokenize RHS outside the perturbative block
1303    let tokens = tokenize(rhs_wo_bracket)?;
1304    let (final_spec, empty_final, rest) = parse_final_states(tokens)?;
1305    let (veto_names, only, amp_couplings, xs_couplings) = parse_process_options(rest)?;
1306
1307    // Validate coupling orders against model
1308    validate_coupling_names(model, &amp_couplings, &xs_couplings, &pert)?;
1309
1310    // Convert names → PDGs using the model
1311    let initial_pdgs = resolve_pdgs(model, &initial_names)?;
1312    let primary_final_pdgs = resolve_pdgs(model, &final_spec.primary)?;
1313    let final_sets_pdgs = final_spec
1314        .sets
1315        .iter()
1316        .map(|set| resolve_pdgs(model, set))
1317        .collect::<Result<Vec<_>, _>>()?;
1318
1319    // Resolve vetoed particles to PDGs and validate they exist
1320    let mut veto_pdgs = resolve_pdgs(model, &veto_names.iter().cloned().collect::<Vec<_>>())?;
1321    if let Some(o) = only.clone() {
1322        let only_vec = o.iter().cloned().collect::<Vec<_>>();
1323        if !only_vec.is_empty() {
1324            let only_pdgs = resolve_pdgs(model, &only_vec)?
1325                .iter()
1326                .map(|pdg| pdg.abs())
1327                .collect::<Vec<_>>();
1328
1329            let all_pdgs = model
1330                .particles
1331                .iter()
1332                .map(|p| p.pdg_code.abs() as i64)
1333                .filter(|pdg| !only_pdgs.contains(pdg))
1334                .collect::<Vec<_>>();
1335
1336            veto_pdgs.extend(all_pdgs);
1337            veto_pdgs.sort_unstable();
1338            veto_pdgs.dedup();
1339        }
1340    }
1341
1342    // Build FeynGenOptions with filters and PDGs
1343    let numerator_grouping = build_grouping_option(args);
1344    let process_definition = feyngen_from_spec_args(
1345        args,
1346        generation_type,
1347        &pert,
1348        &amp_couplings,
1349        &xs_couplings,
1350        &initial_pdgs,
1351        &primary_final_pdgs,
1352        &final_sets_pdgs,
1353        &veto_pdgs,
1354    );
1355
1356    let mut spec = ProcessSpec {
1357        initial: initial_names,
1358        final_: final_spec.primary.clone(),
1359        empty_initial,
1360        empty_final,
1361        veto: veto_names,
1362        only,
1363        amp_couplings,
1364        xs_couplings,
1365        pert,
1366        final_sets: final_spec.sets,
1367        process_definition,
1368        numerator_grouping,
1369    };
1370
1371    if let Some(process_name) = &args.process_name {
1372        spec.process_definition.folder_name = process_name.clone();
1373    } else {
1374        spec.process_definition.folder_name = spec.process_shell_name(true);
1375    }
1376    spec.process_definition.numerator_grouping = spec.numerator_grouping.clone();
1377    spec.process_definition.filter_self_loop = args.filter_self_loop.unwrap_or(false);
1378    spec.process_definition.filter_zero_flow_edges = args.filter_zero_flow_edges.unwrap_or(true);
1379
1380    spec.process_definition.graph_prefix = args
1381        .graph_prefix
1382        .clone()
1383        .unwrap_or_else(|| "GL".to_string());
1384    // spec.process_definition.selected_graphs =
1385    //     args.select_graphs.as_ref().map(|s| parse_csv_list(s));
1386    // spec.process_definition.vetoed_graphs = args.veto_graphs.as_ref().map(|s| parse_csv_list(s));
1387    spec.process_definition.selected_graphs = args.select_graphs.clone();
1388    spec.process_definition.vetoed_graphs = args.veto_graphs.clone();
1389    spec.process_definition.loop_momentum_bases = args
1390        .loop_momentum_bases
1391        .as_deref()
1392        .map(|kvs| {
1393            kvs.iter()
1394                .map(|kv| {
1395                    parse_csv_list(&kv.value)
1396                        .into_iter()
1397                        .map(|s| s.parse::<usize>())
1398                        .collect::<core::result::Result<Vec<_>, _>>()
1399                        .map(|lst| (kv.key.clone(), lst))
1400                })
1401                .collect::<core::result::Result<HashMap<_, _>, _>>()
1402        })
1403        .transpose()
1404        .map_err(ParseError::InvalidLmbSpec)?;
1405    let mut prefactor = GlobalPrefactor::default();
1406    if let Some(projector) = &args.global_prefactor_projector {
1407        prefactor.projector = parse!(projector);
1408    }
1409    if let Some(num) = &args.global_prefactor_num {
1410        prefactor.num = parse!(num);
1411    }
1412    spec.process_definition.prefactor = prefactor;
1413
1414    Ok(spec)
1415}
1416
1417pub(crate) fn parse_process_spec_string(
1418    spec: &str,
1419    generation_type: GenerationType,
1420    model: &Model,
1421) -> std::result::Result<ProcessSpec, ParseError> {
1422    let args = SpecArgs::from_process_spec_string(spec);
1423    parse_spec_with_model(&args, generation_type, model)
1424}
1425
1426// ---- helpers ----
1427
1428fn split_top_level_arrow(s: &str) -> Option<(&str, &str)> {
1429    // Accept either "to" token or '>' outside any [...] or {...}
1430    let mut depth_brace = 0i32;
1431    let mut depth_bracket = 0i32;
1432    let bytes = s.as_bytes();
1433    let mut i = 0usize;
1434    while i < bytes.len() {
1435        let c = bytes[i] as char;
1436        match c {
1437            '{' => depth_brace += 1,
1438            '}' => depth_brace -= 1,
1439            '[' => depth_bracket += 1,
1440            ']' => depth_bracket -= 1,
1441            '>' if depth_brace == 0 && depth_bracket == 0 => {
1442                return Some((&s[..i], &s[i + 1..]));
1443            }
1444            _ => {
1445                if depth_brace == 0
1446                    && depth_bracket == 0
1447                    && i + 2 < bytes.len()
1448                    && (c == 't' || c == 'T')
1449                    && &s[i..].to_ascii_lowercase().as_str()[..2] == "to"
1450                {
1451                    let before = if i == 0 {
1452                        ' '
1453                    } else {
1454                        s.as_bytes()[i - 1] as char
1455                    };
1456                    let after = if i + 2 >= s.len() {
1457                        ' '
1458                    } else {
1459                        s.as_bytes()[i + 2] as char
1460                    };
1461                    if before.is_whitespace() && after.is_whitespace() {
1462                        return Some((&s[..i], &s[i + 2..]));
1463                    }
1464                }
1465            }
1466        }
1467        i += 1;
1468    }
1469    None
1470}
1471
1472fn extract_perturbative(rhs: &str) -> Result<(String, Perturbative), ParseError> {
1473    let mut out = rhs.to_string();
1474    let mut spec = Perturbative::default();
1475    if let Some((start, end)) = find_top_level_block(rhs, '[', ']') {
1476        let inside = &rhs[start + 1..end];
1477        spec = parse_perturbative_block(inside.trim())?;
1478        out.replace_range(start..=end, "");
1479    }
1480    Ok((out, spec))
1481}
1482
1483fn parse_perturbative_block(s: &str) -> Result<Perturbative, ParseError> {
1484    if s.is_empty() {
1485        return Ok(Perturbative::default());
1486    }
1487    let re_amp = Regex::new(r"^\{(\d+)\}$").unwrap();
1488    let re_fwd = Regex::new(r"^\{\{(\d+)\}\}$").unwrap();
1489    let re_order = Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*)=(\d+)$").unwrap();
1490
1491    let mut spec = Perturbative::default();
1492    for tok in split_ws(s) {
1493        if let Some(c) = re_amp.captures(&tok) {
1494            spec.loops_sum_amp_or_sum = Some(c[1].parse().unwrap());
1495            continue;
1496        }
1497        if let Some(c) = re_fwd.captures(&tok) {
1498            spec.loops_forward_graph = Some(c[1].parse().unwrap());
1499            continue;
1500        }
1501        if let Some(c) = re_order.captures(&tok) {
1502            let n: u32 = c[2].parse().unwrap();
1503            spec.orders.insert(c[1].to_string(), n);
1504            continue;
1505        }
1506        if tok.chars().all(|ch| ch.is_ascii_alphabetic()) {
1507            spec.orders.insert(tok, 1);
1508            continue;
1509        }
1510        return Err(ParseError::InvalidToken(tok));
1511    }
1512    Ok(spec)
1513}
1514
1515fn tokenize(s: String) -> Result<Vec<String>, ParseError> {
1516    let mut out = Vec::new();
1517    let mut cur = String::new();
1518    let mut depth = 0i32;
1519    for ch in s.chars() {
1520        match ch {
1521            '{' => {
1522                depth += 1;
1523                cur.push(ch);
1524            }
1525            '}' => {
1526                depth -= 1;
1527                cur.push(ch);
1528                if depth < 0 {
1529                    return Err(ParseError::Unbalanced);
1530                }
1531            }
1532            c if c.is_whitespace() && depth == 0 => {
1533                if !cur.trim().is_empty() {
1534                    out.push(cur.trim().to_string());
1535                }
1536                cur.clear();
1537            }
1538            _ => cur.push(ch),
1539        }
1540    }
1541    if !cur.trim().is_empty() {
1542        out.push(cur.trim().to_string());
1543    }
1544    if depth != 0 {
1545        return Err(ParseError::Unbalanced);
1546    }
1547    Ok(out)
1548}
1549
1550fn parse_final_states(tokens: Vec<String>) -> Result<(FinalSpec, bool, Vec<String>), ParseError> {
1551    if tokens.is_empty() {
1552        return Ok((FinalSpec::default(), false, vec![]));
1553    }
1554    let mut t = tokens;
1555    let mut finals = Vec::<String>::new();
1556    let mut empty_final = false;
1557
1558    if t[0].eq_ignore_ascii_case("empty-final") || t[0] == "{}" {
1559        empty_final = true;
1560        t.remove(0);
1561        return Ok((FinalSpec::default(), empty_final, t));
1562    }
1563
1564    if t[0].starts_with('{') {
1565        let raw = t.remove(0);
1566        let sets = parse_alt_sets(&raw)?;
1567        return Ok((
1568            FinalSpec {
1569                primary: vec![],
1570                sets,
1571            },
1572            empty_final,
1573            t,
1574        ));
1575    }
1576
1577    while !t.is_empty() {
1578        if is_option_token(&t[0]) {
1579            break;
1580        }
1581        finals.push(t.remove(0));
1582    }
1583    Ok((
1584        FinalSpec {
1585            primary: finals,
1586            sets: vec![],
1587        },
1588        empty_final,
1589        t,
1590    ))
1591}
1592
1593#[derive(Default)]
1594struct FinalSpec {
1595    primary: Vec<String>,
1596    sets: Vec<Vec<String>>,
1597}
1598
1599fn parse_alt_sets(s: &str) -> Result<Vec<Vec<String>>, ParseError> {
1600    let trimmed = s.trim();
1601    if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
1602        return Err(ParseError::InvalidToken(s.to_string()));
1603    }
1604    let inner = &trimmed[1..trimmed.len() - 1];
1605    let mut out: Vec<Vec<String>> = Vec::new();
1606    let mut cur = String::new();
1607    let mut depth = 0i32;
1608    for ch in inner.chars() {
1609        match ch {
1610            '{' => {
1611                depth += 1;
1612                cur.push(ch);
1613            }
1614            '}' => {
1615                depth -= 1;
1616                if depth < 0 {
1617                    return Err(ParseError::Unbalanced);
1618                }
1619                cur.push(ch);
1620            }
1621            ',' if depth == 0 => {
1622                let items = split_ws(cur.trim());
1623                if !items.is_empty() {
1624                    out.push(items);
1625                } else {
1626                    out.push(vec![]);
1627                }
1628                cur.clear();
1629            }
1630            _ => cur.push(ch),
1631        }
1632    }
1633    if !cur.trim().is_empty() {
1634        out.push(split_ws(cur.trim()));
1635    }
1636    Ok(out)
1637}
1638
1639fn is_option_token(tok: &str) -> bool {
1640    matches!(
1641        tok,
1642        "/" | "|" | "veto" | "only" | "amp" | "xs" | "pert" | "sets" | "empty-final"
1643    ) || tok.starts_with('[')
1644        || Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*(?:\^\d+)?(==|>=|<=)\d+$")
1645            .unwrap()
1646            .is_match(tok)
1647}
1648
1649#[allow(clippy::type_complexity)]
1650fn parse_process_options(
1651    tokens: Vec<String>,
1652) -> Result<
1653    (
1654        BTreeSet<String>,
1655        Option<BTreeSet<String>>,
1656        BTreeMap<String, OrderRange>,
1657        BTreeMap<CouplingKey, OrderRange>,
1658    ),
1659    ParseError,
1660> {
1661    let mut veto = BTreeSet::<String>::new();
1662    let mut only: Option<BTreeSet<String>> = None;
1663    let mut amp = BTreeMap::<String, OrderRange>::new();
1664    let mut xs = BTreeMap::<CouplingKey, OrderRange>::new();
1665
1666    let re = Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*)(?:\^(\d+))?(==|>=|<=)(\d+)$").unwrap();
1667
1668    let mut i = 0usize;
1669    while i < tokens.len() {
1670        match tokens[i].as_str() {
1671            "/" | "veto" => {
1672                i += 1;
1673                while i < tokens.len() && !is_option_token(&tokens[i]) {
1674                    veto.insert(tokens[i].clone());
1675                    i += 1;
1676                }
1677            }
1678            "|" | "only" => {
1679                i += 1;
1680                let mut s = only.take().unwrap_or_default();
1681                while i < tokens.len() && !is_option_token(&tokens[i]) {
1682                    s.insert(tokens[i].clone());
1683                    i += 1;
1684                }
1685                only = Some(s);
1686            }
1687            "amp" | "xs" | "pert" | "sets" | "empty-final" => {
1688                return Err(ParseError::InvalidToken(tokens[i].clone()));
1689            }
1690            other => {
1691                if let Some(c) = re.captures(other) {
1692                    let name = c[1].to_string();
1693                    let power: u32 = c.get(2).map(|m| m.as_str().parse().unwrap()).unwrap_or(1);
1694                    let op = c.get(3).unwrap().as_str();
1695                    let val: u32 = c.get(4).unwrap().as_str().parse().unwrap();
1696                    if power == 1 {
1697                        let e = amp.entry(name).or_default();
1698                        e.set(op, val);
1699                    } else {
1700                        let key = CouplingKey { name, power };
1701                        let e = xs.entry(key).or_default();
1702                        e.set(op, val);
1703                    }
1704                } else {
1705                    return Err(ParseError::InvalidToken(other.to_string()));
1706                }
1707                i += 1;
1708            }
1709        }
1710    }
1711    Ok((veto, only, amp, xs))
1712}
1713
1714fn find_top_level_block(s: &str, open: char, close: char) -> Option<(usize, usize)> {
1715    let mut depth = 0i32;
1716    let mut start: Option<usize> = None;
1717    for (i, ch) in s.char_indices() {
1718        if ch == open {
1719            if depth == 0 {
1720                start = Some(i);
1721            }
1722            depth += 1;
1723        } else if ch == close {
1724            depth -= 1;
1725            if depth == 0 {
1726                return start.map(|st| (st, i));
1727            }
1728        }
1729    }
1730    None
1731}
1732
1733fn split_ws(s: &str) -> Vec<String> {
1734    s.split_whitespace().map(|t| t.to_string()).collect()
1735}
1736
1737// =================== Build Feyngen options and filters ===================
1738
1739fn feyngen_from_spec_args(
1740    a: &SpecArgs,
1741    generation_type: GenerationType,
1742    pert: &Perturbative,
1743    amp_couplings: &BTreeMap<String, OrderRange>,
1744    xs_couplings: &BTreeMap<CouplingKey, OrderRange>,
1745    initial_pdgs: &[i64],
1746    primary_final_pdgs: &[i64],
1747    final_sets_pdgs: &[Vec<i64>],
1748    veto_pdgs: &[i64],
1749) -> ProcessDefinition {
1750    // Decide vacuum-like topology from PDGs
1751    let is_vacuum = if initial_pdgs.is_empty() {
1752        match generation_type {
1753            GenerationType::CrossSection => true,
1754            GenerationType::Amplitude => {
1755                primary_final_pdgs.is_empty()
1756                    && (final_sets_pdgs
1757                        .first()
1758                        .map(|v| v.is_empty())
1759                        .unwrap_or(true))
1760            }
1761        }
1762    } else {
1763        false
1764    };
1765
1766    // Smart defaults influenced by vacuum
1767    let filter_tadpoles_default = !is_vacuum;
1768    let filter_snails_default = !is_vacuum;
1769    let filter_selfenergies_default = !is_vacuum;
1770
1771    // Normalize filter toggles
1772    let filter_tadpoles = a.filter_tadpoles.unwrap_or(filter_tadpoles_default);
1773    let filter_snails = a.filter_snails.unwrap_or(filter_snails_default);
1774    let filter_selfenergies = a.filter_selfenergies.unwrap_or(filter_selfenergies_default);
1775
1776    // Normalize numeric toggles with the vacuum defaults
1777    let number_of_factorized_loop_subtopologies = a
1778        .number_of_factorized_loop_subtopologies
1779        .clone()
1780        .or(if is_vacuum {
1781            Some(vec![1_i32, 1_i32])
1782        } else {
1783            None
1784        })
1785        .and_then(|v| {
1786            if v[0] < 0 && v[1] < 0 {
1787                None
1788            } else {
1789                Some((v[0].max(0) as usize, v[1].max(0) as usize))
1790            }
1791        });
1792
1793    let max_n_bridges = a
1794        .max_n_bridges
1795        .or(if is_vacuum { Some(0) } else { None })
1796        .and_then(|n| if n < 0 { None } else { Some(n as usize) });
1797
1798    let number_of_fermion_loops = a
1799        .number_of_fermion_loops
1800        .as_ref()
1801        .map(|v| (v[0].max(0) as usize, v[1].max(0) as usize));
1802
1803    // Cut ranges (XS)
1804    let blob_range: RangeInclusive<usize> = {
1805        let (lo, hi) = a
1806            .n_cut_blobs
1807            .as_ref()
1808            .map(|v| (v[0], v[1]))
1809            .unwrap_or((1, 1));
1810        lo..=hi
1811    };
1812    let spectator_range: RangeInclusive<usize> = {
1813        let (lo, hi) = a
1814            .n_cut_spectators
1815            .as_ref()
1816            .map(|v| (v[0], v[1]))
1817            .unwrap_or((0, 0));
1818        lo..=hi
1819    };
1820
1821    // Symmetrization defaults depend on generation type (mirror python logic)
1822    let sym_left_right = a.symmetrize_left_right_states.unwrap_or(false);
1823    let (sym_init, sym_final) = match generation_type {
1824        GenerationType::Amplitude => {
1825            // LR is the amplitude shorthand; explicit side options take precedence.
1826            let s_init = a.symmetrize_initial_states.unwrap_or(sym_left_right);
1827            let s_final = a.symmetrize_final_states.unwrap_or(sym_left_right);
1828            (s_init, s_final)
1829        }
1830        GenerationType::CrossSection => {
1831            let s_init = a.symmetrize_initial_states.unwrap_or(false);
1832            let s_final = a.symmetrize_final_states.unwrap_or(true);
1833            (s_init, s_final)
1834        }
1835    };
1836    let allow_symferm = a
1837        .allow_symmetrization_of_external_fermions_in_amplitudes
1838        .unwrap_or(false);
1839
1840    // Base options
1841    let mut fg = ProcessDefinition {
1842        generation_type,
1843        initial_pdgs: initial_pdgs.to_vec(),
1844        final_pdgs_lists: if final_sets_pdgs.is_empty() {
1845            vec![primary_final_pdgs.to_vec()]
1846        } else if !primary_final_pdgs.is_empty() {
1847            let mut sets = vec![primary_final_pdgs.to_vec()];
1848            sets.extend_from_slice(final_sets_pdgs);
1849            sets
1850        } else {
1851            final_sets_pdgs.to_vec()
1852        },
1853        loop_count_range: (1, 1), // may be overridden below
1854        // (Blob/Spectator ranges are expressed through filters below)
1855        symmetrize_initial_states: sym_init,
1856        symmetrize_final_states: sym_final,
1857        symmetrize_left_right_states: sym_left_right,
1858        allow_symmetrization_of_external_fermions_in_amplitudes: allow_symferm,
1859        max_multiplicity_for_fast_cut_filter: a.max_multiplicity_for_fast_cut_filter,
1860        amplitude_filters: FeynGenFilters(vec![]),
1861        cross_section_filters: FeynGenFilters(vec![]),
1862        ..Default::default()
1863    };
1864
1865    // Build filters
1866    let mut amp_filters: Vec<FeynGenFilter> = Vec::new();
1867    let mut xs_filters: Vec<FeynGenFilter> = Vec::new();
1868
1869    // Loop counts
1870    if let Some(n) = pert.loops_sum_amp_or_sum {
1871        amp_filters.push(FeynGenFilter::LoopCountRange((n as usize, n as usize)));
1872        if fg.generation_type == GenerationType::Amplitude {
1873            fg.loop_count_range = (n as usize, n as usize);
1874        }
1875    }
1876    if let Some(n) = pert.loops_forward_graph {
1877        xs_filters.push(FeynGenFilter::LoopCountRange((n as usize, n as usize)));
1878        if fg.generation_type == GenerationType::CrossSection {
1879            fg.loop_count_range = (n as usize, n as usize);
1880        }
1881    }
1882
1883    // Perturbative orders (only to matching container)
1884    if !pert.orders.is_empty() {
1885        let map: HashMap<String, usize> = pert
1886            .orders
1887            .iter()
1888            .map(|(k, v)| (k.clone(), *v as usize))
1889            .collect();
1890        if fg.generation_type == GenerationType::Amplitude {
1891            amp_filters.push(FeynGenFilter::PerturbativeOrders(map));
1892        } else {
1893            xs_filters.push(FeynGenFilter::PerturbativeOrders(map));
1894        }
1895    }
1896
1897    // CouplingOrders
1898    if !amp_couplings.is_empty() {
1899        let cmap = coupling_orders_from_order_ranges_amp(amp_couplings);
1900        amp_filters.push(FeynGenFilter::CouplingOrders(cmap));
1901    }
1902    if !xs_couplings.is_empty() {
1903        let cmap = coupling_orders_from_order_ranges_xs(xs_couplings);
1904        xs_filters.push(FeynGenFilter::CouplingOrders(cmap));
1905    }
1906
1907    // Factorized loop topologies → FactorizedLoopTopologiesCountRange((n,n))
1908    if let Some((n_min, n_max)) = number_of_factorized_loop_subtopologies {
1909        let filt = FeynGenFilter::FactorizedLoopTopologiesCountRange((n_min, n_max));
1910        if fg.generation_type == GenerationType::Amplitude {
1911            amp_filters.push(filt);
1912        } else {
1913            xs_filters.push(filt);
1914        }
1915    }
1916
1917    // Fermion loop count → FermionLoopCountRange((n,n))
1918    if let Some((n_min, n_max)) = number_of_fermion_loops {
1919        let filt = FeynGenFilter::FermionLoopCountRange((n_min, n_max));
1920        if fg.generation_type == GenerationType::Amplitude {
1921            amp_filters.push(filt);
1922        } else {
1923            xs_filters.push(filt);
1924        }
1925    }
1926
1927    // MaxNumberOfBridges
1928    if let Some(n) = max_n_bridges {
1929        let filt = FeynGenFilter::MaxNumberOfBridges(n);
1930        if fg.generation_type == GenerationType::Amplitude {
1931            amp_filters.push(filt);
1932        } else {
1933            xs_filters.push(filt);
1934        }
1935    }
1936
1937    // Particle vetoes
1938    if !veto_pdgs.is_empty() {
1939        let filt = FeynGenFilter::ParticleVeto(veto_pdgs.to_vec());
1940        if fg.generation_type == GenerationType::Amplitude {
1941            amp_filters.push(filt);
1942        } else {
1943            xs_filters.push(filt);
1944        }
1945    }
1946
1947    // XS cut ranges
1948    if fg.generation_type == GenerationType::CrossSection {
1949        xs_filters.push(FeynGenFilter::BlobRange(blob_range));
1950        xs_filters.push(FeynGenFilter::SpectatorRange(spectator_range));
1951    }
1952
1953    // Self-energy / snails / tadpoles with detailed veto flags:
1954    if filter_selfenergies {
1955        let mut se = SelfEnergyFilterOptions::default();
1956        if let Some(opt) = a.veto_self_energy_of_massive_lines {
1957            se.veto_self_energy_of_massive_lines = opt;
1958        }
1959        if let Some(opt) = a.veto_self_energy_of_massless_lines {
1960            se.veto_self_energy_of_massless_lines = opt;
1961        }
1962        if let Some(opt) = a.veto_only_scaleless_self_energy {
1963            se.veto_only_scaleless_self_energy = opt;
1964        }
1965        let filt = FeynGenFilter::SelfEnergyFilter(se);
1966        if fg.generation_type == GenerationType::Amplitude {
1967            amp_filters.push(filt);
1968        } else {
1969            xs_filters.push(filt);
1970        }
1971    }
1972    if filter_snails {
1973        let mut sn = SnailFilterOptions::default();
1974        if let Some(opt) = a.veto_snails_attached_to_massive_lines {
1975            sn.veto_snails_attached_to_massive_lines = opt;
1976        }
1977        if let Some(opt) = a.veto_snails_attached_to_massless_lines {
1978            sn.veto_snails_attached_to_massless_lines = opt;
1979        }
1980        if let Some(opt) = a.veto_only_scaleless_snails {
1981            sn.veto_only_scaleless_snails = opt;
1982        }
1983        let filt = FeynGenFilter::ZeroSnailsFilter(sn);
1984        if fg.generation_type == GenerationType::Amplitude {
1985            amp_filters.push(filt);
1986        } else {
1987            xs_filters.push(filt);
1988        }
1989    }
1990    if filter_tadpoles {
1991        let mut td = TadpolesFilterOptions::default();
1992        if let Some(opt) = a.veto_tadpoles_attached_to_massive_lines {
1993            td.veto_tadpoles_attached_to_massive_lines = opt;
1994        }
1995        if let Some(opt) = a.veto_tadpoles_attached_to_massless_lines {
1996            td.veto_tadpoles_attached_to_massless_lines = opt;
1997        }
1998        if let Some(opt) = a.veto_only_scaleless_tadpoles {
1999            td.veto_only_scaleless_tadpoles = opt;
2000        }
2001        let filt = FeynGenFilter::TadpolesFilter(td);
2002        if fg.generation_type == GenerationType::Amplitude {
2003            amp_filters.push(filt);
2004        } else {
2005            xs_filters.push(filt);
2006        }
2007    }
2008
2009    // XS-only: sewed filter controlled separately
2010    if fg.generation_type == GenerationType::CrossSection
2011        && a.filter_cross_section_tadpoles.unwrap_or(false)
2012    {
2013        if let Some(opt) = a.filter_cross_section_tadpoles {
2014            xs_filters.push(FeynGenFilter::SewedFilter(SewedFilterOptions {
2015                filter_tadpoles: opt,
2016            }));
2017        }
2018    }
2019
2020    if let Some(vertex_interactions_allowed) = a.allowed_vertex_interactions.as_ref() {
2021        if !vertex_interactions_allowed.is_empty() {
2022            let filt = FeynGenFilter::VertexAllow(vertex_interactions_allowed.clone());
2023            if fg.generation_type == GenerationType::Amplitude {
2024                amp_filters.push(filt);
2025            } else {
2026                xs_filters.push(filt);
2027            }
2028        }
2029    }
2030    if let Some(vertex_interactions_vetoed) = a.veto_vertex_interactions.as_ref() {
2031        let filt = FeynGenFilter::VertexVeto(vertex_interactions_vetoed.clone());
2032        if fg.generation_type == GenerationType::Amplitude {
2033            amp_filters.push(filt);
2034        } else {
2035            xs_filters.push(filt);
2036        }
2037    }
2038
2039    fg.amplitude_filters = FeynGenFilters(amp_filters);
2040    fg.cross_section_filters = FeynGenFilters(xs_filters);
2041
2042    fg
2043}
2044
2045fn coupling_orders_from_order_ranges_amp(
2046    orders: &BTreeMap<String, OrderRange>,
2047) -> HashMap<String, (usize, Option<usize>)> {
2048    let mut out = HashMap::default();
2049    for (name, r) in orders {
2050        if let Some(eq) = r.eq {
2051            out.insert(name.clone(), (eq as usize, Some(eq as usize)));
2052        } else {
2053            let minv = r.min.unwrap_or(0) as usize;
2054            let maxv = r.max.map(|m| m as usize);
2055            out.insert(name.clone(), (minv, maxv));
2056        }
2057    }
2058    out
2059}
2060
2061fn coupling_orders_from_order_ranges_xs(
2062    orders: &BTreeMap<CouplingKey, OrderRange>,
2063) -> HashMap<String, (usize, Option<usize>)> {
2064    // Drop the power and merge constraints per name.
2065    let mut merged: BTreeMap<String, OrderRange> = BTreeMap::new();
2066    for (k, r) in orders {
2067        let e = merged.entry(k.name.clone()).or_default();
2068        if let Some(eq) = r.eq {
2069            e.eq = Some(eq);
2070        }
2071        if let Some(min) = r.min {
2072            e.min = Some(e.min.map_or(min, |m| m.max(min)));
2073        }
2074        if let Some(max) = r.max {
2075            e.max = Some(e.max.map_or(max, |m| m.min(max)));
2076        }
2077    }
2078    coupling_orders_from_order_ranges_amp(&merged)
2079}
2080
2081fn build_grouping_option(a: &SpecArgs) -> NumeratorAwareGraphGroupingOption {
2082    a.numerator_aware_isomorphism_grouping
2083        .unwrap_or(GroupingChoice::GroupIdenticalGraphsUpToScalarRescaling)
2084        .to_strategy(
2085            a.numerical_samples_seed,
2086            a.number_of_samples_for_numerator_comparisons,
2087            a.consider_internal_masses_only_in_numerator_isomorphisms,
2088            a.fully_numerical_substitution_when_comparing_numerators,
2089            a.compare_canonized_numerator,
2090            a.symmetric_left_right_polarizations,
2091        )
2092}
2093
2094// ---- Model-backed validation and resolution ----
2095
2096fn all_particle_names(model: &Model) -> Vec<String> {
2097    model
2098        .particles
2099        .iter()
2100        .map(|p| p.name.clone().to_string())
2101        .collect::<Vec<_>>()
2102}
2103
2104fn resolve_pdgs(model: &Model, names: &[String]) -> Result<Vec<i64>, ParseError> {
2105    let all_particles = all_particle_names(model);
2106    let mut out = Vec::with_capacity(names.len());
2107    'outer: for n in names {
2108        // Accept numeric PDG directly
2109        if let Ok(p) = n.parse::<i64>() {
2110            out.push(p);
2111            continue;
2112        }
2113        // Try exact name lookup via model
2114        if let Ok(p) = model.try_get_particle(n) {
2115            out.push(p.pdg_code as i64);
2116            continue;
2117        }
2118        // Case-insensitive fallback against both names and antinames
2119        for part in model.particles.iter() {
2120            if part.name.eq_ignore_ascii_case(n) || part.antiname.eq_ignore_ascii_case(n) {
2121                out.push(part.pdg_code as i64);
2122                continue 'outer;
2123            }
2124        }
2125        // Unknown
2126        return Err(ParseError::UnknownParticle {
2127            name: n.clone(),
2128            choices: all_particles.join(", "),
2129        });
2130    }
2131    Ok(out)
2132}
2133
2134fn validate_coupling_names(
2135    model: &Model,
2136    amp: &BTreeMap<String, OrderRange>,
2137    xs: &BTreeMap<CouplingKey, OrderRange>,
2138    pert: &Perturbative,
2139) -> Result<(), ParseError> {
2140    // If the model has no coupling metadata, skip validation entirely.
2141    let has_orders_info = !model.orders.is_empty();
2142
2143    if !has_orders_info {
2144        return Ok(());
2145    }
2146
2147    // Collect all mentioned coupling names
2148    let mut names = BTreeSet::<String>::new();
2149    for k in amp.keys() {
2150        names.insert(k.clone());
2151    }
2152    for k in xs.keys() {
2153        names.insert(k.name.clone());
2154    }
2155    for k in pert.orders.keys() {
2156        names.insert(k.clone());
2157    }
2158
2159    if names.is_empty() {
2160        return Ok(());
2161    }
2162
2163    // Known couplings (case-insensitive compare)
2164    let known: Vec<String> = model
2165        .orders
2166        .iter()
2167        .map(|o| o.name.to_string())
2168        .collect::<Vec<_>>();
2169    let known_lower: BTreeSet<String> = known.iter().map(|s| s.to_ascii_lowercase()).collect();
2170
2171    for n in names {
2172        if !known_lower.contains(&n.to_ascii_lowercase()) {
2173            return Err(ParseError::UnknownCoupling {
2174                name: n,
2175                choices: known.join(", "),
2176            });
2177        }
2178    }
2179    Ok(())
2180}
2181
2182// ---- parsing of structured CLI fields ----
2183
2184fn parse_csv_list(s: &str) -> Vec<String> {
2185    s.split(',')
2186        .flat_map(|x| x.split_whitespace())
2187        .filter(|t| !t.is_empty())
2188        .map(|t| t.to_string())
2189        .collect()
2190}
2191
2192#[allow(dead_code)]
2193fn parse_loop_momentum_bases(s: &str) -> Option<HashMap<String, Vec<String>>> {
2194    // Format: "B1=p1,p2;B2=q1,q2"
2195    let mut out: HashMap<String, Vec<String>> = HashMap::default();
2196    for kv in s.split(';') {
2197        let kv = kv.trim();
2198        if kv.is_empty() {
2199            continue;
2200        }
2201        if let Some((k, v)) = kv.split_once('=') {
2202            let key = k.trim().to_string();
2203            let vals = v
2204                .split(',')
2205                .map(|x| x.trim().to_string())
2206                .filter(|x| !x.is_empty())
2207                .collect::<Vec<_>>();
2208            if !key.is_empty() {
2209                out.insert(key, vals);
2210            }
2211        } else {
2212            // single name without '=' is allowed but ignored
2213        }
2214    }
2215    if out.is_empty() {
2216        None
2217    } else {
2218        Some(out)
2219    }
2220}
2221
2222// =================== Tests ===================
2223
2224#[cfg(test)]
2225mod tests {
2226    use super::*;
2227    use crate::{commands::Commands, Repl};
2228    use clap::Parser;
2229    use gammalooprs::utils::load_generic_model;
2230    use gammalooprs::{feyngen::GenerationType, initialisation::test_initialise};
2231    use std::time::Duration;
2232
2233    #[test]
2234    fn thermal_xs_generation_is_rejected_before_changing_processes() -> Result<()> {
2235        use gammalooprs::{processes::ProcessCollection, settings::global::MediumMode};
2236
2237        let mut state = State::new_test();
2238        let mut cross_sections = BTreeMap::new();
2239        cross_sections.insert(
2240            "default".to_string(),
2241            CrossSection::from_graph_list("default".to_string(), vec![], &state.model)?,
2242        );
2243        state.process_list.add_process(Process {
2244            definition: ProcessDefinition {
2245                generation_type: GenerationType::CrossSection,
2246                ..ProcessDefinition::default()
2247            },
2248            settings_history: None,
2249            collection: ProcessCollection::CrossSections(cross_sections),
2250        });
2251        let mut settings = GlobalSettings::default();
2252        let runtime = RuntimeSettings::default();
2253        let temp = tempfile::tempdir()?;
2254
2255        for mode in [
2256            MediumMode::ThermodynamicEquilibrium,
2257            MediumMode::ZeroTemperatureEquilibrium,
2258        ] {
2259            settings.generation.medium.mode = mode;
2260            for command in [
2261                "generate xs a > d d~ --clear-existing-processes",
2262                "generate xs a > d d~ --only-diagrams --clear-existing-processes",
2263                "generate existing -p 0",
2264                "generate existing -p 0 --integrand-name default",
2265                "generate",
2266            ] {
2267                let repl = Repl::try_parse_from(
2268                    std::iter::once("gammaloop").chain(command.split_whitespace()),
2269                )?;
2270                let Commands::Generate(generate) = repl.command else {
2271                    unreachable!();
2272                };
2273                let error = generate
2274                    .run(&mut state, temp.path(), false, &settings, &runtime)
2275                    .unwrap_err();
2276                assert!(error.to_string().contains("`xs`"), "{command}: {error}");
2277                assert!(error.to_string().contains("not supported"));
2278                assert_eq!(state.process_list.processes.len(), 1);
2279                assert!(state.process_list.processes[0].settings_history.is_none());
2280            }
2281        }
2282        // The amplitude must remain untouched when a later cross section
2283        // rejects the batch settings. Empty graphs isolate preprocessing order.
2284        let vacuum_settings = GlobalSettings::default();
2285        state.process_list.processes.insert(
2286            0,
2287            Process {
2288                definition: ProcessDefinition {
2289                    generation_type: GenerationType::Amplitude,
2290                    folder_name: "amplitude".to_string(),
2291                    ..ProcessDefinition::default()
2292                },
2293                settings_history: Some(vacuum_settings.clone()),
2294                collection: ProcessCollection::Amplitudes(BTreeMap::from([(
2295                    "default".to_string(),
2296                    Amplitude::from_graph_list("default", vec![])?,
2297                )])),
2298            },
2299        );
2300        state.process_list.processes[1].definition.process_id = 1;
2301        settings.generation.threshold_subtraction.enable_thresholds = false;
2302        for mode in [
2303            MediumMode::ThermodynamicEquilibrium,
2304            MediumMode::ZeroTemperatureEquilibrium,
2305        ] {
2306            settings.generation.medium.mode = mode;
2307            let error = Generate {
2308                keep_sources: false,
2309                mode: None,
2310            }
2311            .run(&mut state, temp.path(), false, &settings, &runtime)
2312            .unwrap_err();
2313            assert!(error.to_string().contains("`xs`"), "{mode:?}: {error}");
2314            assert!(error.to_string().contains("not supported"));
2315            assert_eq!(state.process_list.processes.len(), 2);
2316            assert!(
2317                state.process_list.processes[0].settings_history.as_ref() == Some(&vacuum_settings),
2318                "failed batch generation changed the preceding amplitude's settings in {mode:?}"
2319            );
2320            assert!(state.process_list.processes[1].settings_history.is_none());
2321        }
2322        assert_eq!(std::fs::read_dir(temp.path())?.count(), 0);
2323        Ok(())
2324    }
2325
2326    #[test]
2327    fn remove_compiled_cpp_sources_only_removes_sources_with_matching_libraries() -> Result<()> {
2328        let temp = tempfile::tempdir()?;
2329        let compiled_source = temp.path().join("integrand.cpp");
2330        let compiled_library = temp.path().join("integrand.so");
2331        let uncompiled_source = temp.path().join("orphan.cpp");
2332        std::fs::write(&compiled_source, "compiled source")?;
2333        std::fs::write(&compiled_library, "compiled library")?;
2334        std::fs::write(&uncompiled_source, "orphan source")?;
2335
2336        let removed_count = remove_compiled_cpp_sources(temp.path())?;
2337
2338        assert_eq!(removed_count, 1);
2339        assert!(!compiled_source.exists());
2340        assert!(compiled_library.is_file());
2341        assert!(uncompiled_source.is_file());
2342        Ok(())
2343    }
2344
2345    #[test]
2346    fn parse_generate_keep_sources_after_subcommand() {
2347        let repl =
2348            Repl::try_parse_from(["gammaloop", "generate", "existing", "--keep-sources"]).unwrap();
2349
2350        match repl.command {
2351            Commands::Generate(generate) => {
2352                assert!(generate.keep_sources);
2353                assert!(matches!(generate.mode, Some(GenerateCmd::Existing(_))));
2354            }
2355            other => panic!("Expected generate command, got {other:?}"),
2356        }
2357    }
2358
2359    #[test]
2360    fn generation_summary_includes_separator_and_total_row() {
2361        let reports = vec![
2362            GeneratedGraphReport {
2363                process_id: 0,
2364                integrand_name: "itg".to_string(),
2365                graph_name: "GL01".to_string(),
2366                stats: GraphGenerationStats {
2367                    evaluator_count: 2,
2368                    total_time: Duration::from_secs(4),
2369                    evaluator_spenso_time: Duration::from_secs(1),
2370                    evaluator_symbolica_time: Duration::from_secs(1),
2371                    evaluator_compile_time: Duration::from_secs(1),
2372                    ..GraphGenerationStats::default()
2373                },
2374            },
2375            GeneratedGraphReport {
2376                process_id: 0,
2377                integrand_name: "itg".to_string(),
2378                graph_name: "GL02".to_string(),
2379                stats: GraphGenerationStats {
2380                    evaluator_count: 3,
2381                    total_time: Duration::from_secs(6),
2382                    evaluator_spenso_time: Duration::from_secs(2),
2383                    evaluator_symbolica_time: Duration::ZERO,
2384                    evaluator_compile_time: Duration::from_secs(3),
2385                    ..GraphGenerationStats::default()
2386                },
2387            },
2388        ];
2389
2390        let summary = render_generation_summary(&reports, 0, Some(4), None).unwrap();
2391        let plain = Regex::new(r"\x1b\[[0-9;]*m")
2392            .unwrap()
2393            .replace_all(&summary, "")
2394            .into_owned();
2395
2396        assert!(plain.contains("Total"));
2397        assert!(plain.contains("5"));
2398        assert!(plain.contains("20%"));
2399        assert!(plain.contains("30%"));
2400        assert!(plain.contains("10%"));
2401        assert!(plain.contains("40%"));
2402
2403        let lines = plain.lines().collect::<Vec<_>>();
2404        let total_line_index = lines
2405            .iter()
2406            .position(|line| line.contains("Total"))
2407            .expect("missing Total row");
2408        let separator_line = lines[..total_line_index]
2409            .iter()
2410            .rev()
2411            .find(|line| !line.trim().is_empty())
2412            .expect("missing line before Total row");
2413        assert!(
2414            separator_line.chars().any(|ch| "┼╪╫┿├┤".contains(ch)),
2415            "missing horizontal separator before Total row: {separator_line}"
2416        );
2417    }
2418
2419    fn base_args(tokens: &str) -> SpecArgs {
2420        SpecArgs {
2421            tokens: tokens.split_whitespace().map(|x| x.to_string()).collect(),
2422            clear_existing_processes: false,
2423            filter_selfenergies: None,
2424            filter_snails: None,
2425            filter_tadpoles: None,
2426            filter_cross_section_tadpoles: None,
2427            veto_tadpoles_attached_to_massive_lines: None,
2428            veto_tadpoles_attached_to_massless_lines: None,
2429            veto_only_scaleless_tadpoles: None,
2430            veto_snails_attached_to_massive_lines: None,
2431            veto_snails_attached_to_massless_lines: None,
2432            veto_only_scaleless_snails: None,
2433            veto_self_energy_of_massive_lines: None,
2434            veto_self_energy_of_massless_lines: None,
2435            veto_only_scaleless_self_energy: None,
2436            max_n_bridges: None,
2437            number_of_factorized_loop_subtopologies: None,
2438            number_of_fermion_loops: None,
2439            symmetric_left_right_polarizations: None,
2440            n_cut_blobs: None,
2441            n_cut_spectators: None,
2442            allow_symmetrization_of_external_fermions_in_amplitudes: None,
2443            symmetrize_initial_states: None,
2444            symmetrize_final_states: None,
2445            symmetrize_left_right_states: None,
2446            numerator_aware_isomorphism_grouping: None,
2447            numerical_samples_seed: None,
2448            number_of_samples_for_numerator_comparisons: None,
2449            consider_internal_masses_only_in_numerator_isomorphisms: None,
2450            fully_numerical_substitution_when_comparing_numerators: None,
2451            compare_canonized_numerator: None,
2452            loop_momentum_bases: None,
2453            select_graphs: None,
2454            veto_graphs: None,
2455            graph_prefix: None,
2456            max_multiplicity_for_fast_cut_filter: 6,
2457            filter_self_loop: None,
2458            filter_zero_flow_edges: None,
2459            process_name: None,
2460            append: false,
2461            integrand_name: None,
2462            only_diagrams: true,
2463            allowed_vertex_interactions: None,
2464            veto_vertex_interactions: None,
2465            global_prefactor_num: None,
2466            global_prefactor_projector: None,
2467        }
2468    }
2469
2470    // Test helpers using a real generic model shipped with the crate
2471    fn parse_ok_amp(s: &str) -> ProcessSpec {
2472        let model = &load_generic_model("sm");
2473        let a = base_args(s);
2474        parse_spec_with_model(&a, GenerationType::Amplitude, model).unwrap()
2475    }
2476
2477    fn parse_ok_xs(s: &str) -> ProcessSpec {
2478        let model = &load_generic_model("sm");
2479        let a = base_args(s);
2480        parse_spec_with_model(&a, GenerationType::CrossSection, model).unwrap()
2481    }
2482
2483    #[test]
2484    fn cp_symmetrization_option_defaults_false_and_roundtrips() {
2485        let model = load_generic_model("sm");
2486        for option in [None, Some(false), Some(true)] {
2487            let mut command = vec!["gammaloop", "generate", "xs", "a", ">", "d", "d~"];
2488            if let Some(enabled) = option {
2489                command.extend([
2490                    "--symmetrize-left-right-states",
2491                    if enabled { "true" } else { "false" },
2492                ]);
2493            }
2494            let repl = Repl::try_parse_from(command).unwrap();
2495            let Commands::Generate(Generate {
2496                mode: Some(GenerateCmd::Xs(args)),
2497                ..
2498            }) = repl.command
2499            else {
2500                panic!("expected a cross-section generation command");
2501            };
2502            assert_eq!(args.symmetrize_left_right_states, option);
2503            let serialized = serde_json::to_value(&args).unwrap();
2504            assert_eq!(
2505                serialized["symmetrize_left_right_states"],
2506                serde_json::json!(option)
2507            );
2508            let decoded: SpecArgs = serde_json::from_value(serialized).unwrap();
2509            assert_eq!(decoded, args);
2510            let spec =
2511                parse_spec_with_model(&decoded, GenerationType::CrossSection, &model).unwrap();
2512            assert_eq!(
2513                spec.process_definition.symmetrize_left_right_states,
2514                option.unwrap_or(false)
2515            );
2516        }
2517    }
2518
2519    #[test]
2520    fn amplitude_left_right_shorthand_respects_explicit_side_options() {
2521        let model = load_generic_model("sm");
2522        let mut args = base_args("e+ e- > d d~");
2523        for (left_right, initial, final_state, expected) in [
2524            (None, None, None, (false, false)),
2525            (Some(true), None, None, (true, true)),
2526            (Some(true), Some(false), None, (false, true)),
2527            (Some(true), None, Some(false), (true, false)),
2528        ] {
2529            args.symmetrize_left_right_states = left_right;
2530            args.symmetrize_initial_states = initial;
2531            args.symmetrize_final_states = final_state;
2532            let definition = parse_spec_with_model(&args, GenerationType::Amplitude, &model)
2533                .unwrap()
2534                .process_definition;
2535            assert_eq!(
2536                (
2537                    definition.symmetrize_initial_states,
2538                    definition.symmetrize_final_states
2539                ),
2540                expected
2541            );
2542            assert_eq!(
2543                definition.symmetrize_left_right_states,
2544                left_right.unwrap_or(false)
2545            );
2546        }
2547    }
2548
2549    #[test]
2550    fn fermion_loop_filter_is_added_in_both_generation_modes() {
2551        let model = &load_generic_model("sm");
2552        let repl = Repl::try_parse_from([
2553            "gammaloop",
2554            "generate",
2555            "amp",
2556            "e+",
2557            "e-",
2558            ">",
2559            "d",
2560            "d~",
2561            "--number-of-fermion-loops",
2562            "1",
2563            "2",
2564        ])
2565        .unwrap();
2566        let Commands::Generate(Generate {
2567            mode: Some(GenerateCmd::Amp(args)),
2568            ..
2569        }) = repl.command
2570        else {
2571            panic!("expected an amplitude generation command");
2572        };
2573        for mode in [GenerationType::Amplitude, GenerationType::CrossSection] {
2574            let spec = parse_spec_with_model(&args, mode, model).unwrap();
2575            let filters = if mode == GenerationType::Amplitude {
2576                &spec.process_definition.amplitude_filters
2577            } else {
2578                &spec.process_definition.cross_section_filters
2579            };
2580            assert!(filters
2581                .0
2582                .iter()
2583                .any(|filter| matches!(filter, FeynGenFilter::FermionLoopCountRange((1, 2)))));
2584        }
2585    }
2586
2587    #[test]
2588    fn allowed_vertex_interactions_are_added_to_generation_filters() {
2589        let model = &load_generic_model("sm");
2590        let mut args = base_args("e+ e- > d d~");
2591        args.allowed_vertex_interactions = Some(vec!["V_6".to_string(), "V_9".to_string()]);
2592
2593        let spec = parse_spec_with_model(&args, GenerationType::Amplitude, model).unwrap();
2594
2595        assert!(spec
2596            .process_definition
2597            .amplitude_filters
2598            .0
2599            .iter()
2600            .any(|filter| matches!(
2601                filter,
2602                FeynGenFilter::VertexAllow(vertex_names)
2603                    if vertex_names == &vec!["V_6".to_string(), "V_9".to_string()]
2604            )));
2605    }
2606
2607    #[test]
2608    fn basic_process_list_arrow() {
2609        test_initialise().unwrap();
2610        let ps = parse_ok_amp("e+ e- > d d~ g");
2611        assert_eq!(ps.initial, vec!["e+", "e-"]);
2612        assert_eq!(ps.final_, vec!["d", "d~", "g"]);
2613        assert!(!ps.empty_initial && !ps.empty_final);
2614    }
2615
2616    #[test]
2617    fn basic_process_list_to() {
2618        test_initialise().unwrap();
2619        let ps = parse_ok_amp("e+ e- to d d~ g");
2620        assert_eq!(ps.initial, vec!["e+", "e-"]);
2621        assert_eq!(ps.final_, vec!["d", "d~", "g"]);
2622    }
2623
2624    #[test]
2625    fn empty_sets_allowed() {
2626        test_initialise().unwrap();
2627        let ps = parse_ok_xs("{} to {}");
2628        assert!(ps.empty_initial);
2629        assert!(ps.empty_final);
2630    }
2631
2632    #[test]
2633    fn alternatives_in_final_states() {
2634        test_initialise().unwrap();
2635        let ps = parse_ok_xs("e+ e- > { Z Z, a a }");
2636        assert!(ps.final_.is_empty());
2637        assert_eq!(ps.final_sets.len(), 2);
2638        assert_eq!(ps.final_sets[0], vec!["Z", "Z"]);
2639        assert_eq!(ps.final_sets[1], vec!["a", "a"]);
2640    }
2641
2642    #[test]
2643    fn veto_and_only() {
2644        test_initialise().unwrap();
2645        let ps = parse_ok_xs("e+ e- > mu+ mu- / u d c g ghG e- | u g ghG");
2646        assert!(ps.veto.contains("u"));
2647        assert!(ps.veto.contains("e-"));
2648        let sel = ps.only.as_ref().unwrap();
2649        assert!(sel.contains("u"));
2650        assert!(sel.contains("g"));
2651        assert!(sel.contains("ghG"));
2652    }
2653
2654    #[test]
2655    fn amp_order_constraints() {
2656        test_initialise().unwrap();
2657        let ps = parse_ok_amp("e+ e- > mu+ mu- QED==2 QCD>=2 QCD<=4");
2658        let qcd = ps.amp_couplings.get("QCD").unwrap();
2659        assert_eq!(qcd.min, Some(2));
2660        assert_eq!(qcd.max, Some(4));
2661        let qed = ps.amp_couplings.get("QED").unwrap();
2662        assert_eq!(qed.eq, Some(2));
2663    }
2664
2665    #[test]
2666    fn xs_order_constraints_powered() {
2667        test_initialise().unwrap();
2668        let ps = parse_ok_xs("e+ e- > mu+ mu- QED^2==2 QCD^2>=2 QCD^2<=4");
2669        let key = CouplingKey {
2670            name: "QCD".into(),
2671            power: 2,
2672        };
2673        let qcd2 = ps.xs_couplings.get(&key).unwrap();
2674        assert_eq!(qcd2.min, Some(2));
2675        assert_eq!(qcd2.max, Some(4));
2676        let keyq = CouplingKey {
2677            name: "QED".into(),
2678            power: 2,
2679        };
2680        let qed2 = ps.xs_couplings.get(&keyq).unwrap();
2681        assert_eq!(qed2.eq, Some(2));
2682    }
2683
2684    #[test]
2685    fn perturbative_block_all_forms() {
2686        test_initialise().unwrap();
2687        let ps = parse_ok_xs("e+ e- > z [ {1} {{2}} QCD=2 QED=1 ]");
2688        assert_eq!(ps.pert.loops_sum_amp_or_sum, Some(1));
2689        assert_eq!(ps.pert.loops_forward_graph, Some(2));
2690        assert_eq!(ps.pert.orders.get("QCD"), Some(&2));
2691        assert_eq!(ps.pert.orders.get("QED"), Some(&1));
2692    }
2693
2694    #[test]
2695    fn perturbative_block_shorthand() {
2696        test_initialise().unwrap();
2697        let ps = parse_ok_xs("e+ e- > z [ QCD ]");
2698        assert_eq!(ps.pert.orders.get("QCD"), Some(&1));
2699    }
2700
2701    #[test]
2702    fn amplitude_generation_type_is_set() {
2703        test_initialise().unwrap();
2704        let ps = parse_ok_amp("e+ e- > mu+ mu-");
2705        assert_eq!(
2706            ps.process_definition.generation_type,
2707            GenerationType::Amplitude
2708        );
2709    }
2710
2711    #[test]
2712    fn rejects_malformed_tokens() {
2713        test_initialise().unwrap();
2714        let model = &load_generic_model("sm");
2715
2716        let args = base_args("e+ > z [ {{}} ]");
2717        let err = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap_err();
2718        assert_eq!(err, ParseError::InvalidToken("{{}}".into()));
2719    }
2720
2721    #[test]
2722    fn missing_arrow_detected() {
2723        test_initialise().unwrap();
2724        let model = &load_generic_model("sm");
2725
2726        let args = base_args("e+ e-  z z");
2727        let err = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap_err();
2728        assert!(matches!(err, ParseError::MissingArrow));
2729    }
2730
2731    #[test]
2732    fn vacuum_defaults_and_filters_xs() {
2733        test_initialise().unwrap();
2734        let ps = parse_ok_xs("{} to {}");
2735        assert_eq!(
2736            ps.process_definition.generation_type,
2737            GenerationType::CrossSection
2738        );
2739
2740        let xs_filters = &ps.process_definition.cross_section_filters.0;
2741
2742        // Smart defaults for vacuum-like graphs
2743        assert!(xs_filters
2744            .iter()
2745            .any(|f| matches!(f, FeynGenFilter::MaxNumberOfBridges(0))));
2746        assert!(xs_filters
2747            .iter()
2748            .any(|f| matches!(f, FeynGenFilter::FactorizedLoopTopologiesCountRange((1, 1)))));
2749
2750        // Default cut ranges present
2751        assert!(xs_filters
2752            .iter()
2753            .any(|f| matches!(f, FeynGenFilter::BlobRange(r) if r.clone()==(1..=1))));
2754        assert!(xs_filters
2755            .iter()
2756            .any(|f| matches!(f, FeynGenFilter::SpectatorRange(r) if r.clone()==(0..=0))));
2757    }
2758
2759    #[test]
2760    fn particle_veto_resolution_cross_section() {
2761        use std::collections::BTreeSet;
2762        test_initialise().unwrap();
2763        // Veto both particles and anti-particles, plus a charged lepton
2764        let ps = parse_ok_xs("e+ e- > mu+ mu- / u d u~ e+");
2765        let xs_filters = &ps.process_definition.cross_section_filters.0;
2766
2767        let veto_pdgs = xs_filters
2768            .iter()
2769            .find_map(|f| match f {
2770                FeynGenFilter::ParticleVeto(v) => Some(v.clone()),
2771                _ => None,
2772            })
2773            .expect("ParticleVeto filter not found");
2774        let set: BTreeSet<i64> = veto_pdgs.into_iter().collect();
2775
2776        // u=2, d=1, u~=-2, e+=-11
2777        for pdg in [2_i64, 1_i64, -2_i64, -11_i64] {
2778            assert!(set.contains(&pdg), "missing PDG {pdg} in veto");
2779        }
2780    }
2781
2782    #[test]
2783    fn particle_inclusion_resolution_cross_section() {
2784        use std::collections::BTreeSet;
2785        test_initialise().unwrap();
2786        // Veto both particles and anti-particles, plus a charged lepton
2787        let ps = parse_ok_xs("e+ e- > mu+ mu- | u d u~ e+");
2788        let xs_filters = &ps.process_definition.cross_section_filters.0;
2789
2790        let veto_pdgs = xs_filters
2791            .iter()
2792            .find_map(|f| match f {
2793                FeynGenFilter::ParticleVeto(v) => Some(v.clone()),
2794                _ => None,
2795            })
2796            .expect("ParticleVeto filter not found");
2797        let set: BTreeSet<i64> = veto_pdgs.into_iter().collect();
2798
2799        for pdg in [
2800            3, 4, 5, 6, 12, 13, 14, 15, 16, 21, 22, 23, 24, 25, 250, 251, 9000001, 9000002,
2801            9000003, 9000004, 9000005,
2802        ] {
2803            assert!(set.contains(&pdg), "missing PDG {pdg} in veto");
2804        }
2805    }
2806
2807    #[test]
2808    fn final_sets_and_orders_and_loops_cross_section() {
2809        test_initialise().unwrap();
2810        // Cross-section with final-state alternatives and perturbative block
2811        let ps = parse_ok_xs("e+ e- > { Z Z, a a, H H } [ {{3}} QCD=2 QED=1 ]");
2812
2813        // Final-state alternatives captured
2814        assert_eq!(ps.final_sets.len(), 3);
2815        assert_eq!(
2816            ps.process_definition.generation_type,
2817            GenerationType::CrossSection
2818        );
2819
2820        // XS loop count from {{3}}
2821        assert_eq!(ps.process_definition.loop_count_range, (3, 3));
2822
2823        // Perturbative orders end up in XS filters
2824        let xs_filters = &ps.process_definition.cross_section_filters.0;
2825        assert!(xs_filters.iter().any(|f| {
2826            if let FeynGenFilter::PerturbativeOrders(m) = f {
2827                m.get("QCD") == Some(&2usize) && m.get("QED") == Some(&1usize)
2828            } else {
2829                false
2830            }
2831        }));
2832    }
2833
2834    #[test]
2835    fn amplitude_with_powered_xs_constraints_and_only() {
2836        test_initialise().unwrap();
2837        // AMP spec including "only" and XS-style powered coupling constraints
2838        let ps = parse_ok_amp("e+ e- > mu+ mu- | g ghG QED^2==2 QCD^2>=2 QCD^2<=4 [ {1} QCD ]");
2839
2840        // AMP-side perturbative bits
2841        assert_eq!(ps.pert.loops_sum_amp_or_sum, Some(1));
2842        assert_eq!(ps.pert.orders.get("QCD"), Some(&1));
2843
2844        // XS-style powered constraints recorded
2845        let key_qed2 = CouplingKey {
2846            name: "QED".into(),
2847            power: 2,
2848        };
2849        let key_qcd2 = CouplingKey {
2850            name: "QCD".into(),
2851            power: 2,
2852        };
2853        let qed2 = ps.xs_couplings.get(&key_qed2).unwrap();
2854        assert_eq!(qed2.eq, Some(2));
2855        let qcd2 = ps.xs_couplings.get(&key_qcd2).unwrap();
2856        assert_eq!(qcd2.min, Some(2));
2857        assert_eq!(qcd2.max, Some(4));
2858
2859        // "only" selection recorded
2860        let only = ps.only.as_ref().expect("missing only-set");
2861        assert!(only.contains("g"));
2862        assert!(only.contains("ghG"));
2863    }
2864
2865    #[test]
2866    fn amplitude_case_insensitive_and_anti_in_veto() {
2867        use std::collections::BTreeSet;
2868        test_initialise().unwrap();
2869        // Mixed case names and anti-particles in veto; ensure AMP-side veto filter present
2870        let ps = parse_ok_amp("E+ e- to Z Z / u~ d~ A");
2871
2872        let amp_filters = &ps.process_definition.amplitude_filters.0;
2873        let veto_pdgs = amp_filters
2874            .iter()
2875            .find_map(|f| match f {
2876                FeynGenFilter::ParticleVeto(v) => Some(v.clone()),
2877                _ => None,
2878            })
2879            .expect("AMP ParticleVeto filter not found");
2880
2881        let set: BTreeSet<i64> = veto_pdgs.into_iter().collect();
2882        // u~=-2, d~=-1, A(=a)=22
2883        for pdg in [-2_i64, -1_i64, 22_i64] {
2884            assert!(set.contains(&pdg), "missing PDG {pdg} in AMP veto");
2885        }
2886    }
2887
2888    #[test]
2889    fn shell_name_long_with_sets_veto_orders_pert_and_xs() {
2890        test_initialise().unwrap();
2891        let model = &load_generic_model("sm");
2892
2893        // Intentionally scrambled input order; output must be canonical.
2894        let args =
2895            base_args("e+ e- > { Z Z, a a } / u d QED==2 QCD>=1 QED^2<=4 QCD^2>=2 [ QED=1 QCD=2 ]");
2896        let ps = parse_spec_with_model(&args, GenerationType::Amplitude, model).unwrap();
2897
2898        let long = ps.process_shell_name(false);
2899
2900        let expected = "epem_zz_or_aa-\
2901                    no__d_u-\
2902                    QCD_ge_1__QED_eq_2-\
2903                    QCDsq_ge_2__QEDsq_le_4-\
2904                    QCDloop_eq_2__QEDloop_eq_1";
2905
2906        assert_eq!(long, expected);
2907    }
2908
2909    #[test]
2910    fn repr_str_canonical_with_amp_xs_veto_loops_orders() {
2911        test_initialise().unwrap();
2912        let model = &load_generic_model("sm");
2913
2914        // Mixed options with non-canonical input ordering.
2915        let args = base_args(
2916            "mu+ mu- > g g / ghg gha QED==1 QCD<=3 [ {2} QED=0 QCD=1 ] QED^2==0 QCD^2>=2",
2917        );
2918        let ps = parse_spec_with_model(&args, GenerationType::Amplitude, model).unwrap();
2919
2920        let r = ps.repr_str();
2921
2922        let expected = "mu+ mu- > g g / gha ghg QCD<=3 QED==1 [ {2} QCD ] QCD^2>=2 QED^2==0";
2923
2924        assert_eq!(r, expected);
2925    }
2926
2927    #[test]
2928    fn repr_str_with_both_loop_counts_and_sorted_orders() {
2929        test_initialise().unwrap();
2930        let model = &load_generic_model("sm");
2931
2932        let args = base_args("e+ e- > z z [ {{2}} QED=2 QCD=1 {1} ]");
2933        let ps = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap();
2934
2935        let r = ps.repr_str();
2936
2937        let expected = "e+ e- > z z [ {1} {{2}} QCD QED=2 ]";
2938
2939        assert_eq!(r, expected);
2940    }
2941
2942    #[test]
2943    fn shell_name_short_canonical_and_sanitization() {
2944        test_initialise().unwrap();
2945        let model = &load_generic_model("sm");
2946
2947        let args = base_args("W+ W- > {}");
2948        let ps = parse_spec_with_model(&args, GenerationType::CrossSection, model).unwrap();
2949
2950        // Short form uses only the base slug and sanitizes +/- and ~.
2951        let short = ps.process_shell_name(true);
2952        let expected = "wpwm_empty";
2953
2954        assert_eq!(short, expected);
2955    }
2956}