Skip to main content

gammaloop_api/commands/
approach.rs

1use std::{
2    collections::BTreeMap,
3    env, fs,
4    io::{self, IsTerminal},
5    path::{Path, PathBuf},
6    sync::{
7        atomic::{AtomicUsize, Ordering},
8        Arc,
9    },
10};
11
12use clap::Args;
13use color_eyre::Result;
14use colored::Colorize;
15use eyre::{eyre, Context};
16use gammalooprs::{
17    integrands::{evaluation::EvaluationResult, process::ProcessIntegrand},
18    model::Model,
19    observables::events::AdditionalWeightKey,
20    utils::F,
21};
22use indicatif::ProgressBar;
23use rayon::{prelude::*, ThreadPoolBuilder};
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use spenso::algebra::complex::Complex;
27use tracing::info;
28
29use crate::{
30    commands::evaluate_samples::{build_havana_sample, build_momentum_input},
31    commands::CliArgumentMetadataExt,
32    completion::CompletionArgExt,
33    state::{ProcessRef, State},
34    CLISettings,
35};
36
37#[derive(Debug, Args, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Default)]
38pub struct Approach {
39    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
40    #[arg(
41        short = 'p',
42        long = "process",
43        value_name = "PROCESS",
44        completion_process_selector(crate::completion::SelectorKind::Any)
45    )]
46    pub process: Option<ProcessRef>,
47
48    /// The integrand name to approach
49    #[arg(
50        short = 'i',
51        long = "integrand-name",
52        value_name = "NAME",
53        completion_integrand_selector(crate::completion::SelectorKind::Any)
54    )]
55    pub integrand_name: Option<String>,
56
57    /// Integration coordinates, or flattened loop momenta `(px, py, pz) ...` with `--momentum-space`
58    #[arg(
59        short = 'x',
60        long = "point",
61        num_args = 1..,
62        value_name = "POINT",
63        value_delimiter = ',',
64        allow_negative_numbers = true,
65    )]
66    pub point: Vec<f64>,
67
68    /// Direction vector in the same coordinate layout and dimension as `--point`
69    #[arg(
70        long = "approach-axis",
71        value_name = "AXIS",
72        num_args = 1..,
73        allow_negative_numbers = true,
74    )]
75    pub approach_axes: Vec<String>,
76
77    /// Number of points on each side of the midpoint
78    #[arg(long = "n-points", value_name = "N")]
79    pub n_points: usize,
80
81    /// Use linear spacing in t
82    #[arg(long, conflicts_with = "logarithmic")]
83    pub linear: bool,
84
85    /// Use logarithmic spacing in |t|
86    #[arg(long, conflicts_with = "linear")]
87    pub logarithmic: bool,
88
89    /// Smallest non-zero |t| for logarithmic spacing
90    #[arg(
91        long = "min-abs-t",
92        default_value_t = 1.0e-6,
93        allow_negative_numbers = true
94    )]
95    pub min_abs_t: f64,
96
97    /// Number of worker threads for approach evaluations
98    #[arg(long = "n-cores", value_name = "N")]
99    pub n_cores: Option<usize>,
100
101    /// Force arbitrary-precision (Arb) internal evaluation; results remain f64
102    #[arg(short = 'f', long = "use_arb_prec")]
103    pub use_arb_prec: bool,
104
105    /// Interpret `--point` and every axis as spatial loop-momentum triplets
106    #[arg(short = 'm', long)]
107    pub momentum_space: bool,
108
109    /// The discrete dimensions of the sample
110    #[arg(
111        short = 'd',
112        long = "discrete-dim",
113        value_name = "DIMS",
114        num_args = 1..,
115        value_delimiter = ',',
116        conflicts_with_all = ["graph_id", "orientation_id"],
117    )]
118    pub discrete_dim: Vec<usize>,
119
120    /// Select a specific graph in momentum-space approach
121    #[arg(long = "graph-id", value_name = "GRAPH_ID")]
122    pub graph_id: Option<usize>,
123
124    /// Select a specific orientation of the selected graph in momentum-space approach
125    #[arg(
126        long = "orientation-id",
127        value_name = "ORIENTATION_ID",
128        cli_requires("graph_id"),
129        conflicts_with = "discrete_dim"
130    )]
131    pub orientation_id: Option<usize>,
132
133    /// Write sampled approach points and run metadata to this JSON file
134    #[arg(long = "output-results", value_hint = clap::ValueHint::FilePath)]
135    pub output_results: Option<PathBuf>,
136}
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "snake_case")]
140enum ApproachSpacing {
141    Linear,
142    Logarithmic,
143}
144
145#[derive(Debug, Clone)]
146struct ApproachJob {
147    index: usize,
148    axis_index: usize,
149    axis_point_index: usize,
150    t: f64,
151    point: Vec<f64>,
152    skip_reason: Option<String>,
153}
154
155#[derive(Debug, Serialize)]
156struct ApproachOutput {
157    schema_version: u32,
158    command: ApproachCommandJson,
159    process: ApproachProcessJson,
160    integrand: ApproachIntegrandJson,
161    space: String,
162    base_point: Vec<f64>,
163    axes: Vec<Vec<f64>>,
164    spacing: ApproachSpacingJson,
165    n_cores: usize,
166    points_per_axis: usize,
167    evaluated_points: usize,
168    skipped_points: usize,
169    points: Vec<ApproachPointRecord>,
170}
171
172#[derive(Debug, Serialize)]
173struct ApproachCommandJson {
174    name: &'static str,
175    use_arb_prec: bool,
176    graph_id: Option<usize>,
177    orientation_id: Option<usize>,
178    discrete_dim: Vec<usize>,
179}
180
181#[derive(Debug, Serialize)]
182struct ApproachProcessJson {
183    id: usize,
184    name: String,
185}
186
187#[derive(Debug, Serialize)]
188struct ApproachIntegrandJson {
189    name: String,
190    kind: String,
191}
192
193#[derive(Debug, Serialize)]
194struct ApproachSpacingJson {
195    kind: ApproachSpacing,
196    n_points: usize,
197    min_abs_t: Option<f64>,
198    t_values: Vec<f64>,
199}
200
201#[derive(Debug, Serialize)]
202struct ApproachPointRecord {
203    index: usize,
204    axis_index: usize,
205    axis_point_index: usize,
206    t: f64,
207    point: Vec<f64>,
208    status: &'static str,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    skip_reason: Option<String>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    evaluation: Option<ApproachEvaluationRecord>,
213}
214
215#[derive(Debug, Serialize)]
216struct ApproachEvaluationRecord {
217    integrand_result: ComplexJson,
218    parameterization_jacobian: Option<f64>,
219    integrator_weight: f64,
220    total_weight: ComplexJson,
221    event_weight_sum: ComplexJson,
222    additional_weight_sums: BTreeMap<String, ComplexJson>,
223    contributions: Vec<ContributionRecord>,
224    events: Vec<EventRecord>,
225    metadata: EvaluationMetadataJson,
226}
227
228#[derive(Debug, Serialize)]
229struct EvaluationMetadataJson {
230    generated_event_count: usize,
231    accepted_event_count: usize,
232    is_nan: bool,
233    total_time_seconds: f64,
234    parameterization_time_seconds: f64,
235    integrand_evaluation_time_seconds: f64,
236    evaluator_evaluation_time_seconds: f64,
237    event_processing_time_seconds: f64,
238}
239
240#[derive(Debug, Serialize)]
241struct EventRecord {
242    event_group_index: usize,
243    event_index: usize,
244    graph_id: usize,
245    graph_name: Option<String>,
246    graph_group_id: Option<usize>,
247    orientation_id: Option<usize>,
248    cut_id: usize,
249    cut_edges: Vec<usize>,
250    lmb_channel_id: Option<usize>,
251    lmb_sample_id: Option<usize>,
252    weight: ComplexJson,
253    additional_weights: BTreeMap<String, ComplexJson>,
254}
255
256#[derive(Debug, Serialize)]
257struct ContributionRecord {
258    label: String,
259    contribution: String,
260    graph_id: usize,
261    graph_name: Option<String>,
262    graph_group_id: Option<usize>,
263    orientation_id: Option<usize>,
264    cut_id: usize,
265    cut_edges: Vec<usize>,
266    lmb_sample_id: Option<usize>,
267    weight: ComplexJson,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
271struct ContributionKey {
272    contribution: String,
273    graph_id: usize,
274    graph_name: Option<String>,
275    graph_group_id: Option<usize>,
276    orientation_id: Option<usize>,
277    cut_id: usize,
278    cut_edges: Vec<usize>,
279    lmb_sample_id: Option<usize>,
280}
281
282#[derive(Debug, Clone, Copy, Serialize)]
283struct ComplexJson {
284    re: f64,
285    im: f64,
286}
287
288impl Approach {
289    pub fn run(&self, state: &mut State, cli_settings: &CLISettings) -> Result<PathBuf> {
290        self.validate_cli()?;
291        let (process_id, integrand_name) =
292            state.find_integrand_ref(self.process.as_ref(), self.integrand_name.as_ref())?;
293        let process_name = state.process_list.processes[process_id]
294            .definition
295            .folder_name
296            .clone();
297        let model = state.resolve_model_for_integrand(process_id, &integrand_name)?;
298        let base_integrand = state
299            .process_list
300            .get_integrand(process_id, &integrand_name)?
301            .require_generated()?
302            .clone();
303
304        if !self.momentum_space && (self.graph_id.is_some() || self.orientation_id.is_some()) {
305            return Err(eyre!(
306                "Graph and orientation selectors are only supported in momentum-space approach."
307            ));
308        }
309        if !self.momentum_space {
310            let expected_dimension =
311                base_integrand.expected_x_space_dimension(&self.discrete_dim)?;
312            if self.point.len() != expected_dimension {
313                return Err(eyre!(
314                    "Expected {} x-space coordinates for this integrand selection, got {}.",
315                    expected_dimension,
316                    self.point.len()
317                ));
318            }
319        }
320
321        let graph_name = self.resolve_graph_name(&base_integrand, &integrand_name)?;
322        let axes = self.parse_axes()?;
323        let spacing = self.spacing()?;
324        let t_values = self.t_values(spacing)?;
325        let jobs = self.build_jobs(&axes, &t_values);
326        let n_cores = self
327            .n_cores
328            .unwrap_or(cli_settings.global.n_cores.integrate);
329        if n_cores == 0 {
330            return Err(eyre!("--n-cores must be at least 1."));
331        }
332
333        let total_jobs = jobs.len();
334        let progress = approach_progress_bar(total_jobs as u64);
335        let pool = ThreadPoolBuilder::new()
336            .num_threads(n_cores)
337            .build()
338            .context("failed to build approach evaluation thread pool")?;
339        let worker_count = n_cores.min(total_jobs.max(1));
340        let worker_inputs = (0..worker_count)
341            .map(|worker_index| {
342                let worker_jobs = jobs
343                    .iter()
344                    .skip(worker_index)
345                    .step_by(worker_count)
346                    .cloned()
347                    .collect::<Vec<_>>();
348                let mut integrand = base_integrand.clone();
349                force_event_output(&mut integrand);
350                (integrand, model.clone(), worker_jobs)
351            })
352            .collect::<Vec<_>>();
353        let completed_jobs = Arc::new(AtomicUsize::new(0));
354        let completed_workers = Arc::new(AtomicUsize::new(0));
355        let results = pool.install(|| {
356            worker_inputs
357                .into_par_iter()
358                .map(
359                    |(mut integrand, model, worker_jobs)| -> Result<Vec<ApproachPointRecord>> {
360                        integrand
361                            .warm_up(&model)
362                            .context("failed to warm up approach worker integrand")?;
363                        let mut records = Vec::with_capacity(worker_jobs.len());
364                        for job in worker_jobs {
365                            let record = self.evaluate_job(
366                                &job,
367                                &mut integrand,
368                                &model,
369                                graph_name.as_deref(),
370                                axes.len(),
371                                t_values.len(),
372                            );
373                            let completed_total =
374                                completed_jobs.fetch_add(1, Ordering::Relaxed) + 1;
375                            progress.inc(1);
376                            progress.set_message(progress_message(
377                                job.axis_index,
378                                axes.len(),
379                                job.axis_point_index,
380                                t_values.len(),
381                                completed_total,
382                                total_jobs,
383                            ));
384                            records.push(record?);
385                        }
386                        let completed_worker_count =
387                            completed_workers.fetch_add(1, Ordering::Relaxed) + 1;
388                        progress.set_message(format!(
389                            "{} {}",
390                            "collecting worker results".bright_magenta().bold(),
391                            format!("{completed_worker_count:>2}/{worker_count:<2}")
392                                .bright_white()
393                                .bold()
394                        ));
395                        if completed_worker_count == worker_count {
396                            progress.println(
397                                "Approach evaluations complete; collecting worker results"
398                                    .bright_blue()
399                                    .bold()
400                                    .to_string(),
401                            );
402                        }
403                        Ok(records)
404                    },
405                )
406                .collect::<Result<Vec<_>>>()
407        });
408        progress.finish_with_message(
409            "approach evaluations complete; finalizing output"
410                .bright_green()
411                .bold()
412                .to_string(),
413        );
414
415        info!(
416            "{} {}",
417            "Finalizing approach results".bright_blue().bold(),
418            "(sorting records and preparing JSON)".bright_black()
419        );
420        let mut point_records = results?
421            .into_iter()
422            .flatten()
423            .collect::<Vec<ApproachPointRecord>>();
424        point_records.sort_by_key(|record| record.index);
425
426        let evaluated_points = point_records
427            .iter()
428            .filter(|record| record.status == "evaluated")
429            .count();
430        let skipped_points = point_records.len() - evaluated_points;
431        let output = ApproachOutput {
432            schema_version: 1,
433            command: ApproachCommandJson {
434                name: "approach",
435                use_arb_prec: self.use_arb_prec,
436                graph_id: self.graph_id,
437                orientation_id: self.orientation_id,
438                discrete_dim: self.discrete_dim.clone(),
439            },
440            process: ApproachProcessJson {
441                id: process_id,
442                name: process_name,
443            },
444            integrand: ApproachIntegrandJson {
445                name: integrand_name,
446                kind: base_integrand.kind_name().to_string(),
447            },
448            space: if self.momentum_space {
449                "momentum".to_string()
450            } else {
451                "coordinate".to_string()
452            },
453            base_point: self.point.clone(),
454            axes,
455            spacing: ApproachSpacingJson {
456                kind: spacing,
457                n_points: self.n_points,
458                min_abs_t: matches!(spacing, ApproachSpacing::Logarithmic)
459                    .then_some(self.min_abs_t),
460                t_values,
461            },
462            n_cores,
463            points_per_axis: 2 * self.n_points + 1,
464            evaluated_points,
465            skipped_points,
466            points: point_records,
467        };
468
469        let output_path = self
470            .output_results
471            .clone()
472            .unwrap_or_else(|| PathBuf::from("approach_result.json"));
473        let relative_output = relative_path_display(&output_path);
474        info!(
475            "{} {}",
476            "Writing approach JSON to".bright_blue().bold(),
477            relative_output.bright_cyan()
478        );
479        if let Some(parent) = output_path.parent() {
480            if !parent.as_os_str().is_empty() {
481                fs::create_dir_all(parent).with_context(|| {
482                    format!(
483                        "failed to create approach output directory '{}'",
484                        parent.display()
485                    )
486                })?;
487            }
488        }
489        let file = fs::File::create(&output_path).with_context(|| {
490            format!(
491                "failed to create approach output file '{}'",
492                output_path.display()
493            )
494        })?;
495        serde_json::to_writer_pretty(file, &output).with_context(|| {
496            format!(
497                "failed to serialize approach output to '{}'",
498                output_path.display()
499            )
500        })?;
501
502        let pdf_path = output_path.with_extension("pdf");
503        let relative_pdf = relative_path_display(&pdf_path);
504        let plot_command = format!(
505            "python3 assets/plot_approach_result.py {} --output {}",
506            shell_quote(&relative_output),
507            shell_quote(&relative_pdf)
508        );
509        info!(
510            "{} {}",
511            "Approach results written to".bright_green().bold(),
512            relative_output.bright_cyan()
513        );
514        info!(
515            "{} {}",
516            "Plot with:".bright_green().bold(),
517            plot_command.bright_cyan()
518        );
519
520        Ok(output_path)
521    }
522
523    fn validate_cli(&self) -> Result<()> {
524        if self.point.is_empty() {
525            return Err(eyre!("approach requires a midpoint supplied with --point."));
526        }
527        if self.approach_axes.is_empty() {
528            return Err(eyre!(
529                "approach requires at least one --approach-axis value."
530            ));
531        }
532        if self.n_points == 0 {
533            return Err(eyre!("--n-points must be at least 1."));
534        }
535        if self.logarithmic && !(self.min_abs_t > 0.0 && self.min_abs_t <= 1.0) {
536            return Err(eyre!(
537                "--min-abs-t must be greater than 0 and at most 1 for logarithmic spacing."
538            ));
539        }
540        if self.n_cores == Some(0) {
541            return Err(eyre!("--n-cores must be at least 1."));
542        }
543        Ok(())
544    }
545
546    fn spacing(&self) -> Result<ApproachSpacing> {
547        if self.linear && self.logarithmic {
548            return Err(eyre!("--linear and --logarithmic are mutually exclusive."));
549        }
550        Ok(if self.logarithmic {
551            ApproachSpacing::Logarithmic
552        } else {
553            ApproachSpacing::Linear
554        })
555    }
556
557    fn parse_axes(&self) -> Result<Vec<Vec<f64>>> {
558        self.approach_axes
559            .iter()
560            .map(|raw_axis| parse_axis(raw_axis, self.point.len()))
561            .collect()
562    }
563
564    fn resolve_graph_name(
565        &self,
566        integrand: &ProcessIntegrand,
567        integrand_name: &str,
568    ) -> Result<Option<String>> {
569        let Some(graph_id) = self.graph_id else {
570            return Ok(None);
571        };
572        let graph_name = integrand.graph_name_by_id(graph_id).ok_or_else(|| {
573            eyre!(
574                "Graph id {} is out of range for integrand '{}'; it has {} graphs.",
575                graph_id,
576                integrand_name,
577                integrand.graph_count()
578            )
579        })?;
580        Ok(Some(graph_name.to_string()))
581    }
582
583    fn t_values(&self, spacing: ApproachSpacing) -> Result<Vec<f64>> {
584        let magnitudes = match spacing {
585            ApproachSpacing::Linear => (1..=self.n_points)
586                .map(|index| index as f64 / self.n_points as f64)
587                .collect::<Vec<_>>(),
588            ApproachSpacing::Logarithmic => {
589                if self.n_points == 1 {
590                    vec![1.0]
591                } else {
592                    let log_min = self.min_abs_t.ln();
593                    let denom = (self.n_points - 1) as f64;
594                    (0..self.n_points)
595                        .map(|index| (log_min * (1.0 - index as f64 / denom)).exp())
596                        .collect()
597                }
598            }
599        };
600        let mut t_values = magnitudes
601            .iter()
602            .rev()
603            .map(|value| -*value)
604            .collect::<Vec<_>>();
605        t_values.push(0.0);
606        t_values.extend(magnitudes);
607        Ok(t_values)
608    }
609
610    fn build_jobs(&self, axes: &[Vec<f64>], t_values: &[f64]) -> Vec<ApproachJob> {
611        let mut jobs = Vec::with_capacity(axes.len() * t_values.len());
612        for (axis_index, axis) in axes.iter().enumerate() {
613            for (axis_point_index, t) in t_values.iter().copied().enumerate() {
614                let point = self
615                    .point
616                    .iter()
617                    .zip(axis)
618                    .map(|(center, direction)| center + t * direction)
619                    .collect::<Vec<_>>();
620                let skip_reason = (!self.momentum_space)
621                    .then(|| coordinate_skip_reason(&point))
622                    .flatten();
623                jobs.push(ApproachJob {
624                    index: jobs.len(),
625                    axis_index,
626                    axis_point_index,
627                    t,
628                    point,
629                    skip_reason,
630                });
631            }
632        }
633        jobs
634    }
635
636    fn evaluate_job(
637        &self,
638        job: &ApproachJob,
639        integrand: &mut ProcessIntegrand,
640        model: &Model,
641        graph_name: Option<&str>,
642        _axis_count: usize,
643        _axis_point_count: usize,
644    ) -> Result<ApproachPointRecord> {
645        if let Some(skip_reason) = &job.skip_reason {
646            return Ok(ApproachPointRecord {
647                index: job.index,
648                axis_index: job.axis_index,
649                axis_point_index: job.axis_point_index,
650                t: job.t,
651                point: job.point.clone(),
652                status: "skipped",
653                skip_reason: Some(skip_reason.clone()),
654                evaluation: None,
655            });
656        }
657
658        let mut samples = if self.momentum_space {
659            let input = build_momentum_input(
660                integrand,
661                &job.point,
662                1.0,
663                &self.discrete_dim,
664                graph_name,
665                self.orientation_id,
666            )?;
667            integrand
668                .evaluate_momentum_configurations_raw(model, &[input], self.use_arb_prec)?
669                .samples
670        } else {
671            let sample = build_havana_sample(integrand, &job.point, &self.discrete_dim, 1.0)?;
672            integrand
673                .evaluate_samples_raw(
674                    model,
675                    &[sample],
676                    1,
677                    self.use_arb_prec,
678                    false,
679                    Complex::new_zero(),
680                )?
681                .samples
682        };
683        let evaluation = samples
684            .pop()
685            .ok_or_else(|| eyre!("approach evaluation did not return a sample"))?;
686        Ok(ApproachPointRecord {
687            index: job.index,
688            axis_index: job.axis_index,
689            axis_point_index: job.axis_point_index,
690            t: job.t,
691            point: job.point.clone(),
692            status: "evaluated",
693            skip_reason: None,
694            evaluation: Some(approach_evaluation_record(integrand, evaluation)?),
695        })
696    }
697}
698
699fn parse_axis(raw_axis: &str, expected_dimension: usize) -> Result<Vec<f64>> {
700    let trimmed = raw_axis
701        .trim()
702        .trim_start_matches('[')
703        .trim_end_matches(']');
704    let axis = trimmed
705        .split(',')
706        .map(str::trim)
707        .filter(|component| !component.is_empty())
708        .map(|component| {
709            component.parse::<f64>().map_err(|err| {
710                eyre!(
711                    "Could not parse approach-axis component '{}' as a float: {}",
712                    component,
713                    err
714                )
715            })
716        })
717        .collect::<Result<Vec<_>>>()?;
718    if axis.len() != expected_dimension {
719        return Err(eyre!(
720            "Approach axis has {} components, but the point has {} components.",
721            axis.len(),
722            expected_dimension
723        ));
724    }
725    if axis.iter().all(|component| *component == 0.0) {
726        return Err(eyre!("Approach axis must not be the zero vector."));
727    }
728    Ok(axis)
729}
730
731fn coordinate_skip_reason(point: &[f64]) -> Option<String> {
732    let invalid = point
733        .iter()
734        .enumerate()
735        .filter(|(_, value)| !(0.0..=1.0).contains(*value))
736        .map(|(index, value)| format!("{index}:{value:+.16e}"))
737        .collect::<Vec<_>>();
738    (!invalid.is_empty()).then(|| {
739        format!(
740            "coordinate-space point lies outside the unit hypercube at component(s) {}",
741            invalid.join(", ")
742        )
743    })
744}
745
746fn force_event_output(integrand: &mut ProcessIntegrand) {
747    let settings = integrand.get_mut_settings();
748    settings.general.generate_events = true;
749    settings.general.store_additional_weights_in_event = true;
750}
751
752fn approach_evaluation_record(
753    integrand: &ProcessIntegrand,
754    evaluation: EvaluationResult,
755) -> Result<ApproachEvaluationRecord> {
756    let parameterization_jacobian = evaluation.parameterization_jacobian.map(|value| value.0);
757    let integrator_weight = evaluation.integrator_weight.0;
758    let total_scale = parameterization_jacobian.unwrap_or(1.0) * integrator_weight;
759    let total_weight = Complex::new(
760        F(evaluation.integrand_result.re.0 * total_scale),
761        F(evaluation.integrand_result.im.0 * total_scale),
762    );
763
764    let mut event_weight_sum = Complex::new(F(0.0), F(0.0));
765    let mut additional_weight_sums = BTreeMap::<String, Complex<F<f64>>>::new();
766    let mut contribution_sums = BTreeMap::<ContributionKey, Complex<F<f64>>>::new();
767    let mut events = Vec::new();
768    let parameterization_settings = integrand
769        .get_settings()
770        .sampling
771        .get_parameterization_settings()
772        .unwrap_or_default();
773
774    for (event_group_index, event_group) in evaluation.event_groups.iter().enumerate() {
775        for (event_index, event) in event_group.iter().enumerate() {
776            event_weight_sum += event.weight;
777            let graph_id = event.cut_info.graph_id;
778            let graph_name = integrand
779                .graph_name_by_id(graph_id)
780                .map(ToString::to_string);
781            let graph_group_id = integrand.graph_group_id_by_graph_id(graph_id);
782            let cut_id = event.cut_info.cut_id;
783            let cut_edges = integrand.cut_edge_ids(graph_id, cut_id).unwrap_or_default();
784            let lmb_channel_id = event.cut_info.lmb_channel_id;
785            let lmb_sample_id = lmb_channel_id
786                .map(|channel_id| {
787                    integrand.lmb_sample_id_for_channel(
788                        graph_id,
789                        channel_id,
790                        &parameterization_settings,
791                    )
792                })
793                .transpose()?
794                .flatten();
795
796            let event_key = ContributionKey {
797                contribution: "event_weight".to_string(),
798                graph_id,
799                graph_name: graph_name.clone(),
800                graph_group_id,
801                orientation_id: event.cut_info.orientation_id,
802                cut_id,
803                cut_edges: cut_edges.clone(),
804                lmb_sample_id,
805            };
806            add_contribution(&mut contribution_sums, event_key, event.weight);
807
808            let mut additional_weights = BTreeMap::new();
809            for (key, value) in &event.additional_weights.weights {
810                let label = additional_weight_key_label(*key);
811                *additional_weight_sums
812                    .entry(label.clone())
813                    .or_insert_with(zero_complex) += *value;
814                additional_weights.insert(label.clone(), complex_json(*value));
815                let contribution_key = ContributionKey {
816                    contribution: label,
817                    graph_id,
818                    graph_name: graph_name.clone(),
819                    graph_group_id,
820                    orientation_id: event.cut_info.orientation_id,
821                    cut_id,
822                    cut_edges: cut_edges.clone(),
823                    lmb_sample_id,
824                };
825                add_contribution(&mut contribution_sums, contribution_key, *value);
826            }
827
828            events.push(EventRecord {
829                event_group_index,
830                event_index,
831                graph_id,
832                graph_name,
833                graph_group_id,
834                orientation_id: event.cut_info.orientation_id,
835                cut_id,
836                cut_edges,
837                lmb_channel_id,
838                lmb_sample_id,
839                weight: complex_json(event.weight),
840                additional_weights,
841            });
842        }
843    }
844
845    Ok(ApproachEvaluationRecord {
846        integrand_result: complex_json(evaluation.integrand_result),
847        parameterization_jacobian,
848        integrator_weight,
849        total_weight: complex_json(total_weight),
850        event_weight_sum: complex_json(event_weight_sum),
851        additional_weight_sums: additional_weight_sums
852            .into_iter()
853            .map(|(key, value)| (key, complex_json(value)))
854            .collect(),
855        contributions: contribution_sums
856            .into_iter()
857            .map(|(key, weight)| contribution_record(key, weight))
858            .collect(),
859        events,
860        metadata: EvaluationMetadataJson {
861            generated_event_count: evaluation.evaluation_metadata.generated_event_count,
862            accepted_event_count: evaluation.evaluation_metadata.accepted_event_count,
863            is_nan: evaluation.evaluation_metadata.is_nan,
864            total_time_seconds: evaluation.evaluation_metadata.total_timing.as_secs_f64(),
865            parameterization_time_seconds: evaluation
866                .evaluation_metadata
867                .parameterization_time
868                .as_secs_f64(),
869            integrand_evaluation_time_seconds: evaluation
870                .evaluation_metadata
871                .integrand_evaluation_time
872                .as_secs_f64(),
873            evaluator_evaluation_time_seconds: evaluation
874                .evaluation_metadata
875                .evaluator_evaluation_time
876                .as_secs_f64(),
877            event_processing_time_seconds: evaluation
878                .evaluation_metadata
879                .event_processing_time
880                .as_secs_f64(),
881        },
882    })
883}
884
885fn add_contribution(
886    contribution_sums: &mut BTreeMap<ContributionKey, Complex<F<f64>>>,
887    key: ContributionKey,
888    value: Complex<F<f64>>,
889) {
890    *contribution_sums.entry(key).or_insert_with(zero_complex) += value;
891}
892
893fn contribution_record(key: ContributionKey, weight: Complex<F<f64>>) -> ContributionRecord {
894    ContributionRecord {
895        label: contribution_label(&key),
896        contribution: key.contribution,
897        graph_id: key.graph_id,
898        graph_name: key.graph_name,
899        graph_group_id: key.graph_group_id,
900        orientation_id: key.orientation_id,
901        cut_id: key.cut_id,
902        cut_edges: key.cut_edges,
903        lmb_sample_id: key.lmb_sample_id,
904        weight: complex_json(weight),
905    }
906}
907
908fn contribution_label(key: &ContributionKey) -> String {
909    let graph = key
910        .graph_name
911        .as_deref()
912        .map(ToString::to_string)
913        .unwrap_or_else(|| format!("#{}", key.graph_id));
914    let edge_label = if key.cut_edges.is_empty() {
915        "[]".to_string()
916    } else {
917        format!(
918            "[{}]",
919            key.cut_edges
920                .iter()
921                .map(ToString::to_string)
922                .collect::<Vec<_>>()
923                .join(",")
924        )
925    };
926    let mut parts = vec![
927        key.contribution.clone(),
928        format!("graph={graph}"),
929        format!("cut={} edges={edge_label}", key.cut_id),
930    ];
931    if let Some(orientation_id) = key.orientation_id {
932        parts.push(format!("orientation={orientation_id}"));
933    }
934    if let Some(lmb_sample_id) = key.lmb_sample_id {
935        parts.push(format!("lmb_sample={lmb_sample_id}"));
936    }
937    parts.join(" ")
938}
939
940fn additional_weight_key_label(key: AdditionalWeightKey) -> String {
941    match key {
942        AdditionalWeightKey::FullMultiplicativeFactor => "full_multiplicative_factor".to_string(),
943        AdditionalWeightKey::Original => "original".to_string(),
944        AdditionalWeightKey::ThresholdCounterterm { subset_index } => {
945            format!("threshold_counterterm_{subset_index}")
946        }
947        AdditionalWeightKey::AmplitudeThresholdCounterterm {
948            esurface_id,
949            overlap_group,
950        } => format!("ct_{esurface_id}_{overlap_group}"),
951    }
952}
953
954fn zero_complex() -> Complex<F<f64>> {
955    Complex::new(F(0.0), F(0.0))
956}
957
958fn complex_json(value: Complex<F<f64>>) -> ComplexJson {
959    ComplexJson {
960        re: value.re.0,
961        im: value.im.0,
962    }
963}
964
965fn approach_progress_bar(len: u64) -> Arc<ProgressBar> {
966    let bar = if io::stderr().is_terminal() {
967        ProgressBar::new(len)
968    } else {
969        ProgressBar::hidden()
970    };
971    bar.set_style(gammalooprs::utils::long_running_progress_style());
972    Arc::new(bar)
973}
974
975fn progress_message(
976    axis_index: usize,
977    axis_count: usize,
978    axis_point_index: usize,
979    axis_point_count: usize,
980    completed_total: usize,
981    total: usize,
982) -> String {
983    format!(
984        "{} {} {} {} {} {}",
985        "axis".bright_cyan().bold(),
986        format!("{:>2}/{:<2}", axis_index + 1, axis_count)
987            .bright_white()
988            .bold(),
989        "point".bright_cyan().bold(),
990        format!("{:>5}/{:<5}", axis_point_index + 1, axis_point_count)
991            .bright_white()
992            .bold(),
993        "total".bright_cyan().bold(),
994        format!("{:>5}/{:<5}", completed_total, total)
995            .bright_green()
996            .bold()
997    )
998}
999
1000fn relative_path_display(path: &Path) -> String {
1001    if path.is_absolute() {
1002        if let Ok(current_dir) = env::current_dir() {
1003            if let Ok(relative) = path.strip_prefix(current_dir) {
1004                return relative.display().to_string();
1005            }
1006        }
1007    }
1008    path.display().to_string()
1009}
1010
1011fn shell_quote(value: &str) -> String {
1012    if value
1013        .chars()
1014        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '='))
1015    {
1016        value.to_string()
1017    } else {
1018        format!("'{}'", value.replace('\'', "'\\''"))
1019    }
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025
1026    fn command_with_spacing(n_points: usize, logarithmic: bool, min_abs_t: f64) -> Approach {
1027        Approach {
1028            point: vec![0.5, 0.5],
1029            approach_axes: vec!["0.1,0.0".to_string()],
1030            n_points,
1031            logarithmic,
1032            min_abs_t,
1033            ..Default::default()
1034        }
1035    }
1036
1037    #[test]
1038    fn linear_t_values_are_ordered_around_zero() {
1039        let command = command_with_spacing(2, false, 1.0e-6);
1040        assert_eq!(
1041            command.t_values(ApproachSpacing::Linear).unwrap(),
1042            vec![-1.0, -0.5, 0.0, 0.5, 1.0]
1043        );
1044    }
1045
1046    #[test]
1047    fn logarithmic_t_values_use_min_abs_t() {
1048        let command = command_with_spacing(3, true, 1.0e-4);
1049        let t_values = command.t_values(ApproachSpacing::Logarithmic).unwrap();
1050        assert_eq!(t_values.len(), 7);
1051        assert!((t_values[0] + 1.0).abs() < 1.0e-14);
1052        assert!((t_values[2] + 1.0e-4).abs() < 1.0e-14);
1053        assert_eq!(t_values[3], 0.0);
1054        assert!((t_values[4] - 1.0e-4).abs() < 1.0e-14);
1055        assert!((t_values[6] - 1.0).abs() < 1.0e-14);
1056    }
1057
1058    #[test]
1059    fn parse_axis_rejects_dimension_mismatch() {
1060        let err = parse_axis("1.0,0.0,2.0", 2).unwrap_err();
1061        assert!(format!("{err:#}").contains("Approach axis has 3 components"));
1062    }
1063
1064    #[test]
1065    fn coordinate_jobs_skip_points_outside_unit_hypercube() {
1066        let command = Approach {
1067            point: vec![0.95, 0.5],
1068            approach_axes: vec!["0.1,0.0".to_string()],
1069            n_points: 1,
1070            ..Default::default()
1071        };
1072        let axes = command.parse_axes().unwrap();
1073        let t_values = command.t_values(ApproachSpacing::Linear).unwrap();
1074        let jobs = command.build_jobs(&axes, &t_values);
1075        assert!(jobs[2].skip_reason.is_some());
1076        assert!(jobs[1].skip_reason.is_none());
1077    }
1078
1079    #[test]
1080    fn zero_n_cores_is_rejected() {
1081        let command = Approach {
1082            point: vec![0.5, 0.5],
1083            approach_axes: vec!["0.1,0.0".to_string()],
1084            n_points: 1,
1085            n_cores: Some(0),
1086            ..Default::default()
1087        };
1088        let err = command.validate_cli().unwrap_err();
1089        assert!(format!("{err:#}").contains("--n-cores must be at least 1"));
1090    }
1091
1092    #[test]
1093    fn invalid_log_min_abs_t_is_rejected() {
1094        let command = Approach {
1095            point: vec![0.5, 0.5],
1096            approach_axes: vec!["0.1,0.0".to_string()],
1097            n_points: 1,
1098            logarithmic: true,
1099            min_abs_t: 0.0,
1100            ..Default::default()
1101        };
1102        let err = command.validate_cli().unwrap_err();
1103        assert!(format!("{err:#}").contains("--min-abs-t must be greater than 0"));
1104    }
1105
1106    #[test]
1107    fn simulated_parallel_completion_is_sorted_back_to_job_order() {
1108        let command = Approach {
1109            point: vec![0.5, 0.5],
1110            approach_axes: vec!["0.1,0.0".to_string(), "0.0,0.2".to_string()],
1111            n_points: 2,
1112            ..Default::default()
1113        };
1114        let axes = command.parse_axes().unwrap();
1115        let t_values = command.t_values(ApproachSpacing::Linear).unwrap();
1116        let jobs = command.build_jobs(&axes, &t_values);
1117        let mut records = (0..2)
1118            .flat_map(|worker_index| jobs.iter().skip(worker_index).step_by(2))
1119            .map(|job| ApproachPointRecord {
1120                index: job.index,
1121                axis_index: job.axis_index,
1122                axis_point_index: job.axis_point_index,
1123                t: job.t,
1124                point: job.point.clone(),
1125                status: "skipped",
1126                skip_reason: None,
1127                evaluation: None,
1128            })
1129            .collect::<Vec<_>>();
1130
1131        assert_ne!(
1132            records
1133                .iter()
1134                .map(|record| record.index)
1135                .collect::<Vec<_>>(),
1136            (0..records.len()).collect::<Vec<_>>(),
1137            "strided worker completion should be out of original order in this simulation",
1138        );
1139        records.sort_by_key(|record| record.index);
1140        assert_eq!(
1141            records
1142                .iter()
1143                .map(|record| (record.index, record.axis_index, record.axis_point_index))
1144                .collect::<Vec<_>>(),
1145            jobs.iter()
1146                .map(|job| (job.index, job.axis_index, job.axis_point_index))
1147                .collect::<Vec<_>>(),
1148        );
1149    }
1150}