Skip to main content

gammalooprs/uv/
profile.rs

1//! UV Profile Analysis Module
2//!
3//! This module provides functionality for analyzing ultraviolet behavior of loop integrands
4//! by evaluating them at different momentum scalings and computing degrees of divergence.
5
6use std::collections::{BTreeMap, HashSet};
7use std::fmt::Display;
8use std::path::Path;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11
12use crate::cff::expression::{OrientationData, OrientationID};
13use crate::cff::orientations::GraphOrientation;
14use crate::graph::parse::string_utils::ToOrderedSimple;
15use crate::graph::{FeynmanGraph, Graph, LmbIndex, LoopMomentumBasis};
16use crate::integrands::evaluation::{GenericEvaluationResult, PreciseEvaluationResult};
17use crate::integrands::process::{
18    OrientationProfileMode, ProcessIntegrand, evaluate_profile_momentum_point_precise,
19    orientation_labels_for_graph,
20};
21use crate::model::Model;
22use crate::momentum::ThreeMomentum;
23use crate::momentum::sample::LoopIndex;
24use crate::processes::{Amplitude, AmplitudeGraph, CrossSection, CutId};
25use crate::settings::RuntimeSettings;
26use crate::utils::{ArbPrec, F, FloatLike};
27use crate::uv::UltravioletGraph;
28use clap::ValueEnum;
29use color_eyre::{Result, eyre::Context};
30use colored::Colorize;
31use eyre::eyre;
32use itertools::Itertools;
33use linnet::half_edge::PowersetIterator;
34use linnet::half_edge::involution::{EdgeIndex, HedgePair, SignOrZero};
35use linnet::half_edge::subgraph::subset::SubSet;
36use linnet::half_edge::subgraph::{Inclusion, ModifySubSet, SuBitGraph, SubSetLike, SubSetOps};
37use rand::Rng;
38use rayon::prelude::*;
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use spenso::algebra::complex::Complex;
42use symbolica::atom::Atom;
43use symbolica::domains::atom::AtomField;
44use symbolica::numerical_integration::MonteCarloRng;
45use symbolica::poly::series::Series;
46use symbolica::symbol;
47use tabled::{
48    Table, Tabled,
49    builder::Builder,
50    settings::{Modify, Span, Style},
51};
52use tracing::{debug, info_span, instrument};
53use tracing_indicatif::{span_ext::IndicatifSpanExt, style::ProgressStyle};
54use typed_index_collections::TiVec;
55
56type LoopMomentumSample = TiVec<LoopIndex, ThreeMomentum<F<f64>>>;
57type ProfileLmbLimits = Vec<(LoopMomentumBasis, Vec<(SubSet<LoopIndex>, i32)>)>;
58/// Numerical UV-profile acceptance threshold, also used to trigger precision retries.
59pub const UV_PROFILE_MAX_DOD: f64 = -0.9;
60const UV_PROFILE_RETRY_MIN_R_SQUARED: f64 = 0.9;
61const UV_PROFILE_ASYMPTOTIC_MIN_R_SQUARED: f64 = 0.99;
62const UV_PROFILE_MIN_POINTS: usize = 5;
63const UV_PROFILE_THREAD_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
64
65#[derive(
66    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum,
67)]
68#[serde(rename_all = "kebab-case")]
69pub enum UVLimitSelection {
70    /// One representative for every expected divergent cycle union: the generation LMB when
71    /// suitable, otherwise the first suitable basis in the deterministically sorted full list.
72    #[default]
73    OnlyDivergent,
74    /// Every nonempty loop subset in every generated physical LMB.
75    All,
76}
77
78impl UVLimitSelection {
79    fn cycle_union(
80        graph: &Graph,
81        lmb: &LoopMomentumBasis,
82        subset: &SubSet<LoopIndex>,
83    ) -> SuBitGraph {
84        let mut subgraph: SuBitGraph = graph.empty_subgraph();
85        for (pair, edge_id, _) in graph.underlying.iter_edges() {
86            if matches!(pair, HedgePair::Paired { .. })
87                && subset.included_iter().any(|loop_index| {
88                    lmb.edge_signatures[edge_id].internal[loop_index] != SignOrZero::Zero
89                })
90            {
91                subgraph.add(pair);
92            }
93        }
94        subgraph
95    }
96
97    fn all_lmb_limits(
98        graph: &Graph,
99        lmb: &LoopMomentumBasis,
100        profile_domain: &SuBitGraph,
101        compatible_cuts: Option<&[SuBitGraph]>,
102    ) -> Vec<(SubSet<LoopIndex>, i32)> {
103        let mut loops = PowersetIterator::<LoopIndex>::new(lmb.loop_edges.len() as u8);
104        loops.next();
105        loops
106            .filter_map(|subset| {
107                let cycle = Self::cycle_union(graph, lmb, &subset);
108                let is_in_profile_domain = cycle.subtract(profile_domain).is_empty();
109                let is_cut_compatible = compatible_cuts
110                    .is_none_or(|cuts| cuts.iter().any(|cut| !cycle.intersects(cut)));
111                (is_in_profile_domain && is_cut_compatible)
112                    .then(|| (subset, graph.compute_dod(&cycle)))
113            })
114            .collect()
115    }
116
117    fn lmb_limits(
118        self,
119        graph: &Graph,
120        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
121        profile_domain: &SuBitGraph,
122        compatible_cuts: Option<&[SuBitGraph]>,
123    ) -> Result<ProfileLmbLimits> {
124        if matches!(self, Self::All) {
125            return Ok(all_lmbs
126                .iter()
127                .cloned()
128                .map(|lmb| {
129                    let limits = Self::all_lmb_limits(graph, &lmb, profile_domain, compatible_cuts);
130                    (lmb, limits)
131                })
132                .collect());
133        }
134
135        let mut sorted_lmbs = all_lmbs.iter().collect_vec();
136        sorted_lmbs.sort_by_key(|lmb| {
137            (
138                lmb.loop_edges.iter().map(|edge| edge.0).collect_vec(),
139                lmb.ext_edges.iter().map(|edge| edge.0).collect_vec(),
140                lmb.tree.string_label(),
141            )
142        });
143        let mut seen_lmbs = HashSet::new();
144        let candidate_lmbs = std::iter::once(&graph.loop_momentum_basis)
145            .chain(sorted_lmbs)
146            .filter(|lmb| seen_lmbs.insert(*lmb))
147            .collect_vec();
148        let profile_domains = compatible_cuts
149            .map(|cuts| {
150                cuts.iter()
151                    .map(|cut| profile_domain.subtract(cut))
152                    .collect_vec()
153            })
154            .unwrap_or_else(|| vec![profile_domain.clone()]);
155        let mut targets = profile_domains
156            .into_iter()
157            .filter(|domain| !domain.is_empty())
158            .flat_map(|domain| graph.all_cycle_unions(&domain))
159            .filter_map(|cycle| {
160                let cycle = cycle.filter;
161                (!cycle.is_empty()).then(|| {
162                    let dod = graph.compute_dod(&cycle);
163                    (cycle, dod)
164                })
165            })
166            .filter(|(_, dod)| *dod >= 0)
167            .collect_vec();
168        targets.sort_by_key(|(cycle, _)| cycle.string_label());
169        targets.dedup_by(|(left, _), (right, _)| left == right);
170        // Most graphs are fully represented by the generation LMB. Build support maps lazily so
171        // stored spanning-tree bases are touched only when an earlier basis cannot represent a
172        // divergent cycle union.
173        let mut candidate_supports = vec![None; candidate_lmbs.len()];
174
175        let mut selected: ProfileLmbLimits = Vec::new();
176        for (target, dod) in targets {
177            let mut representation = None;
178            for (candidate_index, &lmb) in candidate_lmbs.iter().enumerate() {
179                let supports = candidate_supports[candidate_index].get_or_insert_with(|| {
180                    (0..lmb.loop_edges.len())
181                        .map(|loop_index| {
182                            let loop_index = LoopIndex(loop_index);
183                            let mut support: SuBitGraph = graph.empty_subgraph();
184                            for (pair, edge_id, _) in graph.underlying.iter_edges() {
185                                if matches!(pair, HedgePair::Paired { .. })
186                                    && lmb.edge_signatures[edge_id].internal[loop_index]
187                                        != SignOrZero::Zero
188                                {
189                                    support.add(pair);
190                                }
191                            }
192                            support
193                        })
194                        .collect_vec()
195                });
196                let mut subset = SubSet::empty(lmb.loop_edges.len());
197                let mut represented_cycle: SuBitGraph = graph.empty_subgraph();
198                for (loop_index, support) in supports.iter().enumerate() {
199                    let loop_index = LoopIndex(loop_index);
200                    if !support.is_empty() && support.subtract(&target).is_empty() {
201                        subset.add(loop_index);
202                        represented_cycle.union_with(support);
203                    }
204                }
205
206                if represented_cycle == target {
207                    representation = Some((lmb, subset));
208                    break;
209                }
210            }
211
212            let Some((lmb, subset)) = representation else {
213                return Err(eyre!(
214                    "Divergent UV cycle {} (DOD {dod}) in graph '{}' is not represented by the generation LMB or any basis in the complete generated LMB list",
215                    target.string_label(),
216                    graph.name,
217                ));
218            };
219            if let Some((_, limits)) = selected
220                .iter_mut()
221                .find(|(selected_lmb, _)| selected_lmb == lmb)
222            {
223                limits.push((subset, dod));
224            } else {
225                selected.push((lmb.clone(), vec![(subset, dod)]));
226            }
227        }
228        Ok(selected)
229    }
230}
231
232pub struct ProfileSettings {
233    pub fail_fast: bool,
234    pub n_points: usize,
235    pub min_scale_exponent: f64,
236    pub max_scale_exponent: f64,
237    pub seed: u64,
238    pub use_f128: bool,
239    pub analyse_analytically: bool,
240    pub allow_vanishing_missing_fits: bool,
241    pub orientation_mode: OrientationProfileMode,
242    pub fixed_uv_ray: Option<UVProfileFixedRay>,
243    pub graph_id: Option<usize>,
244    pub cutkosky_cut: Option<Vec<EdgeIndex>>,
245    pub selected_limits: UVLimitSelection,
246}
247
248impl Default for ProfileSettings {
249    fn default() -> Self {
250        ProfileSettings {
251            fail_fast: false,
252            n_points: 15,
253            min_scale_exponent: 3.0,
254            max_scale_exponent: 6.0,
255            seed: 42,
256            analyse_analytically: false,
257            use_f128: false,
258            allow_vanishing_missing_fits: false,
259            orientation_mode: OrientationProfileMode::Summed,
260            fixed_uv_ray: None,
261            graph_id: None,
262            cutkosky_cut: None,
263            selected_limits: UVLimitSelection::OnlyDivergent,
264        }
265    }
266}
267
268#[derive(Debug, Clone)]
269pub struct UVProfileFixedRay {
270    directions: Vec<[f64; 3]>,
271    norms: Vec<f64>,
272}
273
274impl UVProfileFixedRay {
275    pub fn from_flat_components(directions: &[f64], norms: &[f64]) -> Result<Self> {
276        if directions.is_empty() {
277            return Err(eyre!(
278                "Fixed UV ray directions cannot be empty when the option is used."
279            ));
280        }
281        if !directions.len().is_multiple_of(3) {
282            return Err(eyre!(
283                "Fixed UV ray directions must contain a multiple of 3 components, got {}.",
284                directions.len()
285            ));
286        }
287        if norms.is_empty() {
288            return Err(eyre!(
289                "Fixed UV ray norms cannot be empty when directions are supplied."
290            ));
291        }
292
293        let directions = directions
294            .as_chunks::<3>()
295            .0
296            .iter()
297            .map(|direction| {
298                if direction.iter().any(|component| !component.is_finite()) {
299                    return Err(eyre!("Fixed UV ray directions must be finite."));
300                }
301                let scale = direction
302                    .iter()
303                    .map(|component| component.abs())
304                    .fold(0.0, f64::max);
305                if scale == 0.0 {
306                    return Err(eyre!(
307                        "Fixed UV ray directions cannot contain a zero vector."
308                    ));
309                }
310                // Normalize after scaling to preserve both subnormal and very large rays.
311                let direction = direction.map(|component| component / scale);
312                let norm = direction[0].hypot(direction[1]).hypot(direction[2]);
313                Ok(direction.map(|component| component / norm))
314            })
315            .collect::<Result<Vec<_>>>()?;
316
317        if norms.iter().any(|norm| !norm.is_finite() || *norm <= 0.0) {
318            return Err(eyre!(
319                "Fixed UV ray norms must be finite and strictly positive."
320            ));
321        }
322
323        Ok(Self {
324            directions,
325            norms: norms.to_vec(),
326        })
327    }
328
329    fn sample(&self, loop_count: usize) -> Result<LoopMomentumSample> {
330        if self.directions.len() != 1 && self.directions.len() != loop_count {
331            return Err(eyre!(
332                "Fixed UV ray directions contain {} ray(s), but this LMB has {} loop variable(s). Use either one direction or one direction per loop variable.",
333                self.directions.len(),
334                loop_count
335            ));
336        }
337        if self.norms.len() != 1 && self.norms.len() != loop_count {
338            return Err(eyre!(
339                "Fixed UV ray norms contain {} value(s), but this LMB has {} loop variable(s). Use either one norm or one norm per loop variable.",
340                self.norms.len(),
341                loop_count
342            ));
343        }
344
345        (0..loop_count)
346            .map(|i| {
347                let direction = self.directions[if self.directions.len() == 1 { 0 } else { i }];
348                let norm = self.norms[if self.norms.len() == 1 { 0 } else { i }];
349                Ok(ThreeMomentum {
350                    px: F(norm * direction[0]),
351                    py: F(norm * direction[1]),
352                    pz: F(norm * direction[2]),
353                })
354            })
355            .collect()
356    }
357}
358
359#[cfg(test)]
360#[path = "profile_tests.rs"]
361mod precision_tests;
362
363#[cfg(test)]
364mod tests {
365    use std::sync::OnceLock;
366
367    use itertools::Itertools;
368    use linnet::half_edge::involution::{EdgeIndex, EdgeVec, Orientation};
369    use linnet::half_edge::subgraph::{
370        Inclusion, ModifySubSet, SuBitGraph, SubSetLike, subset::SubSet,
371    };
372    use typed_index_collections::TiVec;
373
374    use crate::dot;
375    use crate::graph::parse::from_dot::IntoGraph;
376    use crate::graph::{FeynmanGraph, Graph, LMBext, LmbIndex, LoopMomentumBasis};
377    use crate::initialisation::test_initialise;
378    use crate::integrands::evaluation::{EvaluationMetaData, GenericEvaluationResult};
379    use crate::momentum::ThreeMomentum;
380    use crate::momentum::sample::{
381        BareMomentumSample, ExternalThreeMomenta, LoopIndex, LoopMomenta, MomentumSample,
382    };
383    use crate::observables::events::{AdditionalWeightKey, GenericEvent, GenericEventGroupList};
384    use crate::processes::CutId;
385    use crate::utils::{ArbPrec, F, load_generic_model};
386    use crate::uv::UltravioletGraph;
387    use spenso::algebra::complex::Complex;
388    use symbolica::atom::{Atom, AtomCore};
389    use three_dimensional_reps::symbols::sign;
390
391    use super::{
392        Analysis, FitResult, InspectAnalysis, InspectFitStatus, InspectResult,
393        OrientationInspectAnalysis, UV_PROFILE_ASYMPTOTIC_MIN_R_SQUARED, UV_PROFILE_MAX_DOD,
394        UV_PROFILE_RETRY_MIN_R_SQUARED, UVLimitSelection, UVProfile, UVProfileAnalysis,
395        UVProfileFailure, UVProfileFixedRay, UVProfileGraphAnalysis, UVProfileLmbAnalysis,
396        UVProfilePassFail, UVProfileSubsetAnalysis, UVSamplingResult, analyse_inspect_results,
397        analytic_integrand_for_orientation, inspect_cells, inspect_failure_reason,
398        inspect_results_need_arbprec_retry, log_log_slope, logspace,
399    };
400
401    /// Real inspect samples with magnitudes `10^y` in Arb; `None` is an exact zero.
402    fn inspect_samples(
403        log_magnitudes: impl IntoIterator<Item = Option<f64>>,
404    ) -> Vec<InspectResult> {
405        let ten = F::<ArbPrec>::default().from_usize(10);
406        log_magnitudes
407            .into_iter()
408            .map(|log_magnitude| InspectResult {
409                magnitude: Some(log_magnitude.map_or_else(
410                    || ten.zero(),
411                    |log_magnitude| ten.powf(&F::from_f64(log_magnitude)),
412                )),
413            })
414            .collect()
415    }
416
417    /// Final verdict of one sampled ray.
418    fn uv_ray_verdict(
419        log_magnitudes: &[Option<f64>],
420        scales: &[f64],
421        allow_vanishing_missing_fits: bool,
422    ) -> Option<&'static str> {
423        let samples = inspect_samples(log_magnitudes.iter().copied());
424        inspect_failure_reason(
425            analyse_inspect_results(&samples, scales, false).as_ref(),
426            InspectFitStatus::from_results(&samples, scales),
427            UV_PROFILE_MAX_DOD,
428            allow_vanishing_missing_fits,
429        )
430    }
431
432    static THETA_GRAPH: OnceLock<(Graph, TiVec<LmbIndex, LoopMomentumBasis>)> = OnceLock::new();
433
434    fn theta_graph() -> (Graph, TiVec<LmbIndex, LoopMomentumBasis>) {
435        THETA_GRAPH
436            .get_or_init(|| {
437                test_initialise().unwrap();
438                let mut graph: Graph = dot!(digraph theta_profile {
439                    edge [num=1 mass=0]
440                    node [num=1]
441                    A -> B [id=0]
442                    A -> B [id=1]
443                    A -> B [id=2]
444                })
445                .unwrap();
446                let lmbs = graph.generate_loop_momentum_bases();
447                graph.loop_momentum_basis = lmbs[LmbIndex::from(0)].clone();
448                (graph, lmbs)
449            })
450            .clone()
451    }
452
453    #[test]
454    fn analytic_integrand_selects_physical_orientation_and_exact_residue_map_key() {
455        let edge = EdgeIndex(0);
456        let mut orientation =
457            super::OrientationData::new(EdgeVec::from_iter([Orientation::Default]));
458        orientation.label = Some("+|N0".to_string());
459        let integrand =
460            sign(edge) * (super::OrientationID(4).atom() * 2 + super::OrientationID(9).atom() * 3);
461
462        let (four_data, four) =
463            analytic_integrand_for_orientation(super::OrientationID(4), &orientation, &integrand);
464        let (nine_data, nine) =
465            analytic_integrand_for_orientation(super::OrientationID(9), &orientation, &integrand);
466
467        assert_eq!(four_data.label.as_deref(), Some("+|N0|sigma(4)"));
468        assert_eq!(nine_data.label.as_deref(), Some("+|N0|sigma(9)"));
469        assert_eq!(four.expand(), Atom::num(2));
470        assert_eq!(nine.expand(), Atom::num(3));
471        assert!(!four.contains_symbol(super::OrientationID::symbol()));
472        assert!(!nine.contains_symbol(super::OrientationID::symbol()));
473    }
474
475    #[test]
476    fn cycle_union_uses_lmb_signatures_for_a_self_loop() {
477        let _ = theta_graph();
478        let graph: Graph = dot!(digraph tadpole_profile {
479            edge [num=1 mass=0]
480            node [num=1]
481            A -> A [id=0]
482        })
483        .unwrap();
484        assert_eq!(graph.loop_momentum_basis.loop_edges.len(), 1);
485
486        let mut subset = SubSet::empty(1);
487        subset.add(LoopIndex::from(0));
488        let cycle = UVLimitSelection::cycle_union(&graph, &graph.loop_momentum_basis, &subset);
489        assert!(cycle.includes(&graph[&EdgeIndex::from(0)].1));
490    }
491
492    #[test]
493    fn only_divergent_targets_stay_inside_the_production_uv_domain() {
494        let _ = theta_graph();
495        let mut graph: Graph = dot!(digraph theta_profile_domain {
496            edge [num=1 mass=0]
497            node [num=1]
498            A -> B [id=0]
499            A -> B [id=1]
500            A -> B [id=2 is_dummy=true]
501        })
502        .unwrap();
503        let lmbs = graph.generate_loop_momentum_bases();
504        graph.loop_momentum_basis = lmbs[LmbIndex::from(0)].clone();
505        let selected = UVLimitSelection::OnlyDivergent
506            .lmb_limits(&graph, &lmbs, &graph.no_dummy(), None)
507            .unwrap();
508        assert!(!selected.is_empty());
509        assert!(
510            selected
511                .iter()
512                .all(|(lmb, limits)| limits.iter().all(|(subset, _)| {
513                    !UVLimitSelection::cycle_union(&graph, lmb, subset)
514                        .includes(&graph[&EdgeIndex::from(2)].1)
515                }))
516        );
517
518        let mut incoming_domain = graph.full_filter();
519        incoming_domain.sub(graph[&EdgeIndex::from(0)].1);
520        let selected = UVLimitSelection::OnlyDivergent
521            .lmb_limits(&graph, &lmbs, &incoming_domain, None)
522            .unwrap();
523        assert!(!selected.is_empty());
524        assert!(
525            selected
526                .iter()
527                .all(|(lmb, limits)| limits.iter().all(|(subset, _)| {
528                    !UVLimitSelection::cycle_union(&graph, lmb, subset)
529                        .includes(&graph[&EdgeIndex::from(0)].1)
530                }))
531        );
532    }
533
534    #[test]
535    fn only_divergent_profiles_all_theta_cycles_once() {
536        let (graph, lmbs) = theta_graph();
537        let selected = UVLimitSelection::default()
538            .lmb_limits(&graph, &lmbs, &graph.full_filter(), None)
539            .unwrap();
540        let represented = selected
541            .iter()
542            .flat_map(|(lmb, limits)| {
543                limits.iter().map(|(subset, _)| {
544                    UVLimitSelection::cycle_union(&graph, lmb, subset).string_label()
545                })
546            })
547            .collect::<std::collections::BTreeSet<_>>();
548        let expected = graph
549            .all_cycle_unions(&graph.full_filter())
550            .into_iter()
551            .filter_map(|cycle| {
552                let cycle = cycle.filter;
553                (!cycle.is_empty() && graph.compute_dod(&cycle) >= 0).then(|| cycle.string_label())
554            })
555            .collect::<std::collections::BTreeSet<_>>();
556        let selected_limit_count: usize = selected.iter().map(|(_, limits)| limits.len()).sum();
557
558        assert_eq!(represented, expected);
559        assert_eq!(represented.len(), selected_limit_count);
560        assert!(
561            selected
562                .iter()
563                .all(|(selected_lmb, _)| lmbs.iter().any(|lmb| lmb == selected_lmb)),
564            "divergent-only selection must not synthesize loop-momentum bases"
565        );
566        let reversed_lmbs: TiVec<LmbIndex, LoopMomentumBasis> =
567            lmbs.iter().cloned().rev().collect::<Vec<_>>().into();
568        let reversed_selected = UVLimitSelection::OnlyDivergent
569            .lmb_limits(&graph, &reversed_lmbs, &graph.full_filter(), None)
570            .unwrap();
571        let reversed_cycles = reversed_selected
572            .iter()
573            .flat_map(|(lmb, limits)| {
574                limits.iter().map(|(subset, _)| {
575                    UVLimitSelection::cycle_union(&graph, lmb, subset).string_label()
576                })
577            })
578            .collect::<std::collections::BTreeSet<_>>();
579        assert_eq!(reversed_cycles, expected);
580
581        let exhaustive = UVLimitSelection::All
582            .lmb_limits(&graph, &lmbs, &graph.full_filter(), None)
583            .unwrap();
584        assert_eq!(exhaustive.len(), lmbs.len());
585        assert!(exhaustive.iter().all(
586            |(_, limits)| limits.len() == (1 << graph.loop_momentum_basis.loop_edges.len()) - 1
587        ));
588
589        let mut first_physical_cut: SuBitGraph = graph.empty_subgraph();
590        first_physical_cut.add(graph[&EdgeIndex::from(0)].1);
591        let mut second_physical_cut: SuBitGraph = graph.empty_subgraph();
592        second_physical_cut.add(graph[&EdgeIndex::from(1)].1);
593        let physical_cuts = [first_physical_cut.clone(), second_physical_cut.clone()];
594        let cut_compatible_exhaustive = UVLimitSelection::All
595            .lmb_limits(&graph, &lmbs, &graph.full_filter(), Some(&physical_cuts))
596            .unwrap();
597        let cut_compatible_count: usize = cut_compatible_exhaustive
598            .iter()
599            .map(|(_, limits)| limits.len())
600            .sum();
601        assert!(cut_compatible_count > 0 && cut_compatible_count < exhaustive.len() * 3);
602        assert!(
603            cut_compatible_exhaustive
604                .iter()
605                .all(|(lmb, limits)| limits.iter().all(|(subset, _)| {
606                    let cycle = UVLimitSelection::cycle_union(&graph, lmb, subset);
607                    physical_cuts.iter().any(|cut| !cycle.intersects(cut))
608                }))
609        );
610        assert!(
611            cut_compatible_exhaustive.iter().any(|(lmb, limits)| {
612                limits.iter().any(|(subset, _)| {
613                    let cycle = UVLimitSelection::cycle_union(&graph, lmb, subset);
614                    cycle.intersects(&first_physical_cut) && !cycle.intersects(&second_physical_cut)
615                })
616            }),
617            "a limit compatible with one exact physical cut must not be rejected because it intersects another cut in the same residue group"
618        );
619    }
620
621    #[test]
622    fn candidate_lmb_sample_is_transformed_to_the_generation_lmb() {
623        let (graph, lmbs) = theta_graph();
624        let candidate = lmbs
625            .iter()
626            .find(|lmb| *lmb != &graph.loop_momentum_basis)
627            .unwrap();
628        let loop_moms: LoopMomenta<F<f64>> = [
629            ThreeMomentum::new(F(0.25), F(-0.5), F(0.75)),
630            ThreeMomentum::new(F(-0.125), F(0.375), F(0.625)),
631        ]
632        .into_iter()
633        .collect();
634        let sample = MomentumSample {
635            sample: BareMomentumSample {
636                loop_moms: loop_moms.clone(),
637                dual_loop_moms: None,
638                loop_mom_cache_id: 0,
639                loop_mom_base_cache_id: 0,
640                external_moms: Vec::new().into(),
641                external_mom_cache_id: 0,
642                external_mom_base_cache_id: 0,
643                jacobian: F(1.0),
644                orientation: None,
645                parameterization_branch: None,
646            },
647        };
648        let transformed = sample.lmb_transform(candidate, &graph.loop_momentum_basis);
649        let transformed = transformed.loop_moms();
650        let externals: ExternalThreeMomenta<F<f64>> = Vec::new().into();
651
652        for (_, edge_id, _) in graph.underlying.iter_edges() {
653            assert_eq!(
654                candidate.edge_signatures[edge_id].compute_momentum(&loop_moms, &externals),
655                graph.loop_momentum_basis.edge_signatures[edge_id]
656                    .compute_momentum(transformed, &externals)
657            );
658        }
659    }
660
661    #[test]
662    fn analysis_preserves_selected_graph_and_cut_identity() {
663        let profile = UVProfile {
664            stopped_early: false,
665            per_graph: vec![UVSamplingResult {
666                graph_index: 2,
667                graph_name: "GL2".to_string(),
668                cutkosky_cut: Some(vec![EdgeIndex::from(5), EdgeIndex::from(2)]),
669                per_lmb: Vec::new(),
670            }],
671            scales: Vec::new(),
672            allow_vanishing_missing_fits: true,
673        };
674
675        let analysis = profile.analyse();
676        let graph = &analysis.graphs[0];
677        assert_eq!(graph.graph_index, 2);
678        assert_eq!(graph.graph_name, "GL2");
679        assert_eq!(
680            graph.cutkosky_cut,
681            Some(vec![EdgeIndex::from(5), EdgeIndex::from(2)])
682        );
683    }
684
685    #[test]
686    fn pass_fail_display_lists_multiple_failures_with_context() {
687        let report = UVProfilePassFail {
688            max_dod: -0.9,
689            total: 4,
690            failed: 2,
691            failures: vec![
692                UVProfileFailure {
693                    graph_index: 2,
694                    graph_name: "GL2".to_string(),
695                    cutkosky_cut: Some(vec![EdgeIndex::from(5), EdgeIndex::from(2)]),
696                    lmb_index: 0,
697                    fixed: vec![EdgeIndex::from(1)],
698                    free: vec![EdgeIndex::from(3)],
699                    orientation_label: None,
700                    reason: "estimated DOD exceeds threshold".to_string(),
701                },
702                UVProfileFailure {
703                    graph_index: 0,
704                    graph_name: "GL0".to_string(),
705                    cutkosky_cut: None,
706                    lmb_index: 1,
707                    fixed: vec![EdgeIndex::from(4)],
708                    free: vec![EdgeIndex::from(6), EdgeIndex::from(7)],
709                    orientation_label: Some("+--+".to_string()),
710                    reason: "missing fit".to_string(),
711                },
712            ],
713        };
714
715        let rendered = report.to_string();
716        assert!(rendered.contains("UV limit tests:"));
717        assert!(rendered.contains("2/4"));
718        assert!(rendered.contains("GL2"));
719        assert!(rendered.contains("e5,e2"));
720        assert!(rendered.contains("estimated DOD exceeds threshold"));
721        assert!(rendered.contains("GL0"));
722        assert!(rendered.contains("+--+"));
723        assert!(rendered.contains("missing fit"));
724    }
725
726    #[test]
727    fn passing_orientations_do_not_hide_a_failing_summed_limit() {
728        let fit = |slope, estimated_dod| InspectAnalysis {
729            result: FitResult {
730                slope,
731                points: Vec::new(),
732                scales: Vec::new(),
733                fit_start: 0,
734                intercept: 0.0,
735                r_squared: 1.0,
736                full_range_slope: slope,
737                full_range_intercept: 0.0,
738                full_range_r_squared: 1.0,
739            },
740            estimated_dod,
741            used_arb_prec_retry: false,
742        };
743        let analysis = UVProfileAnalysis {
744            stopped_early: false,
745            scales: Vec::new(),
746            graphs: vec![UVProfileGraphAnalysis {
747                graph_index: 0,
748                graph_name: "GL0".to_string(),
749                cutkosky_cut: None,
750                lmbs: vec![UVProfileLmbAnalysis {
751                    lmb_index: 0,
752                    lmb_label: "generation LMB".to_string(),
753                    subsets: vec![UVProfileSubsetAnalysis {
754                        subset_index: 0,
755                        fixed: Vec::new(),
756                        free: vec![EdgeIndex::from(1)],
757                        initial_dod: 0,
758                        analysis: Analysis {
759                            inspect_level: Some(fit(0.0, 0)),
760                            inspect_fit_status: InspectFitStatus::default(),
761                            per_orientation_inspect: Some(vec![OrientationInspectAnalysis {
762                                orientation_label: "+-".to_string(),
763                                analysis: Some(fit(-1.0, -1)),
764                                inspect_fit_status: InspectFitStatus::default(),
765                            }]),
766                            analytic: None,
767                        },
768                        per_orientation_inspect_entries: Some(vec![
769                            super::UVProfileOrientationInspectEntry {
770                                orientation_label: "+-".to_string(),
771                                analysis: Some(fit(-1.0, -1)),
772                                inspect_fit_status: InspectFitStatus::default(),
773                            },
774                        ]),
775                        analytic_entries: None,
776                    }],
777                }],
778            }],
779            allow_vanishing_missing_fits: true,
780        };
781
782        let report = analysis.pass_fail(-0.9);
783        assert_eq!(report.total, 2);
784        assert_eq!(report.failed, 1);
785        assert_eq!(report.failures.len(), 1);
786        assert_eq!(report.failures[0].orientation_label, None);
787        assert_eq!(report.failures[0].reason, "dod_exceeds_threshold");
788        let subset = &analysis.graphs[0].lmbs[0].subsets[0].analysis;
789        assert_eq!(
790            subset.inspect_verdicts(-0.9, true).collect::<Vec<_>>(),
791            vec![(None, Some("dod_exceeds_threshold")), (Some("+-"), None)]
792        );
793
794        let scales = (1..=5).map(|power| 10.0_f64.powi(power)).collect_vec();
795        let status = |log_magnitudes: [Option<f64>; 5]| {
796            InspectFitStatus::from_results(&inspect_samples(log_magnitudes), &scales)
797        };
798        let mut vanishing = subset.clone();
799        vanishing.inspect_level = None;
800        vanishing.inspect_fit_status = status([None; 5]);
801        assert!(
802            vanishing
803                .inspect_verdicts(-0.9, true)
804                .all(|(_, reason)| reason.is_none())
805        );
806        assert_eq!(
807            vanishing.inspect_verdicts(-0.9, false).next(),
808            Some((None, Some("missing_fit")))
809        );
810        for invalid in [f64::NAN, f64::INFINITY] {
811            let samples = [0.0, invalid, invalid, invalid, invalid].map(|value| InspectResult {
812                magnitude: Some(F::<ArbPrec>::from_f64(value)),
813            });
814            let mut invalid_analysis = analysis.clone();
815            let subset = &mut invalid_analysis.graphs[0].lmbs[0].subsets[0].analysis;
816            subset.inspect_level = None;
817            subset.per_orientation_inspect = None;
818            subset.inspect_fit_status = InspectFitStatus::from_results(&samples, &scales);
819            let subset = &mut invalid_analysis.graphs[0].lmbs[0].subsets[0];
820            subset.per_orientation_inspect_entries =
821                subset.analysis.per_orientation_inspect_entries();
822            let report = invalid_analysis.pass_fail(-0.9);
823            assert_eq!(report.failed, 1);
824            assert_eq!(report.failures[0].reason, "missing_fit");
825        }
826
827        // Both tables must distinguish finite decay bounds from zero tails and from
828        // missing or unstable fits that fail the policy, including an empty sample set.
829        let mut unstable = fit(-3.0e8, -300_000_000);
830        unstable.result.r_squared = 0.9;
831        for (fitted, failure) in [(None, "missing_fit"), (Some(unstable), "unstable_fit")] {
832            for (status, tail, slope_cell) in [
833                (InspectFitStatus::default(), None, "-"),
834                (status([None; 5]), Some("zero tail"), "-"),
835                (
836                    status([Some(-3.0), None, None, None, None]),
837                    Some("zero tail"),
838                    "-",
839                ),
840                (
841                    status([Some(-3.0), Some(-30.0), None, None, None]),
842                    Some("decaying tail"),
843                    "≤ -27.000000",
844                ),
845                (
846                    status([
847                        Some(-3.0),
848                        Some(-6.0),
849                        Some(-12.0),
850                        Some(-24.0),
851                        Some(-48.0),
852                    ]),
853                    Some("decaying tail"),
854                    "≤ -3.000000",
855                ),
856                // Two nonzero samples which do not decay are no vanishing tail.
857                (
858                    status([Some(-3.0), Some(-3.0), None, None, None]),
859                    None,
860                    "-",
861                ),
862                (
863                    status([Some(-3.0), None, None, None, Some(-30.0)]),
864                    None,
865                    "-",
866                ),
867            ] {
868                for allow in [false, true] {
869                    let mut displayed = analysis.clone();
870                    displayed.allow_vanishing_missing_fits = allow;
871                    let subset = &mut displayed.graphs[0].lmbs[0].subsets[0].analysis;
872                    subset.inspect_level = fitted.clone();
873                    subset.inspect_fit_status = status;
874                    let orientation = &mut subset.per_orientation_inspect.as_mut().unwrap()[0];
875                    orientation.analysis = fitted.clone();
876                    orientation.inspect_fit_status = status;
877                    let subset = &mut displayed.graphs[0].lmbs[0].subsets[0];
878                    subset.per_orientation_inspect_entries =
879                        subset.analysis.per_orientation_inspect_entries();
880                    let accepted = allow && tail.is_some();
881                    assert_eq!(
882                        displayed.pass_fail(-0.9).failed,
883                        if accepted { 0 } else { 2 }
884                    );
885                    let cells = inspect_cells(fitted.as_ref(), status, -0.9, allow);
886                    if accepted {
887                        assert!(cells[0].contains(slope_cell));
888                        assert_eq!(&cells[1..3], &["-", "-"]);
889                    }
890                    if let Some(tail) = tail {
891                        let label = if accepted {
892                            tail.to_string()
893                        } else {
894                            format!("{failure} ({tail})")
895                        };
896                        assert!(cells[3].contains(&label));
897                    }
898                    let tables = displayed.tables_per_graph(-0.9).into_iter().chain(
899                        displayed
900                            .per_orientation_tables_per_graph(-0.9)
901                            .into_iter()
902                            .flatten(),
903                    );
904                    for table in tables {
905                        let rendered = table.to_string();
906                        assert!(!rendered.contains("-∞"));
907                        assert_eq!(rendered.contains(failure), !accepted);
908                        assert_eq!(rendered.contains("zero tail"), tail == Some("zero tail"));
909                        assert_eq!(
910                            rendered.contains("decaying tail"),
911                            tail == Some("decaying tail")
912                        );
913                    }
914                }
915            }
916        }
917
918        // An asymptotic power law takes precedence over a decaying envelope.
919        let mut power_law = analysis.clone();
920        let subset = &mut power_law.graphs[0].lmbs[0].subsets[0].analysis;
921        subset.inspect_level = Some(fit(-3.0, -3));
922        subset.inspect_fit_status = status([
923            Some(-3.0),
924            Some(-6.0),
925            Some(-12.0),
926            Some(-24.0),
927            Some(-48.0),
928        ]);
929        subset.per_orientation_inspect = None;
930        let subset = &mut power_law.graphs[0].lmbs[0].subsets[0];
931        subset.per_orientation_inspect_entries = subset.analysis.per_orientation_inspect_entries();
932        assert_eq!(power_law.pass_fail(-0.9).failed, 0);
933        let rendered = power_law.tables_per_graph(-0.9)[0].to_string();
934        assert!(rendered.contains("-3.000000"));
935        assert!(!rendered.contains("-∞"));
936        assert!(!rendered.contains("zero tail"));
937        assert!(!rendered.contains("decaying tail"));
938
939        // Rounded DOD -1 is not sufficient: the unrounded slope and fit quality
940        // determine the verdict, and precision-retry provenance remains visible.
941        for (slope, r_squared, failure) in [
942            (-0.8, 1.0, Some("dod_exceeds_threshold")),
943            (-1.0, 1.0, None),
944            (-1.0, 0.98, Some("unstable_fit")),
945            (-1.0, f64::INFINITY, Some("unstable_fit")),
946        ] {
947            let mut displayed = analysis.clone();
948            let subset = &mut displayed.graphs[0].lmbs[0].subsets[0].analysis;
949            let mut fitted = fit(slope, -1);
950            fitted.result.r_squared = r_squared;
951            fitted.used_arb_prec_retry = true;
952            subset.inspect_level = Some(fitted.clone());
953            subset.per_orientation_inspect.as_mut().unwrap()[0].analysis = Some(fitted);
954            let subset = &mut displayed.graphs[0].lmbs[0].subsets[0];
955            subset.per_orientation_inspect_entries =
956                subset.analysis.per_orientation_inspect_entries();
957            let report = displayed.pass_fail(-0.9);
958            assert_eq!(report.failed, if failure.is_some() { 2 } else { 0 });
959            let tables = displayed.tables_per_graph(-0.9).into_iter().chain(
960                displayed
961                    .per_orientation_tables_per_graph(-0.9)
962                    .into_iter()
963                    .flatten(),
964            );
965            for table in tables {
966                let rendered = table.to_string();
967                assert!(rendered.contains("arb retry"));
968                if let Some(reason) = failure {
969                    assert!(rendered.contains(reason));
970                } else {
971                    assert!(!rendered.contains("unstable_fit"));
972                    assert!(!rendered.contains("dod_exceeds_threshold"));
973                }
974            }
975        }
976    }
977
978    #[test]
979    fn profile_rejects_unrepresentable_scale_ranges() {
980        use super::{ProfileSettings, UVProfileable};
981        use crate::processes::{Amplitude, CrossSection};
982        use crate::utils::load_generic_model;
983
984        test_initialise().unwrap();
985        let model = load_generic_model("scalars");
986        for (min_scale_exponent, max_scale_exponent) in [(-400.0, -350.0), (300.0, 400.0)] {
987            let settings = ProfileSettings {
988                min_scale_exponent,
989                max_scale_exponent,
990                ..Default::default()
991            };
992            for result in [
993                Amplitude::new("invalid_profile_scales").profile(&model, &settings),
994                CrossSection::new("invalid_profile_scales".into()).profile(&model, &settings),
995            ] {
996                assert!(
997                    result
998                        .err()
999                        .unwrap()
1000                        .to_string()
1001                        .contains("finite positive scales")
1002                );
1003            }
1004        }
1005    }
1006
1007    #[test]
1008    fn uv_profile_json_round_trip_preserves_pass_fail_policy_and_fit_status() {
1009        let scales = logspace(0.0, 1.0, 25, 10.0);
1010        let fit_status = InspectFitStatus::from_results(&inspect_samples([None; 25]), &scales);
1011        let analysis = Analysis {
1012            inspect_level: None,
1013            inspect_fit_status: fit_status,
1014            per_orientation_inspect: Some(vec![OrientationInspectAnalysis {
1015                orientation_label: "orientation_0".to_string(),
1016                analysis: None,
1017                inspect_fit_status: fit_status,
1018            }]),
1019            analytic: None,
1020        };
1021        let per_orientation_inspect_entries = analysis.per_orientation_inspect_entries();
1022        let profile = UVProfileAnalysis {
1023            stopped_early: false,
1024            scales,
1025            graphs: vec![UVProfileGraphAnalysis {
1026                graph_index: 0,
1027                graph_name: "GL0".to_string(),
1028                cutkosky_cut: None,
1029                lmbs: vec![UVProfileLmbAnalysis {
1030                    lmb_index: 0,
1031                    lmb_label: "loop_edges=[e0]".to_string(),
1032                    subsets: vec![UVProfileSubsetAnalysis {
1033                        subset_index: 0,
1034                        fixed: Vec::new(),
1035                        free: vec![EdgeIndex(0)],
1036                        initial_dod: 0,
1037                        analysis,
1038                        per_orientation_inspect_entries,
1039                        analytic_entries: None,
1040                    }],
1041                }],
1042            }],
1043            allow_vanishing_missing_fits: true,
1044        };
1045
1046        let before = profile.pass_fail(-0.9);
1047        let encoded = serde_json::to_string(&profile).unwrap();
1048        let decoded: UVProfileAnalysis = serde_json::from_str(&encoded).unwrap();
1049        let after = decoded.pass_fail(-0.9);
1050
1051        assert_eq!(before.total, 2);
1052        assert_eq!(before.failed, 0);
1053        assert_eq!(after.total, before.total);
1054        assert_eq!(after.failed, before.failed);
1055        assert!(decoded.allow_vanishing_missing_fits);
1056        // Saved profiles render residues from the serialized entries.
1057        let table = decoded.per_orientation_tables_per_graph(-0.9)[0]
1058            .as_ref()
1059            .expect("a reloaded profile retains its orientation table")
1060            .to_string();
1061        assert!(table.contains("orientation_0") && table.contains("zero tail"));
1062        assert!(!table.contains("-∞"));
1063
1064        for (decay_slope, expected) in [
1065            (Some(f64::NEG_INFINITY), serde_json::json!("-inf")),
1066            (Some(-3.0), serde_json::json!(-3.0)),
1067            (None, serde_json::Value::Null),
1068        ] {
1069            let status = InspectFitStatus { decay_slope };
1070            let encoded = serde_json::to_value(status).unwrap();
1071            assert_eq!(encoded["decay_slope"], expected);
1072            let decoded: InspectFitStatus = serde_json::from_value(encoded).unwrap();
1073            assert_eq!(decoded.decay_slope, decay_slope);
1074        }
1075        assert!(
1076            serde_json::from_str::<InspectFitStatus>("{}")
1077                .unwrap()
1078                .decay_slope
1079                .is_none()
1080        );
1081        for invalid in ["inf", "NaN", "-Infinity", "-3.0", "unknown"] {
1082            assert!(
1083                serde_json::from_value::<InspectFitStatus>(serde_json::json!({
1084                    "decay_slope": invalid,
1085                }))
1086                .is_err()
1087            );
1088        }
1089        for invalid in [f64::INFINITY, f64::NAN] {
1090            assert!(
1091                serde_json::to_string(&InspectFitStatus {
1092                    decay_slope: Some(invalid),
1093                })
1094                .is_err()
1095            );
1096        }
1097    }
1098
1099    #[test]
1100    fn fixed_uv_ray_repeats_single_direction_and_norm() {
1101        for component in [2.0, f64::MAX, f64::from_bits(1)] {
1102            let ray =
1103                UVProfileFixedRay::from_flat_components(&[0.0, 0.0, component], &[3.0]).unwrap();
1104            let sample = ray.sample(2).unwrap();
1105
1106            assert_eq!(sample.len(), 2);
1107            for momentum in &sample {
1108                assert_eq!(momentum.px.0, 0.0);
1109                assert_eq!(momentum.py.0, 0.0);
1110                assert_eq!(momentum.pz.0, 3.0);
1111            }
1112        }
1113    }
1114
1115    #[test]
1116    fn fixed_uv_ray_rejects_invalid_input_shapes() {
1117        assert!(UVProfileFixedRay::from_flat_components(&[1.0, 0.0], &[1.0]).is_err());
1118        assert!(UVProfileFixedRay::from_flat_components(&[0.0, 0.0, 0.0], &[1.0]).is_err());
1119        assert!(UVProfileFixedRay::from_flat_components(&[1.0, 0.0, 0.0], &[0.0]).is_err());
1120        for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1121            assert!(UVProfileFixedRay::from_flat_components(&[invalid, 0.0, 0.0], &[1.0]).is_err());
1122            assert!(UVProfileFixedRay::from_flat_components(&[1.0, 0.0, 0.0], &[invalid]).is_err());
1123        }
1124    }
1125
1126    #[test]
1127    fn uv_fit_retries_low_quality_double_results_before_using_an_asymptotic_tail() {
1128        for normalization in [1.0e200, 1.0e-200] {
1129            let scales: Vec<_> = (1..=5).map(|power| 10.0_f64.powi(power)).collect();
1130            let samples: Vec<_> = scales
1131                .iter()
1132                .map(|scale| InspectResult {
1133                    magnitude: Some(F::<ArbPrec>::from_f64(normalization / scale)),
1134                })
1135                .collect();
1136            let fit = log_log_slope(&samples, &scales).unwrap();
1137            assert!((fit.slope + 1.0).abs() < 1.0e-12);
1138            assert!(fit.r_squared > 0.99);
1139        }
1140        let scales = (0..17)
1141            .map(|i| 10.0_f64.powf(4.0 + 0.25 * i as f64))
1142            .collect_vec();
1143        let double_with_interior_outlier = scales
1144            .iter()
1145            .enumerate()
1146            .map(|(i, scale)| {
1147                let magnitude = if i == 7 {
1148                    scale.recip() * 1.0e10
1149                } else {
1150                    scale.recip()
1151                };
1152                InspectResult {
1153                    magnitude: Some(F::<ArbPrec>::from_f64(magnitude)),
1154                }
1155            })
1156            .collect_vec();
1157
1158        let fit = log_log_slope(&double_with_interior_outlier, &scales).unwrap();
1159        assert!((fit.slope + 1.0).abs() < 1.0e-12);
1160        assert!(fit.full_range_r_squared < 0.9);
1161        assert!(inspect_results_need_arbprec_retry(
1162            &double_with_interior_outlier,
1163            &scales
1164        ));
1165
1166        // A fit can clear the coarse full-range retry gate while still lacking a trustworthy
1167        // asymptotic suffix. Retry that case as well instead of reporting the Double fit as an
1168        // unstable physical limit.
1169        let double_with_distributed_noise = scales
1170            .iter()
1171            .enumerate()
1172            .map(|(i, scale)| {
1173                let log_noise = if i % 2 == 0 { 0.2 } else { -0.2 };
1174                InspectResult {
1175                    magnitude: Some(F::<ArbPrec>::from_f64(
1176                        scale.recip() * 10.0_f64.powf(log_noise),
1177                    )),
1178                }
1179            })
1180            .collect_vec();
1181        let fit = log_log_slope(&double_with_distributed_noise, &scales).unwrap();
1182        assert!(fit.full_range_r_squared > UV_PROFILE_RETRY_MIN_R_SQUARED);
1183        assert!(fit.r_squared < UV_PROFILE_ASYMPTOTIC_MIN_R_SQUARED);
1184        assert!(inspect_results_need_arbprec_retry(
1185            &double_with_distributed_noise,
1186            &scales
1187        ));
1188
1189        // Arb evaluation of an actual banana ray has one pre-asymptotic point followed by a
1190        // clean asymptotic suffix. These logarithms are the retained regression data from that
1191        // ray; no precision retry is required once the full-precision samples are available.
1192        let arb_log_magnitudes = [
1193            -25.361635842,
1194            -24.003219677,
1195            -24.055611693,
1196            -24.224420479,
1197            -24.434654742,
1198            -24.663795182,
1199            -24.902490667,
1200            -25.146260599,
1201            -25.392796041,
1202            -25.640859851,
1203            -25.889774837,
1204            -26.139165860,
1205            -26.388823797,
1206            -26.638631544,
1207            -26.888523551,
1208            -27.138462785,
1209            -27.388428677,
1210        ];
1211        let arb_transient = arb_log_magnitudes
1212            .into_iter()
1213            .map(|log_magnitude| InspectResult {
1214                magnitude: Some(F::<ArbPrec>::from_f64(10.0_f64.powf(log_magnitude))),
1215            })
1216            .collect_vec();
1217        let fit = log_log_slope(&arb_transient, &scales).unwrap();
1218        assert!((fit.slope + 0.945490).abs() < 1.0e-5);
1219        assert!(fit.r_squared > 0.99);
1220        assert!(fit.full_range_r_squared < 0.9);
1221    }
1222
1223    #[test]
1224    fn uv_fit_preserves_native_range_through_weighting_and_logarithms() {
1225        let one = F::<ArbPrec>::default().one();
1226        let ten = one.from_usize(10);
1227        // Cover a raw underflow promoted into f64's normal range, magnitudes
1228        // outside f64 even after weighting, and a weight exceeding f64's range.
1229        for (normalization_power, first_scale_power, slope) in [
1230            (-280, 8, -1),
1231            (-400, 8, -1),
1232            (400, 8, -1),
1233            (-280, 60, -1),
1234            (-400, 8, 3),
1235        ] {
1236            let scales = (first_scale_power..first_scale_power + 5)
1237                .map(|power| 10.0_f64.powi(power))
1238                .collect_vec();
1239            for select_cut in [false, true] {
1240                let samples = scales
1241                    .iter()
1242                    .map(|scale| {
1243                        let scale = F(*scale).to_arb_exact().unwrap();
1244                        let raw = ten.powi(normalization_power) * scale.powi(slope - 6);
1245                        let weight = scale.powi(6);
1246                        let weighted = &raw * &weight;
1247                        let mut events = GenericEventGroupList::default();
1248                        // Cut projection must retain raw values until the measure is
1249                        // applied, exactly once, as for the unprojected total.
1250                        for (cut_id, value) in [(0, raw.clone()), (1, ten.powi(500))] {
1251                            let mut event = GenericEvent::default();
1252                            event.cut_info.cut_id = cut_id;
1253                            event.weight = Complex::new_im(value);
1254                            events.push_singleton(event);
1255                        }
1256                        let result = GenericEvaluationResult {
1257                            reference_moments: None,
1258                            integrand_result: Complex::new_im(raw),
1259                            absolute_integrand_result: None,
1260                            parameterization_jacobian: None,
1261                            integrator_weight: one.clone(),
1262                            event_groups: events,
1263                            evaluation_metadata: EvaluationMetaData::new_empty(),
1264                        };
1265                        let selected = [CutId(0)];
1266                        let sample = InspectResult::from_evaluation(
1267                            result,
1268                            &weight,
1269                            select_cut.then_some(selected.as_slice()),
1270                        );
1271                        assert_eq!(sample.magnitude, Some(weighted));
1272                        sample
1273                    })
1274                    .collect_vec();
1275                let fit = log_log_slope(&samples, &scales).unwrap();
1276                assert!((fit.slope - f64::from(slope)).abs() < 1.0e-10);
1277                assert!(fit.r_squared > 0.999);
1278                assert_eq!(fit.points.len(), scales.len());
1279                // No magnitude may be mistaken for an exact zero.
1280                assert!(
1281                    InspectFitStatus::from_results(&samples, &scales)
1282                        .decay_slope
1283                        .is_none_or(f64::is_finite)
1284                );
1285                assert_eq!(
1286                    inspect_results_need_arbprec_retry(&samples, &scales),
1287                    slope > 0
1288                );
1289                let json = serde_json::to_value(&fit).unwrap();
1290                for (encoded, point) in json["points"].as_array().unwrap().iter().zip(&fit.points) {
1291                    let decoded: ArbPrec = encoded.as_str().unwrap().parse().unwrap();
1292                    assert_eq!(F(decoded), *point);
1293                }
1294            }
1295        }
1296    }
1297
1298    // Arb's default exponent range ends at 2^(1 - 2^30).
1299    const ARB_LOG10_MIN: f64 = -1_073_741_823.0 * std::f64::consts::LOG10_2;
1300
1301    /// `log10(λ^2 exp(-cλ))` along a ray, with exact zeros where Arb underflows.
1302    fn exponential_tail(scales: &[f64], c: f64) -> Vec<Option<f64>> {
1303        scales
1304            .iter()
1305            .map(|scale| {
1306                let log_magnitude = 2.0 * scale.log10() - c * scale / std::f64::consts::LN_10;
1307                (log_magnitude > ARB_LOG10_MIN).then_some(log_magnitude)
1308            })
1309            .collect()
1310    }
1311
1312    #[test]
1313    fn uv_profile_accepts_accelerating_and_zero_tails_without_a_power_law_fit() {
1314        let default_scales = logspace(3.0, 6.0, 20, 10.0);
1315        let test_scales = logspace(8.0, 12.0, 33, 10.0);
1316        // Arb evaluations of the vacuum-subtracted thermal sunrise, both scaled loops on one
1317        // orientation. Its leading occupation cancels; Arb leaves a remainder about 2^-1000
1318        // below it, except at the dips where the cancellation is exact and the smaller, true
1319        // occupation shows. The high-scale ray then underflows.
1320        let thermal_sunrise_default = [
1321            -899.227516,
1322            -1170.753779,
1323            -1556.011907,
1324            -2109.854372,
1325            -3872.079841,
1326            -4053.166454,
1327            -8022.751403,
1328            -11545.281750,
1329            -11489.702032,
1330            -16399.893364,
1331            -23464.327260,
1332            -33626.231335,
1333            -48243.088624,
1334            -69269.163662,
1335            -99515.050667,
1336            -143021.156329,
1337            -205603.701363,
1338            -295625.753875,
1339            -425117.913175,
1340            -907059.249793,
1341        ]
1342        .map(Some);
1343        let thermal_sunrise_test = [
1344            -90707996.511144,
1345            -81492191.133152,
1346            -108671490.984227,
1347            -144915669.453068,
1348            -286843931.683495,
1349            -257700336.248771,
1350        ]
1351        .into_iter()
1352        .map(Some)
1353        .pad_using(test_scales.len(), |_| None)
1354        .collect_vec();
1355
1356        for (log_magnitudes, scales, zeros) in [
1357            (exponential_tail(&default_scales, 1.0), &default_scales, 0),
1358            (exponential_tail(&default_scales, 0.01), &default_scales, 0),
1359            // Underflow beyond the minimal asymptotic window, and inside it.
1360            (exponential_tail(&test_scales, 1.0), &test_scales, 26),
1361            (exponential_tail(&test_scales, 0.03), &test_scales, 13),
1362            (thermal_sunrise_default.to_vec(), &default_scales, 0),
1363            (thermal_sunrise_test.clone(), &test_scales, 27),
1364        ] {
1365            assert_eq!(
1366                log_magnitudes
1367                    .iter()
1368                    .filter(|value| value.is_none())
1369                    .count(),
1370                zeros
1371            );
1372            let samples = inspect_samples(log_magnitudes.iter().copied());
1373            let fit = analyse_inspect_results(&samples, scales, true).unwrap();
1374            assert!(!fit.result.is_asymptotic());
1375            assert!(inspect_results_need_arbprec_retry(&samples, scales));
1376            assert_eq!(
1377                uv_ray_verdict(&log_magnitudes, scales, false),
1378                Some("unstable_fit")
1379            );
1380            assert_eq!(uv_ray_verdict(&log_magnitudes, scales, true), None);
1381        }
1382
1383        // Exact zeros still settle a rising last step of the noisy ray. Without them, that step
1384        // could begin a tail which flattens or diverges, so the ray stays unresolved.
1385        let truncated = &thermal_sunrise_test[..6];
1386        assert_eq!(
1387            uv_ray_verdict(truncated, &test_scales[..6], true),
1388            Some("unstable_fit")
1389        );
1390    }
1391
1392    #[test]
1393    fn uv_profile_requires_accelerating_decay_or_a_zero_tail() {
1394        let scales = logspace(3.0, 6.0, 20, 10.0);
1395        let log_scales = scales.iter().map(|scale| scale.log10()).collect_vec();
1396        let power_law = |power: f64| {
1397            log_scales
1398                .iter()
1399                .map(|log_scale| Some(power * log_scale - 5.0))
1400                .collect_vec()
1401        };
1402        let alternating = |values: Vec<Option<f64>>, amplitude: f64| {
1403            values
1404                .into_iter()
1405                .enumerate()
1406                .map(|(i, value)| {
1407                    value.map(|value| value + if i % 2 == 0 { amplitude } else { -amplitude })
1408                })
1409                .collect_vec()
1410        };
1411        // A tail dominated at high scales by `floor + power * log10(λ)`.
1412        let tail_into = |power: f64, floor: f64| {
1413            exponential_tail(&scales, 1.0)
1414                .into_iter()
1415                .zip(&log_scales)
1416                .map(|(tail, log_scale)| {
1417                    let (tail, power_law) = (tail.unwrap(), floor + power * log_scale);
1418                    let leading = tail.max(power_law);
1419                    Some(leading + (1.0 + 10.0_f64.powf(-(tail - power_law).abs())).log10())
1420                })
1421                .collect_vec()
1422        };
1423        let status = |log_magnitudes: &[Option<f64>]| {
1424            InspectFitStatus::from_results(
1425                &inspect_samples(log_magnitudes.iter().copied()),
1426                &scales,
1427            )
1428        };
1429
1430        // Power laws are fitted, never accepted as vanishing samples.
1431        for (power, failure) in [
1432            (1.0, Some("dod_exceeds_threshold")),
1433            (-0.5, Some("dod_exceeds_threshold")),
1434            (0.0, Some("unstable_fit")),
1435            (-3.0, None),
1436        ] {
1437            let log_magnitudes = power_law(power);
1438            assert_eq!(uv_ray_verdict(&log_magnitudes, &scales, true), failure);
1439            assert_eq!(uv_ray_verdict(&log_magnitudes, &scales, false), failure);
1440        }
1441
1442        // Meeting max_dod cannot excuse an unstable ordinary power law. Neither slight
1443        // curvature nor one steep final step establishes sustained accelerating decay.
1444        let noisy_power_law = alternating(power_law(-3.0), 1.0);
1445        let tiny_curvature = noisy_power_law
1446            .iter()
1447            .zip(&log_scales)
1448            .map(|(value, log_scale)| value.map(|value| value - 0.001 * log_scale.powi(2)))
1449            .collect_vec();
1450        let mut final_drop = noisy_power_law.clone();
1451        *final_drop.last_mut().unwrap() = final_drop.last().unwrap().map(|value| value - 100.0);
1452        for log_magnitudes in [noisy_power_law, tiny_curvature, final_drop] {
1453            let samples = inspect_samples(log_magnitudes.iter().copied());
1454            let fit = analyse_inspect_results(&samples, &scales, false).unwrap();
1455            assert!(fit.result.slope <= UV_PROFILE_MAX_DOD);
1456            assert!(!fit.result.is_asymptotic());
1457            assert!(status(&log_magnitudes).decay_slope.is_none());
1458            for allow in [false, true] {
1459                assert_eq!(
1460                    uv_ray_verdict(&log_magnitudes, &scales, allow),
1461                    Some("unstable_fit")
1462                );
1463            }
1464        }
1465
1466        let mut misplaced_zero = exponential_tail(&scales, 1.0);
1467        misplaced_zero[15] = None;
1468        for log_magnitudes in [
1469            // Noise around a flat or divergent trend.
1470            alternating(power_law(0.0), 0.3),
1471            alternating(power_law(-0.5), 0.3),
1472            // Decay which turns into a divergence or a noise floor at high scales.
1473            tail_into(1.0, -20_000.0),
1474            tail_into(-0.5, -20_000.0),
1475            alternating(tail_into(0.0, -20_000.0), 0.3),
1476            // A zero inside the asymptotic window is no underflow unless only zeros follow it.
1477            misplaced_zero,
1478        ] {
1479            assert!(!status(&log_magnitudes).vanishes_without_power_law(None, UV_PROFILE_MAX_DOD));
1480            assert!(uv_ray_verdict(&log_magnitudes, &scales, true).is_some());
1481        }
1482
1483        // A sparse suffix must reach zero at the endpoint. A late nonzero sample
1484        // can revive a rising ray even when every earlier sample in its suffix is zero.
1485        for sample_count in [5, 15] {
1486            let scales = logspace(3.0, 6.0, sample_count, 10.0);
1487            for final_magnitude in [-100.0, -10.0, 10.0] {
1488                let mut log_magnitudes = vec![None; sample_count];
1489                log_magnitudes[sample_count - 1] = Some(final_magnitude);
1490                let samples = inspect_samples(log_magnitudes.iter().copied());
1491                assert!(
1492                    InspectFitStatus::from_results(&samples, &scales)
1493                        .decay_slope
1494                        .is_none()
1495                );
1496                for allow in [false, true] {
1497                    assert_eq!(
1498                        uv_ray_verdict(&log_magnitudes, &scales, allow),
1499                        Some("missing_fit")
1500                    );
1501                }
1502
1503                for (value, scale) in log_magnitudes
1504                    .iter_mut()
1505                    .zip(&scales)
1506                    .take(sample_count / 2)
1507                {
1508                    *value = Some(0.5 * scale.log10());
1509                }
1510                let samples = inspect_samples(log_magnitudes.iter().copied());
1511                assert!(
1512                    InspectFitStatus::from_results(&samples, &scales)
1513                        .decay_slope
1514                        .is_none()
1515                );
1516                for allow in [false, true] {
1517                    assert_eq!(
1518                        uv_ray_verdict(&log_magnitudes, &scales, allow),
1519                        Some("unstable_fit")
1520                    );
1521                }
1522            }
1523
1524            // Inactive rays and an isolated middle sample followed by zeros retain
1525            // the zero-tail exemption, without manufacturing a power-law fit.
1526            for middle_magnitude in [None, Some(-100.0), Some(10.0)] {
1527                let mut log_magnitudes = vec![None; sample_count];
1528                log_magnitudes[sample_count / 2] = middle_magnitude;
1529                let samples = inspect_samples(log_magnitudes.iter().copied());
1530                assert_eq!(
1531                    InspectFitStatus::from_results(&samples, &scales).decay_slope,
1532                    Some(f64::NEG_INFINITY)
1533                );
1534                assert_eq!(uv_ray_verdict(&log_magnitudes, &scales, true), None);
1535                assert_eq!(
1536                    uv_ray_verdict(&log_magnitudes, &scales, false),
1537                    Some("missing_fit")
1538                );
1539            }
1540        }
1541    }
1542
1543    #[test]
1544    fn uv_fit_json_excludes_raw_lu_evaluation_results() {
1545        let scales = [1.0e3, 1.0e4, 1.0e5, 1.0e6, 1.0e7];
1546        let inspect = scales
1547            .iter()
1548            .map(|scale| {
1549                let one = F::<ArbPrec>::default().one();
1550                let mut result = GenericEvaluationResult {
1551                    reference_moments: None,
1552                    integrand_result: Complex::new_re(F::<ArbPrec>::from_f64(1.0 / scale)),
1553                    absolute_integrand_result: None,
1554                    parameterization_jacobian: None,
1555                    integrator_weight: one.clone(),
1556                    event_groups: GenericEventGroupList::default(),
1557                    evaluation_metadata: EvaluationMetaData::new_empty(),
1558                };
1559                let mut event = GenericEvent::default();
1560                event.additional_weights.weights.insert(
1561                    AdditionalWeightKey::ThresholdCounterterm { subset_index: 0 },
1562                    Complex::new_re(one),
1563                );
1564                result.event_groups.push_singleton(event);
1565                InspectResult::from_evaluation(result, &F::<ArbPrec>::default().one(), None)
1566            })
1567            .collect_vec();
1568
1569        let analysis = super::analyse_inspect_results(&inspect, &scales, false).unwrap();
1570        let json = serde_json::to_value(analysis)
1571            .expect("UV fit JSON must not include raw LU evaluations with non-string map keys");
1572        assert_eq!(
1573            json["result"]["points"].as_array().unwrap().len(),
1574            scales.len()
1575        );
1576        assert_eq!(json["result"]["scales"], serde_json::json!(scales));
1577        assert_eq!(json["result"]["fit_start"], 0);
1578        assert!(json["result"].get("full_range_slope").is_some());
1579        assert!(json["result"].get("full_range_intercept").is_some());
1580        assert!(json["result"].get("full_range_r_squared").is_some());
1581        assert!(json["result"].get("points_detail").is_none());
1582    }
1583
1584    #[test]
1585    fn nested_soft_fixed_e3_is_a_direct_candidate_coordinate() {
1586        test_initialise().unwrap();
1587        let graph: Graph = include_str!(
1588            "../../../../tests/resources/graphs/local_nested_soft_top_self_energy.dot"
1589        )
1590        .into_graph(&load_generic_model("sm"))
1591        .unwrap();
1592        let candidate = graph
1593            .generate_loop_momentum_bases()
1594            .into_iter()
1595            .find(|lmb| lmb.loop_edges.iter().map(|edge| edge.0).eq([3, 6]))
1596            .expect("nested fixture must expose candidate LMB [e3,e6]");
1597        let fixed_e3_signature = &candidate.edge_signatures[3.into()];
1598
1599        assert_eq!(
1600            fixed_e3_signature
1601                .internal
1602                .iter()
1603                .copied()
1604                .collect::<Vec<_>>(),
1605            vec![
1606                crate::momentum::SignOrZero::Plus,
1607                crate::momentum::SignOrZero::Zero
1608            ],
1609        );
1610        assert!(
1611            fixed_e3_signature
1612                .external
1613                .iter()
1614                .all(crate::momentum::SignOrZero::is_zero),
1615            "the fixed e3 momentum must not be reconstructed by cancellation or an external shift",
1616        );
1617    }
1618
1619    #[test]
1620    fn candidate_lmb_affine_shift_is_applied_after_precision_cast() {
1621        test_initialise().unwrap();
1622        let graph: Graph = include_str!(
1623            "../../../../tests/resources/graphs/local_nested_soft_top_self_energy.dot"
1624        )
1625        .into_graph(&load_generic_model("sm"))
1626        .unwrap();
1627        let candidate = graph
1628            .generate_loop_momentum_bases()
1629            .into_iter()
1630            .find(|lmb| lmb.loop_edges.iter().map(|edge| edge.0).eq([3, 6]))
1631            .expect("nested fixture must expose candidate LMB [e3,e6]");
1632        assert_eq!(
1633            graph
1634                .loop_momentum_basis
1635                .loop_edges
1636                .iter()
1637                .map(|edge| edge.0)
1638                .collect::<Vec<_>>(),
1639            vec![2, 3],
1640        );
1641
1642        let e2_signature = &candidate.edge_signatures[EdgeIndex(2)];
1643        let e6_slot = candidate
1644            .loop_edges
1645            .iter_enumerated()
1646            .find_map(|(slot, edge)| (*edge == EdgeIndex(6)).then_some(slot))
1647            .unwrap();
1648        let loop_sign = e2_signature.internal[e6_slot];
1649        let (external_slot, external_sign) = e2_signature
1650            .external
1651            .iter()
1652            .copied()
1653            .enumerate()
1654            .find(|(_, sign)| sign.is_sign())
1655            .expect("the [e3,e6] -> [e2,e3] route must contain an external shift");
1656
1657        let large = 2_f64.powi(53);
1658        let candidate_momenta: LoopMomenta<F<f64>> = candidate
1659            .loop_edges
1660            .iter()
1661            .map(|edge| {
1662                let px = if *edge == EdgeIndex(6) { large } else { 0.0 };
1663                ThreeMomentum::new(F(px), F(0.0), F(0.0))
1664            })
1665            .collect();
1666        let mut externals = (0..candidate.ext_edges.len())
1667            .map(|_| ThreeMomentum::new(F(0.0), F(0.0), F(0.0)))
1668            .collect::<crate::momentum::sample::ExternalThreeMomenta<_>>();
1669        externals[crate::momentum::sample::ExternalIndex::from(external_slot)].px =
1670            F(if external_sign == loop_sign {
1671                1.0
1672            } else {
1673                -1.0
1674            });
1675
1676        let canonical_in_f64 =
1677            candidate_momenta.lmb_transform(&candidate, &graph.loop_momentum_basis, &externals);
1678        let externals_arb = externals
1679            .iter()
1680            .map(|momentum| {
1681                ThreeMomentum::new(
1682                    F::<ArbPrec>::from_ff64(momentum.px),
1683                    F::<ArbPrec>::from_ff64(momentum.py),
1684                    F::<ArbPrec>::from_ff64(momentum.pz),
1685                )
1686            })
1687            .collect::<crate::momentum::sample::ExternalThreeMomenta<_>>();
1688        let old_routed_e6: ThreeMomentum<F<ArbPrec>> = graph.loop_momentum_basis.edge_signatures
1689            [EdgeIndex(6)]
1690        .compute_momentum(&canonical_in_f64.cast::<ArbPrec>(), &externals_arb);
1691
1692        let canonical_in_arb = candidate_momenta.cast::<ArbPrec>().lmb_transform(
1693            &candidate,
1694            &graph.loop_momentum_basis,
1695            &externals_arb,
1696        );
1697        let precisely_routed_e6: ThreeMomentum<F<ArbPrec>> =
1698            graph.loop_momentum_basis.edge_signatures[EdgeIndex(6)]
1699                .compute_momentum(&canonical_in_arb, &externals_arb);
1700        let expected = F::<ArbPrec>::from_f64(large);
1701
1702        assert_ne!(
1703            old_routed_e6.px, expected,
1704            "the oracle must expose the f64 affine-route rounding loss",
1705        );
1706        assert_eq!(
1707            precisely_routed_e6.px, expected,
1708            "the candidate LMB must be cast before its affine transform",
1709        );
1710    }
1711}
1712
1713pub fn logspace(start: f64, stop: f64, num: usize, base: f64) -> Vec<f64> {
1714    let log_start = start;
1715    let log_stop = stop;
1716    let step = (log_stop - log_start) / (num - 1) as f64;
1717
1718    (0..num)
1719        .map(|i| {
1720            let exponent = log_start + step * i as f64;
1721            base.powf(exponent)
1722        })
1723        .collect()
1724}
1725
1726fn lmb_seed(base_seed: u64, graph_id: usize, lmb_index: usize) -> u64 {
1727    base_seed
1728        .wrapping_add((graph_id as u64).wrapping_mul(0x9E3779B97F4A7C15))
1729        .wrapping_add(lmb_index as u64)
1730}
1731
1732struct UVProfileRunner<'a> {
1733    integrand: &'a Arc<Mutex<ProcessIntegrand>>,
1734    scales: &'a [f64],
1735    model: &'a Model,
1736    settings: &'a RuntimeSettings,
1737    profile_settings: &'a ProfileSettings,
1738    base_seed: u64,
1739    stopped: AtomicBool,
1740}
1741
1742pub trait UVProfileable {
1743    fn profile(
1744        &mut self,
1745        model: &Model,
1746        // settings: &RuntimeSettings,
1747        profile_settings: &ProfileSettings,
1748    ) -> Result<UVProfile>;
1749}
1750
1751impl UVProfileable for Amplitude {
1752    #[instrument(skip_all)]
1753    fn profile(
1754        &mut self,
1755        model: &Model,
1756        // settings: &RuntimeSettings,
1757        profile_settings: &ProfileSettings,
1758    ) -> Result<UVProfile> {
1759        if profile_settings.n_points < UV_PROFILE_MIN_POINTS {
1760            return Err(eyre!(
1761                "UV profiling needs at least {UV_PROFILE_MIN_POINTS} scale points for a reliable asymptotic fit"
1762            ));
1763        }
1764        if !profile_settings.min_scale_exponent.is_finite()
1765            || !profile_settings.max_scale_exponent.is_finite()
1766            || profile_settings.min_scale_exponent >= profile_settings.max_scale_exponent
1767        {
1768            return Err(eyre!(
1769                "UV profiling needs finite scale exponents with min < max, got {} and {}",
1770                profile_settings.min_scale_exponent,
1771                profile_settings.max_scale_exponent,
1772            ));
1773        }
1774        if profile_settings.cutkosky_cut.is_some() {
1775            return Err(eyre!(
1776                "Cutkosky-cut selection is only supported for cross sections"
1777            ));
1778        }
1779
1780        let scales = logspace(
1781            profile_settings.min_scale_exponent,
1782            profile_settings.max_scale_exponent,
1783            profile_settings.n_points,
1784            10.0,
1785        );
1786        if scales
1787            .iter()
1788            .any(|scale| !scale.is_finite() || *scale <= 0.0)
1789        {
1790            return Err(eyre!(
1791                "UV profile scale exponents must produce finite positive scales"
1792            ));
1793        }
1794
1795        let settings = self
1796            .integrand
1797            .as_ref()
1798            .ok_or(eyre!("Integrand Not built yet"))?
1799            .get_settings()
1800            .clone();
1801        let graph_inputs = self
1802            .graphs
1803            .iter()
1804            .enumerate()
1805            .filter(|(graph_id, _)| profile_settings.graph_id.is_none_or(|id| id == *graph_id))
1806            .collect::<Vec<_>>();
1807        if let Some(graph_id) = profile_settings.graph_id
1808            && graph_inputs.is_empty()
1809        {
1810            return Err(eyre!(
1811                "Graph id {} is out of range for amplitude '{}'; it has {} graphs",
1812                graph_id,
1813                self.name,
1814                self.graphs.len()
1815            ));
1816        }
1817
1818        let base_seed = profile_settings.seed;
1819        // Profiling owns a private evaluator clone, so an evaluation error cannot leave the
1820        // process without its production integrand.
1821        let integrand = Arc::new(Mutex::new(self.integrand.as_ref().unwrap().clone()));
1822
1823        let profile_span = info_span!("Profiling graphs", indicatif.pb_show = true);
1824        profile_span.pb_set_style(&ProgressStyle::with_template(
1825            "{wide_bar} {pos}/{len} {msg}",
1826        )?);
1827
1828        profile_span.pb_set_length(graph_inputs.len() as u64);
1829        profile_span.pb_set_message("Profiling graphs");
1830        profile_span.pb_set_finish_message("all graphs profiled");
1831        let _profile_span_enter = profile_span.enter();
1832
1833        let runner = UVProfileRunner {
1834            integrand: &integrand,
1835            scales: &scales,
1836            model,
1837            settings: &settings,
1838            profile_settings,
1839            base_seed,
1840            stopped: AtomicBool::new(false),
1841        };
1842
1843        // Generated symbolic evaluators need the same worker-stack headroom while profiling as
1844        // they do in the production generation pool.
1845        let profile_pool = rayon::ThreadPoolBuilder::new()
1846            .num_threads(rayon::current_num_threads())
1847            .stack_size(UV_PROFILE_THREAD_STACK_SIZE_BYTES)
1848            .build()?;
1849        let per_graph = profile_pool.install(|| {
1850            let sample_graph = |(graph_id, graph): &(usize, &AmplitudeGraph)| {
1851                let res = runner.sample_graph(*graph_id, graph)?;
1852                profile_span.pb_inc(1);
1853                Ok(res)
1854            };
1855            if profile_settings.fail_fast {
1856                graph_inputs
1857                    .iter()
1858                    .take_while(|_| !runner.stopped.load(Ordering::Relaxed))
1859                    .map(sample_graph)
1860                    .collect::<Result<Vec<_>>>()
1861            } else {
1862                graph_inputs.par_iter().map(sample_graph).collect()
1863            }
1864        })?;
1865
1866        if runner.stopped.load(Ordering::Relaxed) {
1867            profile_span.pb_set_finish_message("stopped at failing UV limit");
1868        }
1869        drop(_profile_span_enter);
1870        drop(profile_span);
1871
1872        Ok(UVProfile {
1873            stopped_early: runner.stopped.load(Ordering::Relaxed),
1874            per_graph,
1875            scales,
1876            allow_vanishing_missing_fits: profile_settings.allow_vanishing_missing_fits,
1877        })
1878        // results.push((inspect_res, analytic_res));
1879    }
1880}
1881
1882impl UVProfileable for CrossSection {
1883    #[instrument(skip_all)]
1884    fn profile(&mut self, model: &Model, profile_settings: &ProfileSettings) -> Result<UVProfile> {
1885        if profile_settings.n_points < UV_PROFILE_MIN_POINTS {
1886            return Err(eyre!(
1887                "UV profiling needs at least {UV_PROFILE_MIN_POINTS} scale points for a reliable asymptotic fit"
1888            ));
1889        }
1890        if !profile_settings.min_scale_exponent.is_finite()
1891            || !profile_settings.max_scale_exponent.is_finite()
1892            || profile_settings.min_scale_exponent >= profile_settings.max_scale_exponent
1893        {
1894            return Err(eyre!(
1895                "UV profiling needs finite scale exponents with min < max, got {} and {}",
1896                profile_settings.min_scale_exponent,
1897                profile_settings.max_scale_exponent,
1898            ));
1899        }
1900        let scales = logspace(
1901            profile_settings.min_scale_exponent,
1902            profile_settings.max_scale_exponent,
1903            profile_settings.n_points,
1904            10.0,
1905        );
1906        if scales
1907            .iter()
1908            .any(|scale| !scale.is_finite() || *scale <= 0.0)
1909        {
1910            return Err(eyre!(
1911                "UV profile scale exponents must produce finite positive scales"
1912            ));
1913        }
1914
1915        let integrand = self
1916            .integrand
1917            .as_ref()
1918            .ok_or(eyre!("Integrand Not built yet"))?;
1919        let settings = integrand.get_settings().clone();
1920        let graph_inputs = match integrand {
1921            ProcessIntegrand::CrossSection(cross_section) => {
1922                if let Some(graph_id) = profile_settings.graph_id
1923                    && graph_id >= cross_section.data.graph_terms.len()
1924                {
1925                    return Err(eyre!(
1926                        "Graph id {} is out of range for cross section '{}'; it has {} graphs",
1927                        graph_id,
1928                        self.name,
1929                        cross_section.data.graph_terms.len()
1930                    ));
1931                }
1932                if profile_settings.cutkosky_cut.is_some() && profile_settings.graph_id.is_none() {
1933                    return Err(eyre!("A Cutkosky-cut selection requires selecting a graph"));
1934                }
1935
1936                let selected_cut = profile_settings
1937                    .cutkosky_cut
1938                    .as_ref()
1939                    .map(|requested_edges| {
1940                        let graph_id = profile_settings
1941                            .graph_id
1942                            .expect("Cutkosky-cut selection requires a graph");
1943                        let graph_term = &cross_section.data.graph_terms[graph_id];
1944                        let requested_edges = requested_edges.iter().copied().sorted().collect_vec();
1945                        let matching_cuts = graph_term
1946                            .cuts
1947                            .iter_enumerated()
1948                            .filter_map(|(cut_id, cut)| {
1949                                let cut_edges = graph_term
1950                                    .graph
1951                                    .underlying
1952                                    .iter_edges_of(&cut.cut)
1953                                    .map(|(_, edge_id, _)| edge_id)
1954                                    .sorted()
1955                                    .collect_vec();
1956                                (cut_edges == requested_edges).then_some(cut_id)
1957                            })
1958                            .collect_vec();
1959                        let cut_id = match matching_cuts.as_slice() {
1960                            [cut_id] => *cut_id,
1961                            [] => {
1962                                let available = graph_term
1963                                    .cuts
1964                                    .iter_enumerated()
1965                                    .map(|(cut_id, cut)| {
1966                                        let edges = graph_term
1967                                            .graph
1968                                            .underlying
1969                                            .iter_edges_of(&cut.cut)
1970                                            .map(|(_, edge_id, _)| edge_id.to_string())
1971                                            .sorted()
1972                                            .join(",");
1973                                        format!("{}:[{}]", cut_id.0, edges)
1974                                    })
1975                                    .join(", ");
1976                                return Err(eyre!(
1977                                    "No Cutkosky cut with edges [{}] exists for graph '{}'. Available cuts: {}",
1978                                    requested_edges.iter().map(ToString::to_string).join(","),
1979                                    graph_term.graph.name,
1980                                    available
1981                                ));
1982                            }
1983                            _ => {
1984                                return Err(eyre!(
1985                                    "Cutkosky-cut edges [{}] ambiguously match cuts {} for graph '{}'",
1986                                    requested_edges.iter().map(ToString::to_string).join(","),
1987                                    matching_cuts.iter().map(|cut_id| cut_id.0).join(","),
1988                                    graph_term.graph.name
1989                                ));
1990                            }
1991                        };
1992                        Ok(cut_id)
1993                    })
1994                    .transpose()?;
1995
1996                cross_section
1997                    .data
1998                    .graph_terms
1999                    .iter()
2000                    .enumerate()
2001                    .filter(|(graph_id, _)| {
2002                        profile_settings.graph_id.is_none_or(|id| id == *graph_id)
2003                    })
2004                    .map(|(graph_id, graph_term)| -> Result<_> {
2005                        // LU evaluates one contribution per cut group and tags its generated
2006                        // event with the group's first (representative) physical cut. Retain the
2007                        // exact physical support separately because UV-cycle compatibility is not
2008                        // a property of the union of a group's raised-edge aliases.
2009                        let cuts = graph_term
2010                            .cuts
2011                            .iter_enumerated()
2012                            .filter(|(cut_id, _)| {
2013                                selected_cut.is_none_or(|selected| selected == *cut_id)
2014                            })
2015                            .map(|(cut_id, cut)| {
2016                                let cut_group = graph_term
2017                                    .cut_group_data
2018                                    .cut_groups
2019                                    .iter()
2020                                    .find(|cut_group| cut_group.cuts.contains(&cut_id))
2021                                    .ok_or_else(|| {
2022                                        eyre!(
2023                                            "Cut {} is not assigned to a cut group for graph '{}'",
2024                                            cut_id.0,
2025                                            graph_term.graph.name
2026                                        )
2027                                    })?;
2028                                let representative_cut_id =
2029                                    cut_group.cuts.first().copied().ok_or_else(|| {
2030                                        eyre!(
2031                                            "The cut group containing cut {} is empty for graph '{}'",
2032                                            cut_id.0,
2033                                            graph_term.graph.name
2034                                        )
2035                                    })?;
2036                                Ok((cut.cut.as_subgraph(), representative_cut_id))
2037                            })
2038                            .collect::<Result<Vec<_>>>()?;
2039                        Ok((
2040                            graph_id,
2041                            graph_term.graph.clone(),
2042                            graph_term.lmbs.clone(),
2043                            cuts,
2044                        ))
2045                    })
2046                    .collect::<Result<Vec<_>>>()?
2047            }
2048            ProcessIntegrand::Amplitude(_) => {
2049                unreachable!("cross-section UV profiling expects cross-section integrands")
2050            }
2051        };
2052        let base_seed = profile_settings.seed;
2053        // Profiling owns a private evaluator clone, so an evaluation error cannot leave the
2054        // process without its production integrand.
2055        let integrand = Arc::new(Mutex::new(self.integrand.as_ref().unwrap().clone()));
2056
2057        let profile_span = info_span!("Profiling graphs", indicatif.pb_show = true);
2058        profile_span.pb_set_style(&ProgressStyle::with_template(
2059            "{wide_bar} {pos}/{len} {msg}",
2060        )?);
2061        profile_span.pb_set_length(graph_inputs.len() as u64);
2062        profile_span.pb_set_message("Profiling graphs");
2063        profile_span.pb_set_finish_message("all graphs profiled");
2064        let _profile_span_enter = profile_span.enter();
2065
2066        let runner = UVProfileRunner {
2067            integrand: &integrand,
2068            scales: &scales,
2069            model,
2070            settings: &settings,
2071            profile_settings,
2072            base_seed,
2073            stopped: AtomicBool::new(false),
2074        };
2075
2076        // Generated symbolic evaluators need the same worker-stack headroom while profiling as
2077        // they do in the production generation pool.
2078        let profile_pool = rayon::ThreadPoolBuilder::new()
2079            .num_threads(rayon::current_num_threads())
2080            .stack_size(UV_PROFILE_THREAD_STACK_SIZE_BYTES)
2081            .build()?;
2082        let per_graph = profile_pool.install(|| {
2083            let sample_graph = |(graph_id, graph, lmbs, cuts): &(usize, _, _, Vec<_>)| {
2084                let res = runner.sample_cross_section_graph(*graph_id, graph, lmbs, cuts)?;
2085                profile_span.pb_inc(1);
2086                Ok(res)
2087            };
2088            if profile_settings.fail_fast {
2089                graph_inputs
2090                    .iter()
2091                    .take_while(|_| !runner.stopped.load(Ordering::Relaxed))
2092                    .map(sample_graph)
2093                    .collect::<Result<Vec<_>>>()
2094            } else {
2095                graph_inputs.par_iter().map(sample_graph).collect()
2096            }
2097        })?;
2098
2099        if runner.stopped.load(Ordering::Relaxed) {
2100            profile_span.pb_set_finish_message("stopped at failing UV limit");
2101        }
2102        drop(_profile_span_enter);
2103        drop(profile_span);
2104
2105        Ok(UVProfile {
2106            stopped_early: runner.stopped.load(Ordering::Relaxed),
2107            per_graph,
2108            scales,
2109            allow_vanishing_missing_fits: true,
2110        })
2111    }
2112}
2113
2114pub struct UVProfile {
2115    pub stopped_early: bool,
2116    pub per_graph: Vec<UVSamplingResult>,
2117    pub scales: Vec<f64>,
2118    pub allow_vanishing_missing_fits: bool,
2119}
2120
2121impl UVProfile {
2122    pub fn analyse(&self) -> UVProfileAnalysis {
2123        let graphs = self
2124            .per_graph
2125            .iter()
2126            .map(|graph| {
2127                let graph_index = graph.graph_index;
2128                let lmbs: Vec<UVProfileLmbAnalysis> = graph
2129                    .per_lmb
2130                    .iter()
2131                    .enumerate()
2132                    .map(|(lmb_index, lmb)| {
2133                        let lmb_label = lmb_label(&lmb.lmb);
2134                        let subsets: Vec<UVProfileSubsetAnalysis> = lmb
2135                            .per_subsets
2136                            .iter()
2137                            .enumerate()
2138                            .map(|(subset_index, (subset, subset_result))| {
2139                                let mut not_included: SubSet<LoopIndex> =
2140                                    SubSet::full(subset.size());
2141                                not_included.subtract_with(subset);
2142                                let free: Vec<EdgeIndex> = subset
2143                                    .included_iter()
2144                                    .map(|loop_index| lmb.lmb.loop_edges[loop_index])
2145                                    .collect();
2146
2147                                let fixed: Vec<EdgeIndex> = not_included
2148                                    .included_iter()
2149                                    .map(|loop_index| lmb.lmb.loop_edges[loop_index])
2150                                    .collect();
2151                                let analysis = subset_result.analyse(&self.scales);
2152                                let per_orientation_inspect_entries =
2153                                    analysis.per_orientation_inspect_entries();
2154                                let analytic_entries =
2155                                    analysis.analytic.as_ref().and_then(|analytic| {
2156                                        let entries = analytic
2157                                            .per_orientation
2158                                            .iter()
2159                                            .map(|(orientation, orientation_analysis)| {
2160                                                let (orientation_edges, orientation_signs) =
2161                                                    orientation_signs(orientation);
2162                                                UVProfileAnalyticEntry {
2163                                                    graph_index,
2164                                                    lmb_index,
2165                                                    subset_index,
2166                                                    fixed: fixed.clone(),
2167                                                    free: free.clone(),
2168                                                    orientation_label: orientation
2169                                                        .label
2170                                                        .clone()
2171                                                        .unwrap_or_else(|| orientation.to_string()),
2172                                                    orientation_edges,
2173                                                    orientation_signs,
2174                                                    is_constant: orientation_analysis.is_constant,
2175                                                    leading_coef: orientation_analysis
2176                                                        .leading_coef
2177                                                        .to_string(),
2178                                                }
2179                                            })
2180                                            .collect::<Vec<_>>();
2181                                        if entries.is_empty() {
2182                                            None
2183                                        } else {
2184                                            Some(entries)
2185                                        }
2186                                    });
2187                                UVProfileSubsetAnalysis {
2188                                    subset_index,
2189                                    fixed,
2190                                    free,
2191                                    initial_dod: subset_result.initial_dod,
2192                                    analysis,
2193                                    per_orientation_inspect_entries,
2194                                    analytic_entries,
2195                                }
2196                            })
2197                            .collect();
2198
2199                        UVProfileLmbAnalysis {
2200                            lmb_index,
2201                            lmb_label,
2202                            subsets,
2203                        }
2204                    })
2205                    .collect();
2206
2207                UVProfileGraphAnalysis {
2208                    graph_index,
2209                    graph_name: graph.graph_name.clone(),
2210                    cutkosky_cut: graph.cutkosky_cut.clone(),
2211                    lmbs,
2212                }
2213            })
2214            .collect();
2215
2216        UVProfileAnalysis {
2217            stopped_early: self.stopped_early,
2218            scales: self.scales.clone(),
2219            graphs,
2220            allow_vanishing_missing_fits: self.allow_vanishing_missing_fits,
2221        }
2222    }
2223
2224    pub fn write_profile_data<P: AsRef<Path>>(
2225        &self,
2226        _settings: &ProfileSettings,
2227        out_dir: P,
2228    ) -> Result<()> {
2229        self.analyse().write_profile_data(out_dir)
2230    }
2231
2232    pub fn write_typst_bundle<P: AsRef<Path>>(
2233        &self,
2234        settings: &ProfileSettings,
2235        out_dir: P,
2236    ) -> Result<()> {
2237        self.write_profile_data(settings, out_dir)
2238    }
2239
2240    pub fn pass_fail(&self, max_dod: f64, _settings: &ProfileSettings) -> UVProfilePassFail {
2241        self.analyse().pass_fail(max_dod)
2242    }
2243}
2244
2245#[derive(Debug, Clone, Serialize, Deserialize)]
2246pub struct UVProfileAnalysis {
2247    /// Results cover only the completed prefix when fail-fast stopped sampling.
2248    #[serde(default, skip_serializing_if = "crate::utils::serde_utils::is_false")]
2249    pub stopped_early: bool,
2250    pub scales: Vec<f64>,
2251    pub graphs: Vec<UVProfileGraphAnalysis>,
2252    #[serde(default, skip_serializing_if = "crate::utils::serde_utils::is_false")]
2253    pub allow_vanishing_missing_fits: bool,
2254}
2255
2256#[derive(Debug, Clone, Serialize, Deserialize)]
2257pub struct UVProfileGraphAnalysis {
2258    pub graph_index: usize,
2259    pub graph_name: String,
2260    pub cutkosky_cut: Option<Vec<EdgeIndex>>,
2261    pub lmbs: Vec<UVProfileLmbAnalysis>,
2262}
2263
2264#[derive(Debug, Clone, Serialize, Deserialize)]
2265pub struct UVProfileLmbAnalysis {
2266    pub lmb_index: usize,
2267    pub lmb_label: String,
2268    pub subsets: Vec<UVProfileSubsetAnalysis>,
2269}
2270
2271#[derive(Debug, Clone, Serialize, Deserialize)]
2272pub struct UVProfileSubsetAnalysis {
2273    pub subset_index: usize,
2274    pub fixed: Vec<EdgeIndex>,
2275    pub free: Vec<EdgeIndex>,
2276    pub initial_dod: i32,
2277    // pub subset_label: String,
2278    pub analysis: Analysis,
2279    pub per_orientation_inspect_entries: Option<Vec<UVProfileOrientationInspectEntry>>,
2280    pub analytic_entries: Option<Vec<UVProfileAnalyticEntry>>,
2281}
2282
2283impl UVProfileSubsetAnalysis {
2284    pub fn estimated_dod(&self) -> Option<i64> {
2285        self.analysis
2286            .inspect_level
2287            .as_ref()
2288            .map(|analysis| analysis.estimated_dod)
2289    }
2290
2291    pub fn bare_dod_matches_estimate(&self) -> bool {
2292        self.estimated_dod() == Some(i64::from(self.initial_dod))
2293    }
2294}
2295
2296// #[derive(Debug, Clone, Serialize)]
2297// pub struct UVProfilePoint {
2298//     norm:f64,
2299// }
2300
2301#[derive(Debug, Clone, Serialize)]
2302pub struct UVProfileSummary {
2303    pub subset_count: usize,
2304    pub slope_min: Option<f64>,
2305    pub slope_max: Option<f64>,
2306    pub dod_min: Option<i64>,
2307    pub dod_max: Option<i64>,
2308    pub stable_min: Option<f64>,
2309    pub stable_max: Option<f64>,
2310}
2311
2312#[derive(Debug, Clone, Serialize)]
2313pub struct UVProfilePassFail {
2314    pub max_dod: f64,
2315    pub total: usize,
2316    pub failed: usize,
2317    pub failures: Vec<UVProfileFailure>,
2318}
2319
2320#[derive(Debug, Clone, Serialize)]
2321pub struct UVProfileFailure {
2322    pub graph_index: usize,
2323    pub graph_name: String,
2324    pub cutkosky_cut: Option<Vec<EdgeIndex>>,
2325    pub lmb_index: usize,
2326    pub fixed: Vec<EdgeIndex>,
2327    pub free: Vec<EdgeIndex>,
2328    pub orientation_label: Option<String>,
2329    pub reason: String,
2330}
2331
2332impl Display for UVProfilePassFail {
2333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2334        let status = if self.failed == 0 {
2335            "PASS".green().bold()
2336        } else {
2337            "FAIL".red().bold()
2338        };
2339        writeln!(
2340            f,
2341            "UV limit tests: {} ({}/{})",
2342            status,
2343            self.total.saturating_sub(self.failed),
2344            self.total
2345        )?;
2346
2347        if self.failures.is_empty() {
2348            return Ok(());
2349        }
2350
2351        let mut table = Builder::new();
2352        table.push_record([
2353            "status",
2354            "graph",
2355            "cut edges",
2356            "LMB",
2357            "fixed",
2358            "→ ∞",
2359            "orientation",
2360            "reason",
2361        ]);
2362        for failure in &self.failures {
2363            table.push_record([
2364                "FAIL".red().bold().to_string(),
2365                format!("{}:{}", failure.graph_index, failure.graph_name),
2366                failure
2367                    .cutkosky_cut
2368                    .as_ref()
2369                    .map(|edges| edges.iter().map(ToString::to_string).join(","))
2370                    .unwrap_or_else(|| "all".to_string()),
2371                failure.lmb_index.to_string(),
2372                format!(
2373                    "{{{}}}",
2374                    failure.fixed.iter().map(ToString::to_string).join(",")
2375                ),
2376                format!(
2377                    "{{{}}}",
2378                    failure.free.iter().map(ToString::to_string).join(",")
2379                ),
2380                failure
2381                    .orientation_label
2382                    .clone()
2383                    .unwrap_or_else(|| "sum".to_string()),
2384                failure.reason.red().to_string(),
2385            ]);
2386        }
2387
2388        write!(f, "{}", table.build().with(Style::rounded()))
2389    }
2390}
2391
2392#[derive(Debug, Clone, Serialize, Deserialize)]
2393pub struct UVProfileAnalyticEntry {
2394    pub graph_index: usize,
2395    pub lmb_index: usize,
2396    pub subset_index: usize,
2397    pub fixed: Vec<EdgeIndex>,
2398    pub free: Vec<EdgeIndex>,
2399    pub orientation_label: String,
2400    pub orientation_edges: Vec<String>,
2401    pub orientation_signs: Vec<String>,
2402    pub is_constant: bool,
2403    pub leading_coef: String,
2404}
2405
2406#[derive(Debug, Clone, Serialize, Deserialize)]
2407pub struct UVProfileOrientationInspectEntry {
2408    pub orientation_label: String,
2409    pub analysis: Option<InspectAnalysis>,
2410    #[serde(default)]
2411    inspect_fit_status: InspectFitStatus,
2412}
2413
2414#[derive(Tabled)]
2415struct UVProfileSubsetRow {
2416    #[tabled(rename = "fixed")]
2417    fixed: String,
2418    #[tabled(rename = "→ ∞")]
2419    free: String,
2420    #[tabled(rename = "slope")]
2421    slope: String,
2422    #[tabled(rename = "r2")]
2423    r_squared: String,
2424    #[tabled(rename = "DOD")]
2425    estimated_dod: String,
2426    #[tabled(rename = "bare DOD")]
2427    initial_dod: String,
2428    #[tabled(rename = "inspect")]
2429    inspect: String,
2430}
2431
2432#[derive(Tabled)]
2433struct UVProfileOrientationSubsetRow {
2434    #[tabled(rename = "fixed")]
2435    fixed: String,
2436    #[tabled(rename = "→ ∞")]
2437    free: String,
2438    #[tabled(rename = "orientation")]
2439    orientation_label: String,
2440    #[tabled(rename = "slope")]
2441    slope: String,
2442    #[tabled(rename = "r2")]
2443    r_squared: String,
2444    #[tabled(rename = "DOD")]
2445    estimated_dod: String,
2446    #[tabled(rename = "bare DOD")]
2447    initial_dod: String,
2448    #[tabled(rename = "inspect")]
2449    inspect: String,
2450}
2451
2452#[derive(Debug, Clone, Serialize, Deserialize)]
2453struct FitResult {
2454    slope: f64,
2455    // Magnitudes remain precise in output too; only logarithms cross the fit boundary.
2456    points: Vec<F<ArbPrec>>,
2457    scales: Vec<f64>,
2458    fit_start: usize,
2459    intercept: f64,
2460    r_squared: f64,
2461    full_range_slope: f64,
2462    full_range_intercept: f64,
2463    full_range_r_squared: f64,
2464}
2465
2466impl FitResult {
2467    /// Whether the reported slope comes from a linear high-scale suffix.
2468    fn is_asymptotic(&self) -> bool {
2469        self.r_squared.is_finite() && self.r_squared >= UV_PROFILE_ASYMPTOTIC_MIN_R_SQUARED
2470    }
2471}
2472
2473impl UVProfileAnalysis {
2474    pub fn tables_per_graph(&self, max_dod: f64) -> Vec<Table> {
2475        self.graphs
2476            .iter()
2477            .map(|graph| {
2478                let rows = graph
2479                    .lmbs
2480                    .iter()
2481                    .flat_map(|lmb| {
2482                        lmb.subsets.iter().map(|subset| {
2483                            let [slope, r_squared, estimated_dod, inspect] = inspect_cells(
2484                                subset.analysis.inspect_level.as_ref(),
2485                                subset.analysis.inspect_fit_status,
2486                                max_dod,
2487                                self.allow_vanishing_missing_fits,
2488                            );
2489
2490                            UVProfileSubsetRow {
2491                                fixed: format!(
2492                                    "{{{}}}",
2493                                    subset.fixed.iter().map(ToString::to_string).join(",")
2494                                ),
2495                                free: format!(
2496                                    "{{{}}}",
2497                                    subset.free.iter().map(ToString::to_string).join(",")
2498                                ),
2499                                slope,
2500                                r_squared,
2501                                estimated_dod,
2502                                initial_dod: if subset.initial_dod >= 0 {
2503                                    subset.initial_dod.to_string().red().to_string()
2504                                } else {
2505                                    subset.initial_dod.to_string().green().to_string()
2506                                },
2507                                inspect,
2508                            }
2509                        })
2510                    })
2511                    .collect::<Vec<_>>();
2512
2513                let mut table = Table::new(rows);
2514                table.with(Style::rounded());
2515                table
2516            })
2517            .collect()
2518    }
2519
2520    pub fn analytic_tables_per_graph(&self) -> Vec<Option<Table>> {
2521        self.graphs
2522            .iter()
2523            .map(|graph| {
2524                let mut groups: Vec<Vec<&UVProfileAnalyticEntry>> = Vec::new();
2525                let mut orientation_headers: Option<Vec<String>> = None;
2526
2527                for lmb in &graph.lmbs {
2528                    for subset in &lmb.subsets {
2529                        if let Some(entries) = &subset.analytic_entries {
2530                            if orientation_headers.is_none() {
2531                                orientation_headers =
2532                                    entries.first().map(|entry| entry.orientation_edges.clone());
2533                            }
2534                            groups.push(entries.iter().collect());
2535                        }
2536                    }
2537                }
2538
2539                if groups.is_empty() {
2540                    return None;
2541                }
2542
2543                let mut builder = Builder::new();
2544                let mut header = vec![
2545                    "fixed".to_string(),
2546                    "→ ∞".to_string(),
2547                    "orientation key".to_string(),
2548                ];
2549                header.extend(orientation_headers.unwrap_or_default());
2550                header.extend(["const".to_string(), "leading coef".to_string()]);
2551                builder.push_record(header);
2552
2553                let mut span_ops: Vec<(usize, usize, usize)> = Vec::new();
2554                let mut row_index = 1;
2555
2556                for group in groups {
2557                    let span_len = group.len();
2558                    let start_row = row_index;
2559
2560                    for (i, entry) in group.into_iter().enumerate() {
2561                        let fixed = if i == 0 {
2562                            format!(
2563                                "{{{}}}",
2564                                entry.fixed.iter().map(ToString::to_string).join(",")
2565                            )
2566                        } else {
2567                            String::new()
2568                        };
2569                        let free = if i == 0 {
2570                            format!(
2571                                "{{{}}}",
2572                                entry.free.iter().map(ToString::to_string).join(",")
2573                            )
2574                        } else {
2575                            String::new()
2576                        };
2577                        let mut row = vec![fixed, free, entry.orientation_label.clone()];
2578                        row.extend(entry.orientation_signs.iter().cloned());
2579                        row.extend([entry.is_constant.to_string(), "".to_string()]);
2580                        builder.push_record(row);
2581                        row_index += 1;
2582                    }
2583
2584                    if span_len > 1 {
2585                        span_ops.push((start_row, 0, span_len));
2586                        span_ops.push((start_row, 1, span_len));
2587                    }
2588                }
2589
2590                let mut table = builder.build();
2591                for (row, col, span_len) in span_ops {
2592                    table.with(Modify::new((row, col)).with(Span::row(span_len as isize)));
2593                }
2594                table.with(Style::rounded());
2595                Some(table)
2596            })
2597            .collect()
2598    }
2599
2600    pub fn per_orientation_tables_per_graph(&self, max_dod: f64) -> Vec<Option<Table>> {
2601        self.graphs
2602            .iter()
2603            .map(|graph| {
2604                let rows = graph
2605                    .lmbs
2606                    .iter()
2607                    .flat_map(|lmb| {
2608                        lmb.subsets.iter().flat_map(|subset| {
2609                            subset
2610                                .per_orientation_inspect_entries
2611                                .iter()
2612                                .flatten()
2613                                .map(|entry| {
2614                                    let [slope, r_squared, estimated_dod, inspect] = inspect_cells(
2615                                        entry.analysis.as_ref(),
2616                                        entry.inspect_fit_status,
2617                                        max_dod,
2618                                        self.allow_vanishing_missing_fits,
2619                                    );
2620
2621                                    UVProfileOrientationSubsetRow {
2622                                        fixed: format!(
2623                                            "{{{}}}",
2624                                            subset.fixed.iter().map(ToString::to_string).join(",")
2625                                        ),
2626                                        free: format!(
2627                                            "{{{}}}",
2628                                            subset.free.iter().map(ToString::to_string).join(",")
2629                                        ),
2630                                        orientation_label: entry.orientation_label.clone(),
2631                                        slope,
2632                                        r_squared,
2633                                        estimated_dod,
2634                                        initial_dod: if subset.initial_dod >= 0 {
2635                                            subset.initial_dod.to_string().red().to_string()
2636                                        } else {
2637                                            subset.initial_dod.to_string().green().to_string()
2638                                        },
2639                                        inspect,
2640                                    }
2641                                })
2642                                .collect::<Vec<_>>()
2643                        })
2644                    })
2645                    .collect::<Vec<_>>();
2646
2647                if rows.is_empty() {
2648                    None
2649                } else {
2650                    let mut table = Table::new(rows);
2651                    table.with(Style::rounded());
2652                    Some(table)
2653                }
2654            })
2655            .collect()
2656    }
2657
2658    pub fn write_profile_data<P: AsRef<Path>>(&self, out_dir: P) -> Result<()> {
2659        let out_dir = out_dir.as_ref();
2660        std::fs::create_dir_all(out_dir).context("failed to create UV profile output directory")?;
2661
2662        let json_path = out_dir.join("uv_profile.json");
2663        let json = serde_json::to_string_pretty(self)
2664            .context("failed to serialize UV profile analysis to JSON")?;
2665        std::fs::write(&json_path, json).context("failed to write UV profile JSON output")?;
2666
2667        Ok(())
2668    }
2669
2670    pub fn pass_fail(&self, max_dod: f64) -> UVProfilePassFail {
2671        let mut failures = Vec::new();
2672        let mut total = 0;
2673
2674        for graph in &self.graphs {
2675            for lmb in &graph.lmbs {
2676                for subset in &lmb.subsets {
2677                    for (orientation_label, reason) in subset
2678                        .analysis
2679                        .inspect_verdicts(max_dod, self.allow_vanishing_missing_fits)
2680                        .take(1)
2681                        .chain(subset.per_orientation_inspect_entries.iter().flatten().map(
2682                            |entry| {
2683                                (
2684                                    Some(entry.orientation_label.as_str()),
2685                                    inspect_failure_reason(
2686                                        entry.analysis.as_ref(),
2687                                        entry.inspect_fit_status,
2688                                        max_dod,
2689                                        self.allow_vanishing_missing_fits,
2690                                    ),
2691                                )
2692                            },
2693                        ))
2694                    {
2695                        total += 1;
2696                        if let Some(reason) = reason {
2697                            failures.push(UVProfileFailure {
2698                                graph_index: graph.graph_index,
2699                                graph_name: graph.graph_name.clone(),
2700                                cutkosky_cut: graph.cutkosky_cut.clone(),
2701                                lmb_index: lmb.lmb_index,
2702                                fixed: subset.fixed.clone(),
2703                                free: subset.free.clone(),
2704                                orientation_label: orientation_label.map(str::to_owned),
2705                                reason: reason.to_string(),
2706                            });
2707                        }
2708                    }
2709                }
2710            }
2711        }
2712
2713        UVProfilePassFail {
2714            max_dod,
2715            total,
2716            failed: failures.len(),
2717            failures,
2718        }
2719    }
2720}
2721
2722fn inspect_failure_reason(
2723    analysis: Option<&InspectAnalysis>,
2724    fit_status: InspectFitStatus,
2725    max_dod: f64,
2726    allow_vanishing_missing_fits: bool,
2727) -> Option<&'static str> {
2728    if allow_vanishing_missing_fits && fit_status.vanishes_without_power_law(analysis, max_dod) {
2729        return None;
2730    }
2731    match analysis {
2732        None => Some("missing_fit"),
2733        Some(analysis) if !analysis.result.is_asymptotic() => Some("unstable_fit"),
2734        Some(analysis) if analysis.result.slope > max_dod || analysis.result.slope.is_nan() => {
2735            Some("dod_exceeds_threshold")
2736        }
2737        Some(_) => None,
2738    }
2739}
2740
2741/// Slope, r², DOD and status cells of one sampled ray.
2742fn inspect_cells(
2743    analysis: Option<&InspectAnalysis>,
2744    fit_status: InspectFitStatus,
2745    max_dod: f64,
2746    allow_vanishing_missing_fits: bool,
2747) -> [String; 4] {
2748    let failure =
2749        inspect_failure_reason(analysis, fit_status, max_dod, allow_vanishing_missing_fits);
2750    let decay_slope = fit_status
2751        .decay_slope
2752        .filter(|_| fit_status.vanishes_without_power_law(analysis, max_dod));
2753    let status = inspect_label(analysis, failure, decay_slope);
2754    if let Some(bound) = decay_slope.filter(|_| failure.is_none()) {
2755        let slope = if bound.is_finite() {
2756            format!("≤ {bound:.6}").green().to_string()
2757        } else {
2758            "-".to_string()
2759        };
2760        return [slope, "-".to_string(), "-".to_string(), status];
2761    }
2762    match analysis {
2763        Some(analysis) => {
2764            let r2_text = format!("{:.3}", analysis.result.r_squared);
2765            let r2_text = if analysis.result.is_asymptotic() {
2766                r2_text.green()
2767            } else {
2768                r2_text.red()
2769            };
2770            let dod_text = analysis.estimated_dod.to_string();
2771            let dod_text = if failure.is_none() {
2772                dod_text.green()
2773            } else {
2774                dod_text.red()
2775            };
2776            [
2777                format!("{:.6}", analysis.result.slope),
2778                r2_text.to_string(),
2779                dod_text.to_string(),
2780                status,
2781            ]
2782        }
2783        None => ["-".to_string(), "-".to_string(), "-".to_string(), status],
2784    }
2785}
2786
2787fn lmb_label(lmb: &LoopMomentumBasis) -> String {
2788    let edges: Vec<String> = lmb.loop_edges.iter().map(|edge| edge.to_string()).collect();
2789    format!("loop_edges=[{}]", edges.join(","))
2790}
2791
2792fn orientation_signs(orientation: &OrientationData) -> (Vec<String>, Vec<String>) {
2793    let edges = orientation
2794        .orientation
2795        .iter()
2796        .map(|(edge, _)| edge.to_string())
2797        .collect::<Vec<_>>();
2798    let signs = orientation
2799        .orientation
2800        .iter()
2801        .map(|(_, sign)| SignOrZero::from(*sign).to_string())
2802        .collect::<Vec<_>>();
2803    (edges, signs)
2804}
2805
2806fn analytic_integrand_for_orientation(
2807    orientation_id: OrientationID,
2808    orientation: &OrientationData,
2809    integrand: &Atom,
2810) -> (OrientationData, Atom) {
2811    let mut orientation = orientation.clone();
2812    orientation.label = Some(match orientation.label {
2813        Some(label) => format!("{label}|sigma({})", orientation_id.0),
2814        None => format!("sigma({})", orientation_id.0),
2815    });
2816    // The full residue-map key is the primary selector. Drop inactive keyed
2817    // branches before resolving physical-direction metadata, which can be
2818    // singular outside the branch that owns it.
2819    let selected = orientation
2820        .orientation
2821        .select(orientation_id.select(integrand));
2822    (orientation, selected)
2823}
2824
2825pub struct UVSamplingResult {
2826    pub graph_index: usize,
2827    pub graph_name: String,
2828    pub cutkosky_cut: Option<Vec<EdgeIndex>>,
2829    pub per_lmb: Vec<LMBResult>,
2830}
2831
2832impl<'a> UVProfileRunner<'a> {
2833    fn sample_graph(&self, graph_id: usize, g: &AmplitudeGraph) -> Result<UVSamplingResult> {
2834        let profiles_per_orientation = self
2835            .profile_settings
2836            .orientation_mode
2837            .profiles_per_orientation();
2838        if profiles_per_orientation {
2839            let integrand = self.integrand.lock().expect("integrand mutex poisoned");
2840            match &*integrand {
2841                ProcessIntegrand::Amplitude(amplitude)
2842                    if amplitude.data.explicit_orientation_sum_only =>
2843                {
2844                    return Err(eyre!(
2845                        "an explicit orientation sum cannot be profiled by production orientation"
2846                    ));
2847                }
2848                ProcessIntegrand::Amplitude(_) => {}
2849                ProcessIntegrand::CrossSection(_) => {
2850                    unreachable!("UV profiling expects amplitudes")
2851                }
2852            }
2853        }
2854        let analytic_integrands = if !self.profile_settings.analyse_analytically {
2855            Vec::new()
2856        } else {
2857            let integrand = g.derived_data.resolved_integrand()?;
2858            g.derived_data
2859                .cff_expression
2860                .as_ref()
2861                .unwrap()
2862                .expression
2863                .orientations
2864                .iter_enumerated()
2865                .map(|(orientation_id, orientation)| {
2866                    analytic_integrand_for_orientation(
2867                        orientation_id,
2868                        &orientation.data,
2869                        &integrand,
2870                    )
2871                })
2872                .collect()
2873        };
2874        let orientation_labels = if profiles_per_orientation {
2875            let integrand = self.integrand.lock().expect("integrand mutex poisoned");
2876            match &*integrand {
2877                ProcessIntegrand::Amplitude(amplitude) => Some(
2878                    orientation_labels_for_graph(amplitude, graph_id)?
2879                        .into_iter()
2880                        .enumerate()
2881                        .map(|(orientation_id, label)| format!("{label}|sigma({orientation_id})"))
2882                        .collect::<Vec<_>>(),
2883                ),
2884                ProcessIntegrand::CrossSection(_) => {
2885                    unreachable!("UV profiling expects amplitudes")
2886                }
2887            }
2888        } else {
2889            None
2890        };
2891        let all_lmbs = g
2892            .derived_data
2893            .lmbs
2894            .as_ref()
2895            .ok_or_else(|| eyre!("Loop momentum bases have not been generated"))?;
2896        let lmbs = self.profile_settings.selected_limits.lmb_limits(
2897            &g.graph,
2898            all_lmbs,
2899            &g.graph.no_dummy(),
2900            None,
2901        )?;
2902
2903        let lmb_span = info_span!(
2904            "Profiling loop momentum bases",
2905            indicatif.pb_show = true,
2906            graph_id = graph_id
2907        );
2908        lmb_span.pb_set_style(
2909            &ProgressStyle::with_template("{wide_bar} {pos}/{len} {msg}")
2910                .expect("invalid progress bar template"),
2911        );
2912        lmb_span.pb_set_length(lmbs.len() as u64);
2913        lmb_span.pb_set_message("Profiling loop momentum bases");
2914        lmb_span.pb_set_finish_message("all loop momentum bases profiled");
2915        let _lmb_span_enter = lmb_span.enter();
2916
2917        let sample_lmb = |(lmb_index, (lmb, subsets)): (usize, &(LoopMomentumBasis, Vec<_>))| {
2918            let mut result = self.sample_lmb(
2919                graph_id,
2920                &g.graph,
2921                lmb_index,
2922                lmb,
2923                subsets,
2924                orientation_labels.as_deref(),
2925                None,
2926            )?;
2927
2928            // A failed numerical limit stops before optional analytic work.
2929            if self.profile_settings.analyse_analytically && !self.stopped.load(Ordering::Relaxed) {
2930                let orientation_limits: Vec<(
2931                    SubSet<LoopIndex>,
2932                    OrientationData,
2933                    Series<AtomField>,
2934                )> = analytic_integrands
2935                    .par_iter()
2936                    .map(|(orientation, integrand)| {
2937                        g.graph
2938                            .all_limits(&g.graph.full_filter(), integrand, symbol!("lambd"), lmb)
2939                            .into_iter()
2940                            .map(|(limit, value)| (limit, orientation.clone(), value))
2941                            .collect::<Vec<_>>()
2942                    })
2943                    .reduce(Vec::new, |mut limits, mut more_limits| {
2944                        limits.append(&mut more_limits);
2945                        limits
2946                    });
2947
2948                for (limit, orientation, value) in orientation_limits {
2949                    let Some(subset) = result.per_subsets.get_mut(&limit) else {
2950                        continue;
2951                    };
2952                    let analytic = subset.analytic.get_or_insert_with(|| AnalyticResult {
2953                        per_orientations: BTreeMap::new(),
2954                    });
2955                    analytic.per_orientations.insert(orientation, value);
2956                }
2957            }
2958            lmb_span.pb_inc(1);
2959            Ok(result)
2960        };
2961        let per_lmb = if self.profile_settings.fail_fast {
2962            lmbs.iter()
2963                .enumerate()
2964                .take_while(|_| !self.stopped.load(Ordering::Relaxed))
2965                .map(sample_lmb)
2966                .collect::<Result<Vec<_>>>()?
2967        } else {
2968            lmbs.par_iter()
2969                .enumerate()
2970                .map(sample_lmb)
2971                .collect::<Result<Vec<_>>>()?
2972        };
2973
2974        if self.stopped.load(Ordering::Relaxed) {
2975            lmb_span.pb_set_finish_message("stopped at failing UV limit");
2976        }
2977        drop(_lmb_span_enter);
2978        drop(lmb_span);
2979
2980        Ok(UVSamplingResult {
2981            graph_index: graph_id,
2982            graph_name: g.graph.name.clone(),
2983            cutkosky_cut: None,
2984            per_lmb,
2985        })
2986    }
2987
2988    fn sample_cross_section_graph(
2989        &self,
2990        graph_id: usize,
2991        graph: &Graph,
2992        all_lmbs: &TiVec<LmbIndex, LoopMomentumBasis>,
2993        cuts: &[(SuBitGraph, CutId)],
2994    ) -> Result<UVSamplingResult> {
2995        if self.profile_settings.analyse_analytically {
2996            return Err(eyre!(
2997                "Analytic UV profiling is not implemented for cross sections"
2998            ));
2999        }
3000
3001        let orientation_labels = if self
3002            .profile_settings
3003            .orientation_mode
3004            .profiles_per_orientation()
3005        {
3006            let integrand = self.integrand.lock().expect("integrand mutex poisoned");
3007            match &*integrand {
3008                ProcessIntegrand::CrossSection(cross_section) => {
3009                    if cross_section.data.explicit_orientation_sum_only {
3010                        return Err(eyre!(
3011                            "an explicit orientation sum cannot be profiled by production orientation"
3012                        ));
3013                    }
3014                    Some(
3015                        orientation_labels_for_graph(cross_section, graph_id)?
3016                            .into_iter()
3017                            .enumerate()
3018                            .map(|(orientation_id, label)| {
3019                                format!("{label}|sigma({orientation_id})")
3020                            })
3021                            .collect::<Vec<_>>(),
3022                    )
3023                }
3024                ProcessIntegrand::Amplitude(_) => {
3025                    unreachable!("cross-section UV profiling expects cross sections")
3026                }
3027            }
3028        } else {
3029            None
3030        };
3031        let mut profile_domain = graph.full_filter();
3032        profile_domain.subtract_with(&graph.initial_state_cut.left);
3033        let cut_supports = cuts.iter().map(|(cut, _)| cut.clone()).collect_vec();
3034        let lmbs = self.profile_settings.selected_limits.lmb_limits(
3035            graph,
3036            all_lmbs,
3037            &profile_domain,
3038            Some(&cut_supports),
3039        )?;
3040
3041        let lmb_span = info_span!(
3042            "Profiling loop momentum bases",
3043            indicatif.pb_show = true,
3044            graph_id = graph_id
3045        );
3046        lmb_span.pb_set_style(
3047            &ProgressStyle::with_template("{wide_bar} {pos}/{len} {msg}")
3048                .expect("invalid progress bar template"),
3049        );
3050        lmb_span.pb_set_length(lmbs.len() as u64);
3051        lmb_span.pb_set_message("Profiling loop momentum bases");
3052        lmb_span.pb_set_finish_message("all loop momentum bases profiled");
3053        let _lmb_span_enter = lmb_span.enter();
3054
3055        let sample_lmb = |(lmb_index, (lmb, subsets)): (usize, &(LoopMomentumBasis, Vec<_>))| {
3056            let result = self.sample_lmb(
3057                graph_id,
3058                graph,
3059                lmb_index,
3060                lmb,
3061                subsets,
3062                orientation_labels.as_deref(),
3063                Some(cuts),
3064            )?;
3065            lmb_span.pb_inc(1);
3066            Ok(result)
3067        };
3068        let per_lmb = if self.profile_settings.fail_fast {
3069            lmbs.iter()
3070                .enumerate()
3071                .take_while(|_| !self.stopped.load(Ordering::Relaxed))
3072                .map(sample_lmb)
3073                .collect::<Result<Vec<_>>>()?
3074        } else {
3075            lmbs.par_iter()
3076                .enumerate()
3077                .map(sample_lmb)
3078                .collect::<Result<Vec<_>>>()?
3079        };
3080
3081        if self.stopped.load(Ordering::Relaxed) {
3082            lmb_span.pb_set_finish_message("stopped at failing UV limit");
3083        }
3084        drop(_lmb_span_enter);
3085        drop(lmb_span);
3086
3087        Ok(UVSamplingResult {
3088            graph_index: graph_id,
3089            graph_name: graph.name.clone(),
3090            cutkosky_cut: self.profile_settings.cutkosky_cut.clone(),
3091            per_lmb,
3092        })
3093    }
3094}
3095
3096pub struct LMBResult {
3097    pub(crate) lmb: LoopMomentumBasis,
3098    pub(crate) per_subsets: BTreeMap<SubSet<LoopIndex>, SubSetResult>,
3099}
3100
3101impl<'a> UVProfileRunner<'a> {
3102    #[allow(clippy::too_many_arguments)]
3103    fn sample_lmb(
3104        &self,
3105        graph_id: usize,
3106        graph: &Graph,
3107        lmb_index: usize,
3108        lmb: &LoopMomentumBasis,
3109        subsets: &[(SubSet<LoopIndex>, i32)],
3110        orientation_labels: Option<&[String]>,
3111        compatible_cuts: Option<&[(SuBitGraph, CutId)]>,
3112    ) -> Result<LMBResult> {
3113        let sample: LoopMomentumSample =
3114            if let Some(fixed_uv_ray) = &self.profile_settings.fixed_uv_ray {
3115                fixed_uv_ray.sample(lmb.loop_edges.len())?
3116            } else {
3117                let mut rng = MonteCarloRng::new(lmb_seed(self.base_seed, graph_id, lmb_index), 0);
3118                lmb.loop_edges
3119                    .iter()
3120                    .map(|_| ThreeMomentum {
3121                        px: F(rng.random_range(
3122                            -self.settings.kinematics.e_cm..self.settings.kinematics.e_cm,
3123                        )),
3124                        py: F(rng.random_range(
3125                            -self.settings.kinematics.e_cm..self.settings.kinematics.e_cm,
3126                        )),
3127                        pz: F(rng.random_range(
3128                            -self.settings.kinematics.e_cm..self.settings.kinematics.e_cm,
3129                        )),
3130                    })
3131                    .collect()
3132            };
3133
3134        let subset_span = info_span!(
3135            "Profiling subsets",
3136            indicatif.pb_show = true,
3137            graph_id = graph_id,
3138            lmb_index = lmb_index
3139        );
3140        subset_span.pb_set_style(
3141            &ProgressStyle::with_template("{wide_bar} {pos}/{len} {msg}")
3142                .expect("invalid progress bar template"),
3143        );
3144        subset_span.pb_set_length(subsets.len() as u64);
3145        subset_span.pb_set_message("Profiling subsets");
3146        let _subset_span_enter = subset_span.enter();
3147
3148        let prepare_integrand = || {
3149            let mut integrand = self
3150                .integrand
3151                .lock()
3152                .expect("integrand mutex poisoned")
3153                .clone();
3154            // LU cut compatibility is projected from the production event weights.
3155            if compatible_cuts.is_some() {
3156                integrand.get_mut_settings().general.generate_events = true;
3157            }
3158            integrand
3159        };
3160        let sample_subset = |integrand: &mut ProcessIntegrand, (subset, initial_dod): &_| {
3161            let compatible_event_cut_ids = compatible_cuts.map(|cuts| {
3162                let cycle = UVLimitSelection::cycle_union(graph, lmb, subset);
3163                cuts.iter()
3164                    .filter(|(cut, _)| !cycle.intersects(cut))
3165                    .map(|(_, representative_cut_id)| *representative_cut_id)
3166                    .sorted_by_key(|cut_id| cut_id.0)
3167                    .dedup()
3168                    .collect_vec()
3169            });
3170            if compatible_event_cut_ids.as_ref().is_some_and(Vec::is_empty) {
3171                return Err(eyre!(
3172                    "UV subset {subset:?} in LMB '{}' of graph '{}' has no compatible Cutkosky cut after cut-compatible limit selection",
3173                    lmb.tree.string_label(),
3174                    graph.name,
3175                ));
3176            }
3177            let res = self.sample_subset(
3178                integrand,
3179                SubsetSampleInput {
3180                    graph_id,
3181                    subset,
3182                    initial_dod: *initial_dod,
3183                    lmb,
3184                    sample: &sample,
3185                    orientation_labels,
3186                    compatible_event_cut_ids: compatible_event_cut_ids.as_deref(),
3187                },
3188            )?;
3189            subset_span.pb_inc(1);
3190            if self.profile_settings.fail_fast
3191                && res
3192                    .analyse(self.scales)
3193                    .inspect_verdicts(
3194                        UV_PROFILE_MAX_DOD,
3195                        compatible_cuts.is_some()
3196                            || self.profile_settings.allow_vanishing_missing_fits,
3197                    )
3198                    .any(|(_, reason)| reason.is_some())
3199            {
3200                // Retain the completed limit (including all requested
3201                // residues and precision retries) before stopping.
3202                self.stopped.store(true, Ordering::Relaxed);
3203            }
3204            Ok((subset.clone(), res))
3205        };
3206        let per_subsets_vec: Vec<(SubSet<LoopIndex>, SubSetResult)> =
3207            if self.profile_settings.fail_fast {
3208                let mut integrand = prepare_integrand();
3209                subsets
3210                    .iter()
3211                    .take_while(|_| !self.stopped.load(Ordering::Relaxed))
3212                    .map(|subset| sample_subset(&mut integrand, subset))
3213                    .collect::<Result<_>>()?
3214            } else {
3215                subsets
3216                    .par_iter()
3217                    .map_init(prepare_integrand, sample_subset)
3218                    .collect::<Result<_>>()?
3219            };
3220        let per_subsets = per_subsets_vec.into_iter().collect();
3221
3222        subset_span.pb_set_finish_message(if self.stopped.load(Ordering::Relaxed) {
3223            "stopped at failing UV limit"
3224        } else {
3225            "all subsets profiled"
3226        });
3227        drop(_subset_span_enter);
3228        drop(subset_span);
3229
3230        Ok(LMBResult {
3231            lmb: lmb.clone(),
3232            per_subsets,
3233        })
3234    }
3235}
3236
3237pub struct SubSetResult {
3238    pub(crate) initial_dod: i32,
3239    pub(crate) inspect: InspectSamples,
3240    pub(crate) analytic: Option<AnalyticResult>,
3241}
3242
3243#[derive(Debug, Clone, Default)]
3244pub(crate) struct InspectSamples {
3245    pub(crate) summed: Vec<InspectResult>,
3246    pub(crate) summed_used_arb_prec_retry: bool,
3247    pub(crate) per_orientation: Vec<OrientationInspectSamples>,
3248}
3249
3250#[derive(Debug, Clone)]
3251pub(crate) struct OrientationInspectSamples {
3252    pub(crate) label: String,
3253    pub(crate) inspect: Vec<InspectResult>,
3254    pub(crate) used_arb_prec_retry: bool,
3255}
3256
3257#[derive(Debug, Clone)]
3258struct InspectRun {
3259    inspect: Vec<InspectResult>,
3260    used_arb_prec_retry: bool,
3261}
3262
3263#[derive(Clone, Copy)]
3264struct SubsetOrientationInput<'a> {
3265    graph_id: usize,
3266    subset: &'a SubSet<LoopIndex>,
3267    lmb: &'a LoopMomentumBasis,
3268    sample: &'a LoopMomentumSample,
3269    orientation: Option<usize>,
3270    compatible_event_cut_ids: Option<&'a [CutId]>,
3271}
3272
3273struct SubsetSampleInput<'a> {
3274    graph_id: usize,
3275    subset: &'a SubSet<LoopIndex>,
3276    initial_dod: i32,
3277    lmb: &'a LoopMomentumBasis,
3278    sample: &'a LoopMomentumSample,
3279    orientation_labels: Option<&'a [String]>,
3280    compatible_event_cut_ids: Option<&'a [CutId]>,
3281}
3282
3283impl<'a> UVProfileRunner<'a> {
3284    fn sample_subset(
3285        &self,
3286        integrand: &mut ProcessIntegrand,
3287        input: SubsetSampleInput<'_>,
3288    ) -> Result<SubSetResult> {
3289        let inspect = if let Some(orientation_labels) = input.orientation_labels {
3290            let per_orientation = orientation_labels
3291                .iter()
3292                .enumerate()
3293                .map(|(orientation_id, label)| {
3294                    let inspect = self.sample_subset_orientation(
3295                        integrand,
3296                        SubsetOrientationInput {
3297                            graph_id: input.graph_id,
3298                            subset: input.subset,
3299                            lmb: input.lmb,
3300                            sample: input.sample,
3301                            orientation: Some(orientation_id),
3302                            compatible_event_cut_ids: input.compatible_event_cut_ids,
3303                        },
3304                    )?;
3305                    Ok(OrientationInspectSamples {
3306                        label: label.clone(),
3307                        inspect: inspect.inspect,
3308                        used_arb_prec_retry: inspect.used_arb_prec_retry,
3309                    })
3310                })
3311                .collect::<Result<Vec<_>>>()?;
3312            let summed_input = SubsetOrientationInput {
3313                graph_id: input.graph_id,
3314                subset: input.subset,
3315                lmb: input.lmb,
3316                sample: input.sample,
3317                orientation: None,
3318                compatible_event_cut_ids: input.compatible_event_cut_ids,
3319            };
3320            // Evaluate the sum before converting the result to f64. Summing the
3321            // exported per-orientation values can lose the cancellations that
3322            // the local subtraction is specifically meant to expose.
3323            let mut summed = self.sample_subset_orientation(integrand, summed_input)?;
3324            if !self.profile_settings.use_f128
3325                && !summed.used_arb_prec_retry
3326                && per_orientation
3327                    .iter()
3328                    .any(|orientation| orientation.used_arb_prec_retry)
3329            {
3330                summed = InspectRun {
3331                    inspect: self.sample_subset_orientation_with_precision(
3332                        integrand,
3333                        summed_input,
3334                        true,
3335                    )?,
3336                    used_arb_prec_retry: true,
3337                };
3338            }
3339            InspectSamples {
3340                summed: summed.inspect,
3341                summed_used_arb_prec_retry: summed.used_arb_prec_retry,
3342                per_orientation,
3343            }
3344        } else {
3345            let inspect = self.sample_subset_orientation(
3346                integrand,
3347                SubsetOrientationInput {
3348                    graph_id: input.graph_id,
3349                    subset: input.subset,
3350                    lmb: input.lmb,
3351                    sample: input.sample,
3352                    orientation: None,
3353                    compatible_event_cut_ids: input.compatible_event_cut_ids,
3354                },
3355            )?;
3356            InspectSamples {
3357                summed: inspect.inspect,
3358                summed_used_arb_prec_retry: inspect.used_arb_prec_retry,
3359                per_orientation: Vec::new(),
3360            }
3361        };
3362        let analytic = None;
3363
3364        Ok(SubSetResult {
3365            inspect,
3366            initial_dod: input.initial_dod,
3367            analytic,
3368        })
3369    }
3370
3371    fn sample_subset_orientation(
3372        &self,
3373        integrand: &mut ProcessIntegrand,
3374        input: SubsetOrientationInput<'_>,
3375    ) -> Result<InspectRun> {
3376        let inspect = self.sample_subset_orientation_with_precision(
3377            integrand,
3378            input,
3379            self.profile_settings.use_f128,
3380        )?;
3381        if !self.profile_settings.use_f128
3382            && inspect_results_need_arbprec_retry(&inspect, self.scales)
3383        {
3384            return Ok(InspectRun {
3385                inspect: self.sample_subset_orientation_with_precision(integrand, input, true)?,
3386                used_arb_prec_retry: true,
3387            });
3388        }
3389        Ok(InspectRun {
3390            inspect,
3391            used_arb_prec_retry: false,
3392        })
3393    }
3394
3395    fn sample_subset_orientation_with_precision(
3396        &self,
3397        integrand: &mut ProcessIntegrand,
3398        input: SubsetOrientationInput<'_>,
3399        use_arb_prec: bool,
3400    ) -> Result<Vec<InspectResult>> {
3401        let n_included = input.subset.n_included() as i32;
3402        self.scales
3403            .iter()
3404            .map(|s| {
3405                let prefactor = F(*s).to_arb_exact()?.powi(3 * n_included);
3406                let mut scaled_sample = input.sample.clone();
3407                for l in input.subset.included_iter() {
3408                    scaled_sample[l] = scaled_sample[l].map_ref(&|a| a * F(*s));
3409                }
3410                // Keep the profile ray in candidate coordinates. The evaluator
3411                // casts it to the requested precision before routing it to the
3412                // selected graph's canonical LMB.
3413                let result = match integrand {
3414                    ProcessIntegrand::Amplitude(amplitude) => {
3415                        evaluate_profile_momentum_point_precise(
3416                            amplitude,
3417                            self.model,
3418                            input.graph_id,
3419                            input.orientation,
3420                            scaled_sample.raw,
3421                            Some(input.lmb),
3422                            use_arb_prec,
3423                            &prefactor,
3424                        )
3425                    }
3426                    ProcessIntegrand::CrossSection(cross_section) => {
3427                        evaluate_profile_momentum_point_precise(
3428                            cross_section,
3429                            self.model,
3430                            input.graph_id,
3431                            input.orientation,
3432                            scaled_sample.raw,
3433                            Some(input.lmb),
3434                            use_arb_prec,
3435                            &prefactor,
3436                        )
3437                    }
3438                }?;
3439                Ok(match result {
3440                    PreciseEvaluationResult::Double(result) => InspectResult::from_evaluation(
3441                        result,
3442                        &prefactor,
3443                        input.compatible_event_cut_ids,
3444                    ),
3445                    PreciseEvaluationResult::Quad(result) => InspectResult::from_evaluation(
3446                        result,
3447                        &prefactor,
3448                        input.compatible_event_cut_ids,
3449                    ),
3450                    PreciseEvaluationResult::Arb(result) => InspectResult::from_evaluation(
3451                        result,
3452                        &prefactor,
3453                        input.compatible_event_cut_ids,
3454                    ),
3455                })
3456            })
3457            .collect()
3458    }
3459}
3460
3461impl SubSetResult {
3462    pub fn analyse_inspect(&self, scales: &[f64]) -> Option<InspectAnalysis> {
3463        analyse_inspect_results(
3464            &self.inspect.summed,
3465            scales,
3466            self.inspect.summed_used_arb_prec_retry,
3467        )
3468    }
3469
3470    pub fn analyse(&self, scales: &[f64]) -> Analysis {
3471        Analysis {
3472            inspect_level: self.analyse_inspect(scales),
3473            inspect_fit_status: InspectFitStatus::from_results(&self.inspect.summed, scales),
3474            per_orientation_inspect: (!self.inspect.per_orientation.is_empty()).then(|| {
3475                self.inspect
3476                    .per_orientation
3477                    .iter()
3478                    .map(|orientation| OrientationInspectAnalysis {
3479                        orientation_label: orientation.label.clone(),
3480                        analysis: analyse_inspect_results(
3481                            &orientation.inspect,
3482                            scales,
3483                            orientation.used_arb_prec_retry,
3484                        ),
3485                        inspect_fit_status: InspectFitStatus::from_results(
3486                            &orientation.inspect,
3487                            scales,
3488                        ),
3489                    })
3490                    .collect()
3491            }),
3492            analytic: self.analyse_analytic(),
3493        }
3494    }
3495
3496    pub fn analyse_analytic(&self) -> Option<AnalyticAnalysis> {
3497        //             .derived_data
3498        //             .cff_expression
3499        //             .as_ref()
3500        //             .unwrap()
3501        //             .orientations
3502        //             .iter()
3503        //             .enumerate()
3504        //         {
3505        //             let expansion = symbol!("lambd");
3506
3507        //             for (ls, res) in &analytic_res[i_lmb][i] {
3508        //                 // print!("res:{res}");
3509        //                 let l = res.coefficient_list::<i8>(&[Atom::var(expansion)]);
3510
3511        //                 println!(
3512        //                     "In the limit of {:?} going to infinity for orientation \n{}:",
3513        //                     ls.included_iter()
3514        //                         .map(|l| lmb.loop_edges[l].to_string())
3515        //                         .collecqt::<Vec<_>>(),
3516        //                     o.data
3517        //                 );
3518        //                 if l.is_empty() {
3519        //                     println!("\tFull cancellation to order 1");
3520        //                 }
3521        //                 for (t, a) in l {
3522        //                     println!("\t{}: {}", t, a);
3523        //                 }
3524        //             }
3525        //         }
3526
3527        Some(AnalyticAnalysis {
3528            per_orientation: self
3529                .analytic
3530                .as_ref()?
3531                .per_orientations
3532                .par_iter()
3533                .map(|(k, v)| {
3534                    (
3535                        k.clone(),
3536                        OrientationAnalyticAnalysis {
3537                            is_constant: v.is_constant(),
3538                            leading_coef: v.lcoeff().to_ordered_simple(),
3539                        },
3540                    )
3541                })
3542                .collect(),
3543        })
3544    }
3545}
3546
3547fn analyse_inspect_results(
3548    inspect: &[InspectResult],
3549    scales: &[f64],
3550    used_arb_prec_retry: bool,
3551) -> Option<InspectAnalysis> {
3552    let result = log_log_slope(inspect, scales)?;
3553    let dod = result.slope.round() as i64;
3554    Some(InspectAnalysis {
3555        result,
3556        estimated_dod: dod,
3557        used_arb_prec_retry,
3558    })
3559}
3560
3561fn inspect_results_need_arbprec_retry(inspect: &[InspectResult], scales: &[f64]) -> bool {
3562    match analyse_inspect_results(inspect, scales, false) {
3563        None => true,
3564        Some(analysis) => {
3565            analysis.result.slope.is_nan()
3566                || analysis.result.slope > UV_PROFILE_MAX_DOD
3567                || !analysis.result.is_asymptotic()
3568                || analysis.result.full_range_slope.is_nan()
3569                || analysis.result.full_range_slope > UV_PROFILE_MAX_DOD
3570                || !analysis.result.full_range_r_squared.is_finite()
3571                || analysis.result.full_range_r_squared < UV_PROFILE_RETRY_MIN_R_SQUARED
3572        }
3573    }
3574}
3575
3576/// Shortest high-scale suffix of `len` samples which may represent the asymptotic regime: at
3577/// least half of the samples, and at least the minimum ray length whenever the ray provides it.
3578fn asymptotic_suffix_len(len: usize) -> usize {
3579    len.min(UV_PROFILE_MIN_POINTS.max(len.div_ceil(2)))
3580}
3581
3582fn log_log_slope(inspect: &[InspectResult], scales: &[f64]) -> Option<FitResult> {
3583    let mut valid_samples = Vec::new();
3584    let mut points = vec![];
3585    let mut valid_scales = vec![];
3586
3587    for (x, s) in inspect.iter().zip(scales) {
3588        // A failed sample must trigger the Arb retry, and remain a failed ray
3589        // if reevaluation cannot recover it. Finite zero samples are distinct.
3590        let norm = x.magnitude.as_ref()?;
3591        if norm.is_nan() || norm.is_infinite() {
3592            return None;
3593        }
3594        if norm <= &norm.zero() {
3595            debug!(scale = s, "zero UV profile sample");
3596            continue;
3597        }
3598        let y = norm.log10().into_ff64().0;
3599        let x = s.log10();
3600        if !y.is_finite() || !x.is_finite() {
3601            continue;
3602        }
3603        points.push(norm.clone());
3604        valid_scales.push(*s);
3605        valid_samples.push((x, y));
3606    }
3607
3608    if valid_samples.len() < 2 {
3609        return None;
3610    }
3611
3612    let fit = |samples: &[(f64, f64)]| {
3613        let n = samples.len() as f64;
3614        let (sum_x, sum_y, sum_xy, sum_x2) = samples.iter().fold(
3615            (0.0, 0.0, 0.0, 0.0),
3616            |(sum_x, sum_y, sum_xy, sum_x2), (x, y)| {
3617                (sum_x + x, sum_y + y, sum_xy + x * y, sum_x2 + x * x)
3618            },
3619        );
3620        let denominator = n * sum_x2 - sum_x * sum_x;
3621        if denominator.abs() < 1e-15 {
3622            return None;
3623        }
3624        let slope = (n * sum_xy - sum_x * sum_y) / denominator;
3625        let intercept = (sum_y - slope * sum_x) / n;
3626        let y_mean = sum_y / n;
3627        let (ss_tot, ss_res) = samples.iter().fold((0.0, 0.0), |(ss_tot, ss_res), (x, y)| {
3628            let residual = y - (intercept + slope * x);
3629            (ss_tot + (y - y_mean).powi(2), ss_res + residual.powi(2))
3630        });
3631        let r_squared = if ss_tot > 1e-15 {
3632            1.0 - ss_res / ss_tot
3633        } else {
3634            0.0
3635        };
3636        Some((slope, intercept, r_squared))
3637    };
3638
3639    let full_range_fit = fit(&valid_samples)?;
3640    // UV power counting is asymptotic. Retain the longest high-scale suffix which is already
3641    // linear, dropping only a pre-asymptotic prefix.
3642    let minimum_suffix_len = asymptotic_suffix_len(valid_samples.len());
3643    let (fit_start, (slope, intercept, r_squared)) = (0..=valid_samples.len() - minimum_suffix_len)
3644        .find_map(|start| {
3645            let candidate = fit(&valid_samples[start..])?;
3646            (candidate.2 >= UV_PROFILE_ASYMPTOTIC_MIN_R_SQUARED).then_some((start, candidate))
3647        })
3648        .unwrap_or((0, full_range_fit));
3649    if slope > UV_PROFILE_MAX_DOD || r_squared < UV_PROFILE_ASYMPTOTIC_MIN_R_SQUARED {
3650        debug!(
3651            slope,
3652            intercept,
3653            r_squared,
3654            full_range_r_squared = full_range_fit.2,
3655            fit_start,
3656            ?valid_samples,
3657            "unstable UV profile log-log fit"
3658        );
3659    }
3660
3661    Some(FitResult {
3662        points,
3663        scales: valid_scales,
3664        fit_start,
3665        slope,
3666        intercept,
3667        r_squared,
3668        full_range_slope: full_range_fit.0,
3669        full_range_intercept: full_range_fit.1,
3670        full_range_r_squared: full_range_fit.2,
3671    })
3672}
3673
3674fn inspect_label(
3675    analysis: Option<&InspectAnalysis>,
3676    failure: Option<&str>,
3677    decay_slope: Option<f64>,
3678) -> String {
3679    let tail = decay_slope.map(|slope| {
3680        if slope.is_finite() {
3681            "decaying tail"
3682        } else {
3683            "zero tail"
3684        }
3685    });
3686    let status = match (failure, tail) {
3687        (Some(reason), Some(tail)) => format!("{reason} ({tail})").red().to_string(),
3688        (Some(reason), None) => reason.red().to_string(),
3689        (None, Some(tail)) => tail.to_string(),
3690        (None, None) => String::new(),
3691    };
3692    if analysis.is_some_and(|analysis| analysis.used_arb_prec_retry) {
3693        let retry = "arb retry".yellow();
3694        if status.is_empty() {
3695            retry.to_string()
3696        } else {
3697            format!("{status}, {retry}")
3698        }
3699    } else {
3700        status
3701    }
3702}
3703
3704#[derive(Debug, Clone)]
3705pub struct InspectResult {
3706    /// Fully weighted magnitude; None is an invalid sample, not a zero tail.
3707    magnitude: Option<F<ArbPrec>>,
3708}
3709
3710impl InspectResult {
3711    fn from_evaluation<T: FloatLike>(
3712        result: GenericEvaluationResult<T>,
3713        prefactor: &F<ArbPrec>,
3714        compatible_event_cut_ids: Option<&[CutId]>,
3715    ) -> Self {
3716        // The evaluator already sums orientations in its native precision. Only
3717        // cut projection and the profile measure remain before taking a magnitude.
3718        let magnitude = (|| {
3719            if result.evaluation_metadata.is_nan {
3720                return None;
3721            }
3722            let value = if let Some(cuts) = compatible_event_cut_ids {
3723                result
3724                    .event_groups
3725                    .iter()
3726                    .flat_map(|group| group.iter())
3727                    .filter(|event| cuts.contains(&CutId(event.cut_info.cut_id)))
3728                    .try_fold(Complex::new_re(prefactor.zero()), |sum, event| {
3729                        Some(
3730                            sum + Complex::new(
3731                                event.weight.re.to_arb_exact().ok()?,
3732                                event.weight.im.to_arb_exact().ok()?,
3733                            ),
3734                        )
3735                    })?
3736            } else {
3737                Complex::new(
3738                    result.integrand_result.re.to_arb_exact().ok()?,
3739                    result.integrand_result.im.to_arb_exact().ok()?,
3740                ) * result.integrator_weight.to_arb_exact().ok()?
3741            };
3742            let weighted = value * prefactor;
3743            let magnitude = weighted.re.hypot(&weighted.im);
3744            (!magnitude.is_nan() && !magnitude.is_infinite()).then_some(magnitude)
3745        })();
3746        // Nonfinite values and native values which cannot be embedded exactly
3747        // request reevaluation in Arb. Never round before a cut cancellation or
3748        // abort the entire report for an invalid profile sample.
3749        Self { magnitude }
3750    }
3751}
3752
3753/// Sampled evidence of an accelerating decay or a zero tail, without an asymptotic power-law fit.
3754#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
3755struct InspectFitStatus {
3756    /// Smallest initial slope of a high-scale log-log majorant whose decay accelerates or ends
3757    /// at zero; `-∞` for a window ending at zero with at most one nonzero sample.
3758    /// `None` if no window supplies that evidence or any sample is invalid.
3759    #[serde(
3760        default,
3761        serialize_with = "InspectFitStatus::serialize_decay_slope",
3762        deserialize_with = "InspectFitStatus::deserialize_decay_slope"
3763    )]
3764    decay_slope: Option<f64>,
3765}
3766
3767impl InspectFitStatus {
3768    // JSON has no infinity literal; keep exact-zero evidence distinct from missing data.
3769    fn serialize_decay_slope<S: serde::Serializer>(
3770        slope: &Option<f64>,
3771        serializer: S,
3772    ) -> Result<S::Ok, S::Error> {
3773        match slope {
3774            None => serializer.serialize_none(),
3775            Some(slope) if slope.is_finite() => serializer.serialize_some(slope),
3776            Some(slope) if *slope == f64::NEG_INFINITY => serializer.serialize_some("-inf"),
3777            Some(_) => Err(serde::ser::Error::custom("expected a finite slope or -inf")),
3778        }
3779    }
3780
3781    fn deserialize_decay_slope<'de, D: serde::Deserializer<'de>>(
3782        deserializer: D,
3783    ) -> Result<Option<f64>, D::Error> {
3784        #[derive(Deserialize)]
3785        #[serde(untagged)]
3786        enum Slope {
3787            Number(f64),
3788            Marker(String),
3789        }
3790
3791        match Option::<Slope>::deserialize(deserializer)? {
3792            None => Ok(None),
3793            Some(Slope::Number(slope)) if slope.is_finite() => Ok(Some(slope)),
3794            Some(Slope::Marker(marker)) if marker == "-inf" => Ok(Some(f64::NEG_INFINITY)),
3795            Some(_) => Err(serde::de::Error::custom("expected a finite slope or -inf")),
3796        }
3797    }
3798
3799    fn from_results(inspect: &[InspectResult], scales: &[f64]) -> Self {
3800        let mut samples = Vec::with_capacity(inspect.len());
3801        for (result, scale) in inspect.iter().zip(scales) {
3802            let Some(norm) = result.magnitude.as_ref() else {
3803                return Self::default();
3804            };
3805            if norm.is_nan() || norm.is_infinite() {
3806                // An invalid sample cannot certify an inactive, vanishing LU ray.
3807                return Self::default();
3808            }
3809            let log_magnitude = (norm > &norm.zero()).then(|| norm.log10().into_ff64().0);
3810            samples.push((scale.log10(), log_magnitude));
3811        }
3812        // As for the power-law fit, a pre-asymptotic prefix of at most half the ray is ignored.
3813        let window_len = asymptotic_suffix_len(samples.len());
3814        Self {
3815            decay_slope: (0..=samples.len() - window_len)
3816                .filter_map(|start| Self::window_decay_slope(&samples[start..]))
3817                .min_by(f64::total_cmp),
3818        }
3819    }
3820
3821    /// Initial, hence shallowest, slope of the least concave majorant of the window's nonzero
3822    /// `(log λ, log |f|)` samples, provided the samples follow it to their end. The majorant
3823    /// bridges isolated dips, such as an exact cancellation resolved by Arb, but its final segment
3824    /// must be the final sampled step: a tail which flattens or rises leaves it. With at least two
3825    /// nonzero samples, exact zeros may only trail them and then replace that requirement.
3826    /// A sparse window with at most one nonzero sample instead requires an exact zero endpoint.
3827    /// Nonzero windows must also show two successive doublings of their decay rate at separated
3828    /// checkpoints. This dimensionless heuristic rejects noisy power laws and isolated final drops;
3829    /// it does not prove asymptotic behavior beyond the sampled range.
3830    fn window_decay_slope(window: &[(f64, Option<f64>)]) -> Option<f64> {
3831        let nonzero = window
3832            .iter()
3833            .filter_map(|&(log_scale, log_magnitude)| Some((log_scale, log_magnitude?)))
3834            .collect_vec();
3835        let [earlier @ .., penultimate, last] = nonzero.as_slice() else {
3836            // A sparse, inactive ray must end at zero; a late revival is unresolved.
3837            return window
3838                .last()
3839                .is_some_and(|(_, log_magnitude)| log_magnitude.is_none())
3840                .then_some(f64::NEG_INFINITY);
3841        };
3842        let trailing_zeros = window
3843            .iter()
3844            .rev()
3845            .take_while(|(_, log_magnitude)| log_magnitude.is_none())
3846            .count();
3847        if nonzero.len() + trailing_zeros < window.len() {
3848            return None;
3849        }
3850        let slope = |(x0, y0): (f64, f64), (x1, y1): (f64, f64)| (y1 - y0) / (x1 - x0);
3851        let final_slope = slope(*penultimate, *last);
3852        if trailing_zeros == 0
3853            && !earlier
3854                .iter()
3855                .all(|point| final_slope <= slope(*point, *penultimate))
3856        {
3857            return None;
3858        }
3859        let initial_slope = |start| {
3860            nonzero[start + 1..]
3861                .iter()
3862                .map(|point| slope(nonzero[start], *point))
3863                .max_by(f64::total_cmp)
3864        };
3865        let initial = initial_slope(0)?;
3866        if trailing_zeros == 0 {
3867            if nonzero.len() < 4 {
3868                return None;
3869            }
3870            let middle = initial_slope((nonzero.len() - 1) / 3)?;
3871            let late = initial_slope(2 * (nonzero.len() - 1) / 3)?;
3872            if !(initial < 0.0 && middle <= 2.0 * initial && late <= 2.0 * middle) {
3873                return None;
3874            }
3875        }
3876        Some(initial)
3877    }
3878
3879    /// Accept sampled zero tails or accelerating decay only when no asymptotic power law describes
3880    /// them and the initial decay bound meets `max_dod`. A shallow bound alone is insufficient.
3881    fn vanishes_without_power_law(self, analysis: Option<&InspectAnalysis>, max_dod: f64) -> bool {
3882        !analysis.is_some_and(|analysis| analysis.result.is_asymptotic())
3883            && self.decay_slope.is_some_and(|slope| slope <= max_dod)
3884    }
3885}
3886
3887#[derive(Debug, Clone, Serialize, Deserialize)]
3888pub struct Analysis {
3889    ///Is None if the fit hasn't worked
3890    inspect_level: Option<InspectAnalysis>,
3891    #[serde(default)]
3892    inspect_fit_status: InspectFitStatus,
3893    #[serde(skip, default)]
3894    per_orientation_inspect: Option<Vec<OrientationInspectAnalysis>>,
3895    ///Is None if the analytic analysis is disabled
3896    #[serde(skip, default)]
3897    analytic: Option<AnalyticAnalysis>,
3898}
3899
3900impl Analysis {
3901    // The early-stop decision and the final report consume the same summed
3902    // and per-residue verdicts, including the LU vanishing-fit exemption.
3903    fn inspect_verdicts(
3904        &self,
3905        max_dod: f64,
3906        allow_vanishing_missing_fits: bool,
3907    ) -> impl Iterator<Item = (Option<&str>, Option<&'static str>)> {
3908        std::iter::once((None, self.inspect_level.as_ref(), self.inspect_fit_status))
3909            .chain(self.per_orientation_inspect.iter().flatten().map(|entry| {
3910                (
3911                    Some(entry.orientation_label.as_str()),
3912                    entry.analysis.as_ref(),
3913                    entry.inspect_fit_status,
3914                )
3915            }))
3916            .map(move |(orientation, analysis, status)| {
3917                (
3918                    orientation,
3919                    inspect_failure_reason(analysis, status, max_dod, allow_vanishing_missing_fits),
3920                )
3921            })
3922    }
3923
3924    fn per_orientation_inspect_entries(&self) -> Option<Vec<UVProfileOrientationInspectEntry>> {
3925        self.per_orientation_inspect.as_ref().map(|entries| {
3926            entries
3927                .iter()
3928                .map(|entry| UVProfileOrientationInspectEntry {
3929                    orientation_label: entry.orientation_label.clone(),
3930                    analysis: entry.analysis.clone(),
3931                    inspect_fit_status: entry.inspect_fit_status,
3932                })
3933                .collect()
3934        })
3935    }
3936}
3937
3938#[derive(Debug, Clone)]
3939struct OrientationInspectAnalysis {
3940    orientation_label: String,
3941    analysis: Option<InspectAnalysis>,
3942    inspect_fit_status: InspectFitStatus,
3943}
3944
3945#[derive(Debug, Clone, Serialize)]
3946pub struct AnalyticAnalysis {
3947    per_orientation: BTreeMap<OrientationData, OrientationAnalyticAnalysis>,
3948}
3949
3950#[derive(Debug, Clone, Serialize)]
3951pub struct OrientationAnalyticAnalysis {
3952    is_constant: bool,
3953    leading_coef: String,
3954}
3955
3956#[derive(Debug, Clone, Serialize, Deserialize)]
3957pub struct InspectAnalysis {
3958    result: FitResult,
3959    estimated_dod: i64,
3960    used_arb_prec_retry: bool,
3961}
3962
3963pub struct AnalyticResult {
3964    pub(crate) per_orientations: BTreeMap<OrientationData, Series<AtomField>>,
3965}