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 #[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 #[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 #[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 #[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 #[arg(long = "n-points", value_name = "N")]
79 pub n_points: usize,
80
81 #[arg(long, conflicts_with = "logarithmic")]
83 pub linear: bool,
84
85 #[arg(long, conflicts_with = "linear")]
87 pub logarithmic: bool,
88
89 #[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 #[arg(long = "n-cores", value_name = "N")]
99 pub n_cores: Option<usize>,
100
101 #[arg(short = 'f', long = "use_arb_prec")]
103 pub use_arb_prec: bool,
104
105 #[arg(short = 'm', long)]
107 pub momentum_space: bool,
108
109 #[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 #[arg(long = "graph-id", value_name = "GRAPH_ID")]
122 pub graph_id: Option<usize>,
123
124 #[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 #[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 ¶meterization_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}