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