Skip to main content

gammaloop_api/commands/
approach.rs

1use gammalooprs::integrands::process::EvaluationTarget;
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    env, fs,
5    io::{self, IsTerminal},
6    path::{Path, PathBuf},
7    sync::{
8        atomic::{AtomicUsize, Ordering},
9        Arc,
10    },
11};
12
13use clap::Args;
14use color_eyre::Result;
15use colored::Colorize;
16use eyre::{eyre, Context};
17use gammalooprs::{
18    integrands::{evaluation::EvaluationResult, process::ProcessIntegrand},
19    model::Model,
20    observables::events::{AdditionalWeightKey, GenericThresholdCountertermEventInfo},
21    processes::ThresholdCountertermMetadataRegistry,
22    utils::F,
23};
24use indicatif::ProgressBar;
25use rayon::{prelude::*, ThreadPoolBuilder};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use spenso::algebra::complex::Complex;
29use tracing::info;
30
31use crate::{
32    commands::evaluate_samples::{build_havana_sample, build_momentum_input},
33    commands::CliArgumentMetadataExt,
34    completion::CompletionArgExt,
35    state::{ProcessRef, State},
36    CLISettings,
37};
38
39const DEFAULT_MIN_ABS_T: f64 = 1.0e-6;
40const DEFAULT_MAX_ABS_T: f64 = 1.0;
41
42fn default_min_abs_t() -> f64 {
43    DEFAULT_MIN_ABS_T
44}
45
46fn default_max_abs_t() -> f64 {
47    DEFAULT_MAX_ABS_T
48}
49
50fn default_parameter_name() -> String {
51    "t".to_string()
52}
53
54#[derive(Debug, Args, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
55pub struct Approach {
56    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
57    #[arg(
58        short = 'p',
59        long = "process",
60        value_name = "PROCESS",
61        completion_process_selector(crate::completion::SelectorKind::Any)
62    )]
63    pub process: Option<ProcessRef>,
64
65    /// The integrand name to approach
66    #[arg(
67        short = 'i',
68        long = "integrand-name",
69        value_name = "NAME",
70        completion_integrand_selector(crate::completion::SelectorKind::Any)
71    )]
72    pub integrand_name: Option<String>,
73
74    /// Integration coordinates, or flattened loop momenta `(px, py, pz) ...` with `--momentum-space`
75    #[arg(
76        short = 'x',
77        long = "point",
78        num_args = 1..,
79        value_name = "POINT",
80        value_delimiter = ',',
81        allow_negative_numbers = true,
82    )]
83    pub point: Vec<f64>,
84
85    /// Direction vector in the same coordinate layout and dimension as `--point`
86    #[arg(
87        long = "approach-axis",
88        value_name = "AXIS",
89        num_args = 1..,
90        allow_negative_numbers = true,
91    )]
92    pub approach_axes: Vec<String>,
93
94    /// Human-readable label for each approach axis, in axis order
95    #[arg(long = "axis-label", value_name = "LABEL")]
96    #[serde(default)]
97    pub axis_labels: Vec<String>,
98
99    /// Number of points on each side of the midpoint
100    #[arg(long = "n-points", value_name = "N")]
101    pub n_points: usize,
102
103    /// Do not evaluate the midpoint at t = 0
104    #[arg(long)]
105    #[serde(default)]
106    pub skip_midpoint: bool,
107
108    /// Use linear spacing in t
109    #[arg(long, conflicts_with = "logarithmic")]
110    pub linear: bool,
111
112    /// Use logarithmic spacing in |t|
113    #[arg(long, conflicts_with = "linear")]
114    pub logarithmic: bool,
115
116    /// Smallest non-zero |t| for logarithmic spacing
117    #[arg(
118        long = "min-abs-t",
119        default_value_t = DEFAULT_MIN_ABS_T,
120        allow_negative_numbers = true
121    )]
122    #[serde(default = "default_min_abs_t")]
123    pub min_abs_t: f64,
124
125    /// Largest |t| evaluated on either side of the midpoint
126    #[arg(
127        long = "max-abs-t",
128        default_value_t = DEFAULT_MAX_ABS_T,
129        allow_negative_numbers = true
130    )]
131    #[serde(default = "default_max_abs_t")]
132    pub max_abs_t: f64,
133
134    /// Name of the physical parameter t in point(t) = midpoint + t * axis
135    #[arg(long = "parameter-name", default_value = "t", value_name = "NAME")]
136    #[serde(default = "default_parameter_name")]
137    pub parameter_name: String,
138
139    /// Number of worker threads for approach evaluations
140    #[arg(long = "n-cores", value_name = "N")]
141    pub n_cores: Option<usize>,
142
143    /// Force arbitrary-precision (Arb) internal evaluation; results remain f64
144    #[arg(short = 'f', long = "use_arb_prec")]
145    pub use_arb_prec: bool,
146
147    /// Interpret `--point` and every axis as spatial loop-momentum triplets
148    #[arg(short = 'm', long)]
149    pub momentum_space: bool,
150
151    /// The discrete dimensions of the sample
152    #[arg(
153        short = 'd',
154        long = "discrete-dim",
155        value_name = "DIMS",
156        num_args = 1..,
157        value_delimiter = ',',
158        conflicts_with_all = ["graph_id", "orientation_id"],
159    )]
160    pub discrete_dim: Vec<usize>,
161
162    /// Select a specific graph in momentum-space approach
163    #[arg(long = "graph-id", value_name = "GRAPH_ID")]
164    pub graph_id: Option<usize>,
165
166    /// Select a specific orientation of the selected graph in momentum-space approach
167    #[arg(
168        long = "orientation-id",
169        value_name = "ORIENTATION_ID",
170        cli_requires("graph_id"),
171        conflicts_with = "discrete_dim"
172    )]
173    pub orientation_id: Option<usize>,
174
175    /// Write sampled approach points and run metadata to this JSON file
176    #[arg(long = "output-results", value_hint = clap::ValueHint::FilePath)]
177    pub output_results: Option<PathBuf>,
178}
179
180impl Default for Approach {
181    fn default() -> Self {
182        Self {
183            process: None,
184            integrand_name: None,
185            point: Vec::new(),
186            approach_axes: Vec::new(),
187            axis_labels: Vec::new(),
188            n_points: 0,
189            skip_midpoint: false,
190            linear: false,
191            logarithmic: false,
192            min_abs_t: DEFAULT_MIN_ABS_T,
193            max_abs_t: DEFAULT_MAX_ABS_T,
194            parameter_name: default_parameter_name(),
195            n_cores: None,
196            use_arb_prec: false,
197            momentum_space: false,
198            discrete_dim: Vec::new(),
199            graph_id: None,
200            orientation_id: None,
201            output_results: None,
202        }
203    }
204}
205
206#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
207#[serde(rename_all = "snake_case")]
208enum ApproachSpacing {
209    Linear,
210    Logarithmic,
211}
212
213#[derive(Debug, Clone)]
214struct ApproachJob {
215    index: usize,
216    axis_index: usize,
217    axis_point_index: usize,
218    t: f64,
219    point: Vec<f64>,
220    skip_reason: Option<String>,
221}
222
223#[derive(Debug, Serialize)]
224struct ApproachOutput {
225    schema_version: u32,
226    command: ApproachCommandJson,
227    process: ApproachProcessJson,
228    integrand: ApproachIntegrandJson,
229    space: String,
230    base_point: Vec<f64>,
231    parameter_name: String,
232    axes: Vec<ApproachAxisJson>,
233    spacing: ApproachSpacingJson,
234    n_cores: usize,
235    points_per_axis: usize,
236    evaluated_points: usize,
237    skipped_points: usize,
238    points: Vec<ApproachPointRecord>,
239}
240
241#[derive(Debug, Serialize)]
242struct ApproachCommandJson {
243    name: &'static str,
244    use_arb_prec: bool,
245    skip_midpoint: bool,
246    graph_id: Option<usize>,
247    graph_name: Option<String>,
248    orientation_id: Option<usize>,
249    discrete_dim: Vec<usize>,
250}
251
252#[derive(Debug, Serialize)]
253struct ApproachProcessJson {
254    id: usize,
255    name: String,
256}
257
258#[derive(Debug, Serialize)]
259struct ApproachIntegrandJson {
260    name: String,
261    kind: String,
262    #[serde(skip_serializing_if = "Vec::is_empty")]
263    threshold_counterterms: Vec<ApproachThresholdCountertermRegistryJson>,
264}
265
266#[derive(Debug, Serialize)]
267struct ApproachThresholdCountertermRegistryJson {
268    graph_id: usize,
269    registry: ThresholdCountertermMetadataRegistry,
270}
271
272#[derive(Debug, Serialize)]
273struct ApproachSpacingJson {
274    kind: ApproachSpacing,
275    n_points: usize,
276    min_abs_t: Option<f64>,
277    max_abs_t: f64,
278    t_values: Vec<f64>,
279}
280
281#[derive(Debug, Clone, Serialize)]
282struct ApproachAxisJson {
283    label: String,
284    vector: Vec<f64>,
285}
286
287#[derive(Debug, Serialize)]
288struct ApproachPointRecord {
289    index: usize,
290    axis_index: usize,
291    axis_point_index: usize,
292    t: f64,
293    point: Vec<f64>,
294    status: &'static str,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    skip_reason: Option<String>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    evaluation: Option<ApproachEvaluationRecord>,
299}
300
301#[derive(Debug, Serialize)]
302struct ApproachEvaluationRecord {
303    integrand_result: ComplexJson,
304    parameterization_jacobian: Option<f64>,
305    integrator_weight: f64,
306    total_weight: ComplexJson,
307    event_weight_sum: ComplexJson,
308    additional_contribution_sums: BTreeMap<String, ComplexJson>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    threshold_counterterm_summary: Option<ApproachThresholdCountertermSummaryJson>,
311    contributions: Vec<ContributionRecord>,
312    events: Vec<EventRecord>,
313    metadata: EvaluationMetadataJson,
314}
315
316#[derive(Debug, Serialize)]
317struct EvaluationMetadataJson {
318    generated_event_count: usize,
319    accepted_event_count: usize,
320    is_nan: bool,
321    total_time_seconds: f64,
322    parameterization_time_seconds: f64,
323    integrand_evaluation_time_seconds: f64,
324    evaluator_evaluation_time_seconds: f64,
325    event_processing_time_seconds: f64,
326}
327
328#[derive(Debug, Serialize)]
329struct EventRecord {
330    event_group_index: usize,
331    event_index: usize,
332    graph_id: usize,
333    graph_name: Option<String>,
334    graph_group_id: Option<usize>,
335    orientation_id: Option<usize>,
336    cut_id: usize,
337    cut_edges: Vec<usize>,
338    sampling_channel_id: Option<usize>,
339    lmb_sample_id: Option<usize>,
340    weight: ComplexJson,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    full_multiplicative_factor: Option<ComplexJson>,
343    display_only_weights: BTreeMap<String, ComplexJson>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    threshold_counterterms: Option<GenericThresholdCountertermEventInfo<f64>>,
346}
347
348#[derive(Debug, Serialize)]
349struct ApproachThresholdCountertermSummaryJson {
350    original: ComplexJson,
351    counterterm_sum: ComplexJson,
352    reconstructed_total: ComplexJson,
353    decomposed_event_sum: ComplexJson,
354    closure: ComplexJson,
355    components: Vec<ApproachThresholdCountertermComponentSumJson>,
356}
357
358#[derive(Debug, Serialize)]
359struct ApproachThresholdCountertermComponentSumJson {
360    graph_id: usize,
361    component_id: usize,
362    weighted: ComplexJson,
363    occurrence_count: usize,
364    skipped_count: usize,
365}
366
367#[derive(Debug)]
368struct ThresholdCountertermComponentAccumulator {
369    weighted: Complex<F<f64>>,
370    occurrence_count: usize,
371    skipped_count: usize,
372}
373
374#[derive(Debug)]
375struct ThresholdCountertermSummaryAccumulator {
376    decomposed_event_count: usize,
377    original: Complex<F<f64>>,
378    decomposed_event_sum: Complex<F<f64>>,
379    components: BTreeMap<(usize, usize), ThresholdCountertermComponentAccumulator>,
380}
381
382impl Default for ThresholdCountertermSummaryAccumulator {
383    fn default() -> Self {
384        Self {
385            decomposed_event_count: 0,
386            original: zero_complex(),
387            decomposed_event_sum: zero_complex(),
388            components: BTreeMap::new(),
389        }
390    }
391}
392
393impl ThresholdCountertermSummaryAccumulator {
394    fn add(
395        &mut self,
396        graph_id: usize,
397        event_weight: &Complex<F<f64>>,
398        decomposition: &GenericThresholdCountertermEventInfo<f64>,
399    ) {
400        self.decomposed_event_count += 1;
401        self.decomposed_event_sum += *event_weight;
402        self.original += decomposition.original;
403        for component in &decomposition.components {
404            let accumulator = self
405                .components
406                .entry((graph_id, component.component_id))
407                .or_insert_with(|| ThresholdCountertermComponentAccumulator {
408                    weighted: zero_complex(),
409                    occurrence_count: 0,
410                    skipped_count: 0,
411                });
412            accumulator.weighted += component.weighted;
413            accumulator.occurrence_count += 1;
414            accumulator.skipped_count += usize::from(component.evaluation_skipped);
415        }
416    }
417
418    fn into_json(self) -> Option<ApproachThresholdCountertermSummaryJson> {
419        if self.decomposed_event_count == 0 {
420            return None;
421        }
422        let counterterm_sum = self
423            .components
424            .values()
425            .fold(zero_complex(), |total, component| {
426                total + component.weighted
427            });
428        let reconstructed_total = self.original + counterterm_sum;
429        let closure = self.decomposed_event_sum - reconstructed_total;
430        Some(ApproachThresholdCountertermSummaryJson {
431            original: complex_json(self.original),
432            counterterm_sum: complex_json(counterterm_sum),
433            reconstructed_total: complex_json(reconstructed_total),
434            decomposed_event_sum: complex_json(self.decomposed_event_sum),
435            closure: complex_json(closure),
436            components: self
437                .components
438                .into_iter()
439                .map(|((graph_id, component_id), component)| {
440                    ApproachThresholdCountertermComponentSumJson {
441                        graph_id,
442                        component_id,
443                        weighted: complex_json(component.weighted),
444                        occurrence_count: component.occurrence_count,
445                        skipped_count: component.skipped_count,
446                    }
447                })
448                .collect(),
449        })
450    }
451}
452
453#[derive(Debug, Serialize)]
454struct ContributionRecord {
455    label: String,
456    contribution: String,
457    graph_id: usize,
458    graph_name: Option<String>,
459    graph_group_id: Option<usize>,
460    orientation_id: Option<usize>,
461    cut_id: usize,
462    cut_edges: Vec<usize>,
463    lmb_sample_id: Option<usize>,
464    weight: ComplexJson,
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
468struct ContributionKey {
469    contribution: String,
470    graph_id: usize,
471    graph_name: Option<String>,
472    graph_group_id: Option<usize>,
473    orientation_id: Option<usize>,
474    cut_id: usize,
475    cut_edges: Vec<usize>,
476    lmb_sample_id: Option<usize>,
477}
478
479#[derive(Debug, Clone, Copy, Serialize)]
480struct ComplexJson {
481    re: f64,
482    im: f64,
483}
484
485impl Approach {
486    pub fn run(&self, state: &mut State, cli_settings: &CLISettings) -> Result<PathBuf> {
487        let output_path = self
488            .output_results
489            .clone()
490            .unwrap_or_else(|| PathBuf::from("approach_result.json"));
491        cli_settings
492            .ensure_write_target_outside_active_state(&output_path, "write approach results")?;
493        self.validate_cli()?;
494        let (process_id, integrand_name) =
495            state.find_integrand_ref(self.process.as_ref(), self.integrand_name.as_ref())?;
496        let process_name = state.process_list.processes[process_id]
497            .definition
498            .folder_name
499            .clone();
500        let model = state.resolve_model_for_integrand(process_id, &integrand_name)?;
501        let base_integrand = state
502            .process_list
503            .get_integrand(process_id, &integrand_name)?
504            .require_generated()?
505            .clone();
506
507        if !self.momentum_space && (self.graph_id.is_some() || self.orientation_id.is_some()) {
508            return Err(eyre!(
509                "Graph and orientation selectors are only supported in momentum-space approach."
510            ));
511        }
512        if !self.momentum_space {
513            let expected_dimension =
514                base_integrand.expected_x_space_dimension(&self.discrete_dim)?;
515            if self.point.len() != expected_dimension {
516                return Err(eyre!(
517                    "Expected {} x-space coordinates for this integrand selection, got {}.",
518                    expected_dimension,
519                    self.point.len()
520                ));
521            }
522        }
523
524        let graph_name = self.resolve_graph_name(&base_integrand, &integrand_name)?;
525        let axes = self.parse_axes()?;
526        let spacing = self.spacing()?;
527        let t_values = self.t_values(spacing)?;
528        let points_per_axis = t_values.len();
529        let jobs = self.build_jobs(&axes, &t_values);
530        let n_cores = self
531            .n_cores
532            .unwrap_or(cli_settings.global.n_cores.integrate);
533        if n_cores == 0 {
534            return Err(eyre!("--n-cores must be at least 1."));
535        }
536
537        let total_jobs = jobs.len();
538        let progress = approach_progress_bar(total_jobs as u64);
539        let pool = ThreadPoolBuilder::new()
540            .num_threads(n_cores)
541            .build()
542            .context("failed to build approach evaluation thread pool")?;
543        let worker_count = n_cores.min(total_jobs.max(1));
544        let worker_inputs = (0..worker_count)
545            .map(|worker_index| {
546                let worker_jobs = jobs
547                    .iter()
548                    .skip(worker_index)
549                    .step_by(worker_count)
550                    .cloned()
551                    .collect::<Vec<_>>();
552                let mut integrand = base_integrand.clone();
553                force_event_output(&mut integrand);
554                (integrand, model.clone(), worker_jobs)
555            })
556            .collect::<Vec<_>>();
557        let completed_jobs = Arc::new(AtomicUsize::new(0));
558        let completed_workers = Arc::new(AtomicUsize::new(0));
559        let results = pool.install(|| {
560            worker_inputs
561                .into_par_iter()
562                .map(
563                    |(mut integrand, model, worker_jobs)| -> Result<Vec<ApproachPointRecord>> {
564                        integrand
565                            .warm_up(&model)
566                            .context("failed to warm up approach worker integrand")?;
567                        let mut records = Vec::with_capacity(worker_jobs.len());
568                        for job in worker_jobs {
569                            let record = self.evaluate_job(
570                                &job,
571                                &mut integrand,
572                                &model,
573                                graph_name.as_deref(),
574                                axes.len(),
575                                t_values.len(),
576                            );
577                            let completed_total =
578                                completed_jobs.fetch_add(1, Ordering::Relaxed) + 1;
579                            progress.inc(1);
580                            progress.set_message(progress_message(
581                                job.axis_index,
582                                axes.len(),
583                                job.axis_point_index,
584                                t_values.len(),
585                                completed_total,
586                                total_jobs,
587                            ));
588                            records.push(record?);
589                        }
590                        let completed_worker_count =
591                            completed_workers.fetch_add(1, Ordering::Relaxed) + 1;
592                        progress.set_message(format!(
593                            "{} {}",
594                            "collecting worker results".bright_magenta().bold(),
595                            format!("{completed_worker_count:>2}/{worker_count:<2}")
596                                .bright_white()
597                                .bold()
598                        ));
599                        if completed_worker_count == worker_count {
600                            progress.println(
601                                "Approach evaluations complete; collecting worker results"
602                                    .bright_blue()
603                                    .bold()
604                                    .to_string(),
605                            );
606                        }
607                        Ok(records)
608                    },
609                )
610                .collect::<Result<Vec<_>>>()
611        });
612        progress.finish_with_message(
613            "approach evaluations complete; finalizing output"
614                .bright_green()
615                .bold()
616                .to_string(),
617        );
618
619        info!(
620            "{} {}",
621            "Finalizing approach results".bright_blue().bold(),
622            "(sorting records and preparing JSON)".bright_black()
623        );
624        let mut point_records = results?
625            .into_iter()
626            .flatten()
627            .collect::<Vec<ApproachPointRecord>>();
628        point_records.sort_by_key(|record| record.index);
629
630        let evaluated_points = point_records
631            .iter()
632            .filter(|record| record.status == "evaluated")
633            .count();
634        let skipped_points = point_records.len() - evaluated_points;
635        let output = ApproachOutput {
636            schema_version: 3,
637            command: ApproachCommandJson {
638                name: "approach",
639                use_arb_prec: self.use_arb_prec,
640                skip_midpoint: self.skip_midpoint,
641                graph_id: self.graph_id,
642                graph_name: graph_name.clone(),
643                orientation_id: self.orientation_id,
644                discrete_dim: self.discrete_dim.clone(),
645            },
646            process: ApproachProcessJson {
647                id: process_id,
648                name: process_name,
649            },
650            integrand: ApproachIntegrandJson {
651                name: integrand_name,
652                kind: base_integrand.kind_name().to_string(),
653                threshold_counterterms: threshold_counterterm_registries(
654                    &base_integrand,
655                    self.graph_id,
656                ),
657            },
658            space: if self.momentum_space {
659                "momentum".to_string()
660            } else {
661                "coordinate".to_string()
662            },
663            base_point: self.point.clone(),
664            parameter_name: self.parameter_name.trim().to_string(),
665            axes,
666            spacing: ApproachSpacingJson {
667                kind: spacing,
668                n_points: self.n_points,
669                min_abs_t: matches!(spacing, ApproachSpacing::Logarithmic)
670                    .then_some(self.min_abs_t),
671                max_abs_t: self.max_abs_t,
672                t_values,
673            },
674            n_cores,
675            points_per_axis,
676            evaluated_points,
677            skipped_points,
678            points: point_records,
679        };
680
681        let relative_output = relative_path_display(&output_path);
682        info!(
683            "{} {}",
684            "Writing approach JSON to".bright_blue().bold(),
685            relative_output.bright_cyan()
686        );
687        if let Some(parent) = output_path.parent() {
688            if !parent.as_os_str().is_empty() {
689                fs::create_dir_all(parent).with_context(|| {
690                    format!(
691                        "failed to create approach output directory '{}'",
692                        parent.display()
693                    )
694                })?;
695            }
696        }
697        let file = fs::File::create(&output_path).with_context(|| {
698            format!(
699                "failed to create approach output file '{}'",
700                output_path.display()
701            )
702        })?;
703        serde_json::to_writer_pretty(file, &output).with_context(|| {
704            format!(
705                "failed to serialize approach output to '{}'",
706                output_path.display()
707            )
708        })?;
709
710        let pdf_path = output_path.with_extension("pdf");
711        let relative_pdf = relative_path_display(&pdf_path);
712        let plot_command = format!(
713            "python3 assets/plot_approach_result.py {} --output {}",
714            shell_quote(&relative_output),
715            shell_quote(&relative_pdf)
716        );
717        info!(
718            "{} {}",
719            "Approach results written to".bright_green().bold(),
720            relative_output.bright_cyan()
721        );
722        info!(
723            "{} {}",
724            "Plot with:".bright_green().bold(),
725            plot_command.bright_cyan()
726        );
727
728        Ok(output_path)
729    }
730
731    fn validate_cli(&self) -> Result<()> {
732        if self.point.is_empty() {
733            return Err(eyre!("approach requires a midpoint supplied with --point."));
734        }
735        if let Some((index, value)) = self
736            .point
737            .iter()
738            .enumerate()
739            .find(|(_, value)| !value.is_finite())
740        {
741            return Err(eyre!(
742                "Approach midpoint component {index} must be finite, got {value}."
743            ));
744        }
745        if self.approach_axes.is_empty() {
746            return Err(eyre!(
747                "approach requires at least one --approach-axis value."
748            ));
749        }
750        if !self.axis_labels.is_empty() && self.axis_labels.len() != self.approach_axes.len() {
751            return Err(eyre!(
752                "Expected either no --axis-label values or one per approach axis ({}), got {}.",
753                self.approach_axes.len(),
754                self.axis_labels.len(),
755            ));
756        }
757        let mut labels = BTreeSet::new();
758        for label in &self.axis_labels {
759            let label = label.trim();
760            if label.is_empty() {
761                return Err(eyre!("Approach axis labels must not be blank."));
762            }
763            if !labels.insert(label) {
764                return Err(eyre!("Approach axis label '{label}' is duplicated."));
765            }
766        }
767        if self.parameter_name.trim().is_empty() {
768            return Err(eyre!("--parameter-name must not be blank."));
769        }
770        if self.n_points == 0 {
771            return Err(eyre!("--n-points must be at least 1."));
772        }
773        if !self.max_abs_t.is_finite() || self.max_abs_t <= 0.0 {
774            return Err(eyre!("--max-abs-t must be finite and greater than 0."));
775        }
776        if !self.min_abs_t.is_finite() || self.min_abs_t <= 0.0 {
777            return Err(eyre!("--min-abs-t must be finite and greater than 0."));
778        }
779        if self.logarithmic && self.min_abs_t > self.max_abs_t {
780            return Err(eyre!(
781                "--min-abs-t must be at most --max-abs-t for logarithmic spacing."
782            ));
783        }
784        if self.n_cores == Some(0) {
785            return Err(eyre!("--n-cores must be at least 1."));
786        }
787        Ok(())
788    }
789
790    fn spacing(&self) -> Result<ApproachSpacing> {
791        if self.linear && self.logarithmic {
792            return Err(eyre!("--linear and --logarithmic are mutually exclusive."));
793        }
794        Ok(if self.logarithmic {
795            ApproachSpacing::Logarithmic
796        } else {
797            ApproachSpacing::Linear
798        })
799    }
800
801    fn parse_axes(&self) -> Result<Vec<ApproachAxisJson>> {
802        self.approach_axes
803            .iter()
804            .enumerate()
805            .map(|(axis_index, raw_axis)| {
806                Ok(ApproachAxisJson {
807                    label: self
808                        .axis_labels
809                        .get(axis_index)
810                        .map(|label| label.trim().to_string())
811                        .unwrap_or_else(|| format!("axis {}", axis_index + 1)),
812                    vector: parse_axis(raw_axis, self.point.len())?,
813                })
814            })
815            .collect()
816    }
817
818    fn resolve_graph_name(
819        &self,
820        integrand: &ProcessIntegrand,
821        integrand_name: &str,
822    ) -> Result<Option<String>> {
823        let Some(graph_id) = self.graph_id else {
824            return Ok(None);
825        };
826        let graph_name = integrand.graph_name_by_id(graph_id).ok_or_else(|| {
827            eyre!(
828                "Graph id {} is out of range for integrand '{}'; it has {} graphs.",
829                graph_id,
830                integrand_name,
831                integrand.graph_count()
832            )
833        })?;
834        Ok(Some(graph_name.to_string()))
835    }
836
837    fn t_values(&self, spacing: ApproachSpacing) -> Result<Vec<f64>> {
838        let magnitudes = match spacing {
839            ApproachSpacing::Linear => (1..=self.n_points)
840                .map(|index| self.max_abs_t * index as f64 / self.n_points as f64)
841                .collect::<Vec<_>>(),
842            ApproachSpacing::Logarithmic => {
843                if self.n_points == 1 {
844                    vec![self.max_abs_t]
845                } else {
846                    let log_min = self.min_abs_t.ln();
847                    let log_max = self.max_abs_t.ln();
848                    let denom = (self.n_points - 1) as f64;
849                    (0..self.n_points)
850                        .map(|index| (log_min + (log_max - log_min) * index as f64 / denom).exp())
851                        .collect()
852                }
853            }
854        };
855        let mut t_values = magnitudes
856            .iter()
857            .rev()
858            .map(|value| -*value)
859            .collect::<Vec<_>>();
860        if !self.skip_midpoint {
861            t_values.push(0.0);
862        }
863        t_values.extend(magnitudes);
864        Ok(t_values)
865    }
866
867    fn build_jobs(&self, axes: &[ApproachAxisJson], t_values: &[f64]) -> Vec<ApproachJob> {
868        let mut jobs = Vec::with_capacity(axes.len() * t_values.len());
869        for (axis_index, axis) in axes.iter().enumerate() {
870            for (axis_point_index, t) in t_values.iter().copied().enumerate() {
871                let point = self
872                    .point
873                    .iter()
874                    .zip(&axis.vector)
875                    .map(|(center, direction)| center + t * direction)
876                    .collect::<Vec<_>>();
877                let skip_reason = (!self.momentum_space)
878                    .then(|| coordinate_skip_reason(&point))
879                    .flatten();
880                jobs.push(ApproachJob {
881                    index: jobs.len(),
882                    axis_index,
883                    axis_point_index,
884                    t,
885                    point,
886                    skip_reason,
887                });
888            }
889        }
890        jobs
891    }
892
893    fn evaluate_job(
894        &self,
895        job: &ApproachJob,
896        integrand: &mut ProcessIntegrand,
897        model: &Model,
898        graph_name: Option<&str>,
899        _axis_count: usize,
900        _axis_point_count: usize,
901    ) -> Result<ApproachPointRecord> {
902        if let Some(skip_reason) = &job.skip_reason {
903            return Ok(ApproachPointRecord {
904                index: job.index,
905                axis_index: job.axis_index,
906                axis_point_index: job.axis_point_index,
907                t: job.t,
908                point: job.point.clone(),
909                status: "skipped",
910                skip_reason: Some(skip_reason.clone()),
911                evaluation: None,
912            });
913        }
914
915        let mut samples = if self.momentum_space {
916            let input = build_momentum_input(
917                integrand,
918                &job.point,
919                1.0,
920                &self.discrete_dim,
921                graph_name,
922                self.orientation_id,
923            )?;
924            integrand
925                .evaluate_momentum_configurations_raw(model, &[input], self.use_arb_prec)?
926                .samples
927        } else {
928            let sample = build_havana_sample(integrand, &job.point, &self.discrete_dim, 1.0)?;
929            integrand
930                .evaluate_samples_raw(
931                    EvaluationTarget::Physical(model),
932                    &[sample],
933                    1,
934                    self.use_arb_prec,
935                    false,
936                    Complex::new_zero(),
937                )?
938                .samples
939        };
940        let evaluation = samples
941            .pop()
942            .ok_or_else(|| eyre!("approach evaluation did not return a sample"))?;
943        Ok(ApproachPointRecord {
944            index: job.index,
945            axis_index: job.axis_index,
946            axis_point_index: job.axis_point_index,
947            t: job.t,
948            point: job.point.clone(),
949            status: "evaluated",
950            skip_reason: None,
951            evaluation: Some(approach_evaluation_record(integrand, evaluation)?),
952        })
953    }
954}
955
956fn parse_axis(raw_axis: &str, expected_dimension: usize) -> Result<Vec<f64>> {
957    let trimmed = raw_axis
958        .trim()
959        .trim_start_matches('[')
960        .trim_end_matches(']');
961    let axis = trimmed
962        .split(',')
963        .map(str::trim)
964        .filter(|component| !component.is_empty())
965        .map(|component| {
966            component.parse::<f64>().map_err(|err| {
967                eyre!(
968                    "Could not parse approach-axis component '{}' as a float: {}",
969                    component,
970                    err
971                )
972            })
973        })
974        .collect::<Result<Vec<_>>>()?;
975    if axis.len() != expected_dimension {
976        return Err(eyre!(
977            "Approach axis has {} components, but the point has {} components.",
978            axis.len(),
979            expected_dimension
980        ));
981    }
982    if let Some((index, value)) = axis
983        .iter()
984        .enumerate()
985        .find(|(_, value)| !value.is_finite())
986    {
987        return Err(eyre!(
988            "Approach axis component {index} must be finite, got {value}."
989        ));
990    }
991    if axis.iter().all(|component| *component == 0.0) {
992        return Err(eyre!("Approach axis must not be the zero vector."));
993    }
994    Ok(axis)
995}
996
997fn coordinate_skip_reason(point: &[f64]) -> Option<String> {
998    let invalid = point
999        .iter()
1000        .enumerate()
1001        .filter(|(_, value)| !(0.0..=1.0).contains(*value))
1002        .map(|(index, value)| format!("{index}:{value:+.16e}"))
1003        .collect::<Vec<_>>();
1004    (!invalid.is_empty()).then(|| {
1005        format!(
1006            "coordinate-space point lies outside the unit hypercube at component(s) {}",
1007            invalid.join(", ")
1008        )
1009    })
1010}
1011
1012fn force_event_output(integrand: &mut ProcessIntegrand) {
1013    let settings = integrand.get_mut_settings();
1014    settings.general.generate_events = true;
1015    settings.general.store_additional_weights_in_event = true;
1016}
1017
1018fn threshold_counterterm_registries(
1019    integrand: &ProcessIntegrand,
1020    selected_graph_id: Option<usize>,
1021) -> Vec<ApproachThresholdCountertermRegistryJson> {
1022    let collect = |registries: Vec<Option<&ThresholdCountertermMetadataRegistry>>| {
1023        registries
1024            .into_iter()
1025            .enumerate()
1026            .filter(|(graph_id, _)| selected_graph_id.is_none_or(|selected| selected == *graph_id))
1027            .filter_map(|(graph_id, registry)| {
1028                registry
1029                    .cloned()
1030                    .map(|registry| ApproachThresholdCountertermRegistryJson { graph_id, registry })
1031            })
1032            .collect()
1033    };
1034    match integrand {
1035        ProcessIntegrand::Amplitude(integrand) => collect(
1036            integrand
1037                .data
1038                .graph_terms
1039                .iter()
1040                .map(|graph| graph.threshold_counterterm_metadata())
1041                .collect(),
1042        ),
1043        ProcessIntegrand::CrossSection(integrand) => collect(
1044            integrand
1045                .data
1046                .graph_terms
1047                .iter()
1048                .map(|graph| graph.threshold_counterterm_metadata())
1049                .collect(),
1050        ),
1051    }
1052}
1053
1054fn approach_evaluation_record(
1055    integrand: &ProcessIntegrand,
1056    evaluation: EvaluationResult,
1057) -> Result<ApproachEvaluationRecord> {
1058    let parameterization_jacobian = evaluation.parameterization_jacobian.map(|value| value.0);
1059    let integrator_weight = evaluation.integrator_weight.0;
1060    let total_scale = parameterization_jacobian.unwrap_or(1.0) * integrator_weight;
1061    let total_weight = Complex::new(
1062        F(evaluation.integrand_result.re.0 * total_scale),
1063        F(evaluation.integrand_result.im.0 * total_scale),
1064    );
1065
1066    let mut event_weight_sum = zero_complex();
1067    let mut additional_contribution_sums = BTreeMap::<String, Complex<F<f64>>>::new();
1068    let mut contribution_sums = BTreeMap::<ContributionKey, Complex<F<f64>>>::new();
1069    let mut threshold_counterterm_summary = ThresholdCountertermSummaryAccumulator::default();
1070    let mut events = Vec::new();
1071    let parameterization_settings = integrand
1072        .get_settings()
1073        .sampling
1074        .get_parameterization_settings()
1075        .unwrap_or_default();
1076
1077    for (event_group_index, event_group) in evaluation.event_groups.iter().enumerate() {
1078        for (event_index, event) in event_group.iter().enumerate() {
1079            event_weight_sum += event.weight;
1080            let graph_id = event.cut_info.graph_id;
1081            let graph_name = integrand
1082                .graph_name_by_id(graph_id)
1083                .map(ToString::to_string);
1084            let graph_group_id = integrand.graph_group_id_by_graph_id(graph_id);
1085            let cut_id = event.cut_info.cut_id;
1086            let cut_edges = integrand.cut_edge_ids(graph_id, cut_id).unwrap_or_default();
1087            let sampling_channel_id = event.cut_info.sampling_channel_id;
1088            let lmb_sample_id = sampling_channel_id
1089                .map(|channel_id| {
1090                    integrand.lmb_sample_id_for_channel(
1091                        graph_id,
1092                        channel_id,
1093                        &parameterization_settings,
1094                    )
1095                })
1096                .transpose()?
1097                .flatten();
1098
1099            let event_key = ContributionKey {
1100                contribution: "event_weight".to_string(),
1101                graph_id,
1102                graph_name: graph_name.clone(),
1103                graph_group_id,
1104                orientation_id: event.cut_info.orientation_id,
1105                cut_id,
1106                cut_edges: cut_edges.clone(),
1107                lmb_sample_id,
1108            };
1109            add_contribution(&mut contribution_sums, event_key, event.weight);
1110
1111            let full_multiplicative_factor = event
1112                .additional_weights
1113                .weights
1114                .get(&AdditionalWeightKey::FullMultiplicativeFactor)
1115                .cloned();
1116            let has_display_only_weights = event
1117                .additional_weights
1118                .weights
1119                .keys()
1120                .any(|key| *key != AdditionalWeightKey::FullMultiplicativeFactor);
1121            if has_display_only_weights && full_multiplicative_factor.is_none() {
1122                return Err(eyre!(
1123                    "Approach event for graph {} cut {} has display-only weights but no full multiplicative factor.",
1124                    graph_id,
1125                    cut_id,
1126                ));
1127            }
1128
1129            let mut display_only_weights = BTreeMap::new();
1130            for (key, value) in &event.additional_weights.weights {
1131                let Some(label) = additional_weight_key_label(*key) else {
1132                    continue;
1133                };
1134                let normalized = *value
1135                    * *full_multiplicative_factor
1136                        .as_ref()
1137                        .expect("display-only weights require a full multiplicative factor");
1138                *additional_contribution_sums
1139                    .entry(label.clone())
1140                    .or_insert_with(zero_complex) += normalized;
1141                display_only_weights.insert(label.clone(), complex_json(*value));
1142                let contribution_key = ContributionKey {
1143                    contribution: label,
1144                    graph_id,
1145                    graph_name: graph_name.clone(),
1146                    graph_group_id,
1147                    orientation_id: event.cut_info.orientation_id,
1148                    cut_id,
1149                    cut_edges: cut_edges.clone(),
1150                    lmb_sample_id,
1151                };
1152                add_contribution(&mut contribution_sums, contribution_key, normalized);
1153            }
1154
1155            if let Some(decomposition) = &event.additional_weights.threshold_counterterms {
1156                threshold_counterterm_summary.add(graph_id, &event.weight, decomposition);
1157            }
1158
1159            events.push(EventRecord {
1160                event_group_index,
1161                event_index,
1162                graph_id,
1163                graph_name,
1164                graph_group_id,
1165                orientation_id: event.cut_info.orientation_id,
1166                cut_id,
1167                cut_edges,
1168                sampling_channel_id,
1169                lmb_sample_id,
1170                weight: complex_json(event.weight),
1171                full_multiplicative_factor: full_multiplicative_factor.map(complex_json),
1172                display_only_weights,
1173                threshold_counterterms: event.additional_weights.threshold_counterterms.clone(),
1174            });
1175        }
1176    }
1177    let threshold_counterterm_summary = threshold_counterterm_summary.into_json();
1178
1179    Ok(ApproachEvaluationRecord {
1180        integrand_result: complex_json(evaluation.integrand_result),
1181        parameterization_jacobian,
1182        integrator_weight,
1183        total_weight: complex_json(total_weight),
1184        event_weight_sum: complex_json(event_weight_sum),
1185        additional_contribution_sums: additional_contribution_sums
1186            .into_iter()
1187            .map(|(key, value)| (key, complex_json(value)))
1188            .collect(),
1189        threshold_counterterm_summary,
1190        contributions: contribution_sums
1191            .into_iter()
1192            .map(|(key, weight)| contribution_record(key, weight))
1193            .collect(),
1194        events,
1195        metadata: EvaluationMetadataJson {
1196            generated_event_count: evaluation.evaluation_metadata.generated_event_count,
1197            accepted_event_count: evaluation.evaluation_metadata.accepted_event_count,
1198            is_nan: evaluation.evaluation_metadata.is_nan,
1199            total_time_seconds: evaluation.evaluation_metadata.total_timing.as_secs_f64(),
1200            parameterization_time_seconds: evaluation
1201                .evaluation_metadata
1202                .parameterization_time
1203                .as_secs_f64(),
1204            integrand_evaluation_time_seconds: evaluation
1205                .evaluation_metadata
1206                .integrand_evaluation_time
1207                .as_secs_f64(),
1208            evaluator_evaluation_time_seconds: evaluation
1209                .evaluation_metadata
1210                .evaluator_evaluation_time
1211                .as_secs_f64(),
1212            event_processing_time_seconds: evaluation
1213                .evaluation_metadata
1214                .event_processing_time
1215                .as_secs_f64(),
1216        },
1217    })
1218}
1219
1220fn add_contribution(
1221    contribution_sums: &mut BTreeMap<ContributionKey, Complex<F<f64>>>,
1222    key: ContributionKey,
1223    value: Complex<F<f64>>,
1224) {
1225    *contribution_sums.entry(key).or_insert_with(zero_complex) += value;
1226}
1227
1228fn contribution_record(key: ContributionKey, weight: Complex<F<f64>>) -> ContributionRecord {
1229    ContributionRecord {
1230        label: contribution_label(&key),
1231        contribution: key.contribution,
1232        graph_id: key.graph_id,
1233        graph_name: key.graph_name,
1234        graph_group_id: key.graph_group_id,
1235        orientation_id: key.orientation_id,
1236        cut_id: key.cut_id,
1237        cut_edges: key.cut_edges,
1238        lmb_sample_id: key.lmb_sample_id,
1239        weight: complex_json(weight),
1240    }
1241}
1242
1243fn contribution_label(key: &ContributionKey) -> String {
1244    let graph = key
1245        .graph_name
1246        .as_deref()
1247        .map(ToString::to_string)
1248        .unwrap_or_else(|| format!("#{}", key.graph_id));
1249    let edge_label = if key.cut_edges.is_empty() {
1250        "[]".to_string()
1251    } else {
1252        format!(
1253            "[{}]",
1254            key.cut_edges
1255                .iter()
1256                .map(ToString::to_string)
1257                .collect::<Vec<_>>()
1258                .join(",")
1259        )
1260    };
1261    let mut parts = vec![
1262        key.contribution.clone(),
1263        format!("graph={graph}"),
1264        format!("cut={} edges={edge_label}", key.cut_id),
1265    ];
1266    if let Some(orientation_id) = key.orientation_id {
1267        parts.push(format!("orientation={orientation_id}"));
1268    }
1269    if let Some(lmb_sample_id) = key.lmb_sample_id {
1270        parts.push(format!("lmb_sample={lmb_sample_id}"));
1271    }
1272    parts.join(" ")
1273}
1274
1275fn additional_weight_key_label(key: AdditionalWeightKey) -> Option<String> {
1276    match key {
1277        AdditionalWeightKey::FullMultiplicativeFactor => None,
1278        AdditionalWeightKey::Original => Some("original".to_string()),
1279        AdditionalWeightKey::ThresholdCounterterm { subset_index } => {
1280            Some(format!("threshold_counterterm_{subset_index}"))
1281        }
1282        AdditionalWeightKey::AmplitudeThresholdCounterterm {
1283            esurface_id,
1284            overlap_group,
1285        } => Some(format!("ct_{esurface_id}_{overlap_group}")),
1286        AdditionalWeightKey::AmplitudeThresholdCountertermVariant {
1287            variant_id,
1288            esurface_id,
1289            overlap_group,
1290        } => Some(format!(
1291            "ct_variant_{variant_id}_{esurface_id}_{overlap_group}"
1292        )),
1293    }
1294}
1295
1296fn zero_complex() -> Complex<F<f64>> {
1297    Complex::new(F(0.0), F(0.0))
1298}
1299
1300fn complex_json(value: Complex<F<f64>>) -> ComplexJson {
1301    ComplexJson {
1302        re: value.re.0,
1303        im: value.im.0,
1304    }
1305}
1306
1307fn approach_progress_bar(len: u64) -> Arc<ProgressBar> {
1308    let bar = if io::stderr().is_terminal() {
1309        ProgressBar::new(len)
1310    } else {
1311        ProgressBar::hidden()
1312    };
1313    bar.set_style(gammalooprs::utils::long_running_progress_style());
1314    Arc::new(bar)
1315}
1316
1317fn progress_message(
1318    axis_index: usize,
1319    axis_count: usize,
1320    axis_point_index: usize,
1321    axis_point_count: usize,
1322    completed_total: usize,
1323    total: usize,
1324) -> String {
1325    format!(
1326        "{} {} {} {} {} {}",
1327        "axis".bright_cyan().bold(),
1328        format!("{:>2}/{:<2}", axis_index + 1, axis_count)
1329            .bright_white()
1330            .bold(),
1331        "point".bright_cyan().bold(),
1332        format!("{:>5}/{:<5}", axis_point_index + 1, axis_point_count)
1333            .bright_white()
1334            .bold(),
1335        "total".bright_cyan().bold(),
1336        format!("{:>5}/{:<5}", completed_total, total)
1337            .bright_green()
1338            .bold()
1339    )
1340}
1341
1342fn relative_path_display(path: &Path) -> String {
1343    if path.is_absolute() {
1344        if let Ok(current_dir) = env::current_dir() {
1345            if let Ok(relative) = path.strip_prefix(current_dir) {
1346                return relative.display().to_string();
1347            }
1348        }
1349    }
1350    path.display().to_string()
1351}
1352
1353fn shell_quote(value: &str) -> String {
1354    if value
1355        .chars()
1356        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '='))
1357    {
1358        value.to_string()
1359    } else {
1360        format!("'{}'", value.replace('\'', "'\\''"))
1361    }
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    fn command_with_spacing(n_points: usize, logarithmic: bool, min_abs_t: f64) -> Approach {
1369        Approach {
1370            point: vec![0.5, 0.5],
1371            approach_axes: vec!["0.1,0.0".to_string()],
1372            n_points,
1373            logarithmic,
1374            min_abs_t,
1375            ..Default::default()
1376        }
1377    }
1378
1379    #[test]
1380    fn linear_t_values_are_ordered_around_zero() {
1381        let command = command_with_spacing(2, false, 1.0e-6);
1382        assert_eq!(
1383            command.t_values(ApproachSpacing::Linear).unwrap(),
1384            vec![-1.0, -0.5, 0.0, 0.5, 1.0]
1385        );
1386    }
1387
1388    #[test]
1389    fn linear_t_values_use_physical_maximum() {
1390        let command = Approach {
1391            max_abs_t: 0.2,
1392            ..command_with_spacing(2, false, 1.0e-6)
1393        };
1394        assert_eq!(
1395            command.t_values(ApproachSpacing::Linear).unwrap(),
1396            vec![-0.2, -0.1, 0.0, 0.1, 0.2]
1397        );
1398    }
1399
1400    #[test]
1401    fn logarithmic_t_values_use_min_abs_t() {
1402        let command = command_with_spacing(3, true, 1.0e-4);
1403        let t_values = command.t_values(ApproachSpacing::Logarithmic).unwrap();
1404        assert_eq!(t_values.len(), 7);
1405        assert!((t_values[0] + 1.0).abs() < 1.0e-14);
1406        assert!((t_values[2] + 1.0e-4).abs() < 1.0e-14);
1407        assert_eq!(t_values[3], 0.0);
1408        assert!((t_values[4] - 1.0e-4).abs() < 1.0e-14);
1409        assert!((t_values[6] - 1.0).abs() < 1.0e-14);
1410    }
1411
1412    #[test]
1413    fn logarithmic_t_values_span_physical_bounds() {
1414        let command = Approach {
1415            max_abs_t: 1.0e-2,
1416            ..command_with_spacing(3, true, 1.0e-6)
1417        };
1418        let t_values = command.t_values(ApproachSpacing::Logarithmic).unwrap();
1419        assert!((t_values[0] + 1.0e-2).abs() < 1.0e-14);
1420        assert!((t_values[1] + 1.0e-4).abs() < 1.0e-14);
1421        assert!((t_values[2] + 1.0e-6).abs() < 1.0e-14);
1422        assert_eq!(t_values[3], 0.0);
1423        assert!((t_values[4] - 1.0e-6).abs() < 1.0e-14);
1424        assert!((t_values[5] - 1.0e-4).abs() < 1.0e-14);
1425        assert!((t_values[6] - 1.0e-2).abs() < 1.0e-14);
1426    }
1427
1428    #[test]
1429    fn skip_midpoint_omits_zero_from_t_values() {
1430        let command = Approach {
1431            skip_midpoint: true,
1432            ..command_with_spacing(2, false, 1.0e-6)
1433        };
1434        assert_eq!(
1435            command.t_values(ApproachSpacing::Linear).unwrap(),
1436            vec![-1.0, -0.5, 0.5, 1.0]
1437        );
1438    }
1439
1440    #[test]
1441    fn missing_skip_midpoint_deserializes_to_false() {
1442        let mut serialized = serde_json::to_value(command_with_spacing(2, false, 1.0e-6)).unwrap();
1443        serialized.as_object_mut().unwrap().remove("skip_midpoint");
1444        let command: Approach = serde_json::from_value(serialized).unwrap();
1445        assert!(!command.skip_midpoint);
1446    }
1447
1448    #[test]
1449    fn new_path_metadata_uses_coherent_serde_defaults() {
1450        let mut serialized = serde_json::to_value(command_with_spacing(2, false, 1.0e-6)).unwrap();
1451        let object = serialized.as_object_mut().unwrap();
1452        object.remove("axis_labels");
1453        object.remove("max_abs_t");
1454        object.remove("parameter_name");
1455        let command: Approach = serde_json::from_value(serialized).unwrap();
1456        assert!(command.axis_labels.is_empty());
1457        assert_eq!(command.max_abs_t, DEFAULT_MAX_ABS_T);
1458        assert_eq!(command.parameter_name, "t");
1459    }
1460
1461    #[test]
1462    fn parse_axis_rejects_dimension_mismatch() {
1463        let err = parse_axis("1.0,0.0,2.0", 2).unwrap_err();
1464        assert!(format!("{err:#}").contains("Approach axis has 3 components"));
1465    }
1466
1467    #[test]
1468    fn parse_axis_rejects_non_finite_components() {
1469        let err = parse_axis("NaN,0.0", 2).unwrap_err();
1470        assert!(format!("{err:#}").contains("component 0 must be finite"));
1471    }
1472
1473    #[test]
1474    fn axes_receive_trimmed_user_labels() {
1475        let command = Approach {
1476            point: vec![0.5, 0.5],
1477            approach_axes: vec!["0.1,0.0".to_string(), "0.0,0.2".to_string()],
1478            axis_labels: vec![" soft ".to_string(), "threshold".to_string()],
1479            n_points: 1,
1480            ..Default::default()
1481        };
1482        command.validate_cli().unwrap();
1483        let axes = command.parse_axes().unwrap();
1484        assert_eq!(axes[0].label, "soft");
1485        assert_eq!(axes[0].vector, vec![0.1, 0.0]);
1486        assert_eq!(axes[1].label, "threshold");
1487        assert_eq!(axes[1].vector, vec![0.0, 0.2]);
1488    }
1489
1490    #[test]
1491    fn axis_labels_must_be_complete_nonblank_and_unique() {
1492        for labels in [
1493            vec!["one".to_string()],
1494            vec!["one".to_string(), " ".to_string()],
1495            vec!["one".to_string(), "one".to_string()],
1496        ] {
1497            let command = Approach {
1498                point: vec![0.5, 0.5],
1499                approach_axes: vec!["0.1,0.0".to_string(), "0.0,0.2".to_string()],
1500                axis_labels: labels,
1501                n_points: 1,
1502                ..Default::default()
1503            };
1504            assert!(command.validate_cli().is_err());
1505        }
1506    }
1507
1508    #[test]
1509    fn coordinate_jobs_skip_points_outside_unit_hypercube() {
1510        let command = Approach {
1511            point: vec![0.95, 0.5],
1512            approach_axes: vec!["0.1,0.0".to_string()],
1513            n_points: 1,
1514            ..Default::default()
1515        };
1516        let axes = command.parse_axes().unwrap();
1517        let t_values = command.t_values(ApproachSpacing::Linear).unwrap();
1518        let jobs = command.build_jobs(&axes, &t_values);
1519        assert!(jobs[2].skip_reason.is_some());
1520        assert!(jobs[1].skip_reason.is_none());
1521    }
1522
1523    #[test]
1524    fn zero_n_cores_is_rejected() {
1525        let command = Approach {
1526            point: vec![0.5, 0.5],
1527            approach_axes: vec!["0.1,0.0".to_string()],
1528            n_points: 1,
1529            n_cores: Some(0),
1530            ..Default::default()
1531        };
1532        let err = command.validate_cli().unwrap_err();
1533        assert!(format!("{err:#}").contains("--n-cores must be at least 1"));
1534    }
1535
1536    #[test]
1537    fn invalid_log_min_abs_t_is_rejected() {
1538        let command = Approach {
1539            point: vec![0.5, 0.5],
1540            approach_axes: vec!["0.1,0.0".to_string()],
1541            n_points: 1,
1542            logarithmic: true,
1543            min_abs_t: 0.0,
1544            ..Default::default()
1545        };
1546        let err = command.validate_cli().unwrap_err();
1547        assert!(format!("{err:#}").contains("--min-abs-t must be finite and greater than 0"));
1548    }
1549
1550    #[test]
1551    fn invalid_physical_parameter_bounds_are_rejected() {
1552        for (min_abs_t, max_abs_t, logarithmic) in [(1.0e-2, 1.0e-3, true), (1.0e-3, 0.0, false)] {
1553            let command = Approach {
1554                point: vec![0.5, 0.5],
1555                approach_axes: vec!["0.1,0.0".to_string()],
1556                n_points: 1,
1557                min_abs_t,
1558                max_abs_t,
1559                logarithmic,
1560                ..Default::default()
1561            };
1562            assert!(command.validate_cli().is_err());
1563        }
1564    }
1565
1566    #[test]
1567    fn non_finite_midpoint_and_blank_parameter_name_are_rejected() {
1568        let non_finite = Approach {
1569            point: vec![f64::INFINITY, 0.5],
1570            approach_axes: vec!["0.1,0.0".to_string()],
1571            n_points: 1,
1572            ..Default::default()
1573        };
1574        assert!(format!("{:#}", non_finite.validate_cli().unwrap_err()).contains("must be finite"));
1575
1576        let blank_parameter = Approach {
1577            point: vec![0.5, 0.5],
1578            approach_axes: vec!["0.1,0.0".to_string()],
1579            n_points: 1,
1580            parameter_name: " ".to_string(),
1581            ..Default::default()
1582        };
1583        assert!(format!("{:#}", blank_parameter.validate_cli().unwrap_err())
1584            .contains("--parameter-name must not be blank"));
1585    }
1586
1587    #[test]
1588    fn threshold_summary_is_scoped_and_grouped_by_graph_component() {
1589        use gammalooprs::observables::events::{
1590            GenericThresholdCountertermComponentWeight, ThresholdCountertermComponentOccurrence,
1591        };
1592
1593        let component = |component_id, weighted, evaluation_skipped| {
1594            GenericThresholdCountertermComponentWeight {
1595                component_id,
1596                occurrence: ThresholdCountertermComponentOccurrence::Amplitude {
1597                    raised_esurface_id: component_id,
1598                    overlap_group: 0,
1599                },
1600                multiplier_values: Default::default(),
1601                effective_multiplier: F(1.0),
1602                bare: None,
1603                weighted: Complex::new_re(F(weighted)),
1604                evaluation_skipped,
1605            }
1606        };
1607        let mut accumulator = ThresholdCountertermSummaryAccumulator::default();
1608        let first = GenericThresholdCountertermEventInfo {
1609            original: Complex::new_re(F(10.0)),
1610            components: vec![component(2, 1.0, false), component(2, 2.0, true)],
1611        };
1612        let second = GenericThresholdCountertermEventInfo {
1613            original: Complex::new_re(F(20.0)),
1614            components: vec![component(3, -4.0, false)],
1615        };
1616        let third = GenericThresholdCountertermEventInfo {
1617            original: Complex::new_re(F(5.0)),
1618            components: vec![component(2, 1.0, false)],
1619        };
1620        accumulator.add(1, &Complex::new_re(F(13.0)), &first);
1621        accumulator.add(1, &Complex::new_re(F(16.0)), &second);
1622        accumulator.add(2, &Complex::new_re(F(6.0)), &third);
1623
1624        let summary = accumulator.into_json().unwrap();
1625        assert_eq!(summary.original.re, 35.0);
1626        assert_eq!(summary.counterterm_sum.re, 0.0);
1627        assert_eq!(summary.reconstructed_total.re, 35.0);
1628        assert_eq!(summary.decomposed_event_sum.re, 35.0);
1629        assert_eq!(summary.closure.re, 0.0);
1630        assert_eq!(summary.components.len(), 3);
1631        assert_eq!(
1632            (
1633                summary.components[0].graph_id,
1634                summary.components[0].component_id,
1635                summary.components[0].weighted.re,
1636                summary.components[0].occurrence_count,
1637                summary.components[0].skipped_count,
1638            ),
1639            (1, 2, 3.0, 2, 1),
1640        );
1641        assert_eq!(
1642            (
1643                summary.components[2].graph_id,
1644                summary.components[2].component_id,
1645            ),
1646            (2, 2),
1647        );
1648    }
1649
1650    #[test]
1651    fn multiplicative_factor_is_not_an_additive_weight_label() {
1652        assert_eq!(
1653            additional_weight_key_label(AdditionalWeightKey::FullMultiplicativeFactor),
1654            None,
1655        );
1656        assert_eq!(
1657            additional_weight_key_label(AdditionalWeightKey::Original).as_deref(),
1658            Some("original"),
1659        );
1660    }
1661
1662    #[test]
1663    fn simulated_parallel_completion_is_sorted_back_to_job_order() {
1664        let command = Approach {
1665            point: vec![0.5, 0.5],
1666            approach_axes: vec!["0.1,0.0".to_string(), "0.0,0.2".to_string()],
1667            n_points: 2,
1668            ..Default::default()
1669        };
1670        let axes = command.parse_axes().unwrap();
1671        let t_values = command.t_values(ApproachSpacing::Linear).unwrap();
1672        let jobs = command.build_jobs(&axes, &t_values);
1673        let mut records = (0..2)
1674            .flat_map(|worker_index| jobs.iter().skip(worker_index).step_by(2))
1675            .map(|job| ApproachPointRecord {
1676                index: job.index,
1677                axis_index: job.axis_index,
1678                axis_point_index: job.axis_point_index,
1679                t: job.t,
1680                point: job.point.clone(),
1681                status: "skipped",
1682                skip_reason: None,
1683                evaluation: None,
1684            })
1685            .collect::<Vec<_>>();
1686
1687        assert_ne!(
1688            records
1689                .iter()
1690                .map(|record| record.index)
1691                .collect::<Vec<_>>(),
1692            (0..records.len()).collect::<Vec<_>>(),
1693            "strided worker completion should be out of original order in this simulation",
1694        );
1695        records.sort_by_key(|record| record.index);
1696        assert_eq!(
1697            records
1698                .iter()
1699                .map(|record| (record.index, record.axis_index, record.axis_point_index))
1700                .collect::<Vec<_>>(),
1701            jobs.iter()
1702                .map(|job| (job.index, job.axis_index, job.axis_point_index))
1703                .collect::<Vec<_>>(),
1704        );
1705    }
1706}