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 #[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 #[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 #[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 #[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 #[arg(long = "axis-label", value_name = "LABEL")]
96 #[serde(default)]
97 pub axis_labels: Vec<String>,
98
99 #[arg(long = "n-points", value_name = "N")]
101 pub n_points: usize,
102
103 #[arg(long)]
105 #[serde(default)]
106 pub skip_midpoint: bool,
107
108 #[arg(long, conflicts_with = "logarithmic")]
110 pub linear: bool,
111
112 #[arg(long, conflicts_with = "linear")]
114 pub logarithmic: bool,
115
116 #[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 #[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 #[arg(long = "parameter-name", default_value = "t", value_name = "NAME")]
136 #[serde(default = "default_parameter_name")]
137 pub parameter_name: String,
138
139 #[arg(long = "n-cores", value_name = "N")]
141 pub n_cores: Option<usize>,
142
143 #[arg(short = 'f', long = "use_arb_prec")]
145 pub use_arb_prec: bool,
146
147 #[arg(short = 'm', long)]
149 pub momentum_space: bool,
150
151 #[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 #[arg(long = "graph-id", value_name = "GRAPH_ID")]
164 pub graph_id: Option<usize>,
165
166 #[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 #[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 ¶meterization_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}