Skip to main content

gammaloop_api/commands/
profile.rs

1use std::{fs::File, path::PathBuf};
2
3use crate::{
4    commands::CliArgumentMetadataExt,
5    completion::CompletionArgExt,
6    state::{ProcessRef, State},
7    CLISettings,
8};
9use color_eyre::Result;
10use eyre::eyre;
11use gammalooprs::{
12    integrands::process::{
13        ir::{IRProfileSetting, IrLimitTestReport},
14        OrientationProfileMode, ProcessIntegrand,
15    },
16    processes::ProcessCollection,
17    uv::{
18        profile::{
19            ProfileSettings, UVLimitSelection, UVProfileFixedRay, UVProfileable, UV_PROFILE_MAX_DOD,
20        },
21        UVProfileAnalysis,
22    },
23};
24use linnet::half_edge::involution::EdgeIndex;
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use tracing::{info, instrument};
28
29use clap::{Args, Subcommand};
30
31#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
32pub enum Profile {
33    /// Sample ultraviolet scaling rays and report the fitted large-momentum behavior.
34    UltraViolet(#[command(flatten)] UltraVioletProfile),
35    /// Probe multiple infrared scaling limits and report their fitted behavior in one run.
36    #[command(name = "bulk")]
37    InfraRed(#[command(flatten)] InfraRedProfile),
38}
39
40#[derive(Args, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
41pub struct UltraVioletProfile {
42    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
43    #[arg(
44        short = 'p',
45        long = "process",
46        value_name = "PROCESS",
47        completion_process_selector(crate::completion::SelectorKind::Any)
48    )]
49    pub process: Option<ProcessRef>,
50
51    /// The integrand name to inspect
52    #[arg(
53        short = 'i',
54        long = "integrand-name",
55        value_name = "NAME",
56        completion_integrand_selector(crate::completion::SelectorKind::Any)
57    )]
58    pub integrand_name: Option<String>,
59
60    /// Restrict profiling to this graph
61    #[arg(
62        short = 'g',
63        long = "graph",
64        value_name = "GRAPH",
65        completion_selected_master_graph()
66    )]
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub graph: Option<String>,
69
70    /// Restrict a cross section to the Cutkosky cut with these edge IDs
71    #[arg(
72        long = "cutkosky-cut",
73        visible_alias = "cut-edges",
74        value_name = "EDGE",
75        num_args = 1..,
76        value_delimiter = ',',
77        requires = "graph"
78    )]
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub cutkosky_cut: Vec<usize>,
81
82    /// UV limits to profile
83    #[arg(long = "selected-limits", value_enum, default_value = "only-divergent")]
84    #[serde(default)]
85    pub selected_limits: UVLimitSelection,
86
87    /// Stop after the first failing numerical UV limit, including precision retries
88    #[arg(long = "fail-fast")]
89    #[serde(
90        default,
91        skip_serializing_if = "gammalooprs::utils::serde_utils::is_false"
92    )]
93    pub fail_fast: bool,
94
95    /// Number of scaling points to sample
96    #[arg(long = "n-points", default_value_t = 20)]
97    pub n_points: usize,
98
99    /// Starting base-10 exponent of the sampled ultraviolet scaling range.
100    #[arg(long = "min-scaling", default_value_t = 3.0)]
101    pub min_scale_exponent: f64,
102
103    /// Ending base-10 exponent of the sampled ultraviolet scaling range.
104    #[arg(long = "max-scaling", default_value_t = 6.0)]
105    pub max_scale_exponent: f64,
106
107    /// Use f128 precision for evaluation
108    #[arg(long = "use_f128")]
109    pub use_f128: bool,
110
111    /// Derive the analytic ultraviolet series in addition to sampled numerical values (amplitudes only).
112    #[arg(long = "analyse_analytically")]
113    pub analyse_analytically: bool,
114
115    /// Profile each visible orientation separately and include per-orientation results
116    #[arg(long = "per-orientation")]
117    pub per_orientation: bool,
118
119    /// Accept zero tails or sustained accelerating decay when no reliable power-law fit exists
120    #[arg(long = "allow-vanishing-missing-fits")]
121    pub allow_vanishing_missing_fits: bool,
122
123    /// Random seed for momentum sampling
124    #[arg(long = "seed")]
125    pub seed: Option<u64>,
126
127    /// Fixed UV ray directions as flattened 3-vectors, repeated for all rays if one direction is supplied
128    #[arg(
129        long = "uv-ray-directions",
130        num_args = 1..,
131        value_delimiter = ',',
132        allow_negative_numbers = true
133    )]
134    pub uv_ray_directions: Vec<f64>,
135
136    /// Fixed UV ray starting norms, repeated for all rays if one norm is supplied
137    #[arg(
138        long = "uv-ray-norms",
139        num_args = 1..,
140        value_delimiter = ',',
141        allow_negative_numbers = true,
142        cli_requires("uv_ray_directions")
143    )]
144    pub uv_ray_norms: Vec<f64>,
145
146    /// Output directory for uv_profile.json (optional)
147    #[arg(short = 'o', long = "output", value_hint = clap::ValueHint::DirPath)]
148    pub output_file: Option<PathBuf>,
149}
150
151#[derive(Args, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
152pub struct InfraRedProfile {
153    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
154    #[arg(
155        short = 'p',
156        long = "process",
157        value_name = "PROCESS",
158        completion_process_selector(crate::completion::SelectorKind::CrossSection)
159    )]
160    pub process: Option<ProcessRef>,
161
162    /// The cross-section name to inspect
163    #[arg(
164        short = 'i',
165        long = "integrand-name",
166        value_name = "NAME",
167        completion_integrand_selector(crate::completion::SelectorKind::CrossSection)
168    )]
169    pub integrand_name: Option<String>,
170
171    /// Number of scaling points to sample
172    #[arg(long = "n-points", default_value_t = 20)]
173    pub n_points: usize,
174
175    /// Starting exponent of the sampled bulk scaling range
176    #[arg(long = "min-scaling", default_value_t = -2.0)]
177    pub min_scale_exponent: f64,
178
179    /// Ending exponent of the sampled bulk scaling range
180    #[arg(long = "max-scaling", default_value_t = -3.0)]
181    pub max_scale_exponent: f64,
182
183    /// Random seed for momentum sampling
184    #[arg(long = "seed")]
185    pub seed: Option<u64>,
186
187    /// Output file for results (optional)
188    #[arg(short = 'o', long = "output", value_hint = clap::ValueHint::FilePath)]
189    pub output_file: Option<PathBuf>,
190
191    /// restrict test to particular graphs or limits
192    #[arg(short = 's', long = "select")]
193    pub select: Option<String>,
194
195    /// Profile each visible orientation separately and report one row per orientation
196    #[arg(long = "per-orientation")]
197    pub per_orientation: bool,
198
199    /// Retain generated event data so per-cut IR fits can be computed and displayed
200    #[arg(long = "show-per-cut-info")]
201    pub show_per_cut_info: bool,
202}
203
204impl Default for InfraRedProfile {
205    fn default() -> Self {
206        Self {
207            process: None,
208            integrand_name: None,
209            n_points: 20,
210            min_scale_exponent: -2.0,
211            max_scale_exponent: -3.0,
212            seed: None,
213            output_file: None,
214            select: None,
215            per_orientation: false,
216            show_per_cut_info: false,
217        }
218    }
219}
220impl Default for UltraVioletProfile {
221    fn default() -> Self {
222        Self {
223            process: None,
224            integrand_name: None,
225            graph: None,
226            cutkosky_cut: Vec::new(),
227            selected_limits: UVLimitSelection::OnlyDivergent,
228            fail_fast: false,
229            n_points: 20,
230            min_scale_exponent: 3.0,
231            max_scale_exponent: 6.0,
232            use_f128: false,
233            analyse_analytically: false,
234            per_orientation: false,
235            allow_vanishing_missing_fits: false,
236            seed: None,
237            uv_ray_directions: Vec::new(),
238            uv_ray_norms: Vec::new(),
239            output_file: None,
240        }
241    }
242}
243
244pub enum ProfileResult {
245    UltraViolet(UVProfileAnalysis),
246    InfraRed(IrLimitTestReport),
247}
248
249impl ProfileResult {
250    pub fn unwrap_uv(self) -> UVProfileAnalysis {
251        match self {
252            ProfileResult::UltraViolet(uv_analyis) => uv_analyis,
253            _ => panic!("result does not contain uv profilel analysis data"),
254        }
255    }
256
257    pub fn unwrap_ir(self) -> IrLimitTestReport {
258        match self {
259            ProfileResult::InfraRed(ir_analyis) => ir_analyis,
260            _ => panic!("result does not contain ir profile analysis data"),
261        }
262    }
263}
264
265impl Profile {
266    #[instrument(skip_all)]
267    #[allow(clippy::needless_update)]
268    pub fn run(
269        &self,
270        state: &mut State,
271        global_cli_settings: &CLISettings,
272    ) -> Result<ProfileResult> {
273        match self {
274            Profile::UltraViolet(UltraVioletProfile {
275                process,
276                integrand_name,
277                graph,
278                cutkosky_cut,
279                selected_limits,
280                fail_fast,
281                n_points,
282                min_scale_exponent,
283                max_scale_exponent,
284                use_f128,
285                seed,
286                uv_ray_directions,
287                uv_ray_norms,
288                analyse_analytically,
289                per_orientation,
290                allow_vanishing_missing_fits,
291                output_file,
292            }) => {
293                let (process_id, integrand_name) =
294                    state.find_integrand_ref(process.as_ref(), integrand_name.as_ref())?;
295                let model = state.resolve_model_for_integrand(process_id, &integrand_name)?;
296                let (default_uv_ray_norm, graph_id) = {
297                    let integrand = state
298                        .process_list
299                        .get_integrand_mut(process_id, &integrand_name)?;
300                    integrand.warm_up(&model)?;
301                    let graph_id = graph
302                        .as_ref()
303                        .map(|graph_selector| {
304                            let numeric_selector =
305                                graph_selector.strip_prefix('#').unwrap_or(graph_selector);
306                            if let Some(graph_id) = numeric_selector
307                                .parse::<usize>()
308                                .ok()
309                                .filter(|graph_id| *graph_id < integrand.graph_count())
310                            {
311                                return Ok(graph_id);
312                            }
313                            if let Some(graph_id) = integrand.find_graph_id_by_name(graph_selector)
314                            {
315                                return Ok(graph_id);
316                            }
317
318                            let available = (0..integrand.graph_count())
319                                .filter_map(|graph_id| {
320                                    integrand
321                                        .graph_name_by_id(graph_id)
322                                        .map(|name| format!("{graph_id}:{name}"))
323                                })
324                                .collect::<Vec<_>>()
325                                .join(", ");
326                            Err(eyre!(
327                                "No graph '{}' exists in integrand '{}'. Available graphs: {}",
328                                graph_selector,
329                                integrand_name,
330                                available
331                            ))
332                        })
333                        .transpose()?;
334                    if !cutkosky_cut.is_empty()
335                        && matches!(integrand, ProcessIntegrand::Amplitude(_))
336                    {
337                        return Err(eyre!(
338                            "Cutkosky-cut selection is only supported for cross sections"
339                        ));
340                    }
341                    (integrand.get_settings().kinematics.e_cm, graph_id)
342                };
343
344                let fixed_uv_ray = if uv_ray_directions.is_empty() {
345                    None
346                } else {
347                    let uv_ray_norms = if uv_ray_norms.is_empty() {
348                        vec![default_uv_ray_norm]
349                    } else {
350                        uv_ray_norms.clone()
351                    };
352                    Some(UVProfileFixedRay::from_flat_components(
353                        uv_ray_directions,
354                        &uv_ray_norms,
355                    )?)
356                };
357
358                let profile_settings = ProfileSettings {
359                    fail_fast: *fail_fast,
360                    n_points: *n_points,
361                    min_scale_exponent: *min_scale_exponent,
362                    max_scale_exponent: *max_scale_exponent,
363                    seed: (*seed).unwrap_or(42),
364                    use_f128: *use_f128,
365                    analyse_analytically: *analyse_analytically,
366                    allow_vanishing_missing_fits: *allow_vanishing_missing_fits,
367                    orientation_mode: if *per_orientation {
368                        OrientationProfileMode::PerOrientation
369                    } else {
370                        OrientationProfileMode::Summed
371                    },
372                    fixed_uv_ray,
373                    graph_id,
374                    cutkosky_cut: (!cutkosky_cut.is_empty())
375                        .then(|| cutkosky_cut.iter().copied().map(EdgeIndex::from).collect()),
376                    selected_limits: *selected_limits,
377                    ..Default::default()
378                };
379                let profile_res = {
380                    let process = &mut state.process_list.processes[process_id];
381                    match &mut process.collection {
382                        ProcessCollection::Amplitudes(amplitudes) => amplitudes
383                            .get_mut(&integrand_name)
384                            .ok_or_else(|| {
385                                eyre!(
386                                    "No amplitude named '{}' in process '{}'",
387                                    integrand_name,
388                                    process.definition.folder_name
389                                )
390                            })?
391                            .profile(&model, &profile_settings)?,
392                        ProcessCollection::CrossSections(cross_sections) => cross_sections
393                            .get_mut(&integrand_name)
394                            .ok_or_else(|| {
395                                eyre!(
396                                    "No cross section named '{}' in process '{}'",
397                                    integrand_name,
398                                    process.definition.folder_name
399                                )
400                            })?
401                            .profile(&model, &profile_settings)?,
402                    }
403                }
404                .analyse();
405
406                for t in profile_res.tables_per_graph(UV_PROFILE_MAX_DOD) {
407                    info!("\n{}", t);
408                }
409
410                for t in profile_res.analytic_tables_per_graph() {
411                    let Some(t) = t else {
412                        continue;
413                    };
414                    info!("\n{}", t);
415                }
416
417                for t in profile_res.per_orientation_tables_per_graph(UV_PROFILE_MAX_DOD) {
418                    let Some(t) = t else {
419                        continue;
420                    };
421                    info!("\n{}", t);
422                }
423
424                let verdict = profile_res.pass_fail(UV_PROFILE_MAX_DOD);
425                if profile_res.stopped_early {
426                    info!(
427                        "Stopped after the first failing UV limit; the summary covers completed limits."
428                    );
429                }
430                info!("\n{}", verdict);
431
432                if let Some(file) = output_file {
433                    global_cli_settings
434                        .ensure_write_target_outside_active_state(file, "write profile output")?;
435                    profile_res.write_profile_data(file)?
436                }
437
438                Ok(ProfileResult::UltraViolet(profile_res))
439            }
440            Profile::InfraRed(InfraRedProfile {
441                process,
442                integrand_name,
443                n_points,
444                min_scale_exponent,
445                max_scale_exponent,
446                seed,
447                output_file,
448                select,
449                per_orientation,
450                show_per_cut_info,
451            }) => {
452                let ir_profile_settings = IRProfileSetting {
453                    lambda_exp_start: *min_scale_exponent,
454                    lambda_exp_end: *max_scale_exponent,
455                    steps: *n_points,
456                    seed: seed.unwrap_or(420),
457                    select_limits_and_graphs: select.clone(),
458                    orientation_mode: if *per_orientation {
459                        OrientationProfileMode::PerOrientation
460                    } else {
461                        OrientationProfileMode::Summed
462                    },
463                    show_per_cut_info: *show_per_cut_info,
464                };
465
466                let (process_id, integrand_name) =
467                    state.find_integrand_ref(process.as_ref(), integrand_name.as_ref())?;
468
469                let model = state.resolve_model_for_integrand(process_id, &integrand_name)?;
470
471                let process = &mut state.process_list.processes[process_id];
472                let integrand = match &mut process.collection {
473                    ProcessCollection::Amplitudes(amplitudes) => amplitudes
474                        .get_mut(&integrand_name)
475                        .ok_or_else(|| {
476                            eyre!(
477                                "No amplitude named '{}' in process '{}'",
478                                integrand_name,
479                                process.definition.folder_name
480                            )
481                        })?
482                        .integrand
483                        .as_mut(),
484                    ProcessCollection::CrossSections(xs) => xs
485                        .get_mut(&integrand_name)
486                        .ok_or_else(|| {
487                            eyre!(
488                                "No xs named '{}' in process '{}'",
489                                integrand_name,
490                                process.definition.folder_name
491                            )
492                        })?
493                        .integrand
494                        .as_mut(),
495                };
496
497                let profile_result = match integrand.ok_or(eyre!(
498                    "Integrand {} has not yet been generated",
499                    integrand_name
500                ))? {
501                    ProcessIntegrand::CrossSection(cross_section_integrand) => {
502                        cross_section_integrand.test_ir(&ir_profile_settings, &model)?
503                    }
504                    ProcessIntegrand::Amplitude(amplitude_integrand) => {
505                        amplitude_integrand.test_ir(&ir_profile_settings, &model)?
506                    }
507                };
508
509                info!("\n{}", profile_result);
510                if let Some(file) = output_file {
511                    global_cli_settings
512                        .ensure_write_target_outside_active_state(file, "write profile output")?;
513                    if let Some(parent) = file
514                        .parent()
515                        .filter(|parent| !parent.as_os_str().is_empty())
516                    {
517                        std::fs::create_dir_all(parent)?;
518                    }
519                    let graphs = profile_result
520                        .results_per_graph
521                        .iter()
522                        .map(|graph| {
523                            let reports = graph
524                                .single_limit_reports
525                                .iter()
526                                .map(|report| {
527                                    serde_json::json!({
528                                        "limit_name": report.limit_name,
529                                        "orientation_label": report.orientation_label,
530                                        "ray_fingerprint": report.ray_fingerprint,
531                                        "passed": report.passed,
532                                        "scaling": report.scaling,
533                                        "r_squared": report.power_law_fit.r_squared(),
534                                    })
535                                })
536                                .collect::<Vec<_>>();
537                            serde_json::json!({
538                                "graph_name": graph.graph_name,
539                                "all_limits_passed": graph.all_limits_passed,
540                                "single_limit_reports": reports,
541                            })
542                        })
543                        .collect::<Vec<_>>();
544                    serde_json::to_writer_pretty(
545                        File::create(file)?,
546                        &serde_json::json!({
547                            "settings": {
548                                "n_points": n_points,
549                                "min_scale_exponent": min_scale_exponent,
550                                "max_scale_exponent": max_scale_exponent,
551                                "seed": seed.unwrap_or(420),
552                                "select": select,
553                                "per_orientation": per_orientation,
554                            },
555                            "all_passed": profile_result.all_passed,
556                            "graphs": graphs,
557                        }),
558                    )?;
559                }
560                Ok(ProfileResult::InfraRed(profile_result))
561            }
562        }
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use clap::Parser;
569    use gammalooprs::uv::profile::UVLimitSelection;
570
571    use crate::{commands::Commands, Repl};
572
573    use super::Profile;
574
575    #[test]
576    fn uv_profile_cli_defaults_to_only_divergent_limits() {
577        let repl = Repl::try_parse_from(["gammaloop", "profile", "ultra-violet"]).unwrap();
578        let Commands::Profile(Profile::UltraViolet(profile)) = repl.command else {
579            panic!("expected an ultraviolet profile command");
580        };
581
582        assert_eq!(profile.selected_limits, UVLimitSelection::OnlyDivergent);
583        assert_eq!(profile.graph, None);
584        assert!(profile.cutkosky_cut.is_empty());
585        assert!(!profile.fail_fast);
586        let mut legacy = serde_json::to_value(&profile).unwrap();
587        legacy.as_object_mut().unwrap().remove("fail_fast");
588        let restored: super::UltraVioletProfile = serde_json::from_value(legacy).unwrap();
589        assert!(!restored.fail_fast);
590    }
591
592    #[test]
593    fn uv_profile_cli_parses_graph_cut_and_all_limits() {
594        let repl = Repl::try_parse_from([
595            "gammaloop",
596            "profile",
597            "ultra-violet",
598            "--graph",
599            "GL2",
600            "--cutkosky-cut",
601            "5,2",
602            "--selected-limits",
603            "all",
604            "--fail-fast",
605        ])
606        .unwrap();
607        let Commands::Profile(Profile::UltraViolet(profile)) = repl.command else {
608            panic!("expected an ultraviolet profile command");
609        };
610
611        assert_eq!(profile.selected_limits, UVLimitSelection::All);
612        assert_eq!(profile.graph.as_deref(), Some("GL2"));
613        assert_eq!(profile.cutkosky_cut, [5, 2]);
614        assert!(profile.fail_fast);
615        assert_eq!(
616            serde_json::from_value::<super::UltraVioletProfile>(
617                serde_json::to_value(&profile).unwrap()
618            )
619            .unwrap(),
620            profile
621        );
622
623        let alias = Repl::try_parse_from([
624            "gammaloop",
625            "profile",
626            "ultra-violet",
627            "--graph",
628            "GL2",
629            "--cut-edges",
630            "5,2",
631        ])
632        .unwrap();
633        let Commands::Profile(Profile::UltraViolet(alias)) = alias.command else {
634            panic!("expected an ultraviolet profile command");
635        };
636        assert_eq!(alias.cutkosky_cut, [5, 2]);
637    }
638
639    #[test]
640    fn uv_profile_cli_requires_a_graph_for_a_cut() {
641        let result = Repl::try_parse_from([
642            "gammaloop",
643            "profile",
644            "ultra-violet",
645            "--cutkosky-cut",
646            "5,2",
647        ]);
648
649        assert!(result.is_err());
650    }
651}