1use 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)>)>;
58pub 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 #[default]
73 OnlyDivergent,
74 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 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 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 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 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 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 (
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 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 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 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 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 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 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 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 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 const ARB_LOG10_MIN: f64 = -1_073_741_823.0 * std::f64::consts::LOG10_2;
1300
1301 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 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 (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 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 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 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 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 alternating(power_law(0.0), 0.3),
1471 alternating(power_law(-0.5), 0.3),
1472 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 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 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 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 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 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 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 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 }
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 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 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 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 #[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 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)]
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 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 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
2741fn 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 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 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 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 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 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 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 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
3576fn 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 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 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 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 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 Self { magnitude }
3750 }
3751}
3752
3753#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
3755struct InspectFitStatus {
3756 #[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 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 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 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 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 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 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 inspect_level: Option<InspectAnalysis>,
3891 #[serde(default)]
3892 inspect_fit_status: InspectFitStatus,
3893 #[serde(skip, default)]
3894 per_orientation_inspect: Option<Vec<OrientationInspectAnalysis>>,
3895 #[serde(skip, default)]
3897 analytic: Option<AnalyticAnalysis>,
3898}
3899
3900impl Analysis {
3901 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}