Skip to main content

gammalooprs/integrands/process/
ir.rs

1use std::{
2    collections::{BTreeMap, HashSet},
3    fmt::Display,
4};
5
6use color_eyre::eyre::Result;
7use colored::Colorize;
8use eyre::eyre;
9use itertools::Itertools;
10use linnet::half_edge::involution::{EdgeIndex, Orientation};
11use rand::Rng;
12use spenso::algebra::complex::Complex;
13use symbolica::numerical_integration::MonteCarloRng;
14use tabled::{builder::Builder, settings::Style};
15use tracing::warn;
16use typed_index_collections::TiVec;
17
18use crate::{
19    DependentMomentaConstructor,
20    cff::esurface::{ExistingEsurfaceId, ExistingEsurfaces, GroupEsurfaceId, RaisedEsurfaceId},
21    graph::{FeynmanGraph, GraphGroupPosition, LmbError, lmb::LMBwithEdges},
22    integrands::{
23        evaluation::PreciseEvaluationResult,
24        process::{
25            GraphTerm, OrientationProfileMode, ProcessIntegrandImpl,
26            amplitude::{AmplitudeGraphTerm, AmplitudeIntegrand},
27            cross_section::{CrossSectionGraphTerm, CrossSectionIntegrand},
28            evaluate_profile_momentum_point_precise, orientation_labels_for_graph,
29        },
30    },
31    model::Model,
32    momentum::{
33        ThreeMomentum,
34        sample::{LoopIndex, LoopMomenta, MomentumSample},
35    },
36    observables::events::AdditionalWeightKey,
37    settings::{
38        RuntimeSettings, SamplingSettings,
39        runtime::{
40            DiscreteGraphSamplingSettings, DiscreteGraphSamplingType, ParameterizationMapping,
41            ParameterizationMode, ParameterizationSettings,
42        },
43    },
44    subtraction::amplitude_counterterm::OverlapStructureWithKinematics,
45    utils::{
46        ArbPrec, F, FloatLike, box_muller,
47        fitting::{constant_dropped_fit_points, log_log_slope_constant_dropped},
48    },
49};
50
51/// The range is from 10^start to 10^end.
52pub struct IRProfileSetting {
53    pub lambda_exp_start: f64,
54    pub lambda_exp_end: f64,
55    pub steps: usize,
56    pub seed: u64,
57    pub select_limits_and_graphs: Option<String>,
58    pub orientation_mode: OrientationProfileMode,
59    pub show_per_cut_info: bool,
60}
61
62impl AmplitudeGraphTerm {
63    fn enumerate_ir_limits(&self) -> Vec<IrLimit> {
64        let mut limits: HashSet<IrLimit> = HashSet::new();
65
66        let massless_edges: Vec<EdgeIndex> = self
67            .graph
68            .iter_edges_of(&!self.graph.tree_edges.clone())
69            .filter_map(|(_a, b, c)| {
70                if c.data.particle.is_massless() {
71                    Some(b)
72                } else {
73                    None
74                }
75            })
76            .collect();
77
78        for subset in massless_edges.iter().powerset() {
79            if subset.is_empty() {
80                continue;
81            }
82            let _lmb = match self.lmb_with_loop_edges(subset.as_slice()) {
83                Ok(lmb) => lmb,
84                Err(err) => match err {
85                    LmbError::NotLoopEdges { .. } => {
86                        // warn!("{loop_edges} is not a valid loop edge subset");
87                        continue;
88                    }
89                    a => panic!("Failed to build IR loop momentum basis for cut graph:\n{a}"),
90                },
91            };
92
93            let ir_limit = IrLimit::new_pure_soft(subset.into_iter().copied().collect());
94
95            limits.insert(ir_limit);
96        }
97
98        limits.into_iter().sorted().collect()
99    }
100}
101
102impl CrossSectionGraphTerm {
103    fn enumerate_ir_limits(&self) -> Vec<IrLimit> {
104        let mut limits: HashSet<IrLimit> = HashSet::new();
105        let loop_count = self.graph.loop_momentum_basis.loop_edges.len();
106
107        for cut_group in self.cut_group_data.cut_groups.iter() {
108            let mut limits_of_cut: HashSet<IrLimit> = HashSet::new();
109
110            let representative_cut_esurface = &self.cut_esurface[*cut_group.cuts.first().unwrap()];
111            let massless_edges_in_cut = representative_cut_esurface
112                .energies
113                .iter()
114                .filter(|edge_id| self.graph[**edge_id].particle.is_massless())
115                .copied()
116                .collect_vec();
117
118            if massless_edges_in_cut.len() >= 2 {
119                let subsets = massless_edges_in_cut
120                    .iter()
121                    .powerset()
122                    .filter(|subset| {
123                        subset.len() >= 2
124                            && subset.len() <= loop_count
125                            && subset.len() < representative_cut_esurface.energies.len()
126                    })
127                    .collect_vec();
128
129                for subset in subsets {
130                    let ir_limit =
131                        IrLimit::new_pure_colinear(subset.into_iter().copied().collect());
132                    limits_of_cut.insert(ir_limit);
133                }
134            }
135
136            if !massless_edges_in_cut.is_empty() {
137                let subsets = massless_edges_in_cut
138                    .iter()
139                    .powerset()
140                    .filter(|subset| {
141                        !subset.is_empty()
142                            && subset.len() <= loop_count
143                            && subset.len() < representative_cut_esurface.energies.len()
144                    })
145                    .collect_vec();
146                for subset in subsets {
147                    let ir_limit = IrLimit::new_pure_soft(subset.into_iter().copied().collect());
148                    limits_of_cut.insert(ir_limit);
149                }
150            }
151
152            for limit in limits_of_cut.drain() {
153                limits.insert(limit);
154            }
155        }
156
157        limits.into_iter().sorted().collect()
158    }
159}
160
161pub struct IrLimitTestReport {
162    pub all_passed: bool,
163    pub results_per_graph: Vec<GraphIRLimitReport>,
164}
165
166pub struct GraphIRLimitReport {
167    pub graph_name: String,
168    pub all_limits_passed: bool,
169    pub cut_definitions: Vec<GraphCutDefinition>,
170    pub single_limit_reports: Vec<SingleLimitReport>,
171}
172
173#[derive(Debug, Clone)]
174pub struct GraphCutDefinition {
175    pub cut_id: usize,
176    pub edges: Vec<EdgeIndex>,
177}
178
179pub struct SingleLimitReport {
180    pub limit_name: String,
181    pub orientation_label: Option<String>,
182    pub passed: bool,
183    pub power_law_fit: PowerLawFit,
184    pub scaling: f64,
185    pub per_cut_reports: Vec<CutLimitReport>,
186    pub display_only_reports: Vec<DisplayOnlyLimitReport>,
187    num_soft: usize,
188}
189
190pub struct CutLimitReport {
191    pub cut_id: usize,
192    pub power_law_fit: Option<PowerLawFit>,
193    pub scaling: Option<f64>,
194    pub fit_error: Option<String>,
195}
196
197pub struct DisplayOnlyLimitReport {
198    pub label: String,
199    pub power_law_fit: Option<PowerLawFit>,
200    pub scaling: Option<f64>,
201    pub fit_error: Option<String>,
202}
203
204impl Display for IrLimitTestReport {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        let overall_status = if self.all_passed {
207            "PASS".green().bold()
208        } else {
209            "FAIL".red().bold()
210        };
211
212        let passed_graphs = self
213            .results_per_graph
214            .iter()
215            .filter(|graph_report| graph_report.all_limits_passed)
216            .count();
217
218        writeln!(
219            f,
220            "IR limit tests: {} ({}/{})",
221            overall_status,
222            passed_graphs,
223            self.results_per_graph.len()
224        )?;
225
226        let mut graph_summary_table = Builder::new();
227        graph_summary_table.push_record(["graph", "status", "passed", "total"]);
228
229        for graph_report in &self.results_per_graph {
230            let graph_status = if graph_report.all_limits_passed {
231                "PASS".green().bold().to_string()
232            } else {
233                "FAIL".red().bold().to_string()
234            };
235
236            let passed_limits = graph_report
237                .single_limit_reports
238                .iter()
239                .filter(|report| report.passed)
240                .count();
241
242            graph_summary_table.push_record([
243                graph_report.graph_name.clone(),
244                graph_status,
245                passed_limits.to_string(),
246                graph_report.single_limit_reports.len().to_string(),
247            ]);
248        }
249
250        writeln!(f, "{}", graph_summary_table.build().with(Style::rounded()))?;
251
252        for graph_report in &self.results_per_graph {
253            writeln!(f)?;
254            writeln!(f, "{graph_report}")?;
255        }
256
257        Ok(())
258    }
259}
260
261impl Display for GraphIRLimitReport {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        let graph_status = if self.all_limits_passed {
264            "PASS".green().bold()
265        } else {
266            "FAIL".red().bold()
267        };
268
269        let passed_limits = self
270            .single_limit_reports
271            .iter()
272            .filter(|report| report.passed)
273            .count();
274
275        writeln!(
276            f,
277            "  {} {} ({}/{})",
278            graph_status,
279            self.graph_name.bold(),
280            passed_limits,
281            self.single_limit_reports.len()
282        )?;
283
284        if !self.cut_definitions.is_empty() {
285            render_graph_cut_definitions(f, &self.cut_definitions)?;
286            writeln!(f)?;
287        }
288
289        let mut limit_table = Builder::new();
290        let mut separators_after_data_rows = Vec::new();
291        let mut data_row_count = 0;
292        limit_table.push_record([
293            "status",
294            "limit",
295            "orientation",
296            "item",
297            "scaling",
298            "p",
299            "r_squared",
300            "n_soft",
301        ]);
302
303        for (report_index, report) in self.single_limit_reports.iter().enumerate() {
304            let status = if report.passed {
305                "PASS".green().bold().to_string()
306            } else {
307                "FAIL".red().bold().to_string()
308            };
309
310            limit_table.push_record([
311                status,
312                report.limit_name.clone(),
313                report
314                    .orientation_label
315                    .clone()
316                    .unwrap_or_else(|| "sum".to_string()),
317                "sum".to_string(),
318                format!("{:+.4}", report.scaling),
319                format!("{:+.4}", report.power_law_fit.exponent),
320                format!("{:.4}", report.power_law_fit.r_squared),
321                report.num_soft.to_string(),
322            ]);
323            data_row_count += 1;
324
325            if !report.per_cut_reports.is_empty() || !report.display_only_reports.is_empty() {
326                separators_after_data_rows.push(data_row_count);
327            }
328
329            for cut_report in &report.per_cut_reports {
330                let [item, scaling, exponent, r_squared] = cut_report_display_row(cut_report);
331                limit_table.push_record([
332                    "INFO".cyan().bold().to_string(),
333                    String::new(),
334                    String::new(),
335                    item,
336                    scaling,
337                    exponent,
338                    r_squared,
339                    String::new(),
340                ]);
341                data_row_count += 1;
342            }
343
344            for display_only_report in &report.display_only_reports {
345                let [item, scaling, exponent, r_squared] =
346                    display_only_report_display_row(display_only_report);
347                limit_table.push_record([
348                    "INFO".cyan().bold().to_string(),
349                    String::new(),
350                    String::new(),
351                    item,
352                    scaling,
353                    exponent,
354                    r_squared,
355                    String::new(),
356                ]);
357                data_row_count += 1;
358            }
359
360            if report_index + 1 < self.single_limit_reports.len() {
361                separators_after_data_rows.push(data_row_count);
362            }
363        }
364
365        let mut table = limit_table.build();
366        table.with(Style::rounded());
367        let rendered =
368            insert_limit_table_separators(table.to_string(), &separators_after_data_rows);
369
370        write!(f, "{rendered}")?;
371
372        Ok(())
373    }
374}
375
376impl Display for SingleLimitReport {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        let status = if self.passed {
379            "PASS".green().bold()
380        } else {
381            "FAIL".red().bold()
382        };
383
384        write!(
385            f,
386            "{} {}{} | scaling={:+.4} | p={:+.4} | R²={:.4} | n_soft={}",
387            status,
388            self.limit_name,
389            self.orientation_label
390                .as_ref()
391                .map(|label| format!(" @ {label}"))
392                .unwrap_or_default(),
393            self.scaling,
394            self.power_law_fit.exponent,
395            self.power_law_fit.r_squared,
396            self.num_soft
397        )?;
398
399        render_per_cut_reports(f, self, "")?;
400        render_display_only_reports(f, self, "")
401    }
402}
403
404fn render_per_cut_reports(
405    f: &mut std::fmt::Formatter<'_>,
406    report: &SingleLimitReport,
407    indent: &str,
408) -> std::fmt::Result {
409    if report.per_cut_reports.is_empty() {
410        return Ok(());
411    }
412
413    writeln!(f)?;
414    writeln!(
415        f,
416        "\n{indent}per-cut fits for {}{}",
417        report.limit_name,
418        report
419            .orientation_label
420            .as_ref()
421            .map(|label| format!(" @ {label}"))
422            .unwrap_or_default(),
423    )?;
424
425    let mut cut_table = Builder::new();
426    cut_table.push_record(["cut", "scaling", "p", "r_squared"]);
427
428    for cut_report in &report.per_cut_reports {
429        cut_table.push_record(cut_report_display_row(cut_report));
430    }
431
432    write!(f, "{}", cut_table.build().with(Style::rounded()))
433}
434
435fn render_graph_cut_definitions(
436    f: &mut std::fmt::Formatter<'_>,
437    cut_definitions: &[GraphCutDefinition],
438) -> std::fmt::Result {
439    writeln!(f, "  cut definitions")?;
440
441    let mut cut_table = Builder::new();
442    cut_table.push_record(["cut", "edges"]);
443
444    for cut_definition in cut_definitions {
445        cut_table.push_record([
446            cut_definition.cut_id.to_string(),
447            cut_definition
448                .edges
449                .iter()
450                .map(ToString::to_string)
451                .join(", "),
452        ]);
453    }
454
455    writeln!(f, "{}", cut_table.build().with(Style::rounded()))
456}
457
458fn render_display_only_reports(
459    f: &mut std::fmt::Formatter<'_>,
460    report: &SingleLimitReport,
461    indent: &str,
462) -> std::fmt::Result {
463    if report.display_only_reports.is_empty() {
464        return Ok(());
465    }
466
467    writeln!(f)?;
468    writeln!(
469        f,
470        "\n{indent}display-only fits for {}{}",
471        report.limit_name,
472        report
473            .orientation_label
474            .as_ref()
475            .map(|label| format!(" @ {label}"))
476            .unwrap_or_default(),
477    )?;
478
479    let mut display_only_table = Builder::new();
480    display_only_table.push_record(["component", "scaling", "p", "r_squared"]);
481
482    for display_only_report in &report.display_only_reports {
483        display_only_table.push_record(display_only_report_display_row(display_only_report));
484    }
485
486    write!(f, "{}", display_only_table.build().with(Style::rounded()))
487}
488
489fn format_missing_fit_item(label: String, fit_error: Option<&str>) -> String {
490    match fit_error {
491        Some(error) => format!("{label} (no fit: {error})"),
492        None => label,
493    }
494}
495
496fn cut_report_display_row(cut_report: &CutLimitReport) -> [String; 4] {
497    let (scaling, exponent, r_squared) = match (&cut_report.power_law_fit, &cut_report.fit_error) {
498        (Some(fit), None) => (
499            format!("{:+.4}", cut_report.scaling.unwrap_or(fit.exponent)),
500            format!("{:+.4}", fit.exponent),
501            format!("{:.4}", fit.r_squared),
502        ),
503        (None, Some(_)) => ("-".to_string(), "-".to_string(), "-".to_string()),
504        _ => ("-".to_string(), "-".to_string(), "-".to_string()),
505    };
506
507    [
508        format_missing_fit_item(
509            format!("cut {}", cut_report.cut_id),
510            cut_report.fit_error.as_deref(),
511        ),
512        scaling,
513        exponent,
514        r_squared,
515    ]
516}
517
518fn display_only_report_display_row(display_only_report: &DisplayOnlyLimitReport) -> [String; 4] {
519    let (scaling, exponent, r_squared) = match (
520        &display_only_report.power_law_fit,
521        &display_only_report.fit_error,
522    ) {
523        (Some(fit), None) => (
524            format!(
525                "{:+.4}",
526                display_only_report.scaling.unwrap_or(fit.exponent)
527            ),
528            format!("{:+.4}", fit.exponent),
529            format!("{:.4}", fit.r_squared),
530        ),
531        (None, Some(_)) => ("-".to_string(), "-".to_string(), "-".to_string()),
532        _ => ("-".to_string(), "-".to_string(), "-".to_string()),
533    };
534
535    [
536        format_missing_fit_item(
537            display_only_report.label.clone(),
538            display_only_report.fit_error.as_deref(),
539        ),
540        scaling,
541        exponent,
542        r_squared,
543    ]
544}
545
546fn insert_limit_table_separators(rendered: String, separators_after_data_rows: &[usize]) -> String {
547    if separators_after_data_rows.is_empty() {
548        return rendered;
549    }
550
551    let lines = rendered.lines().collect_vec();
552    if lines.len() < 4 {
553        return rendered;
554    }
555
556    let line_count = lines.len();
557    let separator_line = lines[2].to_string();
558    let mut next_separator = separators_after_data_rows.iter().copied().peekable();
559    let mut output = Vec::with_capacity(lines.len() + separators_after_data_rows.len());
560    let mut seen_data_rows = 0;
561
562    for (line_index, line) in lines.into_iter().enumerate() {
563        output.push(line.to_string());
564
565        if line_index >= 3 && line_index + 1 < line_count {
566            seen_data_rows += 1;
567            while next_separator.peek().copied() == Some(seen_data_rows) {
568                output.push(separator_line.clone());
569                next_separator.next();
570            }
571        }
572    }
573
574    output.join("\n")
575}
576
577fn ir_profile_completion_entries(
578    limits: Vec<(String, Vec<ProfileLimit>)>,
579) -> Vec<(String, Vec<String>)> {
580    limits
581        .into_iter()
582        .map(|(graph_name, limits)| {
583            (
584                graph_name,
585                limits.into_iter().map(|limit| limit.to_string()).collect(),
586            )
587        })
588        .collect()
589}
590
591fn graph_cut_definitions_for_cross_section_term(
592    graph_term: &CrossSectionGraphTerm,
593) -> Vec<GraphCutDefinition> {
594    graph_term
595        .cuts
596        .iter_enumerated()
597        .map(|(cut_id, cut)| GraphCutDefinition {
598            cut_id: cut_id.into(),
599            edges: graph_term
600                .graph
601                .underlying
602                .iter_edges_of(&cut.cut)
603                .map(|(_, edge_id, _)| edge_id)
604                .sorted()
605                .collect(),
606        })
607        .collect()
608}
609
610fn graph_id_by_name<I: ProcessIntegrandImpl>(integrand: &I, graph_name: &str) -> Option<usize> {
611    (0..integrand.graph_count())
612        .find(|graph_id| integrand.get_graph(*graph_id).name() == graph_name)
613}
614
615fn parse_select_limits_and_graphs<I: ProcessIntegrandImpl>(
616    integrand: &I,
617    input: &str,
618) -> Result<Vec<(String, Vec<ProfileLimit>)>> {
619    input
620        .split(';')
621        .map(|graph_info_string| {
622            let mut parts = graph_info_string.split(' ');
623            let graph_name = parts
624                .next()
625                .ok_or_else(|| eyre!("Expected graph name in select_limits_and_graphs"))?
626                .to_string();
627
628            let Some(graph_id) = graph_id_by_name(integrand, &graph_name) else {
629                return Err(eyre!(
630                    "Graph name '{}' in select_limits_and_graphs does not match any graph in the integrand",
631                    graph_name
632                ));
633            };
634
635            let profile_limits = parts
636                .map(ProfileLimit::parse_limit)
637                .collect::<Result<Vec<_>, _>>()?;
638
639            if profile_limits.is_empty() {
640                return Err(eyre!(
641                    "No limits specified for graph '{}' in select_limits_and_graphs",
642                    graph_name
643                ));
644            }
645
646            let loop_number = integrand
647                .get_graph(graph_id)
648                .get_graph()
649                .loop_momentum_basis
650                .loop_edges
651                .len();
652            if profile_limits
653                .iter()
654                .any(|limit| limit.is_valid(loop_number).is_err())
655            {
656                return Err(eyre!(
657                    "One or more limits specified for graph '{}' in select_limits_and_graphs are not valid",
658                    graph_name
659                ));
660            }
661
662            Ok((graph_name, profile_limits))
663        })
664        .collect::<Result<Vec<_>, _>>()
665}
666
667fn requested_orientations<I: ProcessIntegrandImpl>(
668    integrand: &I,
669    graph_id: usize,
670    settings: &IRProfileSetting,
671) -> Result<Vec<(Option<usize>, Option<String>)>> {
672    if settings.orientation_mode.profiles_per_orientation() {
673        Ok(orientation_labels_for_graph(integrand, graph_id)?
674            .into_iter()
675            .enumerate()
676            .map(|(orientation_id, label)| (Some(orientation_id), Some(label)))
677            .collect())
678    } else {
679        Ok(vec![(None, None)])
680    }
681}
682
683fn build_single_limit_report(
684    ir_limit: &IrLimit,
685    orientation_label: Option<String>,
686    slope: PowerLawFit,
687    per_cut_reports: Vec<CutLimitReport>,
688) -> SingleLimitReport {
689    let num_soft = ir_limit.num_soft();
690    let scaling = slope.exponent + ((num_soft * 3) as f64);
691    SingleLimitReport {
692        limit_name: format!("{}", ir_limit),
693        orientation_label,
694        passed: scaling > 0.0,
695        power_law_fit: slope,
696        scaling,
697        per_cut_reports,
698        display_only_reports: Vec::new(),
699        num_soft,
700    }
701}
702
703fn build_threshold_limit_report(
704    threshold_limit: &ThresholdLimit,
705    orientation_label: Option<String>,
706    slope: PowerLawFit,
707    per_cut_reports: Vec<CutLimitReport>,
708) -> SingleLimitReport {
709    let scaling = slope.exponent;
710    SingleLimitReport {
711        limit_name: format!("{}", threshold_limit),
712        orientation_label,
713        passed: scaling > 0.0,
714        power_law_fit: slope,
715        scaling,
716        per_cut_reports,
717        display_only_reports: Vec::new(),
718        num_soft: 0,
719    }
720}
721
722fn build_cut_limit_reports(
723    num_soft: usize,
724    cut_fits: Vec<(usize, Result<PowerLawFit>)>,
725) -> Vec<CutLimitReport> {
726    cut_fits
727        .into_iter()
728        .map(|(cut_id, fit)| match fit {
729            Ok(power_law_fit) => CutLimitReport {
730                cut_id,
731                scaling: Some(power_law_fit.exponent + ((num_soft * 3) as f64)),
732                power_law_fit: Some(power_law_fit),
733                fit_error: None,
734            },
735            Err(error) => CutLimitReport {
736                cut_id,
737                scaling: None,
738                power_law_fit: None,
739                fit_error: Some(error.to_string()),
740            },
741        })
742        .collect()
743}
744
745fn build_display_only_limit_reports(
746    component_fits: Vec<(AdditionalWeightKey, Result<PowerLawFit>)>,
747) -> Vec<DisplayOnlyLimitReport> {
748    component_fits
749        .into_iter()
750        .map(|(key, fit)| match fit {
751            Ok(power_law_fit) => DisplayOnlyLimitReport {
752                label: display_only_limit_label(key),
753                scaling: Some(power_law_fit.exponent),
754                power_law_fit: Some(power_law_fit),
755                fit_error: None,
756            },
757            Err(error) => DisplayOnlyLimitReport {
758                label: display_only_limit_label(key),
759                scaling: None,
760                power_law_fit: None,
761                fit_error: Some(error.to_string()),
762            },
763        })
764        .collect()
765}
766
767fn display_only_limit_label(key: AdditionalWeightKey) -> String {
768    match key {
769        AdditionalWeightKey::Original => "original".to_string(),
770        AdditionalWeightKey::AmplitudeThresholdCounterterm {
771            esurface_id,
772            overlap_group,
773        } => format!("ct_{esurface_id}_{overlap_group}"),
774        AdditionalWeightKey::ThresholdCounterterm { subset_index } => {
775            format!("ct_{subset_index}")
776        }
777        AdditionalWeightKey::FullMultiplicativeFactor => "full multiplicative factor".to_string(),
778    }
779}
780
781fn threshold_approach_loop_momenta<T: FloatLike>(
782    overlap_group_center: &LoopMomenta<F<f64>>,
783    threshold_point: &MomentumSample<T>,
784    lambda: &F<T>,
785) -> LoopMomenta<F<T>> {
786    let overlap_group_center = overlap_group_center.cast::<T>();
787    let threshold_loop_momenta = threshold_point.loop_moms();
788    let offset_towards_center =
789        (&overlap_group_center - threshold_loop_momenta).rescale(lambda, None);
790
791    threshold_loop_momenta + &offset_towards_center
792}
793
794fn run_ir_profile<I: ProcessIntegrandImpl>(
795    integrand: &mut I,
796    ir_profile_settings: &IRProfileSetting,
797    model: &Model,
798    enumerate_limits: impl Fn(&I) -> Vec<(String, Vec<ProfileLimit>)>,
799    graph_cut_definitions: impl Fn(&I, usize) -> Vec<GraphCutDefinition>,
800    points_on_threshold: &[OverlapStructureWithKinematics<ArbPrec>],
801    mut test_single_limit: impl FnMut(
802        &mut I,
803        usize,
804        &ProfileLimit,
805        &mut MonteCarloRng,
806        &IRProfileSetting,
807        &Model,
808        &[OverlapStructureWithKinematics<ArbPrec>],
809    ) -> Result<Vec<SingleLimitReport>>,
810) -> Result<IrLimitTestReport> {
811    let mut rng = MonteCarloRng::new(ir_profile_settings.seed, 0);
812    let limits_to_check =
813        if let Some(select_limits_and_graphs) = &ir_profile_settings.select_limits_and_graphs {
814            parse_select_limits_and_graphs(integrand, select_limits_and_graphs)?
815        } else {
816            enumerate_limits(integrand)
817        };
818
819    let mut result = IrLimitTestReport {
820        all_passed: false,
821        results_per_graph: Vec::new(),
822    };
823
824    for (graph_name, limits) in limits_to_check {
825        let Some(graph_id) = graph_id_by_name(integrand, &graph_name) else {
826            return Err(eyre!("Graph name '{}' not found in integrand", graph_name));
827        };
828
829        let mut graph_report = GraphIRLimitReport {
830            graph_name: graph_name.clone(),
831            all_limits_passed: false,
832            cut_definitions: graph_cut_definitions(integrand, graph_id),
833            single_limit_reports: Vec::new(),
834        };
835
836        for limit in limits {
837            graph_report.single_limit_reports.extend(test_single_limit(
838                integrand,
839                graph_id,
840                &limit,
841                &mut rng,
842                ir_profile_settings,
843                model,
844                points_on_threshold,
845            )?);
846        }
847
848        graph_report.all_limits_passed = graph_report
849            .single_limit_reports
850            .iter()
851            .all(|report| report.passed);
852
853        result.results_per_graph.push(graph_report);
854    }
855
856    result.all_passed = result
857        .results_per_graph
858        .iter()
859        .all(|graph_report| graph_report.all_limits_passed);
860
861    Ok(result)
862}
863
864impl AmplitudeIntegrand {
865    pub fn ir_profile_completion_entries(&self) -> Vec<(String, Vec<String>)> {
866        ir_profile_completion_entries(self.enumerate_ir_limits())
867    }
868
869    pub fn test_ir(
870        &mut self,
871        ir_profile_settings: &IRProfileSetting,
872        model: &Model,
873    ) -> Result<IrLimitTestReport> {
874        // override the sampling to be in momentum space
875        self.settings.sampling = SamplingSettings::DiscreteGraphs(DiscreteGraphSamplingSettings {
876            graph_names: Vec::new(),
877            sample_orientations: false,
878            sampling_type: DiscreteGraphSamplingType::Default(ParameterizationSettings {
879                mode: ParameterizationMode::MomentumSpace,
880                mapping: ParameterizationMapping::default(),
881                b: 10.0,
882                power: 1.0,
883                lmb_basis_ids: Default::default(),
884            }),
885        });
886
887        let previous_generate_events = self.settings.general.generate_events;
888        let previous_store_additional_weights =
889            self.settings.general.store_additional_weights_in_event;
890        self.settings.general.generate_events = true;
891        self.settings.general.store_additional_weights_in_event = true;
892
893        let result = (|| {
894            self.warm_up(model)?;
895
896            let dependent_momenta_constructor = DependentMomentaConstructor::Amplitude(
897                &self.data.graph_terms[0].graph.get_external_signature(),
898            );
899
900            let mut rng = MonteCarloRng::new(ir_profile_settings.seed, 0);
901            let random_loop_momenta = (0..self.data.graph_terms[0]
902                .graph
903                .loop_momentum_basis
904                .loop_edges
905                .len())
906                .map(|_| sample_random_unit_vector(&mut rng))
907                .collect();
908
909            let momentum_sample = MomentumSample::new(
910                random_loop_momenta,
911                0,
912                &self.settings.kinematics.externals,
913                0,
914                F::from_f64(1.0),
915                dependent_momenta_constructor,
916                None,
917            )?;
918
919            let points_on_threshold =
920                self.kinematics_for_threshold_approach(&momentum_sample, model)?;
921
922            run_ir_profile(
923                self,
924                ir_profile_settings,
925                model,
926                Self::enumerate_ir_limits,
927                Self::graph_cut_definitions,
928                &points_on_threshold,
929                Self::test_single_ir_limit_impl,
930            )
931        })();
932
933        if self.settings.general.generate_events != previous_generate_events {
934            self.settings.general.generate_events = previous_generate_events;
935        }
936        if self.settings.general.store_additional_weights_in_event
937            != previous_store_additional_weights
938        {
939            self.settings.general.store_additional_weights_in_event =
940                previous_store_additional_weights;
941        }
942
943        result
944    }
945
946    fn enumerate_ir_limits(&self) -> Vec<(String, Vec<ProfileLimit>)> {
947        self.data
948            .graph_terms
949            .iter()
950            .map(|term| {
951                let graph_name = term.graph.name.clone();
952                let mut limits = term
953                    .enumerate_ir_limits()
954                    .into_iter()
955                    .map(ProfileLimit::Ir)
956                    .collect_vec();
957                limits.extend(
958                    ThresholdLimit::enumerate_from_overlap_structure(
959                        &term.threshold_counterterm.overlap.existing_esurfaces,
960                        &term.threshold_counterterm.esurface_map,
961                        term.threshold_counterterm.own_group_position,
962                    )
963                    .into_iter()
964                    .map(ProfileLimit::Threshold),
965                );
966                limits.sort();
967                limits.dedup();
968                (graph_name, limits)
969            })
970            .collect()
971    }
972
973    fn graph_cut_definitions(&self, _graph_id: usize) -> Vec<GraphCutDefinition> {
974        Vec::new()
975    }
976
977    fn test_single_ir_limit_impl(
978        &mut self,
979        graph_id: usize,
980        profile_limit: &ProfileLimit,
981        rng: &mut MonteCarloRng,
982        approach_settings: &IRProfileSetting,
983        model: &Model,
984        points_on_threshold: &[OverlapStructureWithKinematics<ArbPrec>],
985    ) -> Result<Vec<SingleLimitReport>> {
986        match profile_limit {
987            ProfileLimit::Ir(ir_limit) => {
988                let edges_in_limit = ir_limit.get_all_edges()?;
989                let lmb = self.data.graph_terms[graph_id]
990                    .lmb_with_loop_edges(edges_in_limit.as_slice())?;
991                let momenta = ir_limit.get_momenta(rng, &self.settings, approach_settings)?;
992                let non_limit_loops = lmb
993                    .loop_edges
994                    .iter_enumerated()
995                    .filter_map(|(loop_id, edge_id)| {
996                        if !edges_in_limit.contains(edge_id) {
997                            Some(loop_id)
998                        } else {
999                            None
1000                        }
1001                    })
1002                    .collect_vec();
1003
1004                let non_limit_momenta = non_limit_loops
1005                    .iter()
1006                    .map(|loop_id| (*loop_id, sample_random_unit_vector(rng)))
1007                    .collect_vec();
1008
1009                let externals = self.data.graph_terms[graph_id]
1010                    .graph
1011                    .get_external_signature();
1012
1013                let dependent_momenta_constructor =
1014                    DependentMomentaConstructor::Amplitude(&externals);
1015
1016                let loop_number = lmb.loop_edges.len();
1017                let orientations = requested_orientations(self, graph_id, approach_settings)?;
1018                let mut reports = Vec::with_capacity(orientations.len());
1019
1020                for (orientation, orientation_label) in orientations {
1021                    let mut limit_data = LimitData { data: Vec::new() };
1022
1023                    for (loop_mom_id, lambda_point) in momenta.iter().cloned().enumerate() {
1024                        let mut loop_moms: LoopMomenta<F<_>> = (0..loop_number)
1025                            .map(|_| {
1026                                ThreeMomentum::new(
1027                                    F::from_f64(0.0),
1028                                    F::from_f64(0.0),
1029                                    F::from_f64(0.0),
1030                                )
1031                            })
1032                            .collect();
1033
1034                        for (loop_id, momentum) in non_limit_momenta.iter() {
1035                            loop_moms[*loop_id] = *momentum;
1036                        }
1037
1038                        for tagged_momenta in &lambda_point.momenta {
1039                            let edge_id = tagged_momenta.tag;
1040                            let loop_id = lmb
1041                                .loop_edges
1042                                .iter()
1043                                .position(|loop_edge| loop_edge == &edge_id)
1044                                .unwrap_or_else(|| {
1045                                    unreachable!("corrupted lmb and ir limit: {}", ir_limit);
1046                                });
1047
1048                            loop_moms[LoopIndex(loop_id)] = tagged_momenta.momentum;
1049                        }
1050
1051                        let sample_in_cmb = MomentumSample::new(
1052                            loop_moms,
1053                            loop_mom_id,
1054                            &self.settings.kinematics.externals,
1055                            0,
1056                            F::from_f64(1.0),
1057                            dependent_momenta_constructor,
1058                            orientation,
1059                        )?;
1060
1061                        let sample = sample_in_cmb.lmb_transform(
1062                            &lmb,
1063                            &self.data.graph_terms[graph_id].graph.loop_momentum_basis,
1064                        );
1065
1066                        limit_data.data.push(LambdaPointEval {
1067                            lambda: lambda_point.lambda,
1068                            value: evaluate_profile_momentum_point_arb(
1069                                self,
1070                                model,
1071                                graph_id,
1072                                orientation,
1073                                sample.loop_moms().iter().cloned().collect_vec(),
1074                                approach_settings.show_per_cut_info,
1075                            )?,
1076                        });
1077                    }
1078
1079                    let power_fits = limit_data.extract_power()?;
1080                    let mut report = build_single_limit_report(
1081                        ir_limit,
1082                        orientation_label,
1083                        power_fits.total,
1084                        build_cut_limit_reports(ir_limit.num_soft(), power_fits.per_cut),
1085                    );
1086                    report.display_only_reports =
1087                        build_display_only_limit_reports(power_fits.display_components);
1088                    reports.push(report);
1089                }
1090
1091                Ok(reports)
1092            }
1093            ProfileLimit::Threshold(threshold_limit) => {
1094                let overlap_structure = points_on_threshold.get(graph_id).ok_or_else(|| {
1095                    eyre!(
1096                        "Missing threshold-approach kinematics for amplitude graph {}",
1097                        graph_id
1098                    )
1099                })?;
1100
1101                let existing_esurface_id = threshold_limit.resolve_existing_esurface_id(
1102                    &self.data.graph_terms[graph_id]
1103                        .threshold_counterterm
1104                        .esurface_map,
1105                    self.data.graph_terms[graph_id]
1106                        .threshold_counterterm
1107                        .own_group_position,
1108                    &overlap_structure.existing_esurfaces,
1109                )?;
1110
1111                let momenta_per_overlap_group = threshold_limit.get_momenta_per_overlap_group(
1112                    overlap_structure,
1113                    existing_esurface_id,
1114                    approach_settings,
1115                )?;
1116                let orientations = requested_orientations(self, graph_id, approach_settings)?;
1117                let mut reports =
1118                    Vec::with_capacity(orientations.len() * momenta_per_overlap_group.len());
1119
1120                for (overlap_group_label, momenta) in momenta_per_overlap_group {
1121                    for (orientation, orientation_label) in &orientations {
1122                        let mut limit_data = LimitData { data: Vec::new() };
1123
1124                        for lambda_point in &momenta {
1125                            limit_data.data.push(LambdaPointEval {
1126                                lambda: lambda_point.lambda.clone(),
1127                                value: evaluate_profile_momentum_point_arb(
1128                                    self,
1129                                    model,
1130                                    graph_id,
1131                                    *orientation,
1132                                    lambda_point
1133                                        .loop_momenta
1134                                        .iter()
1135                                        .map(|momentum| {
1136                                            ThreeMomentum::new(
1137                                                F::from_f64(momentum.px.into_f64()),
1138                                                F::from_f64(momentum.py.into_f64()),
1139                                                F::from_f64(momentum.pz.into_f64()),
1140                                            )
1141                                        })
1142                                        .collect_vec(),
1143                                    approach_settings.show_per_cut_info,
1144                                )?,
1145                            });
1146                        }
1147
1148                        let power_fits = limit_data.extract_power()?;
1149                        let context_label = Some(match orientation_label.as_deref() {
1150                            Some(orientation_label) => {
1151                                format!("{overlap_group_label} / {orientation_label}")
1152                            }
1153                            None => overlap_group_label.clone(),
1154                        });
1155
1156                        let mut report = build_threshold_limit_report(
1157                            threshold_limit,
1158                            context_label,
1159                            power_fits.total,
1160                            build_cut_limit_reports(0, power_fits.per_cut),
1161                        );
1162                        report.display_only_reports =
1163                            build_display_only_limit_reports(power_fits.display_components);
1164                        reports.push(report);
1165                    }
1166                }
1167
1168                Ok(reports)
1169            }
1170        }
1171    }
1172}
1173
1174impl CrossSectionIntegrand {
1175    pub fn ir_profile_completion_entries(&self) -> Vec<(String, Vec<String>)> {
1176        ir_profile_completion_entries(self.enumerate_ir_limits())
1177    }
1178
1179    pub fn test_ir(
1180        &mut self,
1181        ir_profile_settings: &IRProfileSetting,
1182        model: &Model,
1183    ) -> Result<IrLimitTestReport> {
1184        // override the sampling to be in momentum space
1185        self.settings.sampling = SamplingSettings::DiscreteGraphs(DiscreteGraphSamplingSettings {
1186            graph_names: Vec::new(),
1187            sample_orientations: false,
1188            sampling_type: DiscreteGraphSamplingType::Default(ParameterizationSettings {
1189                mode: ParameterizationMode::MomentumSpace,
1190                mapping: ParameterizationMapping::default(),
1191                b: 10.0,
1192                power: 1.0,
1193                lmb_basis_ids: Default::default(),
1194            }),
1195        });
1196
1197        let previous_generate_events = self.settings.general.generate_events;
1198        if ir_profile_settings.show_per_cut_info {
1199            self.settings.general.generate_events = true;
1200        }
1201
1202        let points_on_threshold = vec![]; // threshold limits are not yet supported for cross-section IR profiling
1203
1204        let result = (|| {
1205            self.warm_up(model)?;
1206            run_ir_profile(
1207                self,
1208                ir_profile_settings,
1209                model,
1210                Self::enumerate_ir_limits,
1211                Self::graph_cut_definitions,
1212                &points_on_threshold,
1213                Self::test_single_ir_limit_impl,
1214            )
1215        })();
1216
1217        if self.settings.general.generate_events != previous_generate_events {
1218            self.settings.general.generate_events = previous_generate_events;
1219        }
1220
1221        result
1222    }
1223
1224    fn enumerate_ir_limits(&self) -> Vec<(String, Vec<ProfileLimit>)> {
1225        self.data
1226            .graph_terms
1227            .iter()
1228            .map(|term| {
1229                let graph_name = term.graph.name.clone();
1230                let limits = term
1231                    .enumerate_ir_limits()
1232                    .into_iter()
1233                    .map(ProfileLimit::Ir)
1234                    .collect();
1235                (graph_name, limits)
1236            })
1237            .collect()
1238    }
1239
1240    fn graph_cut_definitions(&self, graph_id: usize) -> Vec<GraphCutDefinition> {
1241        graph_cut_definitions_for_cross_section_term(&self.data.graph_terms[graph_id])
1242    }
1243
1244    fn test_single_ir_limit_impl(
1245        &mut self,
1246        graph_id: usize,
1247        profile_limit: &ProfileLimit,
1248        rng: &mut MonteCarloRng,
1249        approach_settings: &IRProfileSetting,
1250        model: &Model,
1251        _points_on_threshold: &[OverlapStructureWithKinematics<ArbPrec>],
1252    ) -> Result<Vec<SingleLimitReport>> {
1253        let ir_limit = match profile_limit {
1254            ProfileLimit::Ir(ir_limit) => ir_limit,
1255            ProfileLimit::Threshold(threshold_limit) => {
1256                return Err(eyre!(
1257                    "Threshold limit '{}' is not yet supported in cross-section IR profiling",
1258                    threshold_limit
1259                ));
1260            }
1261        };
1262
1263        let edges_in_limit = ir_limit.get_all_edges()?;
1264
1265        // find cut that for that as all edges of the limit
1266        let (cut_id, _esurface) = self.data.graph_terms[graph_id]
1267            .cut_esurface
1268            .iter_enumerated()
1269            .find(|(_cut_id, esurface)| {
1270                edges_in_limit
1271                    .iter()
1272                    .all(|edge| esurface.energies.contains(edge))
1273            })
1274            .ok_or(eyre!(
1275                "could not find cut with all edges of the limit: {}",
1276                ir_limit
1277            ))?;
1278
1279        let cs_cut = &self.data.graph_terms[graph_id].cuts[cut_id];
1280
1281        let edges_to_flip = cs_cut
1282            .cut
1283            .iter_edges(&self.data.graph_terms[graph_id].graph.underlying)
1284            .map(|(or, _)| or)
1285            .zip(
1286                self.data.graph_terms[graph_id]
1287                    .graph
1288                    .underlying
1289                    .iter_edges_of(&cs_cut.cut)
1290                    .map(|x| x.1),
1291            )
1292            .filter_map(|(orientation, edge_id)| {
1293                if edges_in_limit.contains(&edge_id) && matches!(orientation, Orientation::Reversed)
1294                {
1295                    Some(edge_id)
1296                } else {
1297                    None
1298                }
1299            })
1300            .collect_vec();
1301
1302        let lmb = self.data.graph_terms[graph_id].lmb_with_loop_edges(edges_in_limit.as_slice())?;
1303        let momenta = ir_limit.get_momenta(rng, &self.settings, approach_settings)?;
1304        let non_limit_loops = lmb
1305            .loop_edges
1306            .iter_enumerated()
1307            .filter_map(|(loop_id, edge_id)| {
1308                if !edges_in_limit.contains(edge_id) {
1309                    Some(loop_id)
1310                } else {
1311                    None
1312                }
1313            })
1314            .collect_vec();
1315
1316        let non_limit_momenta = non_limit_loops
1317            .iter()
1318            .map(|loop_id| (*loop_id, sample_random_unit_vector(rng)))
1319            .collect_vec();
1320
1321        let dependent_momenta_constructor = DependentMomentaConstructor::CrossSection;
1322
1323        let loop_number = lmb.loop_edges.len();
1324        let orientations = requested_orientations(self, graph_id, approach_settings)?;
1325        let mut reports = Vec::with_capacity(orientations.len());
1326
1327        for (orientation, orientation_label) in orientations {
1328            let mut limit_data = LimitData { data: Vec::new() };
1329
1330            for (loop_mom_id, lambda_point) in momenta.iter().cloned().enumerate() {
1331                let mut loop_moms: LoopMomenta<F<_>> = (0..loop_number)
1332                    .map(|_| {
1333                        ThreeMomentum::new(F::from_f64(0.0), F::from_f64(0.0), F::from_f64(0.0))
1334                    })
1335                    .collect();
1336
1337                for (loop_id, momentum) in non_limit_momenta.iter() {
1338                    loop_moms[*loop_id] = *momentum;
1339                }
1340
1341                for tagged_momenta in &lambda_point.momenta {
1342                    let edge_id = tagged_momenta.tag;
1343                    let loop_id = lmb
1344                        .loop_edges
1345                        .iter()
1346                        .position(|loop_edge| loop_edge == &edge_id)
1347                        .unwrap_or_else(|| {
1348                            unreachable!("corrupted lmb and ir limit: {}", ir_limit);
1349                        });
1350
1351                    loop_moms[LoopIndex(loop_id)] = if edges_to_flip.contains(&edge_id) {
1352                        -tagged_momenta.momentum
1353                    } else {
1354                        tagged_momenta.momentum
1355                    };
1356                }
1357
1358                let sample_in_cmb = MomentumSample::new(
1359                    loop_moms,
1360                    loop_mom_id,
1361                    &self.settings.kinematics.externals,
1362                    0,
1363                    F::from_f64(1.0),
1364                    dependent_momenta_constructor,
1365                    orientation,
1366                )?;
1367
1368                let sample = sample_in_cmb.lmb_transform(
1369                    &lmb,
1370                    &self.data.graph_terms[graph_id].graph.loop_momentum_basis,
1371                );
1372
1373                limit_data.data.push(LambdaPointEval {
1374                    lambda: lambda_point.lambda,
1375                    value: evaluate_profile_momentum_point_arb(
1376                        self,
1377                        model,
1378                        graph_id,
1379                        orientation,
1380                        sample.loop_moms().iter().cloned().collect_vec(),
1381                        approach_settings.show_per_cut_info,
1382                    )?,
1383                });
1384            }
1385
1386            let power_fits = limit_data.extract_power()?;
1387            reports.push(build_single_limit_report(
1388                ir_limit,
1389                orientation_label,
1390                power_fits.total,
1391                build_cut_limit_reports(ir_limit.num_soft(), power_fits.per_cut),
1392            ));
1393        }
1394
1395        Ok(reports)
1396    }
1397}
1398
1399#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1400struct IrLimit {
1401    colinear: Vec<Vec<HardOrSoft>>,
1402    soft: Vec<EdgeIndex>,
1403}
1404
1405#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1406struct ThresholdLimit {
1407    esurface_id: RaisedEsurfaceId,
1408}
1409
1410#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1411enum ProfileLimit {
1412    Ir(IrLimit),
1413    Threshold(ThresholdLimit),
1414}
1415
1416enum MomentumBuilder<T: FloatLike> {
1417    Colinear {
1418        edge_id: EdgeIndex,
1419        x: F<T>,
1420        colinear_direction: ThreeMomentum<F<T>>,
1421        perpendicular_direction: ThreeMomentum<F<T>>,
1422        is_soft: bool,
1423    },
1424    Soft {
1425        edge_id: EdgeIndex,
1426        direction: ThreeMomentum<F<T>>,
1427    },
1428}
1429
1430impl Display for IrLimit {
1431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1432        for colinear_set in &self.colinear {
1433            write!(f, "C[")?;
1434
1435            let mut iter = colinear_set.iter();
1436            if let Some(last) = iter.next_back() {
1437                for item in iter {
1438                    write!(f, "{},", item)?;
1439                }
1440                write!(f, "{}]", last)?;
1441            } else {
1442                write!(f, "]")?;
1443            }
1444        }
1445
1446        for soft in self.soft.iter() {
1447            write!(f, "S({})", soft)?;
1448        }
1449
1450        Ok(())
1451    }
1452}
1453
1454impl Display for ThresholdLimit {
1455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1456        write!(f, "T(t{})", self.esurface_id.0)
1457    }
1458}
1459
1460impl Display for ProfileLimit {
1461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1462        match self {
1463            ProfileLimit::Ir(ir_limit) => write!(f, "{}", ir_limit),
1464            ProfileLimit::Threshold(threshold_limit) => write!(f, "{}", threshold_limit),
1465        }
1466    }
1467}
1468
1469impl ThresholdLimit {
1470    fn enumerate_from_overlap_structure(
1471        existing_esurfaces: &ExistingEsurfaces,
1472        esurface_map: &TiVec<GroupEsurfaceId, TiVec<GraphGroupPosition, Option<RaisedEsurfaceId>>>,
1473        own_group_position: GraphGroupPosition,
1474    ) -> Vec<Self> {
1475        existing_esurfaces
1476            .iter()
1477            .filter_map(|group_esurface_id| {
1478                esurface_map[*group_esurface_id][own_group_position]
1479                    .map(|esurface_id| Self { esurface_id })
1480            })
1481            .collect::<HashSet<_>>()
1482            .into_iter()
1483            .sorted()
1484            .collect()
1485    }
1486
1487    fn parse_threshold(threshold: &str) -> Result<Self> {
1488        let mut threshold = String::from(threshold);
1489        threshold = String::from(threshold.trim());
1490
1491        if threshold.len() < 2 {
1492            return Err(eyre!("Threshold must be at least two characters long"));
1493        }
1494
1495        if threshold.remove(0) != 't' {
1496            return Err(eyre!("Threshold must start with 't'"));
1497        }
1498
1499        let threshold_id: usize = threshold
1500            .parse()
1501            .map_err(|_| eyre!("Threshold must be a valid integer, got: {}", threshold))?;
1502
1503        Ok(Self {
1504            esurface_id: RaisedEsurfaceId::from(threshold_id),
1505        })
1506    }
1507
1508    fn resolve_existing_esurface_id(
1509        &self,
1510        esurface_map: &TiVec<GroupEsurfaceId, TiVec<GraphGroupPosition, Option<RaisedEsurfaceId>>>,
1511        own_group_position: GraphGroupPosition,
1512        existing_esurfaces: &ExistingEsurfaces,
1513    ) -> Result<ExistingEsurfaceId> {
1514        existing_esurfaces
1515            .iter_enumerated()
1516            .find_map(|(existing_esurface_id, group_esurface_id)| {
1517                esurface_map.get(*group_esurface_id).and_then(|graph_map| {
1518                    (graph_map[own_group_position] == Some(self.esurface_id))
1519                        .then_some(existing_esurface_id)
1520                })
1521            })
1522            .ok_or_else(|| {
1523                eyre!(
1524                    "Threshold '{}' does not exist in the selected overlap structure",
1525                    self
1526                )
1527            })
1528    }
1529
1530    fn get_momenta_per_overlap_group<T: FloatLike>(
1531        &self,
1532        overlap_structure: &OverlapStructureWithKinematics<T>,
1533        existing_esurface_id: ExistingEsurfaceId,
1534        approach_settings: &IRProfileSetting,
1535    ) -> Result<Vec<(String, Vec<LambdaLoopMomentaPoint<T>>)>> {
1536        let lambda_values = constant_dropped_fit_points(
1537            &F::from_f64(10.0_f64.powf(approach_settings.lambda_exp_start)),
1538            &F::from_f64(10.0_f64.powf(approach_settings.lambda_exp_end)),
1539            approach_settings.steps,
1540        )?;
1541
1542        let mut momenta_per_overlap_group = Vec::new();
1543
1544        for (overlap_group_id, overlap_group_with_kinematics) in overlap_structure
1545            .overlap_groups_with_kinematics
1546            .iter()
1547            .enumerate()
1548        {
1549            let mut contains_existing_esurface = false;
1550            let mut threshold_point = None;
1551
1552            for (group_existing_esurface_id, maybe_threshold_point) in overlap_group_with_kinematics
1553                .overlap_group
1554                .existing_esurfaces
1555                .iter()
1556                .copied()
1557                .zip(
1558                    overlap_group_with_kinematics
1559                        .loop_momenta_at_esurface
1560                        .iter(),
1561                )
1562            {
1563                if group_existing_esurface_id != existing_esurface_id {
1564                    continue;
1565                }
1566
1567                contains_existing_esurface = true;
1568                threshold_point = maybe_threshold_point.as_ref();
1569                break;
1570            }
1571
1572            if !contains_existing_esurface {
1573                continue;
1574            }
1575
1576            let threshold_point = threshold_point.ok_or_else(|| {
1577                eyre!(
1578                    "Threshold '{}' is missing stored approach kinematics for overlap group {}",
1579                    self,
1580                    overlap_group_id
1581                )
1582            })?;
1583
1584            momenta_per_overlap_group.push((
1585                format!("overlap group {}", overlap_group_id),
1586                lambda_values
1587                    .iter()
1588                    .cloned()
1589                    .map(|lambda| LambdaLoopMomentaPoint {
1590                        loop_momenta: threshold_approach_loop_momenta(
1591                            &overlap_group_with_kinematics.overlap_group.center,
1592                            threshold_point,
1593                            &lambda,
1594                        ),
1595                        lambda,
1596                    })
1597                    .collect(),
1598            ));
1599        }
1600
1601        if momenta_per_overlap_group.is_empty() {
1602            return Err(eyre!(
1603                "Threshold '{}' does not appear in any overlap group for the selected graph term",
1604                self
1605            ));
1606        }
1607
1608        Ok(momenta_per_overlap_group)
1609    }
1610}
1611
1612impl ProfileLimit {
1613    fn parse_limit(limit: &str) -> Result<Self> {
1614        let mut colinear_sets = Vec::new();
1615        let mut soft_edges = Vec::new();
1616        let mut threshold_limit = None;
1617
1618        let mut char_iter = limit.chars().enumerate();
1619
1620        while let Some((char_position, char)) = char_iter.next() {
1621            match char {
1622                'C' => {
1623                    let (_opening_bracket_position, opening_bracket) =
1624                        char_iter.next().ok_or_else(|| {
1625                            eyre!(
1626                                "Expected opening bracket after 'C' at position {}",
1627                                char_position
1628                            )
1629                        })?;
1630
1631                    if opening_bracket != '[' {
1632                        return Err(eyre!(
1633                            "Expected '[' after 'C' at position {}, found '{}'",
1634                            char_position,
1635                            opening_bracket
1636                        ));
1637                    }
1638
1639                    let mut colinear_set_str = String::new();
1640
1641                    let mut closing_bracket_found = false;
1642                    for (_next_char_position, next_char) in char_iter.by_ref() {
1643                        if next_char == ']' {
1644                            closing_bracket_found = true;
1645                            break;
1646                        }
1647                        colinear_set_str.push(next_char);
1648                    }
1649
1650                    if !closing_bracket_found {
1651                        return Err(eyre!(
1652                            "Expected closing bracket ']' for colinear set at position {}",
1653                            char_position
1654                        ));
1655                    }
1656
1657                    let edges = colinear_set_str.trim().split(',');
1658
1659                    let mut colinear_set = Vec::new();
1660
1661                    for edge in edges {
1662                        let trimmed_edge = edge.trim();
1663                        if trimmed_edge.is_empty() {
1664                            return Err(eyre!(
1665                                "Empty edge found in colinear set at position {}",
1666                                char_position
1667                            ));
1668                        }
1669
1670                        if trimmed_edge.starts_with('S') {
1671                            let mut trimmed_edge_iter = trimmed_edge.chars().skip(1);
1672                            let opening_bracket = trimmed_edge_iter
1673                                .next()
1674                                .ok_or(eyre!("Expected '(' after 'S' in soft edge at position"))?;
1675
1676                            if opening_bracket != '(' {
1677                                return Err(eyre!(
1678                                    "Expected '(' after 'S' in soft edge at position , found ''",
1679                                ));
1680                            }
1681
1682                            let mut edge_str = String::new();
1683                            let mut closing_bracket_found = false;
1684
1685                            for next_char in trimmed_edge_iter {
1686                                if next_char == ')' {
1687                                    closing_bracket_found = true;
1688                                    break;
1689                                }
1690                                edge_str.push(next_char);
1691                            }
1692
1693                            if !closing_bracket_found {
1694                                return Err(eyre!(
1695                                    "Expected closing bracket ')' for soft edge at position {}",
1696                                    char_position
1697                                ));
1698                            }
1699
1700                            let edge_index = IrLimit::parse_edge(&edge_str)?;
1701                            colinear_set.push(HardOrSoft::Soft(edge_index));
1702                        } else {
1703                            let edge_index = IrLimit::parse_edge(trimmed_edge)?;
1704                            colinear_set.push(HardOrSoft::Hard(edge_index));
1705                        }
1706                    }
1707                    colinear_sets.push(colinear_set);
1708                }
1709                'S' => {
1710                    let (_opening_bracket_position, opening_bracket) =
1711                        char_iter.next().ok_or_else(|| {
1712                            eyre!(
1713                                "Expected opening bracket '(' after 'S' at position {}",
1714                                char_position
1715                            )
1716                        })?;
1717
1718                    if opening_bracket != '(' {
1719                        return Err(eyre!(
1720                            "Expected '(' after 'S' at position {}, found '{}'",
1721                            char_position,
1722                            opening_bracket
1723                        ));
1724                    }
1725
1726                    let mut edge_str = String::new();
1727                    let mut closing_bracket_found = false;
1728
1729                    for (_next_char_position, next_char) in char_iter.by_ref() {
1730                        if next_char == ')' {
1731                            closing_bracket_found = true;
1732                            break;
1733                        }
1734                        edge_str.push(next_char);
1735                    }
1736
1737                    if !closing_bracket_found {
1738                        return Err(eyre!(
1739                            "Expected closing bracket ')' for soft edge at position {}",
1740                            char_position
1741                        ));
1742                    }
1743
1744                    let edge_index = IrLimit::parse_edge(&edge_str)?;
1745                    soft_edges.push(edge_index);
1746                }
1747                'T' => {
1748                    if threshold_limit.is_some() {
1749                        return Err(eyre!("Only one threshold limit can be specified"));
1750                    }
1751
1752                    let (_opening_bracket_position, opening_bracket) =
1753                        char_iter.next().ok_or_else(|| {
1754                            eyre!(
1755                                "Expected opening bracket '(' after 'T' at position {}",
1756                                char_position
1757                            )
1758                        })?;
1759
1760                    if opening_bracket != '(' {
1761                        return Err(eyre!(
1762                            "Expected '(' after 'T' at position {}, found '{}'",
1763                            char_position,
1764                            opening_bracket
1765                        ));
1766                    }
1767
1768                    let mut threshold_str = String::new();
1769                    let mut closing_bracket_found = false;
1770
1771                    for (_next_char_position, next_char) in char_iter.by_ref() {
1772                        if next_char == ')' {
1773                            closing_bracket_found = true;
1774                            break;
1775                        }
1776                        threshold_str.push(next_char);
1777                    }
1778
1779                    if !closing_bracket_found {
1780                        return Err(eyre!(
1781                            "Expected closing bracket ')' for threshold at position {}",
1782                            char_position
1783                        ));
1784                    }
1785
1786                    threshold_limit = Some(ThresholdLimit::parse_threshold(&threshold_str)?);
1787                }
1788                _ => {
1789                    return Err(eyre!(
1790                        "Unexpected character '{}' at position {}",
1791                        char,
1792                        char_position
1793                    ));
1794                }
1795            }
1796        }
1797
1798        if let Some(threshold_limit) = threshold_limit {
1799            if !colinear_sets.is_empty() || !soft_edges.is_empty() {
1800                return Err(eyre!(
1801                    "Threshold limits cannot be combined with soft or colinear limits"
1802                ));
1803            }
1804
1805            return Ok(ProfileLimit::Threshold(threshold_limit));
1806        }
1807
1808        let mut ir_limit = IrLimit {
1809            colinear: colinear_sets,
1810            soft: soft_edges,
1811        };
1812
1813        ir_limit.canonize();
1814
1815        Ok(ProfileLimit::Ir(ir_limit))
1816    }
1817
1818    fn is_valid(&self, loop_number: usize) -> Result<()> {
1819        match self {
1820            ProfileLimit::Ir(ir_limit) => ir_limit.is_valid(loop_number),
1821            ProfileLimit::Threshold(_) => Ok(()),
1822        }
1823    }
1824}
1825
1826#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1827enum HardOrSoft {
1828    Hard(EdgeIndex),
1829    Soft(EdgeIndex),
1830}
1831
1832impl HardOrSoft {
1833    fn index(&self) -> EdgeIndex {
1834        match self {
1835            HardOrSoft::Hard(index) => *index,
1836            HardOrSoft::Soft(index) => *index,
1837        }
1838    }
1839}
1840
1841impl Display for HardOrSoft {
1842    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1843        match self {
1844            HardOrSoft::Hard(index) => write!(f, "{}", index),
1845            HardOrSoft::Soft(index) => write!(f, "S({})", index),
1846        }
1847    }
1848}
1849
1850impl IrLimit {
1851    fn canonize(&mut self) {
1852        for colinear_set in &mut self.colinear.iter_mut() {
1853            colinear_set.sort();
1854        }
1855
1856        self.colinear.sort();
1857        self.soft.sort();
1858    }
1859
1860    fn new_pure_colinear(colinear_edges: Vec<EdgeIndex>) -> Self {
1861        let colinear = vec![colinear_edges.into_iter().map(HardOrSoft::Hard).collect()];
1862
1863        let mut result = IrLimit {
1864            colinear,
1865            soft: Vec::new(),
1866        };
1867        result.canonize();
1868        result
1869    }
1870
1871    fn new_pure_soft(soft_edges: Vec<EdgeIndex>) -> Self {
1872        let mut result = IrLimit {
1873            colinear: Vec::new(),
1874            soft: soft_edges,
1875        };
1876        result.canonize();
1877        result
1878    }
1879
1880    fn num_soft(&self) -> usize {
1881        self.colinear
1882            .iter()
1883            .flatten()
1884            .filter(|edge| matches!(edge, HardOrSoft::Soft(_)))
1885            .count()
1886            + self.soft.len()
1887    }
1888
1889    fn check_min_colinear_size(&self) -> bool {
1890        self.colinear
1891            .iter()
1892            .all(|colinear_set| colinear_set.len() >= 2)
1893    }
1894
1895    fn is_valid(&self, loop_number: usize) -> Result<()> {
1896        if !self.check_min_colinear_size() {
1897            return Err(eyre!("colinear sets must have at least two edges"));
1898        }
1899
1900        let all_edges = self.get_all_edges()?;
1901
1902        if all_edges.len() > loop_number {
1903            return Err(eyre!("not enough degrees of freedom to setup IR limit"));
1904        }
1905
1906        Ok(())
1907    }
1908
1909    fn get_all_edges(&self) -> Result<Vec<EdgeIndex>> {
1910        let colinear_edges = self
1911            .colinear
1912            .iter()
1913            .flatten()
1914            .map(HardOrSoft::index)
1915            .collect_vec();
1916        let soft_edges = self.soft.iter().copied().collect_vec();
1917
1918        let all_edges: Vec<EdgeIndex> = colinear_edges
1919            .into_iter()
1920            .chain(soft_edges)
1921            .sorted()
1922            .collect();
1923
1924        // check for duplicates
1925        let mut unique_edges = all_edges.clone();
1926        unique_edges.dedup();
1927
1928        if unique_edges.len() != all_edges.len() {
1929            return Err(eyre!("Edges specified in ir limit must be unique")); // duplicates found
1930        }
1931
1932        Ok(all_edges)
1933    }
1934
1935    fn parse_edge(edge: &str) -> Result<EdgeIndex> {
1936        let mut edge = String::from(edge);
1937        edge = String::from(edge.trim());
1938
1939        if edge.len() < 2 {
1940            return Err(eyre!("Edge must be at least two characters long"));
1941        }
1942
1943        if edge.remove(0) != 'e' {
1944            return Err(eyre!("Edge must start with 'e'"));
1945        }
1946
1947        let edge_id: usize = edge
1948            .parse()
1949            .map_err(|_| eyre!("Edge must be a valid integer, got: {}", edge))?;
1950
1951        Ok(EdgeIndex::from(edge_id))
1952    }
1953
1954    fn get_momentum_builders(&self, rng: &mut MonteCarloRng) -> Vec<MomentumBuilder<f64>> {
1955        let mut momentum_builder = Vec::new();
1956
1957        for colinear_set in &self.colinear {
1958            let direction_for_set: ThreeMomentum<F<f64>> = sample_random_unit_vector(rng);
1959
1960            let x_variables: Vec<F<f64>> = (0..colinear_set.len())
1961                .map(|_| F::from_f64(rng.random::<f64>() * 0.8 + 0.1))
1962                .sorted_by(|a, b| a.partial_cmp(b).unwrap())
1963                .collect_vec();
1964
1965            for (edge, x) in colinear_set.iter().zip(x_variables) {
1966                let edge_id = edge.index();
1967                let direction: ThreeMomentum<F<f64>> = sample_random_unit_vector(rng);
1968
1969                let perpendicular = direction - direction * (direction * direction_for_set);
1970
1971                let perpendicular_norm = perpendicular.norm();
1972                let perpendicular = perpendicular * perpendicular_norm.inv();
1973
1974                let is_soft = matches!(edge, HardOrSoft::Soft(_));
1975
1976                momentum_builder.push(MomentumBuilder::Colinear {
1977                    edge_id,
1978                    x,
1979                    colinear_direction: direction_for_set,
1980                    perpendicular_direction: perpendicular,
1981                    is_soft,
1982                });
1983            }
1984        }
1985
1986        for soft_edge in &self.soft {
1987            let direction = sample_random_unit_vector(rng);
1988            momentum_builder.push(MomentumBuilder::Soft {
1989                edge_id: *soft_edge,
1990                direction,
1991            });
1992        }
1993
1994        momentum_builder
1995    }
1996
1997    fn get_momenta(
1998        &self,
1999        rng: &mut MonteCarloRng,
2000        settings: &RuntimeSettings,
2001        approach_settings: &IRProfileSetting,
2002    ) -> Result<Vec<LambdaPoint<f64>>> {
2003        let momentum_builders = self.get_momentum_builders(rng);
2004
2005        let lambda_values = constant_dropped_fit_points(
2006            &F::from_f64(10.0_f64.powf(approach_settings.lambda_exp_start)),
2007            &F::from_f64(10.0_f64.powf(approach_settings.lambda_exp_end)),
2008            approach_settings.steps,
2009        )?;
2010
2011        Ok(lambda_values
2012            .into_iter()
2013            .map(|lambda| LambdaPoint {
2014                momenta: momentum_builders
2015                    .iter()
2016                    .map(|builder| match builder {
2017                        MomentumBuilder::Colinear {
2018                            edge_id,
2019                            x,
2020                            colinear_direction,
2021                            perpendicular_direction,
2022                            is_soft,
2023                        } => {
2024                            let momentum = if *is_soft {
2025                                (colinear_direction * x + perpendicular_direction * lambda)
2026                                    * F::from_f64(settings.kinematics.e_cm)
2027                                    * lambda
2028                            } else {
2029                                (colinear_direction * x + perpendicular_direction * lambda)
2030                                    * F::from_f64(settings.kinematics.e_cm)
2031                            };
2032                            TaggedMomenta {
2033                                momentum,
2034                                tag: *edge_id,
2035                            }
2036                        }
2037                        MomentumBuilder::Soft { edge_id, direction } => {
2038                            let momentum =
2039                                direction * lambda * F::from_f64(settings.kinematics.e_cm);
2040                            TaggedMomenta {
2041                                momentum,
2042                                tag: *edge_id,
2043                            }
2044                        }
2045                    })
2046                    .collect(),
2047                lambda,
2048            })
2049            .collect())
2050    }
2051}
2052
2053fn evaluate_profile_momentum_point_arb<I: ProcessIntegrandImpl>(
2054    integrand: &mut I,
2055    model: &Model,
2056    graph_id: usize,
2057    orientation: Option<usize>,
2058    loop_momenta: Vec<ThreeMomentum<F<f64>>>,
2059    show_per_cut_info: bool,
2060) -> Result<ProfilePointValue> {
2061    match evaluate_profile_momentum_point_precise(
2062        integrand,
2063        model,
2064        graph_id,
2065        orientation,
2066        loop_momenta,
2067        true,
2068    )? {
2069        PreciseEvaluationResult::Arb(result) => {
2070            let zero_complex = Complex::new_re(result.integrand_result.re.zero());
2071            let per_cut = if show_per_cut_info {
2072                let mut per_cut = BTreeMap::new();
2073                for event_group in result.event_groups.iter() {
2074                    for event in event_group.iter() {
2075                        let entry = per_cut
2076                            .entry(event.cut_info.cut_id)
2077                            .or_insert_with(|| zero_complex.clone());
2078                        *entry += event.weight.clone();
2079                    }
2080                }
2081                per_cut
2082                    .into_iter()
2083                    .map(|(cut_id, weight)| (cut_id, weight.norm_squared().sqrt()))
2084                    .collect()
2085            } else {
2086                BTreeMap::new()
2087            };
2088
2089            let mut display_only_components = BTreeMap::new();
2090            for event_group in result.event_groups.iter() {
2091                for event in event_group.iter() {
2092                    for (key, weight) in &event.additional_weights.weights {
2093                        if !matches!(
2094                            key,
2095                            AdditionalWeightKey::Original
2096                                | AdditionalWeightKey::ThresholdCounterterm { .. }
2097                                | AdditionalWeightKey::AmplitudeThresholdCounterterm { .. }
2098                        ) {
2099                            continue;
2100                        }
2101
2102                        let entry = display_only_components
2103                            .entry(*key)
2104                            .or_insert_with(|| zero_complex.clone());
2105                        *entry += weight.clone();
2106                    }
2107                }
2108            }
2109            let display_only_components = display_only_components
2110                .into_iter()
2111                .map(|(key, weight)| (key, weight.norm_squared().sqrt()))
2112                .collect();
2113
2114            Ok(ProfilePointValue {
2115                total: result.integrand_result.norm_squared().sqrt(),
2116                per_cut,
2117                display_only_components,
2118            })
2119        }
2120        PreciseEvaluationResult::Double(_) => Err(eyre!(
2121            "IR profiling requested arbitrary precision but received a double-precision result"
2122        )),
2123        PreciseEvaluationResult::Quad(_) => Err(eyre!(
2124            "IR profiling requested arbitrary precision but received a quad-precision result"
2125        )),
2126    }
2127}
2128
2129fn sample_random_unit_vector<T: FloatLike>(rng: &mut MonteCarloRng) -> ThreeMomentum<F<T>> {
2130    let x_1 = F::<T>::from_f64(rng.random::<f64>());
2131    let x_2 = F::<T>::from_f64(rng.random::<f64>());
2132    let x_3 = F::<T>::from_f64(rng.random::<f64>());
2133    let x_4 = F::<T>::from_f64(rng.random::<f64>());
2134
2135    let (k_x, k_y) = box_muller(x_1, x_2);
2136    let k_z = box_muller(x_3, x_4).0;
2137
2138    let unnormalized_momentum = ThreeMomentum::new(k_x, k_y, k_z);
2139    let norm = unnormalized_momentum.norm();
2140    unnormalized_momentum * norm.inv()
2141}
2142
2143#[derive(Clone)]
2144struct TaggedMomenta<T> {
2145    momentum: ThreeMomentum<T>,
2146    tag: EdgeIndex,
2147}
2148
2149#[derive(Clone)]
2150struct LambdaPoint<T: FloatLike> {
2151    lambda: F<T>,
2152    momenta: Vec<TaggedMomenta<F<T>>>,
2153}
2154
2155#[derive(Clone)]
2156struct LambdaLoopMomentaPoint<T: FloatLike> {
2157    lambda: F<T>,
2158    loop_momenta: LoopMomenta<F<T>>,
2159}
2160
2161struct LambdaPointEval<T: FloatLike> {
2162    lambda: F<T>,
2163    value: ProfilePointValue,
2164}
2165
2166struct ProfilePointValue {
2167    total: F<ArbPrec>,
2168    per_cut: BTreeMap<usize, F<ArbPrec>>,
2169    display_only_components: BTreeMap<AdditionalWeightKey, F<ArbPrec>>,
2170}
2171
2172struct LimitData<T: FloatLike> {
2173    data: Vec<LambdaPointEval<T>>,
2174}
2175
2176type PerCutPowerLawFits = Vec<(usize, Result<PowerLawFit>)>;
2177type DisplayComponentPowerLawFits = Vec<(AdditionalWeightKey, Result<PowerLawFit>)>;
2178
2179struct ExtractedPowerFits {
2180    total: PowerLawFit,
2181    per_cut: PerCutPowerLawFits,
2182    display_components: DisplayComponentPowerLawFits,
2183}
2184
2185impl<T: FloatLike> LimitData<T> {
2186    fn extract_power(&self) -> Result<ExtractedPowerFits> {
2187        let x = self
2188            .data
2189            .iter()
2190            .map(|point_eval| F::<ArbPrec>::from_ff64(point_eval.lambda.into_ff64()))
2191            .collect_vec();
2192
2193        let y = self
2194            .data
2195            .iter()
2196            .map(|point_eval| point_eval.value.total.clone())
2197            .collect_vec();
2198
2199        let result = fit_power_law(x.clone(), y.clone())?;
2200
2201        let zero = x
2202            .first()
2203            .map(|value| value.zero())
2204            .unwrap_or_else(|| F::<ArbPrec>::from_f64(0.0));
2205        let cut_fits = self
2206            .data
2207            .iter()
2208            .flat_map(|point_eval| point_eval.value.per_cut.keys().copied())
2209            .unique()
2210            .sorted()
2211            .map(|cut_id| {
2212                let y = self
2213                    .data
2214                    .iter()
2215                    .map(|point_eval| {
2216                        point_eval
2217                            .value
2218                            .per_cut
2219                            .get(&cut_id)
2220                            .cloned()
2221                            .unwrap_or_else(|| zero.clone())
2222                    })
2223                    .collect_vec();
2224                (cut_id, fit_power_law(x.clone(), y))
2225            })
2226            .collect_vec();
2227
2228        let component_fits = self
2229            .data
2230            .iter()
2231            .flat_map(|point_eval| point_eval.value.display_only_components.keys().copied())
2232            .filter(|key| {
2233                matches!(
2234                    key,
2235                    AdditionalWeightKey::Original
2236                        | AdditionalWeightKey::ThresholdCounterterm { .. }
2237                        | AdditionalWeightKey::AmplitudeThresholdCounterterm { .. }
2238                )
2239            })
2240            .unique()
2241            .sorted()
2242            .map(|key| {
2243                let y = self
2244                    .data
2245                    .iter()
2246                    .map(|point_eval| {
2247                        point_eval
2248                            .value
2249                            .display_only_components
2250                            .get(&key)
2251                            .cloned()
2252                            .unwrap_or_else(|| zero.clone())
2253                    })
2254                    .collect_vec();
2255                (key, fit_power_law(x.clone(), y))
2256            })
2257            .collect_vec();
2258
2259        if result.r_squared < 0.9 {
2260            warn!("low r^2 value found for input data");
2261            warn!(
2262                "x: {:?}",
2263                x.iter().map(|value| format!("{}", value)).collect_vec()
2264            );
2265            warn!(
2266                "y: {:?}",
2267                y.iter().map(|value| format!("{}", value)).collect_vec()
2268            );
2269        }
2270
2271        Ok(ExtractedPowerFits {
2272            total: result,
2273            per_cut: cut_fits,
2274            display_components: component_fits,
2275        })
2276    }
2277}
2278
2279#[derive(Debug, Clone)]
2280pub struct PowerLawFit {
2281    exponent: f64,
2282    r_squared: f64,
2283}
2284
2285fn fit_power_law(x: Vec<F<ArbPrec>>, y: Vec<F<ArbPrec>>) -> Result<PowerLawFit> {
2286    if x.len() != y.len() {
2287        return Err(eyre!(
2288            "fit_power_law requires x and y to have the same length"
2289        ));
2290    }
2291    if x.len() < 3 {
2292        return Err(eyre!("fit_power_law requires at least three observations"));
2293    }
2294    if x.iter()
2295        .any(|value| value.is_nan() || value.is_infinite() || value <= &value.zero())
2296    {
2297        return Err(eyre!(
2298            "fit_power_law requires strictly positive, finite x values"
2299        ));
2300    }
2301    if y.iter().any(|value| value.is_nan() || value.is_infinite()) {
2302        return Err(eyre!("fit_power_law requires finite y values"));
2303    }
2304
2305    let fit = log_log_slope_constant_dropped(&x, &y)?;
2306
2307    Ok(PowerLawFit {
2308        exponent: fit.slope.into_f64(),
2309        r_squared: fit.r_squared().into_f64(),
2310    })
2311}
2312
2313#[cfg(test)]
2314mod tests {
2315    use super::*;
2316    use typed_index_collections::ti_vec;
2317
2318    #[test]
2319    fn test_display() {
2320        let ir_limit = IrLimit {
2321            colinear: vec![
2322                vec![
2323                    HardOrSoft::Hard(EdgeIndex::from(1)),
2324                    HardOrSoft::Hard(EdgeIndex::from(2)),
2325                    HardOrSoft::Hard(EdgeIndex::from(3)),
2326                ],
2327                vec![
2328                    HardOrSoft::Hard(EdgeIndex::from(4)),
2329                    HardOrSoft::Soft(EdgeIndex::from(5)),
2330                ],
2331            ],
2332            soft: vec![EdgeIndex::from(6), EdgeIndex::from(7)],
2333        };
2334
2335        let display = ir_limit.to_string();
2336        let expected = "C[e1,e2,e3]C[e4,S(e5)]S(e6)S(e7)";
2337
2338        assert_eq!(display, expected);
2339    }
2340
2341    #[test]
2342    fn test_threshold_display() {
2343        let threshold_limit = ThresholdLimit {
2344            esurface_id: RaisedEsurfaceId::from(8usize),
2345        };
2346
2347        let display = threshold_limit.to_string();
2348        let expected = "T(t8)";
2349
2350        assert_eq!(display, expected);
2351    }
2352
2353    #[test]
2354    fn parse_edge() {
2355        let edge_str = "e5";
2356        let edge_index = IrLimit::parse_edge(edge_str).unwrap();
2357        assert_eq!(edge_index, EdgeIndex::from(5));
2358
2359        let invalid_edge_str = "5"; // missing 'e'
2360        assert!(IrLimit::parse_edge(invalid_edge_str).is_err());
2361
2362        let invalid_edge_str2 = "e"; // too short
2363        assert!(IrLimit::parse_edge(invalid_edge_str2).is_err());
2364
2365        let invalid_edge_str3 = "e5a"; // not a valid integer
2366        assert!(IrLimit::parse_edge(invalid_edge_str3).is_err());
2367    }
2368
2369    #[test]
2370    fn parse_threshold() {
2371        let threshold_str = "t5";
2372        let threshold_limit = ThresholdLimit::parse_threshold(threshold_str).unwrap();
2373        assert_eq!(threshold_limit.esurface_id, RaisedEsurfaceId::from(5usize));
2374
2375        let invalid_threshold_str = "5"; // missing 't'
2376        assert!(ThresholdLimit::parse_threshold(invalid_threshold_str).is_err());
2377
2378        let invalid_threshold_str2 = "t"; // too short
2379        assert!(ThresholdLimit::parse_threshold(invalid_threshold_str2).is_err());
2380
2381        let invalid_threshold_str3 = "t5a"; // not a valid integer
2382        assert!(ThresholdLimit::parse_threshold(invalid_threshold_str3).is_err());
2383    }
2384
2385    #[test]
2386    fn parse_limit() {
2387        let limit_str = "C[e1,e2,e3]C[e4,S(e5)]S(e6)S(e7)";
2388        let limit = ProfileLimit::parse_limit(limit_str).unwrap();
2389        let ir_limit = match limit {
2390            ProfileLimit::Ir(ir_limit) => ir_limit,
2391            ProfileLimit::Threshold(_) => panic!("Expected an IR limit"),
2392        };
2393
2394        assert_eq!(ir_limit.colinear.len(), 2, "Expected two colinear sets");
2395        assert_eq!(ir_limit.soft.len(), 2, "Expected two soft edges");
2396
2397        assert_eq!(
2398            ir_limit.colinear[0],
2399            vec![
2400                HardOrSoft::Hard(EdgeIndex::from(1)),
2401                HardOrSoft::Hard(EdgeIndex::from(2)),
2402                HardOrSoft::Hard(EdgeIndex::from(3))
2403            ],
2404            "First colinear set does not match"
2405        );
2406        assert_eq!(
2407            ir_limit.colinear[1],
2408            vec![
2409                HardOrSoft::Hard(EdgeIndex::from(4)),
2410                HardOrSoft::Soft(EdgeIndex::from(5))
2411            ],
2412            "Second colinear set does not match"
2413        );
2414
2415        assert_eq!(
2416            ir_limit.soft,
2417            vec![EdgeIndex::from(6), EdgeIndex::from(7)],
2418            "Soft edges do not match"
2419        );
2420
2421        let threshold_limit = ProfileLimit::parse_limit("T(t8)").unwrap();
2422        assert_eq!(
2423            threshold_limit,
2424            ProfileLimit::Threshold(ThresholdLimit {
2425                esurface_id: RaisedEsurfaceId::from(8usize),
2426            }),
2427            "Threshold limit does not match"
2428        );
2429
2430        let invalid_limit_str = "C[e1,e2,e3]C[e4,S(e5)]S(e6)S(e7, e8)";
2431        assert!(
2432            ProfileLimit::parse_limit(invalid_limit_str).is_err(),
2433            "Expected error"
2434        );
2435
2436        let invalid_limit_str2 = "C[e1,e2,e3C[e4,S(e5)]S(e6)";
2437        assert!(
2438            ProfileLimit::parse_limit(invalid_limit_str2).is_err(),
2439            "Expected error for unmatched brackets"
2440        );
2441
2442        let invalid_limit_str3 = "C[e1,e2,e3]T(e8)";
2443        assert!(
2444            ProfileLimit::parse_limit(invalid_limit_str3).is_err(),
2445            "Expected error for invalid threshold syntax"
2446        );
2447
2448        let invalid_limit_str4 = "C[e1,e2,e3]T(t8)";
2449        assert!(
2450            ProfileLimit::parse_limit(invalid_limit_str4).is_err(),
2451            "Expected error for mixed threshold and IR limit syntax"
2452        );
2453
2454        let invalid_limit_str5 = "T(t8)T(t9)";
2455        assert!(
2456            ProfileLimit::parse_limit(invalid_limit_str5).is_err(),
2457            "Expected error for multiple threshold limits"
2458        );
2459    }
2460
2461    #[test]
2462    fn resolve_existing_esurface_id_for_threshold_limit() {
2463        let threshold_limit = ThresholdLimit {
2464            esurface_id: RaisedEsurfaceId::from(7usize),
2465        };
2466        let esurface_map = ti_vec![
2467            ti_vec![
2468                Some(RaisedEsurfaceId::from(5usize)),
2469                Some(RaisedEsurfaceId::from(6usize))
2470            ],
2471            ti_vec![Some(RaisedEsurfaceId::from(7usize)), None],
2472        ];
2473        let existing_esurfaces =
2474            ti_vec![GroupEsurfaceId::from(0usize), GroupEsurfaceId::from(1usize)];
2475
2476        let existing_esurface_id = threshold_limit
2477            .resolve_existing_esurface_id(
2478                &esurface_map,
2479                GraphGroupPosition::from(0usize),
2480                &existing_esurfaces,
2481            )
2482            .unwrap();
2483
2484        assert_eq!(existing_esurface_id, ExistingEsurfaceId::from(1usize));
2485    }
2486
2487    #[test]
2488    fn resolve_existing_esurface_id_rejects_threshold_missing_from_graph() {
2489        let threshold_limit = ThresholdLimit {
2490            esurface_id: RaisedEsurfaceId::from(9usize),
2491        };
2492        let esurface_map = ti_vec![ti_vec![
2493            Some(RaisedEsurfaceId::from(5usize)),
2494            Some(RaisedEsurfaceId::from(6usize))
2495        ]];
2496        let existing_esurfaces = ti_vec![GroupEsurfaceId::from(0usize)];
2497
2498        assert!(
2499            threshold_limit
2500                .resolve_existing_esurface_id(
2501                    &esurface_map,
2502                    GraphGroupPosition::from(0usize),
2503                    &existing_esurfaces,
2504                )
2505                .is_err()
2506        );
2507    }
2508
2509    #[test]
2510    fn resolve_existing_esurface_id_rejects_threshold_missing_from_overlap() {
2511        let threshold_limit = ThresholdLimit {
2512            esurface_id: RaisedEsurfaceId::from(7usize),
2513        };
2514        let esurface_map = ti_vec![ti_vec![Some(RaisedEsurfaceId::from(7usize))]];
2515        let existing_esurfaces = ti_vec![GroupEsurfaceId::from(1usize)];
2516
2517        assert!(
2518            threshold_limit
2519                .resolve_existing_esurface_id(
2520                    &esurface_map,
2521                    GraphGroupPosition::from(0usize),
2522                    &existing_esurfaces,
2523                )
2524                .is_err()
2525        );
2526    }
2527
2528    #[test]
2529    fn enumerate_threshold_limits_from_overlap_structure() {
2530        let esurface_map = ti_vec![
2531            ti_vec![
2532                Some(RaisedEsurfaceId::from(5usize)),
2533                Some(RaisedEsurfaceId::from(8usize))
2534            ],
2535            ti_vec![Some(RaisedEsurfaceId::from(7usize)), None],
2536            ti_vec![
2537                Some(RaisedEsurfaceId::from(5usize)),
2538                Some(RaisedEsurfaceId::from(9usize))
2539            ],
2540            ti_vec![None, Some(RaisedEsurfaceId::from(3usize))],
2541        ];
2542        let existing_esurfaces = ti_vec![
2543            GroupEsurfaceId::from(2usize),
2544            GroupEsurfaceId::from(0usize),
2545            GroupEsurfaceId::from(1usize),
2546            GroupEsurfaceId::from(3usize),
2547        ];
2548
2549        let threshold_limits = ThresholdLimit::enumerate_from_overlap_structure(
2550            &existing_esurfaces,
2551            &esurface_map,
2552            GraphGroupPosition::from(0usize),
2553        );
2554        let threshold_limits_for_other_group = ThresholdLimit::enumerate_from_overlap_structure(
2555            &existing_esurfaces,
2556            &esurface_map,
2557            GraphGroupPosition::from(1usize),
2558        );
2559
2560        assert_eq!(
2561            threshold_limits,
2562            vec![
2563                ThresholdLimit {
2564                    esurface_id: RaisedEsurfaceId::from(5usize),
2565                },
2566                ThresholdLimit {
2567                    esurface_id: RaisedEsurfaceId::from(7usize),
2568                },
2569            ]
2570        );
2571        assert_eq!(
2572            threshold_limits_for_other_group,
2573            vec![
2574                ThresholdLimit {
2575                    esurface_id: RaisedEsurfaceId::from(3usize),
2576                },
2577                ThresholdLimit {
2578                    esurface_id: RaisedEsurfaceId::from(8usize),
2579                },
2580                ThresholdLimit {
2581                    esurface_id: RaisedEsurfaceId::from(9usize),
2582                },
2583            ]
2584        );
2585    }
2586
2587    fn test_ir_profile_settings(steps: usize) -> IRProfileSetting {
2588        IRProfileSetting {
2589            lambda_exp_start: -3.0,
2590            lambda_exp_end: -1.0,
2591            steps,
2592            seed: 0,
2593            select_limits_and_graphs: None,
2594            orientation_mode: OrientationProfileMode::Summed,
2595            show_per_cut_info: false,
2596        }
2597    }
2598
2599    fn test_momentum_sample(loop_momenta: Vec<ThreeMomentum<F<f64>>>) -> MomentumSample<f64> {
2600        MomentumSample {
2601            sample: crate::momentum::sample::BareMomentumSample {
2602                loop_moms: loop_momenta.into_iter().collect(),
2603                dual_loop_moms: None,
2604                loop_mom_cache_id: 0,
2605                loop_mom_base_cache_id: 0,
2606                external_moms: ti_vec![],
2607                external_mom_cache_id: 0,
2608                external_mom_base_cache_id: 0,
2609                jacobian: F::from_f64(1.0),
2610                orientation: None,
2611                parameterization_branch: None,
2612            },
2613        }
2614    }
2615
2616    #[test]
2617    fn threshold_approach_loop_momenta_starts_at_stored_threshold_point() {
2618        let overlap_group_center: LoopMomenta<F<f64>> = vec![ThreeMomentum::new(
2619            F::from_f64(5.0),
2620            F::from_f64(7.0),
2621            F::from_f64(9.0),
2622        )]
2623        .into_iter()
2624        .collect();
2625        let threshold_point = test_momentum_sample(vec![ThreeMomentum::new(
2626            F::from_f64(1.0),
2627            F::from_f64(3.0),
2628            F::from_f64(5.0),
2629        )]);
2630
2631        let at_threshold = threshold_approach_loop_momenta(
2632            &overlap_group_center,
2633            &threshold_point,
2634            &F::from_f64(0.0),
2635        );
2636        let halfway = threshold_approach_loop_momenta(
2637            &overlap_group_center,
2638            &threshold_point,
2639            &F::from_f64(0.5),
2640        );
2641        let at_center = threshold_approach_loop_momenta(
2642            &overlap_group_center,
2643            &threshold_point,
2644            &F::from_f64(1.0),
2645        );
2646
2647        assert_eq!(at_threshold, threshold_point.loop_moms().clone());
2648        assert_eq!(
2649            halfway,
2650            vec![ThreeMomentum::new(
2651                F::from_f64(3.0),
2652                F::from_f64(5.0),
2653                F::from_f64(7.0),
2654            )]
2655            .into_iter()
2656            .collect()
2657        );
2658        assert_eq!(at_center, overlap_group_center);
2659    }
2660
2661    #[test]
2662    fn threshold_limit_builds_group_trajectories_for_matching_overlap_groups() {
2663        let threshold_limit = ThresholdLimit {
2664            esurface_id: RaisedEsurfaceId::from(7usize),
2665        };
2666        let threshold_point = test_momentum_sample(vec![ThreeMomentum::new(
2667            F::from_f64(1.0),
2668            F::from_f64(2.0),
2669            F::from_f64(3.0),
2670        )]);
2671        let overlap_structure = OverlapStructureWithKinematics {
2672            existing_esurfaces: ti_vec![
2673                GroupEsurfaceId::from(0usize),
2674                GroupEsurfaceId::from(1usize)
2675            ],
2676            overlap_groups_with_kinematics: vec![
2677                crate::subtraction::amplitude_counterterm::OverlapGroupWithKinematics {
2678                    overlap_group: crate::subtraction::overlap::OverlapGroup {
2679                        existing_esurfaces: vec![ExistingEsurfaceId::from(1usize)],
2680                        complement: vec![],
2681                        center: vec![ThreeMomentum::new(
2682                            F::from_f64(5.0),
2683                            F::from_f64(6.0),
2684                            F::from_f64(7.0),
2685                        )]
2686                        .into_iter()
2687                        .collect(),
2688                        prefactor_evaluator: None,
2689                    },
2690                    loop_momenta_at_esurface: ti_vec![Some(threshold_point.clone())],
2691                },
2692                crate::subtraction::amplitude_counterterm::OverlapGroupWithKinematics {
2693                    overlap_group: crate::subtraction::overlap::OverlapGroup {
2694                        existing_esurfaces: vec![ExistingEsurfaceId::from(0usize)],
2695                        complement: vec![],
2696                        center: vec![ThreeMomentum::new(
2697                            F::from_f64(8.0),
2698                            F::from_f64(9.0),
2699                            F::from_f64(10.0),
2700                        )]
2701                        .into_iter()
2702                        .collect(),
2703                        prefactor_evaluator: None,
2704                    },
2705                    loop_momenta_at_esurface: ti_vec![Some(test_momentum_sample(vec![
2706                        ThreeMomentum::new(F::from_f64(2.0), F::from_f64(3.0), F::from_f64(4.0),),
2707                    ]))],
2708                },
2709            ],
2710        };
2711
2712        let trajectories = threshold_limit
2713            .get_momenta_per_overlap_group(
2714                &overlap_structure,
2715                ExistingEsurfaceId::from(1usize),
2716                &test_ir_profile_settings(4),
2717            )
2718            .unwrap();
2719
2720        assert_eq!(trajectories.len(), 1);
2721        assert_eq!(trajectories[0].0, "overlap group 0");
2722        assert_eq!(trajectories[0].1.len(), 4);
2723
2724        for lambda_point in &trajectories[0].1 {
2725            assert_eq!(
2726                lambda_point.loop_momenta,
2727                threshold_approach_loop_momenta(
2728                    &overlap_structure.overlap_groups_with_kinematics[0]
2729                        .overlap_group
2730                        .center,
2731                    &threshold_point,
2732                    &lambda_point.lambda,
2733                )
2734            );
2735        }
2736    }
2737
2738    #[test]
2739    fn threshold_limit_rejects_group_missing_threshold_kinematics() {
2740        let threshold_limit = ThresholdLimit {
2741            esurface_id: RaisedEsurfaceId::from(7usize),
2742        };
2743        let overlap_structure: OverlapStructureWithKinematics<f64> =
2744            OverlapStructureWithKinematics {
2745                existing_esurfaces: ti_vec![GroupEsurfaceId::from(1usize)],
2746                overlap_groups_with_kinematics: vec![
2747                    crate::subtraction::amplitude_counterterm::OverlapGroupWithKinematics {
2748                        overlap_group: crate::subtraction::overlap::OverlapGroup {
2749                            existing_esurfaces: vec![ExistingEsurfaceId::from(0usize)],
2750                            complement: vec![],
2751                            center: vec![ThreeMomentum::new(
2752                                F::from_f64(5.0),
2753                                F::from_f64(6.0),
2754                                F::from_f64(7.0),
2755                            )]
2756                            .into_iter()
2757                            .collect(),
2758                            prefactor_evaluator: None,
2759                        },
2760                        loop_momenta_at_esurface: ti_vec![None],
2761                    },
2762                ],
2763            };
2764
2765        assert!(
2766            threshold_limit
2767                .get_momenta_per_overlap_group(
2768                    &overlap_structure,
2769                    ExistingEsurfaceId::from(0usize),
2770                    &test_ir_profile_settings(3),
2771                )
2772                .is_err()
2773        );
2774    }
2775
2776    #[test]
2777    fn fit_power_law_recovers_known_parameters() {
2778        let exponent = -1.75_f64;
2779        let coefficient = 3.2_f64;
2780        let offset = 0.6_f64;
2781
2782        let x = constant_dropped_fit_points(
2783            &F::<ArbPrec>::from_f64(0.2_f64 * 1.6_f64.powi(7)),
2784            &F::<ArbPrec>::from_f64(0.2_f64),
2785            8,
2786        )
2787        .expect("geometric fit points should be generated");
2788        let y = x
2789            .iter()
2790            .map(|xv| F::<ArbPrec>::from_f64(coefficient * xv.into_f64().powf(exponent) + offset))
2791            .collect::<Vec<_>>();
2792
2793        let fit = fit_power_law(x, y).expect("power-law fit should succeed");
2794
2795        assert!((fit.exponent - exponent).abs() < 1e-10);
2796        assert!(fit.r_squared > 0.999_999);
2797    }
2798
2799    #[test]
2800    fn fit_power_law_rejects_non_geometric_grid() {
2801        let exponent = -1.75_f64;
2802        let coefficient = 3.2_f64;
2803        let offset = 0.6_f64;
2804
2805        let x = [
2806            0.2_f64, 0.37_f64, 0.55_f64, 0.92_f64, 1.3_f64, 1.85_f64, 2.75_f64, 3.6_f64,
2807        ]
2808        .into_iter()
2809        .map(F::<ArbPrec>::from_f64)
2810        .collect::<Vec<_>>();
2811        let y = x
2812            .iter()
2813            .map(|xv| F::<ArbPrec>::from_f64(coefficient * xv.into_f64().powf(exponent) + offset))
2814            .collect::<Vec<_>>();
2815
2816        let fit = fit_power_law(x, y);
2817
2818        assert!(fit.is_err());
2819    }
2820
2821    #[test]
2822    fn negative_cut_scaling_does_not_fail_limit() {
2823        let ir_limit = IrLimit::new_pure_soft(vec![EdgeIndex::from(1)]);
2824        let total_fit = PowerLawFit {
2825            exponent: -2.5,
2826            r_squared: 0.999,
2827        };
2828        let cut_reports = build_cut_limit_reports(
2829            ir_limit.num_soft(),
2830            vec![
2831                (
2832                    0,
2833                    Ok(PowerLawFit {
2834                        exponent: -4.5,
2835                        r_squared: 0.999,
2836                    }),
2837                ),
2838                (
2839                    1,
2840                    Ok(PowerLawFit {
2841                        exponent: -2.5,
2842                        r_squared: 0.999,
2843                    }),
2844                ),
2845            ],
2846        );
2847
2848        let report = build_single_limit_report(&ir_limit, None, total_fit, cut_reports);
2849
2850        assert!(report.passed);
2851        assert_eq!(report.per_cut_reports.len(), 2);
2852        assert!(report.per_cut_reports[0].scaling.unwrap() < 0.0);
2853        assert!(report.per_cut_reports[1].scaling.unwrap() > 0.0);
2854    }
2855
2856    #[test]
2857    fn single_limit_display_includes_per_cut_table() {
2858        let ir_limit = IrLimit::new_pure_soft(vec![EdgeIndex::from(1)]);
2859        let total_fit = PowerLawFit {
2860            exponent: -2.5,
2861            r_squared: 0.999,
2862        };
2863        let cut_reports = build_cut_limit_reports(
2864            ir_limit.num_soft(),
2865            vec![(
2866                0,
2867                Ok(PowerLawFit {
2868                    exponent: -4.5,
2869                    r_squared: 0.999,
2870                }),
2871            )],
2872        );
2873
2874        let report = build_single_limit_report(&ir_limit, None, total_fit, cut_reports);
2875        let rendered = format!("{report}");
2876
2877        assert!(rendered.contains("per-cut fits for"));
2878        assert!(rendered.contains("cut"));
2879        assert!(rendered.contains("r_squared"));
2880    }
2881
2882    #[test]
2883    fn single_limit_display_includes_display_only_table() {
2884        let ir_limit = IrLimit::new_pure_soft(vec![EdgeIndex::from(1)]);
2885        let total_fit = PowerLawFit {
2886            exponent: -2.5,
2887            r_squared: 0.999,
2888        };
2889        let mut report = build_single_limit_report(&ir_limit, None, total_fit, Vec::new());
2890        report.display_only_reports = build_display_only_limit_reports(vec![
2891            (
2892                AdditionalWeightKey::Original,
2893                Ok(PowerLawFit {
2894                    exponent: -1.5,
2895                    r_squared: 0.995,
2896                }),
2897            ),
2898            (
2899                AdditionalWeightKey::AmplitudeThresholdCounterterm {
2900                    esurface_id: 3,
2901                    overlap_group: 1,
2902                },
2903                Ok(PowerLawFit {
2904                    exponent: -0.5,
2905                    r_squared: 0.991,
2906                }),
2907            ),
2908        ]);
2909
2910        let rendered = format!("{report}");
2911
2912        assert!(rendered.contains("display-only fits for"));
2913        assert!(rendered.contains("original"));
2914        assert!(rendered.contains("ct_3_1"));
2915        assert!(!rendered.contains("note"));
2916    }
2917
2918    #[test]
2919    fn single_limit_display_includes_display_only_fit_error_reason() {
2920        let ir_limit = IrLimit::new_pure_soft(vec![EdgeIndex::from(1)]);
2921        let total_fit = PowerLawFit {
2922            exponent: -2.5,
2923            r_squared: 0.999,
2924        };
2925        let mut report = build_single_limit_report(&ir_limit, None, total_fit, Vec::new());
2926        report.display_only_reports = build_display_only_limit_reports(vec![(
2927            AdditionalWeightKey::Original,
2928            Err(eyre!("fit_power_law requires at least three observations")),
2929        )]);
2930
2931        let rendered = format!("{report}");
2932
2933        assert!(
2934            rendered
2935                .contains("original (no fit: fit_power_law requires at least three observations)")
2936        );
2937    }
2938
2939    #[test]
2940    fn graph_limit_display_includes_per_cut_fit_error_reason() {
2941        let ir_limit = IrLimit::new_pure_soft(vec![EdgeIndex::from(1)]);
2942        let total_fit = PowerLawFit {
2943            exponent: -2.5,
2944            r_squared: 0.999,
2945        };
2946        let graph_report = GraphIRLimitReport {
2947            graph_name: "GL0".to_string(),
2948            all_limits_passed: true,
2949            cut_definitions: Vec::new(),
2950            single_limit_reports: vec![build_single_limit_report(
2951                &ir_limit,
2952                None,
2953                total_fit,
2954                build_cut_limit_reports(
2955                    ir_limit.num_soft(),
2956                    vec![(0, Err(eyre!("fit_power_law requires finite y values")))],
2957                ),
2958            )],
2959        };
2960
2961        let rendered = format!("{graph_report}");
2962
2963        assert!(rendered.contains("cut 0 (no fit: fit_power_law requires finite y values)"));
2964    }
2965
2966    #[test]
2967    fn graph_limit_display_weaves_per_cut_rows_into_limit_table() {
2968        let ir_limit = IrLimit::new_pure_soft(vec![EdgeIndex::from(1)]);
2969        let total_fit = PowerLawFit {
2970            exponent: -2.5,
2971            r_squared: 0.999,
2972        };
2973        let cut_reports = build_cut_limit_reports(
2974            ir_limit.num_soft(),
2975            vec![
2976                (
2977                    0,
2978                    Ok(PowerLawFit {
2979                        exponent: -4.5,
2980                        r_squared: 0.999,
2981                    }),
2982                ),
2983                (
2984                    1,
2985                    Ok(PowerLawFit {
2986                        exponent: -2.5,
2987                        r_squared: 0.999,
2988                    }),
2989                ),
2990            ],
2991        );
2992
2993        let mut report = build_single_limit_report(&ir_limit, None, total_fit, cut_reports);
2994        report.display_only_reports = build_display_only_limit_reports(vec![
2995            (
2996                AdditionalWeightKey::Original,
2997                Ok(PowerLawFit {
2998                    exponent: -1.5,
2999                    r_squared: 0.995,
3000                }),
3001            ),
3002            (
3003                AdditionalWeightKey::AmplitudeThresholdCounterterm {
3004                    esurface_id: 3,
3005                    overlap_group: 1,
3006                },
3007                Ok(PowerLawFit {
3008                    exponent: -0.5,
3009                    r_squared: 0.991,
3010                }),
3011            ),
3012        ]);
3013        let second_report = build_single_limit_report(
3014            &ir_limit,
3015            Some("ori-1".to_string()),
3016            PowerLawFit {
3017                exponent: -2.5,
3018                r_squared: 0.999,
3019            },
3020            build_cut_limit_reports(
3021                ir_limit.num_soft(),
3022                vec![(
3023                    2,
3024                    Ok(PowerLawFit {
3025                        exponent: -1.5,
3026                        r_squared: 0.995,
3027                    }),
3028                )],
3029            ),
3030        );
3031        let graph_report = GraphIRLimitReport {
3032            graph_name: "GL0".to_string(),
3033            all_limits_passed: true,
3034            cut_definitions: vec![
3035                GraphCutDefinition {
3036                    cut_id: 0,
3037                    edges: vec![EdgeIndex::from(1), EdgeIndex::from(3)],
3038                },
3039                GraphCutDefinition {
3040                    cut_id: 1,
3041                    edges: vec![EdgeIndex::from(2), EdgeIndex::from(4)],
3042                },
3043            ],
3044            single_limit_reports: vec![report, second_report],
3045        };
3046        let rendered = format!("{graph_report}");
3047
3048        assert!(rendered.contains("cut definitions"));
3049        assert!(rendered.contains("edges"));
3050        assert!(rendered.contains("e1, e3"));
3051        assert!(rendered.find("cut definitions").unwrap() < rendered.find("status").unwrap());
3052        assert!(rendered.contains("item"));
3053        assert!(rendered.contains("cut 0"));
3054        assert!(rendered.contains("original"));
3055        assert!(rendered.contains("ct_3_1"));
3056        assert!(!rendered.contains("note"));
3057        assert!(rendered.contains("sum"));
3058        assert!(rendered.contains("INFO"));
3059        assert!(!rendered.contains("per-cut fits for"));
3060        assert!(rendered.matches('├').count() >= 3);
3061    }
3062
3063    #[test]
3064    fn limit_data_extract_power_includes_display_only_component_fits() {
3065        let lambdas = constant_dropped_fit_points(
3066            &F::<f64>::from_f64(1.0e-3),
3067            &F::<f64>::from_f64(1.0e-1),
3068            5,
3069        )
3070        .unwrap();
3071
3072        let data = lambdas
3073            .into_iter()
3074            .map(|lambda| {
3075                let lambda_f64 = lambda.into_ff64().0;
3076                let mut display_only_components = BTreeMap::new();
3077                display_only_components.insert(
3078                    AdditionalWeightKey::Original,
3079                    F::<ArbPrec>::from_f64(3.0 * lambda_f64.powf(-1.5) + 0.5),
3080                );
3081                display_only_components.insert(
3082                    AdditionalWeightKey::AmplitudeThresholdCounterterm {
3083                        esurface_id: 3,
3084                        overlap_group: 1,
3085                    },
3086                    F::<ArbPrec>::from_f64(5.0 * lambda_f64.powf(-0.5) + 1.0),
3087                );
3088                display_only_components.insert(
3089                    AdditionalWeightKey::FullMultiplicativeFactor,
3090                    F::<ArbPrec>::from_f64(7.0 * lambda_f64.powf(-2.5) + 1.0),
3091                );
3092
3093                LambdaPointEval {
3094                    lambda,
3095                    value: ProfilePointValue {
3096                        total: F::<ArbPrec>::from_f64(2.0 * lambda_f64.powf(-2.0) + 1.0),
3097                        per_cut: BTreeMap::new(),
3098                        display_only_components,
3099                    },
3100                }
3101            })
3102            .collect();
3103
3104        let power_fits = LimitData { data }.extract_power().unwrap();
3105
3106        assert!((power_fits.total.exponent + 2.0).abs() < 1.0e-10);
3107        assert_eq!(power_fits.display_components.len(), 2);
3108
3109        let component_fits = power_fits
3110            .display_components
3111            .into_iter()
3112            .collect::<BTreeMap<_, _>>();
3113        let original_fit = component_fits
3114            .get(&AdditionalWeightKey::Original)
3115            .unwrap()
3116            .as_ref()
3117            .unwrap();
3118        let ct_fit = component_fits
3119            .get(&AdditionalWeightKey::AmplitudeThresholdCounterterm {
3120                esurface_id: 3,
3121                overlap_group: 1,
3122            })
3123            .unwrap()
3124            .as_ref()
3125            .unwrap();
3126
3127        assert!((original_fit.exponent + 1.5).abs() < 1.0e-10);
3128        assert!((ct_fit.exponent + 0.5).abs() < 1.0e-10);
3129    }
3130}