Skip to main content

gammalooprs/integrands/
evaluation.rs

1use std::collections::BTreeMap;
2use std::fmt::Display;
3use std::time::Duration;
4
5use bincode::{Decode, Encode};
6use colored::Colorize;
7use serde::{Deserialize, Serialize};
8use spenso::algebra::complex::Complex;
9use tabled::{
10    Table, Tabled,
11    builder::Builder,
12    settings::{
13        Alignment, Modify, Panel, Style,
14        object::{Columns, Object, Rows},
15        style::{HorizontalLine, On, VerticalLine},
16        themes::BorderCorrection,
17    },
18};
19
20use crate::observables::{
21    AdditionalWeightKey, EventGroupList, GenericEventGroupList, HistogramAccumulatorState,
22    HistogramMedianPosition, HistogramSnapshot, ObservablePhase, ObservableSnapshotBundle,
23    ObservableValueTransform,
24    events::{format_complex_generic, format_optional_real_generic, format_real_generic},
25};
26use crate::{
27    integrands::process::{
28        sampling::context::SamplingProposalPolicies, sampling::reference::ReferenceMoments,
29    },
30    settings::runtime::{IntegrationStatisticsSnapshot, Precision},
31    utils::{
32        ArbPrec, F, FloatLike, duration_from_secs_f64_saturating, f128, format_evaluation_time,
33        newton_solver::RadialRootDiagnostics, normalize_tabled_separator_rows,
34    },
35};
36
37#[derive(Clone, Debug)]
38pub struct GraphEvaluationResult<T: FloatLike> {
39    pub integrand_result: Complex<F<T>>,
40    /// Componentwise absolute values after physical cancellation at each point,
41    /// summed over distinct sampling-channel points of the same outer draw.
42    pub absolute_integrand_result: Option<Complex<F<T>>>,
43    /// Private amplitude stability monitor: sum of complex norms at distinct
44    /// channel points, after common-point physical cancellation. Unlike the
45    /// componentwise absolute report, it is invariant under helicity phases.
46    pub(crate) channel_norm_sum: Option<F<T>>,
47    pub reference_moments: Option<ReferenceMoments<T>>,
48    pub event_groups: GenericEventGroupList<T>,
49    pub event_processing_time: Duration,
50    pub generated_event_count: usize,
51    pub accepted_event_count: usize,
52}
53
54impl<T: FloatLike> GraphEvaluationResult<T> {
55    pub fn zero(zero: F<T>) -> Self {
56        Self {
57            integrand_result: Complex::new_re(zero),
58            absolute_integrand_result: None,
59            channel_norm_sum: None,
60            reference_moments: None,
61            event_groups: GenericEventGroupList::default(),
62            event_processing_time: Duration::ZERO,
63            generated_event_count: 0,
64            accepted_event_count: 0,
65        }
66    }
67
68    pub fn merge_in_place(&mut self, mut other: Self) {
69        self.integrand_result += other.integrand_result;
70        if let Some(absolute) = other.absolute_integrand_result {
71            if let Some(current) = &mut self.absolute_integrand_result {
72                *current += absolute;
73            } else {
74                self.absolute_integrand_result = Some(absolute);
75            }
76        }
77        if let Some(norm) = other.channel_norm_sum {
78            if let Some(current) = &mut self.channel_norm_sum {
79                *current += norm;
80            } else {
81                self.channel_norm_sum = Some(norm);
82            }
83        }
84        if let Some(moments) = other.reference_moments {
85            if let Some(current) = &mut self.reference_moments {
86                current.merge_in_place(moments);
87            } else {
88                self.reference_moments = Some(moments);
89            }
90        }
91        self.event_groups.append(&mut other.event_groups);
92        self.event_processing_time += other.event_processing_time;
93        self.generated_event_count += other.generated_event_count;
94        self.accepted_event_count += other.accepted_event_count;
95    }
96
97    /// Apply one sampling factor to the physical value/events or reference
98    /// value/moments at the same boundary. Channel sums must not reweight a
99    /// moment reconstructed from just one representative momentum point.
100    pub(crate) fn apply_sampling_factor(&mut self, factor: F<T>) {
101        if let Some(absolute) = &mut self.absolute_integrand_result {
102            *absolute *= factor.abs();
103        }
104        if let Some(norm) = &mut self.channel_norm_sum {
105            *norm *= factor.abs();
106        }
107        if let Some(moments) = &mut self.reference_moments {
108            moments.rescale(&factor);
109        }
110        let factor = Complex::new_re(factor);
111        self.integrand_result *= &factor;
112        crate::integrands::process::apply_full_event_multiplicative_factor_precise(
113            &mut self.event_groups,
114            &factor,
115        );
116    }
117}
118
119/// The result of an evaluation of the integrand
120#[derive(Clone, Serialize, Debug)]
121pub struct EvaluationResult {
122    /// Native map/partition/physics contribution, including its Jacobian.
123    /// The separately reported unapplied top-level Jacobian is unity. Summed
124    /// sampling channels likewise include their individual
125    /// map Jacobians and partition factors and report a unit top-level Jacobian.
126    pub integrand_result: Complex<F<f64>>,
127    /// Unweighted by the outer grid. Distinct channel points contribute their
128    /// absolute physical values before summing, preserving their covariance.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub absolute_integrand_result: Option<Complex<F<f64>>>,
131    pub parameterization_jacobian: Option<F<f64>>,
132    /// Monte Carlo sample weight supplied by the integrator/grid, excluding the parameterization Jacobian.
133    pub integrator_weight: F<f64>,
134    pub event_groups: EventGroupList,
135    pub evaluation_metadata: EvaluationMetaData,
136}
137
138#[derive(Clone, Serialize, Debug)]
139pub struct EvaluationResultOutput {
140    pub integrand_result: Complex<F<f64>>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub absolute_integrand_result: Option<Complex<F<f64>>>,
143    pub parameterization_jacobian: Option<F<f64>>,
144    pub integrator_weight: F<f64>,
145    pub event_groups: EventGroupList,
146    pub evaluation_metadata: Option<EvaluationMetaData>,
147}
148
149#[derive(Clone, Debug)]
150pub struct GenericEvaluationResult<T: FloatLike> {
151    /// Runtime-only acceptance data, retained until the selected native precision
152    /// has passed both value and moment checks. Physical output schemas omit it.
153    pub(crate) reference_moments: Option<ReferenceMoments<T>>,
154    pub integrand_result: Complex<F<T>>,
155    pub absolute_integrand_result: Option<Complex<F<T>>>,
156    pub parameterization_jacobian: Option<F<T>>,
157    pub integrator_weight: F<T>,
158    pub event_groups: GenericEventGroupList<T>,
159    pub evaluation_metadata: EvaluationMetaData,
160}
161
162#[derive(Clone, Debug)]
163pub struct GenericEvaluationResultOutput<T: FloatLike> {
164    pub integrand_result: Complex<F<T>>,
165    pub absolute_integrand_result: Option<Complex<F<T>>>,
166    pub parameterization_jacobian: Option<F<T>>,
167    pub integrator_weight: F<T>,
168    pub event_groups: GenericEventGroupList<T>,
169    pub evaluation_metadata: Option<EvaluationMetaData>,
170}
171
172impl<T: FloatLike> GenericEvaluationResult<T> {
173    /// Narrow only the final native map/partition/physics contribution at the
174    /// ordinary integration/reporting boundary. Precise APIs retain this value.
175    pub(crate) fn try_into_f64(self) -> eyre::Result<EvaluationResult> {
176        use eyre::WrapErr;
177
178        let one = self.integrator_weight.one();
179        let remaining_factor =
180            self.parameterization_jacobian.as_ref().unwrap_or(&one) * &self.integrator_weight;
181        eyre::ensure!(
182            remaining_factor.0.is_finite(),
183            "nonfinite remaining native integration factor {remaining_factor}"
184        );
185        // Ordinary final rounding may yield zero. A small intermediate must still
186        // be retained if any unapplied factor can promote its contribution.
187        // None keeps the strict policy for separately stored factors and auxiliary
188        // entries whose complete remaining multiplier is unavailable.
189        let check = |value: &F<T>, remaining: Option<&F<T>>| -> eyre::Result<()> {
190            let reported = value.into_f64();
191            if value.0.is_finite() {
192                if !reported.is_finite() {
193                    return Err(eyre::eyre!(
194                        "native value {value} overflows the f64 integration/reporting boundary"
195                    ));
196                }
197                if reported == 0.0 && value != &value.zero() {
198                    let complete = remaining.map(|factor| value * factor);
199                    if complete.as_ref().is_none_or(|weighted| {
200                        // A completed contribution below binary64's normal
201                        // range is an exponentially suppressed tail and may
202                        // be rounded to zero at the reporting boundary.  A
203                        // remaining factor that promotes it into the normal
204                        // range must still be preserved and therefore fails.
205                        !weighted.0.is_finite()
206                            || weighted.abs() >= F::<T>::from_f64(f64::MIN_POSITIVE)
207                    }) {
208                        return Err(eyre::eyre!(
209                            "native value {value} rounds to zero before remaining factor {}; complete contribution {} cannot be preserved at the f64 integration/reporting boundary",
210                            remaining.map_or_else(
211                                || "separately stored factorized component".to_owned(),
212                                ToString::to_string
213                            ),
214                            complete
215                                .as_ref()
216                                .map_or_else(|| "not combined".to_owned(), ToString::to_string)
217                        ));
218                    }
219                }
220            }
221            Ok(())
222        };
223        // These factors remain separate in ordinary output. Each must survive
224        // conversion even when their complete native product rounds to zero.
225        check(&self.integrator_weight, None).wrap_err("integrator_weight")?;
226        if let Some(jacobian) = &self.parameterization_jacobian {
227            check(jacobian, None).wrap_err("parameterization_jacobian")?;
228        }
229        for (field, weight) in std::iter::once(("integrand_result", &self.integrand_result)).chain(
230            self.absolute_integrand_result
231                .iter()
232                .map(|weight| ("absolute_integrand_result", weight)),
233        ) {
234            for (component, value) in [("re", &weight.re), ("im", &weight.im)] {
235                check(value, Some(&remaining_factor))
236                    .wrap_err_with(|| format!("{field}.{component}"))?;
237            }
238        }
239        for (group_index, group) in self.event_groups.iter().enumerate() {
240            for (event_index, event) in group.iter().enumerate() {
241                // Event totals already contain their full native sampling factor.
242                for (component, value) in [("re", &event.weight.re), ("im", &event.weight.im)] {
243                    check(value, Some(&one)).wrap_err_with(|| {
244                        format!("event_groups[{group_index}][{event_index}].weight.{component}")
245                    })?;
246                }
247                // Auxiliary components remain factorized, but their complete
248                // multiplier is known. Bound each component's possible contribution
249                // to BOTH final phases before allowing an underflowed tail to round
250                // to zero; a complex factor may promote real weight into imaginary.
251                let component_factor = event
252                    .additional_weights
253                    .weights
254                    .get(&AdditionalWeightKey::FullMultiplicativeFactor)
255                    .filter(|factor| factor.re.0.is_finite() && factor.im.0.is_finite())
256                    .map(|factor| {
257                        let real = factor.re.abs();
258                        let imag = factor.im.abs();
259                        if real > imag { real } else { imag }
260                    });
261                for (key, weight) in &event.additional_weights.weights {
262                    let remaining = if *key == AdditionalWeightKey::FullMultiplicativeFactor {
263                        None
264                    } else {
265                        component_factor.as_ref()
266                    };
267                    for (component, value) in [("re", &weight.re), ("im", &weight.im)] {
268                        check(value, remaining).wrap_err_with(|| {
269                            format!("event_groups[{group_index}][{event_index}].additional_weights[{key:?}].{component}")
270                        })?;
271                    }
272                }
273            }
274        }
275        Ok(EvaluationResult {
276            integrand_result: Complex::new(
277                self.integrand_result.re.into_ff64(),
278                self.integrand_result.im.into_ff64(),
279            ),
280            absolute_integrand_result: self
281                .absolute_integrand_result
282                .map(|value| Complex::new(value.re.into_ff64(), value.im.into_ff64())),
283            parameterization_jacobian: self
284                .parameterization_jacobian
285                .map(|value| value.into_ff64()),
286            integrator_weight: self.integrator_weight.into_ff64(),
287            event_groups: self.event_groups.to_f64(),
288            evaluation_metadata: self.evaluation_metadata,
289        })
290    }
291
292    pub fn into_output(self, minimal_output: bool) -> GenericEvaluationResultOutput<T> {
293        GenericEvaluationResultOutput {
294            integrand_result: self.integrand_result,
295            absolute_integrand_result: self.absolute_integrand_result,
296            parameterization_jacobian: self.parameterization_jacobian,
297            integrator_weight: self.integrator_weight,
298            event_groups: self.event_groups,
299            evaluation_metadata: (!minimal_output).then_some(self.evaluation_metadata),
300        }
301    }
302}
303
304#[derive(Clone, Debug)]
305pub enum PreciseEvaluationResultOutput {
306    Double(GenericEvaluationResultOutput<f64>),
307    Quad(GenericEvaluationResultOutput<f128>),
308    Arb(GenericEvaluationResultOutput<ArbPrec>),
309}
310
311#[derive(Clone, Debug)]
312pub enum PreciseEvaluationResult {
313    Double(GenericEvaluationResult<f64>),
314    Quad(GenericEvaluationResult<f128>),
315    Arb(GenericEvaluationResult<ArbPrec>),
316}
317
318impl PreciseEvaluationResult {
319    pub(crate) fn try_into_f64(self) -> eyre::Result<EvaluationResult> {
320        match self {
321            Self::Double(result) => result.try_into_f64(),
322            Self::Quad(result) => result.try_into_f64(),
323            Self::Arb(result) => result.try_into_f64(),
324        }
325    }
326
327    pub fn into_output(self, minimal_output: bool) -> PreciseEvaluationResultOutput {
328        match self {
329            PreciseEvaluationResult::Double(result) => {
330                PreciseEvaluationResultOutput::Double(result.into_output(minimal_output))
331            }
332            PreciseEvaluationResult::Quad(result) => {
333                PreciseEvaluationResultOutput::Quad(result.into_output(minimal_output))
334            }
335            PreciseEvaluationResult::Arb(result) => {
336                PreciseEvaluationResultOutput::Arb(result.into_output(minimal_output))
337            }
338        }
339    }
340}
341
342impl PreciseEvaluationResultOutput {
343    pub fn precision(&self) -> Precision {
344        match self {
345            PreciseEvaluationResultOutput::Double(_) => Precision::Double,
346            PreciseEvaluationResultOutput::Quad(_) => Precision::Quad,
347            PreciseEvaluationResultOutput::Arb(_) => Precision::Arb,
348        }
349    }
350
351    pub fn evaluation_metadata(&self) -> Option<&EvaluationMetaData> {
352        match self {
353            PreciseEvaluationResultOutput::Double(result) => result.evaluation_metadata.as_ref(),
354            PreciseEvaluationResultOutput::Quad(result) => result.evaluation_metadata.as_ref(),
355            PreciseEvaluationResultOutput::Arb(result) => result.evaluation_metadata.as_ref(),
356        }
357    }
358}
359
360impl EvaluationResult {
361    pub fn zero() -> Self {
362        Self {
363            integrand_result: Complex::new_zero(),
364            absolute_integrand_result: None,
365            parameterization_jacobian: None,
366            integrator_weight: F(0.0),
367            event_groups: EventGroupList::default(),
368            evaluation_metadata: EvaluationMetaData::new_empty(),
369        }
370    }
371
372    pub fn into_output(self, minimal_output: bool) -> EvaluationResultOutput {
373        EvaluationResultOutput {
374            integrand_result: self.integrand_result,
375            absolute_integrand_result: self.absolute_integrand_result,
376            parameterization_jacobian: self.parameterization_jacobian,
377            integrator_weight: self.integrator_weight,
378            event_groups: self.event_groups,
379            evaluation_metadata: (!minimal_output).then_some(self.evaluation_metadata),
380        }
381    }
382}
383
384#[allow(clippy::too_many_arguments)]
385fn fmt_evaluation_result_output<T: FloatLike>(
386    f: &mut std::fmt::Formatter<'_>,
387    precision: Option<Precision>,
388    integrand_result: &Complex<F<T>>,
389    absolute_integrand_result: Option<&Complex<F<T>>>,
390    parameterization_jacobian: Option<&F<T>>,
391    integrator_weight: &F<T>,
392    event_groups: &GenericEventGroupList<T>,
393    evaluation_metadata: Option<&EvaluationMetaData>,
394) -> std::fmt::Result {
395    let mut summary_rows = Vec::with_capacity(5);
396    if let Some(precision) = precision {
397        summary_rows.push(EvaluationSummaryRow {
398            field: "precision".to_string(),
399            value: precision.to_string(),
400        });
401    }
402    summary_rows.extend([
403        EvaluationSummaryRow {
404            field: "integrand result".to_string(),
405            value: format_complex_generic(integrand_result),
406        },
407        EvaluationSummaryRow {
408            field: "parameterization jacobian".to_string(),
409            value: format_optional_real_generic(parameterization_jacobian),
410        },
411        EvaluationSummaryRow {
412            field: "integrator weight".to_string(),
413            value: format_real_generic(integrator_weight),
414        },
415        EvaluationSummaryRow {
416            field: "event groups".to_string(),
417            value: format_count(event_groups.len()),
418        },
419    ]);
420
421    if let Some(absolute) = absolute_integrand_result {
422        summary_rows.push(EvaluationSummaryRow {
423            field: "absolute physical contribution (|Re|, |Im|)".to_string(),
424            value: format!(
425                "({}, {})",
426                format_real_generic(&absolute.re),
427                format_real_generic(&absolute.im)
428            ),
429        });
430    }
431    writeln!(f, "{}", "Evaluation result".bold().bright_green())?;
432    writeln!(f, "{}", Table::new(summary_rows).with(Style::rounded()))?;
433    if let Some(metadata) = evaluation_metadata {
434        writeln!(f)?;
435        write!(f, "{metadata}")?;
436    }
437
438    if !event_groups.is_empty() {
439        writeln!(f)?;
440        writeln!(
441            f,
442            "{}",
443            format!(
444                "Generated {} event group(s)",
445                format_count(event_groups.len())
446            )
447            .bold()
448            .bright_cyan()
449        )?;
450        write!(f, "{event_groups}")?;
451    }
452
453    Ok(())
454}
455
456fn fmt_sample_result<D: Display>(
457    f: &mut std::fmt::Formatter<'_>,
458    evaluation: &D,
459    observables: &ObservableSnapshotBundle,
460) -> std::fmt::Result {
461    write!(f, "{evaluation}")?;
462
463    if let Some(observables_table) = summarize_observables(observables) {
464        writeln!(f)?;
465        writeln!(f)?;
466        writeln!(f, "{}", "Observable snapshots".bold().bright_magenta())?;
467        write!(f, "{observables_table}")?;
468    }
469
470    Ok(())
471}
472
473fn fmt_batch_result<D: Display>(
474    f: &mut std::fmt::Formatter<'_>,
475    samples: &[D],
476    observables: &ObservableSnapshotBundle,
477    numerical_stability: Option<&NumericalStabilityHistograms>,
478) -> std::fmt::Result {
479    let summary_rows = [EvaluationSummaryRow {
480        field: "samples".to_string(),
481        value: format_count(samples.len()),
482    }];
483
484    writeln!(f, "{}", "Batch evaluation result".bold().bright_green())?;
485    writeln!(f, "{}", Table::new(summary_rows).with(Style::rounded()))?;
486
487    if let Some(observables_table) = summarize_observables(observables) {
488        writeln!(f)?;
489        writeln!(f, "{}", "Observable snapshots".bold().bright_magenta())?;
490        writeln!(f, "{observables_table}")?;
491    }
492
493    if let Some(stability_table) = numerical_stability.and_then(summarize_numerical_stability) {
494        writeln!(f)?;
495        writeln!(f, "{}", "Numerical stability".bold().bright_magenta())?;
496        writeln!(f, "{stability_table}")?;
497    }
498
499    for (sample_index, sample) in samples.iter().enumerate() {
500        writeln!(f)?;
501        if sample_index > 0 {
502            writeln!(f)?;
503        }
504        writeln!(
505            f,
506            "{}",
507            format!("Sample {sample_index}").bold().bright_cyan()
508        )?;
509        write!(f, "{sample}")?;
510    }
511
512    Ok(())
513}
514
515impl Display for EvaluationResultOutput {
516    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        fmt_evaluation_result_output(
518            f,
519            None,
520            &self.integrand_result,
521            self.absolute_integrand_result.as_ref(),
522            self.parameterization_jacobian.as_ref(),
523            &self.integrator_weight,
524            &self.event_groups,
525            self.evaluation_metadata.as_ref(),
526        )
527    }
528}
529
530impl<T: FloatLike> Display for GenericEvaluationResultOutput<T> {
531    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532        fmt_evaluation_result_output(
533            f,
534            None,
535            &self.integrand_result,
536            self.absolute_integrand_result.as_ref(),
537            self.parameterization_jacobian.as_ref(),
538            &self.integrator_weight,
539            &self.event_groups,
540            self.evaluation_metadata.as_ref(),
541        )
542    }
543}
544
545impl Display for PreciseEvaluationResultOutput {
546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547        match self {
548            PreciseEvaluationResultOutput::Double(result) => fmt_evaluation_result_output(
549                f,
550                Some(Precision::Double),
551                &result.integrand_result,
552                result.absolute_integrand_result.as_ref(),
553                result.parameterization_jacobian.as_ref(),
554                &result.integrator_weight,
555                &result.event_groups,
556                result.evaluation_metadata.as_ref(),
557            ),
558            PreciseEvaluationResultOutput::Quad(result) => fmt_evaluation_result_output(
559                f,
560                Some(Precision::Quad),
561                &result.integrand_result,
562                result.absolute_integrand_result.as_ref(),
563                result.parameterization_jacobian.as_ref(),
564                &result.integrator_weight,
565                &result.event_groups,
566                result.evaluation_metadata.as_ref(),
567            ),
568            PreciseEvaluationResultOutput::Arb(result) => fmt_evaluation_result_output(
569                f,
570                Some(Precision::Arb),
571                &result.integrand_result,
572                result.absolute_integrand_result.as_ref(),
573                result.parameterization_jacobian.as_ref(),
574                &result.integrator_weight,
575                &result.event_groups,
576                result.evaluation_metadata.as_ref(),
577            ),
578        }
579    }
580}
581
582impl Display for SampleEvaluationResult {
583    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584        write!(f, "{}", self.evaluation)
585    }
586}
587
588#[derive(Clone, Serialize, Debug)]
589pub struct SampleEvaluationResult {
590    pub evaluation: EvaluationResultOutput,
591}
592
593#[derive(Clone, Debug)]
594pub struct PreciseSampleEvaluationResult {
595    pub evaluation: PreciseEvaluationResultOutput,
596}
597
598#[derive(Clone, Serialize, Debug)]
599pub struct SingleSampleEvaluationResult {
600    /// Evaluation record for the requested f64 sample.
601    pub sample: SampleEvaluationResult,
602    /// Persistent runtime-observable snapshot after processing the requested sample.
603    ///
604    /// The snapshot can include samples accumulated by earlier API calls.
605    pub observables: ObservableSnapshotBundle,
606}
607
608#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Hash)]
609pub enum NumericalStabilityLevel {
610    Double,
611    Quad,
612    ArbPrec,
613}
614
615impl NumericalStabilityLevel {
616    pub fn all() -> [Self; 3] {
617        [Self::Double, Self::Quad, Self::ArbPrec]
618    }
619
620    pub fn key(self) -> &'static str {
621        match self {
622            Self::Double => "Double",
623            Self::Quad => "Quad",
624            Self::ArbPrec => "ArbPrec",
625        }
626    }
627
628    pub fn short_label(self) -> &'static str {
629        match self {
630            Self::Double => "f64",
631            Self::Quad => "f128",
632            Self::ArbPrec => "arb",
633        }
634    }
635
636    fn from_precision(precision: Precision) -> Self {
637        match precision {
638            Precision::Double => Self::Double,
639            Precision::Quad => Self::Quad,
640            Precision::Arb => Self::ArbPrec,
641        }
642    }
643
644    fn histogram_range(self) -> (f64, f64, usize) {
645        match self {
646            Self::Double => (-1.0, 17.0, 72),
647            Self::Quad => (-1.0, 34.0, 140),
648            Self::ArbPrec => (-1.0, 1000.0, 100),
649        }
650    }
651}
652
653impl Display for NumericalStabilityLevel {
654    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655        f.write_str(self.key())
656    }
657}
658
659#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
660pub struct NumericalStabilityHistograms {
661    pub histograms: BTreeMap<String, HistogramSnapshot>,
662}
663
664impl NumericalStabilityHistograms {
665    pub fn as_observable_snapshot_bundle(&self) -> ObservableSnapshotBundle {
666        ObservableSnapshotBundle {
667            histograms: self.histograms.clone(),
668        }
669    }
670
671    pub fn processed_sample_count(&self, level: NumericalStabilityLevel) -> usize {
672        self.histograms
673            .get(level.key())
674            .map(|histogram| histogram.sample_count)
675            .unwrap_or_default()
676    }
677
678    pub fn median(&self, level: NumericalStabilityLevel) -> Option<NumericalStabilityMedian> {
679        let histogram = self.histograms.get(level.key())?;
680        NumericalStabilityMedian::from_histogram(histogram)
681    }
682}
683
684#[derive(Clone, Copy, Debug, PartialEq)]
685pub enum NumericalStabilityMedian {
686    Underflow { log10_lower_bound: f64 },
687    InRange { log10_relative_accuracy: f64 },
688    Overflow { log10_upper_bound: f64 },
689}
690
691impl NumericalStabilityMedian {
692    fn from_histogram(histogram: &HistogramSnapshot) -> Option<Self> {
693        match histogram.approximate_weighted_median_position()? {
694            HistogramMedianPosition::Underflow => Some(Self::Underflow {
695                log10_lower_bound: -histogram.x_min?,
696            }),
697            HistogramMedianPosition::InRange(log_accuracy) => Some(Self::InRange {
698                log10_relative_accuracy: -log_accuracy,
699            }),
700            HistogramMedianPosition::Overflow => Some(Self::Overflow {
701                log10_upper_bound: -histogram.x_max?,
702            }),
703        }
704    }
705
706    pub fn formatted_relative_accuracy(self) -> String {
707        match self {
708            Self::Underflow { log10_lower_bound } => {
709                format!(">{}", format_stability_scientific(log10_lower_bound))
710            }
711            Self::InRange {
712                log10_relative_accuracy,
713            } => format_stability_scientific(log10_relative_accuracy),
714            Self::Overflow { log10_upper_bound } => {
715                format!("<={}", format_stability_scientific(log10_upper_bound))
716            }
717        }
718    }
719}
720
721fn format_stability_scientific(log10_value: f64) -> String {
722    if !log10_value.is_finite() {
723        return "N/A".to_string();
724    }
725    // Keep the exponent separate so Arb histogram values never underflow through f64.
726    let mut exponent = log10_value.floor();
727    let mut mantissa = (10.0_f64.powf(log10_value - exponent) * 10.0).round() / 10.0;
728    if mantissa >= 10.0 {
729        mantissa = 1.0;
730        exponent += 1.0;
731    }
732    format!("{mantissa:.1}e{exponent:.0}")
733}
734
735#[derive(Clone, Debug, Serialize, Deserialize)]
736pub(crate) struct NumericalStabilityHistogramAccumulator {
737    histograms: BTreeMap<String, HistogramAccumulatorState>,
738}
739
740impl Default for NumericalStabilityHistogramAccumulator {
741    fn default() -> Self {
742        Self::new_empty()
743    }
744}
745
746impl NumericalStabilityHistogramAccumulator {
747    fn new_empty() -> Self {
748        let histograms = NumericalStabilityLevel::all()
749            .into_iter()
750            .map(|level| {
751                let (x_min, x_max, n_bins) = level.histogram_range();
752                (
753                    level.key().to_string(),
754                    HistogramAccumulatorState::continuous(
755                        format!("Numerical stability {}", level.key()),
756                        "-log10(relative accuracy)".to_string(),
757                        ObservablePhase::Real,
758                        ObservableValueTransform::Identity,
759                        x_min,
760                        x_max,
761                        false,
762                        false,
763                        n_bins,
764                    ),
765                )
766            })
767            .collect();
768        Self { histograms }
769    }
770
771    fn fill_from_stability_results(&mut self, stability_results: &[StabilityResult]) {
772        for result in stability_results {
773            let level = NumericalStabilityLevel::from_precision(result.precision);
774            let Some(histogram) = self.histograms.get_mut(level.key()) else {
775                continue;
776            };
777            let value = match (
778                result.estimated_decimal_digits,
779                result.estimated_relative_accuracy,
780            ) {
781                (Some(digits), _) if digits.0.is_finite() => digits.0,
782                (None, Some(accuracy)) if accuracy.0 == 0.0 => {
783                    let (_, x_max, _) = level.histogram_range();
784                    x_max + 1.0
785                }
786                _ => continue,
787            };
788            histogram
789                .fill_continuous_sample(&[(value, 1.0)])
790                .expect("numerical stability histograms are fixed continuous histograms");
791        }
792    }
793
794    fn merge_in_place(&mut self, other: &Self) {
795        for (name, histogram) in &mut self.histograms {
796            let mut other_histogram = other
797                .histograms
798                .get(name)
799                .unwrap_or_else(|| panic!("missing numerical stability histogram '{name}'"))
800                .clone();
801            histogram
802                .merge_in_place(&mut other_histogram)
803                .expect("fixed numerical stability histograms must be compatible");
804        }
805    }
806
807    fn merged(&self, other: &Self) -> Self {
808        let mut merged = self.clone();
809        merged.merge_in_place(other);
810        merged
811    }
812
813    pub(crate) fn snapshot(&self) -> NumericalStabilityHistograms {
814        NumericalStabilityHistograms {
815            histograms: self
816                .histograms
817                .iter()
818                .map(|(name, histogram)| (name.clone(), histogram.snapshot()))
819                .collect(),
820        }
821    }
822}
823
824#[derive(Clone, Serialize, Debug)]
825pub struct BatchSampleEvaluationResult {
826    /// Evaluation records in the same order as the requested f64 samples.
827    pub samples: Vec<SampleEvaluationResult>,
828    /// Persistent runtime-observable snapshot after processing the requested batch.
829    ///
830    /// The snapshot can include samples accumulated by earlier API calls.
831    pub observables: ObservableSnapshotBundle,
832    /// Numerical-stability histograms for this batch, when collected.
833    pub numerical_stability: Option<NumericalStabilityHistograms>,
834}
835
836#[derive(Clone, Debug)]
837pub struct RawBatchEvaluationResult {
838    pub samples: Vec<EvaluationResult>,
839    pub statistics: StatisticsCounter,
840}
841
842#[derive(Clone, Debug)]
843pub struct PreciseSingleSampleEvaluationResult {
844    /// Evaluation record retaining the numerical precision selected for the sample.
845    pub sample: PreciseSampleEvaluationResult,
846    /// Observable snapshot produced from the same Monte Carlo sample.
847    pub observables: ObservableSnapshotBundle,
848}
849
850#[derive(Clone, Debug)]
851pub struct PreciseBatchSampleEvaluationResult {
852    /// Precision-preserving evaluation records in requested sample order.
853    pub samples: Vec<PreciseSampleEvaluationResult>,
854    /// Observable snapshot accumulated across the complete sample batch.
855    pub observables: ObservableSnapshotBundle,
856}
857
858#[derive(Clone, Debug)]
859pub struct RawPreciseBatchEvaluationResult {
860    pub samples: Vec<PreciseEvaluationResult>,
861}
862
863impl Display for PreciseSampleEvaluationResult {
864    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
865        write!(f, "{}", self.evaluation)
866    }
867}
868
869impl Display for SingleSampleEvaluationResult {
870    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
871        fmt_sample_result(f, &self.sample.evaluation, &self.observables)
872    }
873}
874
875impl Display for BatchSampleEvaluationResult {
876    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
877        fmt_batch_result(
878            f,
879            &self.samples,
880            &self.observables,
881            self.numerical_stability.as_ref(),
882        )
883    }
884}
885
886impl Display for PreciseSingleSampleEvaluationResult {
887    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
888        fmt_sample_result(f, &self.sample.evaluation, &self.observables)
889    }
890}
891
892impl Display for PreciseBatchSampleEvaluationResult {
893    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
894        fmt_batch_result(f, &self.samples, &self.observables, None)
895    }
896}
897
898#[derive(Tabled)]
899struct EvaluationSummaryRow {
900    field: String,
901    value: String,
902}
903
904#[derive(Tabled)]
905struct HistogramSummaryRow {
906    name: String,
907    bins: usize,
908    #[tabled(rename = "in-range")]
909    in_range_entries: String,
910    phase: String,
911    range: String,
912    underflow: String,
913    overflow: String,
914}
915
916#[derive(Tabled)]
917struct StabilitySummaryRow {
918    level: String,
919    relative_accuracy: String,
920    time: String,
921    status: String,
922}
923
924#[derive(Tabled)]
925struct NumericalStabilitySummaryRow {
926    level: String,
927    median: String,
928    samples: String,
929    range: String,
930    overflow: String,
931}
932
933fn format_duration(duration: Duration) -> String {
934    format_evaluation_time(duration)
935}
936
937fn format_count(value: usize) -> String {
938    if value < 1_000 {
939        return value.to_string();
940    }
941
942    let value = value as f64;
943    for (scale, suffix) in [
944        (1_000_000_000_f64, "B"),
945        (1_000_000_f64, "M"),
946        (1_000_f64, "K"),
947    ] {
948        if value >= scale {
949            let scaled = value / scale;
950            let precision = if scaled >= 100.0 {
951                0
952            } else if scaled >= 10.0 {
953                1
954            } else {
955                2
956            };
957            return format!("{scaled:.precision$}{suffix}");
958        }
959    }
960
961    value.round().to_string()
962}
963
964fn format_percentage(value: f64, significant_digits: usize) -> String {
965    if !value.is_finite() {
966        return "None".red().to_string();
967    }
968
969    if value == 0.0 {
970        let decimals = significant_digits.saturating_sub(1);
971        return format!("{:.*}%", decimals, 0.0);
972    }
973
974    let abs_value = value.abs();
975    let exponent = abs_value.log10().floor() as i32;
976    if exponent < -2 || exponent >= significant_digits as i32 {
977        return format!("{:.*e}%", significant_digits.saturating_sub(1), value);
978    }
979
980    let decimals = (significant_digits as i32 - exponent - 1).max(0) as usize;
981    format!("{value:.decimals$}%")
982}
983
984fn format_status_header(label: &str) -> String {
985    format!("{label:<7}").blue().bold().to_string()
986}
987
988fn format_status_key(label: &str) -> String {
989    format!("{label} :  ")
990}
991
992fn pad_status_value(value: impl Display, width: usize) -> String {
993    format!("{value:<width$}")
994}
995
996fn status_group_separator() -> VerticalLine<On, On, ()> {
997    VerticalLine::new('│').top('┬').bottom('┴')
998}
999
1000fn summarize_observables(observables: &ObservableSnapshotBundle) -> Option<String> {
1001    if observables.histograms.is_empty() {
1002        return None;
1003    }
1004
1005    let rows = observables
1006        .histograms
1007        .iter()
1008        .map(|(name, histogram)| HistogramSummaryRow {
1009            name: name.clone(),
1010            bins: histogram.bins.len(),
1011            in_range_entries: format_count(histogram.statistics.in_range_entry_count),
1012            phase: format!("{:?}", histogram.phase).to_lowercase(),
1013            range: match histogram.kind {
1014                crate::observables::HistogramSnapshotKind::Continuous => format!(
1015                    "[{:+.16e}, {:+.16e}]",
1016                    histogram.x_min.unwrap_or_default(),
1017                    histogram.x_max.unwrap_or_default()
1018                ),
1019                crate::observables::HistogramSnapshotKind::Discrete => {
1020                    let min = histogram.discrete_min_bin_id.unwrap_or_default();
1021                    let max = min + histogram.bins.len() as isize - 1;
1022                    format!("[{}, {}]", min, max)
1023                }
1024            },
1025            underflow: format_count(histogram.underflow_bin.entry_count),
1026            overflow: format_count(histogram.overflow_bin.entry_count),
1027        })
1028        .collect::<Vec<_>>();
1029
1030    Some(Table::new(rows).with(Style::rounded()).to_string())
1031}
1032
1033fn summarize_numerical_stability(stability: &NumericalStabilityHistograms) -> Option<String> {
1034    if stability.histograms.is_empty() {
1035        return None;
1036    }
1037
1038    let rows = NumericalStabilityLevel::all()
1039        .into_iter()
1040        .filter_map(|level| {
1041            let histogram = stability.histograms.get(level.key())?;
1042            Some(NumericalStabilitySummaryRow {
1043                level: level.key().to_string(),
1044                median: stability
1045                    .median(level)
1046                    .map(NumericalStabilityMedian::formatted_relative_accuracy)
1047                    .unwrap_or_else(|| "N/A".to_string()),
1048                samples: format_count(histogram.sample_count),
1049                range: format!(
1050                    "[{:+.0}, {:+.0}]",
1051                    histogram.x_min.unwrap_or_default(),
1052                    histogram.x_max.unwrap_or_default()
1053                ),
1054                overflow: format_count(histogram.overflow_bin.entry_count),
1055            })
1056        })
1057        .collect::<Vec<_>>();
1058
1059    Some(Table::new(rows).with(Style::rounded()).to_string())
1060}
1061
1062/// Per-precision evaluation details produced during stability checks.
1063#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
1064pub enum StabilityFailureReason {
1065    ErrorThreshold,
1066    ZeroError,
1067    WeightThreshold,
1068}
1069
1070#[derive(Clone, Debug, Serialize)]
1071pub struct RotatedEvaluation {
1072    pub rotation: String,
1073    pub result: Complex<F<f64>>,
1074}
1075
1076#[derive(Clone, Debug, Serialize)]
1077pub struct StabilityResult {
1078    pub precision: Precision,
1079    pub estimated_relative_accuracy: Option<F<f64>>,
1080    /// -log10 of the nonzero relative estimate, computed before narrowing to f64.
1081    /// None denotes missing/nonfinite data or an exact zero, which has no finite logarithm.
1082    pub estimated_decimal_digits: Option<F<f64>>,
1083    pub status: StabilityStatus,
1084    pub total_time: Duration,
1085}
1086
1087#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
1088pub enum StabilityStatus {
1089    Unknown,
1090    Stable(usize),
1091    Unstable(usize),
1092}
1093
1094impl StabilityStatus {
1095    pub fn from_sample_count(sample_count: usize, accepted_as_stable: bool) -> Self {
1096        if sample_count <= 1 {
1097            Self::Unknown
1098        } else if accepted_as_stable {
1099            Self::Stable(sample_count)
1100        } else {
1101            Self::Unstable(sample_count)
1102        }
1103    }
1104
1105    pub fn sample_count(&self) -> usize {
1106        match self {
1107            Self::Unknown => 1,
1108            Self::Stable(sample_count) | Self::Unstable(sample_count) => *sample_count,
1109        }
1110    }
1111
1112    fn styled_label(&self) -> String {
1113        let label = self.to_string();
1114        match self {
1115            Self::Unknown => label.yellow().to_string(),
1116            Self::Stable(_) => label.green().to_string(),
1117            Self::Unstable(_) => label.red().to_string(),
1118        }
1119    }
1120}
1121
1122impl Display for StabilityStatus {
1123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1124        match self {
1125            Self::Unknown => write!(f, "Unknown(1 sample)"),
1126            Self::Stable(sample_count) => write!(f, "Stable({sample_count} samples)"),
1127            Self::Unstable(sample_count) => write!(f, "Unstable({sample_count} samples)"),
1128        }
1129    }
1130}
1131
1132#[derive(Clone, Debug, Serialize)]
1133pub struct LoopMomentaEscalationMetrics {
1134    pub sum_norm: f64,
1135    pub threshold: f64,
1136}
1137
1138/// Useful metadata generated during the evaluation, this may be expanded in the future to include more information
1139#[derive(Clone, Serialize, Debug)]
1140pub struct EvaluationMetaData {
1141    pub total_timing: Duration,
1142    /// All actual target calls across native attempts and rotations; includes
1143    /// evaluator/event work and excludes separately timed sampling maps and
1144    /// host-adoption certificates, even when these run inside a target body.
1145    pub integrand_evaluation_time: Duration,
1146    /// Subset of physical time: one canonical representative-cut/overlap prepass.
1147    /// Report separately while native primal roots are still solved again, so
1148    /// repeated preparation cannot inflate the sampling-budget denominator.
1149    pub canonical_physical_preparation_time: Duration,
1150    /// All actual evaluator calls across attempts and rotations, including work
1151    /// performed before a target fails; a subset of integrand_evaluation_time.
1152    pub evaluator_evaluation_time: Duration,
1153    /// Source/map/partition work across all attempts and replays. Canonical
1154    /// policy preparation is inclusive and charged once, never again per child.
1155    /// Host-only adoption work is included here and excluded from physical time.
1156    pub parameterization_time: Duration,
1157    /// Subset of sampling time: the one canonical source/map/partition preparation,
1158    /// including failed preparation. Native materialization and adoption remain
1159    /// in the remaining parameterization time.
1160    pub canonical_sampling_preparation_time: Duration,
1161    pub event_processing_time: Duration,
1162    pub generated_event_count: usize,
1163    pub accepted_event_count: usize,
1164    pub relative_instability_error: Complex<F<f64>>,
1165    pub is_nan: bool,
1166    pub loop_momenta_escalation: Option<LoopMomentaEscalationMetrics>,
1167    pub stability_results: Vec<StabilityResult>,
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    pub(crate) threshold_counterterm_error: Option<String>,
1170    #[serde(skip)]
1171    pub(crate) radial_root_diagnostics: RadialRootDiagnostics,
1172    #[serde(skip)]
1173    pub(crate) sampling_proposal_policies: SamplingProposalPolicies,
1174}
1175
1176impl Display for EvaluationMetaData {
1177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1178        let rows = vec![
1179            EvaluationSummaryRow {
1180                field: "nan".to_string(),
1181                value: self.is_nan.to_string(),
1182            },
1183            EvaluationSummaryRow {
1184                field: "generated events".to_string(),
1185                value: format_count(self.generated_event_count),
1186            },
1187            EvaluationSummaryRow {
1188                field: "accepted events".to_string(),
1189                value: format_count(self.accepted_event_count),
1190            },
1191            EvaluationSummaryRow {
1192                field: "parameterization time".to_string(),
1193                value: format_duration(self.parameterization_time),
1194            },
1195            EvaluationSummaryRow {
1196                field: "canonical sampling (included)".to_string(),
1197                value: format_duration(self.canonical_sampling_preparation_time),
1198            },
1199            EvaluationSummaryRow {
1200                field: "integrand evaluation time".to_string(),
1201                value: format_duration(self.integrand_evaluation_time),
1202            },
1203            EvaluationSummaryRow {
1204                field: "canonical physical (included)".to_string(),
1205                value: format_duration(self.canonical_physical_preparation_time),
1206            },
1207            EvaluationSummaryRow {
1208                field: "evaluator evaluation time".to_string(),
1209                value: format_duration(self.evaluator_evaluation_time),
1210            },
1211            EvaluationSummaryRow {
1212                field: "event processing time".to_string(),
1213                value: format_duration(self.event_processing_time),
1214            },
1215            EvaluationSummaryRow {
1216                field: "total evaluation time".to_string(),
1217                value: format_duration(self.total_timing),
1218            },
1219        ];
1220        writeln!(f, "{}", "Evaluation metadata".bold().bright_yellow())?;
1221        writeln!(f, "{}", Table::new(rows).with(Style::rounded()))?;
1222
1223        if !self.stability_results.is_empty() {
1224            let stability_rows = self
1225                .stability_results
1226                .iter()
1227                .map(|result| StabilitySummaryRow {
1228                    level: result.precision.to_string(),
1229                    relative_accuracy: format_optional_real_generic(
1230                        result.estimated_relative_accuracy.as_ref(),
1231                    ),
1232                    time: format_duration(result.total_time),
1233                    status: result.status.styled_label(),
1234                })
1235                .collect::<Vec<_>>();
1236            writeln!(f)?;
1237            writeln!(f, "{}", "Stability results".bold().bright_magenta())?;
1238            write!(f, "{}", Table::new(stability_rows).with(Style::rounded()))?;
1239        }
1240
1241        Ok(())
1242    }
1243}
1244
1245impl EvaluationMetaData {
1246    pub(crate) fn new_empty() -> Self {
1247        Self {
1248            total_timing: Duration::ZERO,
1249            integrand_evaluation_time: Duration::ZERO,
1250            canonical_physical_preparation_time: Duration::ZERO,
1251            evaluator_evaluation_time: Duration::ZERO,
1252            parameterization_time: Duration::ZERO,
1253            canonical_sampling_preparation_time: Duration::ZERO,
1254            event_processing_time: Duration::ZERO,
1255            generated_event_count: 0,
1256            accepted_event_count: 0,
1257            relative_instability_error: Complex::new_zero(),
1258            is_nan: false,
1259            loop_momenta_escalation: None,
1260            stability_results: Vec::new(),
1261            threshold_counterterm_error: None,
1262            radial_root_diagnostics: RadialRootDiagnostics::default(),
1263            sampling_proposal_policies: SamplingProposalPolicies::default(),
1264        }
1265    }
1266
1267    pub(crate) fn clear_threshold_counterterm_error(&mut self) {
1268        self.threshold_counterterm_error = None;
1269    }
1270
1271    pub(crate) fn record_threshold_counterterm_error(&mut self, error: impl Into<String>) {
1272        if self.threshold_counterterm_error.is_none() {
1273            self.threshold_counterterm_error = Some(error.into());
1274        }
1275    }
1276
1277    pub(crate) fn final_precision(&self) -> Option<Precision> {
1278        self.stability_results.last().map(|result| result.precision)
1279    }
1280}
1281
1282/// This struct merges the evaluation metadata of many evaluations into a single struct
1283#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)]
1284pub struct StatisticsCounter {
1285    pub num_evals: usize,
1286    num_sample_points: usize,
1287    sum_integrand_evaluation_time: Duration,
1288    sum_evaluator_evaluation_time: Duration,
1289    sum_parameterization_time: Duration,
1290    sum_event_time: Duration,
1291    sum_integrator_overhead_time: Duration,
1292    sum_total_evaluation_time: Duration,
1293    sum_relative_instability_error: (F<f64>, F<f64>),
1294    num_double_precision_evals: usize,
1295    num_quadruple_precision_evals: usize,
1296    num_arb_precision_evals: usize,
1297    num_nan_evals: usize,
1298    num_nan_or_unstable_evals: usize,
1299    sum_generated_event_count: usize,
1300    sum_accepted_event_count: usize,
1301    #[bincode(with_serde)]
1302    numerical_stability: NumericalStabilityHistogramAccumulator,
1303}
1304
1305impl StatisticsCounter {
1306    /// Turn a slice of evaluation results into a statistics counter
1307    pub(crate) fn from_evaluation_results(data: &[EvaluationResult]) -> Self {
1308        data.iter().fold(
1309            StatisticsCounter::new_empty(),
1310            |mut accumulator, data_entry| {
1311                accumulator.sum_integrand_evaluation_time +=
1312                    data_entry.evaluation_metadata.integrand_evaluation_time;
1313                accumulator.sum_evaluator_evaluation_time +=
1314                    data_entry.evaluation_metadata.evaluator_evaluation_time;
1315                accumulator.sum_parameterization_time +=
1316                    data_entry.evaluation_metadata.parameterization_time;
1317                accumulator.sum_event_time += data_entry.evaluation_metadata.event_processing_time;
1318                accumulator.sum_relative_instability_error.0 +=
1319                    data_entry.evaluation_metadata.relative_instability_error.re;
1320                accumulator.sum_relative_instability_error.1 +=
1321                    data_entry.evaluation_metadata.relative_instability_error.im;
1322                accumulator.sum_total_evaluation_time +=
1323                    data_entry.evaluation_metadata.total_timing;
1324                accumulator.sum_generated_event_count +=
1325                    data_entry.evaluation_metadata.generated_event_count;
1326                accumulator.sum_accepted_event_count +=
1327                    data_entry.evaluation_metadata.accepted_event_count;
1328                accumulator
1329                    .numerical_stability
1330                    .fill_from_stability_results(&data_entry.evaluation_metadata.stability_results);
1331
1332                accumulator.num_evals += 1;
1333                match data_entry
1334                    .evaluation_metadata
1335                    .final_precision()
1336                    .unwrap_or(Precision::Double)
1337                {
1338                    Precision::Double => accumulator.num_double_precision_evals += 1,
1339                    Precision::Quad => accumulator.num_quadruple_precision_evals += 1,
1340                    Precision::Arb => accumulator.num_arb_precision_evals += 1,
1341                    // _ => (),
1342                }
1343
1344                if data_entry.evaluation_metadata.is_nan {
1345                    accumulator.num_nan_evals += 1;
1346                }
1347                if data_entry.evaluation_metadata.is_nan
1348                    || data_entry
1349                        .evaluation_metadata
1350                        .stability_results
1351                        .last()
1352                        .map(|result| matches!(result.status, StabilityStatus::Unstable(_)))
1353                        .unwrap_or(false)
1354                {
1355                    accumulator.num_nan_or_unstable_evals += 1;
1356                }
1357
1358                accumulator
1359            },
1360        )
1361    }
1362
1363    /// Merge two statistics counters into a single one, but keeping the original ones unchanged
1364    pub(crate) fn merged(&self, other: &Self) -> Self {
1365        Self {
1366            sum_integrand_evaluation_time: self.sum_integrand_evaluation_time
1367                + other.sum_integrand_evaluation_time,
1368            sum_evaluator_evaluation_time: self.sum_evaluator_evaluation_time
1369                + other.sum_evaluator_evaluation_time,
1370            sum_parameterization_time: self.sum_parameterization_time
1371                + other.sum_parameterization_time,
1372            sum_integrator_overhead_time: self.sum_integrator_overhead_time
1373                + other.sum_integrator_overhead_time,
1374            sum_relative_instability_error: (
1375                self.sum_relative_instability_error.0 + other.sum_relative_instability_error.0,
1376                self.sum_relative_instability_error.1 + other.sum_relative_instability_error.1,
1377            ),
1378            num_evals: self.num_evals + other.num_evals,
1379            num_sample_points: self.num_sample_points + other.num_sample_points,
1380            num_double_precision_evals: (self.num_double_precision_evals
1381                + other.num_double_precision_evals),
1382            num_quadruple_precision_evals: (self.num_quadruple_precision_evals
1383                + other.num_quadruple_precision_evals),
1384            num_arb_precision_evals: self.num_arb_precision_evals + other.num_arb_precision_evals,
1385            sum_total_evaluation_time: self.sum_total_evaluation_time
1386                + other.sum_total_evaluation_time,
1387            num_nan_evals: self.num_nan_evals + other.num_nan_evals,
1388            num_nan_or_unstable_evals: self.num_nan_or_unstable_evals
1389                + other.num_nan_or_unstable_evals,
1390            sum_event_time: self.sum_event_time + other.sum_event_time,
1391            sum_generated_event_count: self.sum_generated_event_count
1392                + other.sum_generated_event_count,
1393            sum_accepted_event_count: self.sum_accepted_event_count
1394                + other.sum_accepted_event_count,
1395            numerical_stability: self.numerical_stability.merged(&other.numerical_stability),
1396        }
1397    }
1398
1399    pub(crate) fn new_empty() -> Self {
1400        Self {
1401            sum_integrand_evaluation_time: Duration::ZERO,
1402            sum_evaluator_evaluation_time: Duration::ZERO,
1403            sum_parameterization_time: Duration::ZERO,
1404            sum_event_time: Duration::ZERO,
1405            sum_integrator_overhead_time: Duration::ZERO,
1406            sum_relative_instability_error: (F(0.0), F(0.0)),
1407            sum_total_evaluation_time: Duration::ZERO,
1408            num_evals: 0,
1409            num_sample_points: 0,
1410            num_double_precision_evals: 0,
1411            num_quadruple_precision_evals: 0,
1412            num_arb_precision_evals: 0,
1413            num_nan_evals: 0,
1414            num_nan_or_unstable_evals: 0,
1415            sum_generated_event_count: 0,
1416            sum_accepted_event_count: 0,
1417            numerical_stability: NumericalStabilityHistogramAccumulator::new_empty(),
1418        }
1419    }
1420
1421    /// Merge a list of statistics counters into a single one.
1422    #[allow(dead_code)]
1423    pub(crate) fn merge_list(list: Vec<Self>) -> Self {
1424        if let Some(merged) = list.into_iter().reduce(|acc, x| acc.merged(&x)) {
1425            merged
1426        } else {
1427            Self::new_empty()
1428        }
1429    }
1430
1431    fn avg_duration(sum: Duration, count: usize) -> Duration {
1432        if count == 0 {
1433            return Duration::ZERO;
1434        }
1435
1436        duration_from_secs_f64_saturating(sum.as_secs_f64() / count as f64)
1437    }
1438
1439    fn eval_percentage(&self, count: usize) -> f64 {
1440        if self.num_evals == 0 {
1441            0.0
1442        } else {
1443            count as f64 / self.num_evals as f64 * 100.0
1444        }
1445    }
1446
1447    pub(crate) fn add_integrator_overhead(&mut self, duration: Duration, sample_points: usize) {
1448        self.sum_integrator_overhead_time += duration;
1449        self.num_sample_points += sample_points;
1450    }
1451
1452    /// Compute the average time spent in the evaluate_sample function.
1453    pub(crate) fn get_avg_total_timing(&self) -> Duration {
1454        Self::avg_duration(self.sum_total_evaluation_time, self.num_evals)
1455    }
1456
1457    /// Compute the average physical time across all attempts and rotations.
1458    pub(crate) fn get_avg_integrand_timing(&self) -> Duration {
1459        Self::avg_duration(self.sum_integrand_evaluation_time, self.num_evals)
1460    }
1461
1462    /// Compute the average time spent inside Symbolica evaluator function calls.
1463    pub(crate) fn get_avg_evaluator_timing(&self) -> Duration {
1464        Self::avg_duration(self.sum_evaluator_evaluation_time, self.num_evals)
1465    }
1466
1467    /// Compute the average time spent in the parameterization of the integrand. Especaially useful for monitoring the performance of tropical sampling.
1468    pub(crate) fn get_avg_param_timing(&self) -> Duration {
1469        Self::avg_duration(self.sum_parameterization_time, self.num_evals)
1470    }
1471
1472    pub(crate) fn get_avg_event_timing(&self) -> Duration {
1473        Self::avg_duration(self.sum_event_time, self.num_evals)
1474    }
1475
1476    pub(crate) fn get_avg_integrator_overhead_timing(&self) -> Duration {
1477        Self::avg_duration(self.sum_integrator_overhead_time, self.num_sample_points)
1478    }
1479
1480    /// Get the average relative error computed during instability checks
1481    #[allow(dead_code)]
1482    pub(crate) fn get_avg_instabillity_error(&self) -> (F<f64>, F<f64>) {
1483        if self.num_evals == 0 {
1484            return (
1485                self.sum_relative_instability_error.0.zero(),
1486                self.sum_relative_instability_error.1.zero(),
1487            );
1488        }
1489
1490        (
1491            self.sum_relative_instability_error.0
1492                / self
1493                    .sum_relative_instability_error
1494                    .0
1495                    .from_usize(self.num_evals),
1496            self.sum_relative_instability_error.1
1497                / self
1498                    .sum_relative_instability_error
1499                    .1
1500                    .from_usize(self.num_evals),
1501        )
1502    }
1503
1504    /// Get the percentage of evaluations that were done in double precision and were stable.
1505    pub(crate) fn get_percentage_f64(&self) -> f64 {
1506        self.eval_percentage(self.num_double_precision_evals)
1507    }
1508
1509    /// Get the percentage of evaluations that went to quadruple precision and were stable.
1510    pub(crate) fn get_percentage_f128(&self) -> f64 {
1511        self.eval_percentage(self.num_quadruple_precision_evals)
1512    }
1513
1514    pub(crate) fn get_percentage_arb(&self) -> f64 {
1515        self.eval_percentage(self.num_arb_precision_evals)
1516    }
1517
1518    pub(crate) fn get_percentage_nan(&self) -> f64 {
1519        self.eval_percentage(self.num_nan_evals)
1520    }
1521
1522    pub(crate) fn get_percentage_nan_or_unstable(&self) -> f64 {
1523        self.eval_percentage(self.num_nan_or_unstable_evals)
1524    }
1525
1526    pub(crate) fn selection_efficiency_percentage(&self) -> Option<f64> {
1527        if self.sum_generated_event_count == 0 {
1528            None
1529        } else {
1530            Some(
1531                self.sum_accepted_event_count as f64 / self.sum_generated_event_count as f64
1532                    * 100.0,
1533            )
1534        }
1535    }
1536
1537    pub fn numerical_stability_snapshot(&self) -> NumericalStabilityHistograms {
1538        self.numerical_stability.snapshot()
1539    }
1540
1541    pub(crate) fn snapshot(&self) -> IntegrationStatisticsSnapshot {
1542        IntegrationStatisticsSnapshot {
1543            num_evals: self.num_evals,
1544            average_total_time_seconds: self.get_avg_total_timing().as_secs_f64(),
1545            average_parameterization_time_seconds: self.get_avg_param_timing().as_secs_f64(),
1546            average_integrand_time_seconds: self.get_avg_integrand_timing().as_secs_f64(),
1547            average_evaluator_time_seconds: self.get_avg_evaluator_timing().as_secs_f64(),
1548            average_observable_time_seconds: self.get_avg_event_timing().as_secs_f64(),
1549            average_integrator_time_seconds: self
1550                .get_avg_integrator_overhead_timing()
1551                .as_secs_f64(),
1552            f64_percentage: self.get_percentage_f64(),
1553            f128_percentage: self.get_percentage_f128(),
1554            arb_percentage: self.get_percentage_arb(),
1555            nan_percentage: self.get_percentage_nan(),
1556            nan_or_unstable_percentage: self.get_percentage_nan_or_unstable(),
1557            generated_event_count: self.sum_generated_event_count,
1558            accepted_event_count: self.sum_accepted_event_count,
1559            selection_efficiency_percentage: self.selection_efficiency_percentage(),
1560        }
1561    }
1562
1563    pub(crate) fn build_status_table(&self) -> Table {
1564        let time_integrand_formatted = format_evaluation_time(self.get_avg_integrand_timing());
1565        let time_evaluators_formatted = format_evaluation_time(self.get_avg_evaluator_timing());
1566        let param_time_formatted = format_evaluation_time(self.get_avg_param_timing());
1567        let event_time_formatted = format_evaluation_time(self.get_avg_event_timing());
1568        let integrator_time_formatted =
1569            format_evaluation_time(self.get_avg_integrator_overhead_timing());
1570        let total_time = format_evaluation_time(self.get_avg_total_timing());
1571        let selection_efficiency = self.selection_efficiency_percentage();
1572        let selection_efficiency_display = selection_efficiency
1573            .map(|value| {
1574                pad_status_value(format_percentage(value, 3), 9)
1575                    .green()
1576                    .to_string()
1577            })
1578            .unwrap_or_else(|| pad_status_value("N/A", 9));
1579        let nan_or_unstable = self.get_percentage_nan_or_unstable();
1580        let nan_or_unstable_display = if nan_or_unstable > 0.0 {
1581            pad_status_value(format_percentage(nan_or_unstable, 2), 9)
1582                .red()
1583                .to_string()
1584        } else {
1585            pad_status_value(format_percentage(nan_or_unstable, 2), 9)
1586                .green()
1587                .to_string()
1588        };
1589        let mut table = Builder::new();
1590        table.push_record([
1591            format_status_header("timing"),
1592            format_status_key("total"),
1593            pad_status_value(total_time, 9).green().to_string(),
1594            format_status_key("param"),
1595            pad_status_value(param_time_formatted, 9)
1596                .green()
1597                .to_string(),
1598            format_status_key("itg"),
1599            pad_status_value(time_integrand_formatted, 9)
1600                .green()
1601                .to_string(),
1602            format_status_key("evaluators"),
1603            pad_status_value(time_evaluators_formatted, 9)
1604                .green()
1605                .to_string(),
1606        ]);
1607        table.push_record([
1608            format_status_header("evals"),
1609            format_status_key("f64"),
1610            pad_status_value(format!("{:.2}%", self.get_percentage_f64()), 9)
1611                .green()
1612                .to_string(),
1613            format_status_key("f128"),
1614            pad_status_value(format!("{:.2}%", self.get_percentage_f128()), 9)
1615                .green()
1616                .to_string(),
1617            format_status_key("arb"),
1618            pad_status_value(format!("{:.2}%", self.get_percentage_arb()), 9)
1619                .green()
1620                .to_string(),
1621            format_status_key("nans+unstable"),
1622            nan_or_unstable_display.clone(),
1623        ]);
1624        table.push_record([
1625            format_status_header("events"),
1626            format_status_key("evts #"),
1627            pad_status_value(format_count(self.sum_generated_event_count), 9)
1628                .green()
1629                .to_string(),
1630            format_status_key("sel. %"),
1631            selection_efficiency_display,
1632            format_status_key("obs"),
1633            pad_status_value(event_time_formatted, 9)
1634                .green()
1635                .to_string(),
1636            format_status_key("integrator"),
1637            pad_status_value(integrator_time_formatted, 9)
1638                .green()
1639                .to_string(),
1640        ]);
1641
1642        let mut table = table.build();
1643        table.with(Panel::header(
1644            "Integration statistics".bold().green().to_string(),
1645        ));
1646        table.with(
1647            Style::rounded()
1648                .remove_horizontals()
1649                .verticals([
1650                    (1, status_group_separator()),
1651                    (3, status_group_separator()),
1652                    (5, status_group_separator()),
1653                    (7, status_group_separator()),
1654                ])
1655                .horizontals([(
1656                    1,
1657                    HorizontalLine::new('─')
1658                        .intersection('┬')
1659                        .left('├')
1660                        .right('┤'),
1661                )])
1662                .remove_vertical(),
1663        );
1664        table.with(BorderCorrection::span());
1665        table.with(Modify::new(Rows::new(0..1)).with(Alignment::center()));
1666        table.with(Modify::new(Rows::new(1..)).with(Alignment::left()));
1667        for column in [1usize, 3, 5, 7] {
1668            table.with(
1669                Modify::new(Rows::new(1..).intersect(Columns::one(column)))
1670                    .with(Alignment::right()),
1671            );
1672        }
1673        table
1674    }
1675
1676    pub(crate) fn render_status_table(&self) -> String {
1677        normalize_tabled_separator_rows(&self.build_status_table().to_string())
1678    }
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683    use std::time::Duration;
1684
1685    use crate::settings::runtime::Precision;
1686
1687    use super::{
1688        BatchSampleEvaluationResult, EvaluationMetaData, EvaluationResult,
1689        NumericalStabilityHistogramAccumulator, NumericalStabilityLevel, NumericalStabilityMedian,
1690        StabilityResult, StabilityStatus, StatisticsCounter,
1691    };
1692
1693    #[test]
1694    fn status_table_renders_three_rows_without_header() {
1695        let stats = StatisticsCounter {
1696            num_evals: 10,
1697            num_sample_points: 10,
1698            sum_integrand_evaluation_time: Duration::from_micros(414),
1699            sum_evaluator_evaluation_time: Duration::from_micros(279),
1700            sum_parameterization_time: Duration::from_nanos(5_800),
1701            sum_event_time: Duration::ZERO,
1702            sum_integrator_overhead_time: Duration::from_micros(110),
1703            sum_total_evaluation_time: Duration::from_micros(462),
1704            sum_relative_instability_error: (0.0.into(), 0.0.into()),
1705            num_double_precision_evals: 10,
1706            num_quadruple_precision_evals: 0,
1707            num_arb_precision_evals: 0,
1708            num_nan_evals: 0,
1709            num_nan_or_unstable_evals: 0,
1710            sum_generated_event_count: 0,
1711            sum_accepted_event_count: 0,
1712            numerical_stability: NumericalStabilityHistogramAccumulator::new_empty(),
1713        };
1714
1715        let rendered = stats.render_status_table();
1716        let lines = rendered.lines().collect::<Vec<_>>();
1717
1718        assert!(
1719            rendered.starts_with('╭') && rendered.ends_with('╯'),
1720            "{rendered}"
1721        );
1722        assert_eq!(lines.len(), 7, "{rendered}");
1723        assert!(rendered.contains("Integration statistics"), "{rendered}");
1724        assert!(lines[2].contains('┬'), "{rendered}");
1725        assert!(lines[6].contains('┴'), "{rendered}");
1726        assert_eq!(lines[3].matches('│').count(), 6, "{rendered}");
1727        assert_eq!(lines[4].matches('│').count(), 6, "{rendered}");
1728        assert_eq!(lines[5].matches('│').count(), 6, "{rendered}");
1729        assert!(rendered.contains("timing"), "{rendered}");
1730        assert!(rendered.contains("evals"), "{rendered}");
1731        assert!(rendered.contains("events"), "{rendered}");
1732        assert!(rendered.contains("nans+unstable :"), "{rendered}");
1733        assert!(rendered.contains("integrator :"), "{rendered}");
1734    }
1735
1736    #[test]
1737    fn statistics_snapshot_reports_selection_efficiency_from_event_counts() {
1738        let stats = StatisticsCounter {
1739            num_evals: 2,
1740            num_sample_points: 2,
1741            sum_integrand_evaluation_time: Duration::ZERO,
1742            sum_evaluator_evaluation_time: Duration::ZERO,
1743            sum_parameterization_time: Duration::ZERO,
1744            sum_event_time: Duration::ZERO,
1745            sum_integrator_overhead_time: Duration::ZERO,
1746            sum_total_evaluation_time: Duration::ZERO,
1747            sum_relative_instability_error: (0.0.into(), 0.0.into()),
1748            num_double_precision_evals: 2,
1749            num_quadruple_precision_evals: 0,
1750            num_arb_precision_evals: 0,
1751            num_nan_evals: 0,
1752            num_nan_or_unstable_evals: 0,
1753            sum_generated_event_count: 10,
1754            sum_accepted_event_count: 4,
1755            numerical_stability: NumericalStabilityHistogramAccumulator::new_empty(),
1756        };
1757
1758        let snapshot = stats.snapshot();
1759
1760        assert_eq!(snapshot.generated_event_count, 10);
1761        assert_eq!(snapshot.accepted_event_count, 4);
1762        assert_eq!(snapshot.selection_efficiency_percentage, Some(40.0));
1763
1764        let rendered = stats.render_status_table();
1765        assert!(rendered.contains("sel. % :     40.0%"), "{rendered}");
1766    }
1767
1768    #[test]
1769    fn evaluation_metadata_renders_stability_status_with_sample_counts() {
1770        let metadata = EvaluationMetaData {
1771            stability_results: vec![
1772                StabilityResult {
1773                    precision: Precision::Double,
1774                    estimated_relative_accuracy: Some(1.0e-5.into()),
1775                    estimated_decimal_digits: Some(5.0.into()),
1776                    status: StabilityStatus::Stable(3),
1777                    total_time: Duration::from_micros(120),
1778                },
1779                StabilityResult {
1780                    precision: Precision::Quad,
1781                    estimated_relative_accuracy: None,
1782                    estimated_decimal_digits: None,
1783                    status: StabilityStatus::Unknown,
1784                    total_time: Duration::from_micros(240),
1785                },
1786            ],
1787            ..EvaluationMetaData::new_empty()
1788        };
1789
1790        let rendered = metadata.to_string();
1791        assert!(rendered.contains("Stable(3 samples)"), "{rendered}");
1792        assert!(rendered.contains("Unknown(1 sample)"), "{rendered}");
1793        assert!(rendered.contains("None"), "{rendered}");
1794    }
1795
1796    #[test]
1797    fn numerical_stability_histograms_aggregate_stability_results() {
1798        let mut first = EvaluationResult::zero();
1799        first.evaluation_metadata.stability_results = vec![
1800            StabilityResult {
1801                precision: Precision::Double,
1802                estimated_relative_accuracy: Some(1.0e-8.into()),
1803                estimated_decimal_digits: Some(8.0.into()),
1804                status: StabilityStatus::Stable(3),
1805                total_time: Duration::ZERO,
1806            },
1807            StabilityResult {
1808                precision: Precision::Quad,
1809                estimated_relative_accuracy: Some(0.0.into()),
1810                estimated_decimal_digits: None,
1811                status: StabilityStatus::Stable(3),
1812                total_time: Duration::ZERO,
1813            },
1814        ];
1815
1816        let stats = StatisticsCounter::from_evaluation_results(&[first]);
1817        let stability = stats.numerical_stability_snapshot();
1818        let double_histogram = stability
1819            .histograms
1820            .get(NumericalStabilityLevel::Double.key())
1821            .expect("double histogram");
1822        let quad_histogram = stability
1823            .histograms
1824            .get(NumericalStabilityLevel::Quad.key())
1825            .expect("quad histogram");
1826
1827        assert_eq!(double_histogram.sample_count, 1);
1828        assert_eq!(double_histogram.statistics.in_range_entry_count, 1);
1829        assert_eq!(quad_histogram.sample_count, 1);
1830        assert_eq!(quad_histogram.overflow_bin.entry_count, 1);
1831        let Some(NumericalStabilityMedian::InRange {
1832            log10_relative_accuracy,
1833        }) = stability.median(NumericalStabilityLevel::Double)
1834        else {
1835            panic!("double median should be populated in range");
1836        };
1837        assert!((-9.0..-7.0).contains(&log10_relative_accuracy));
1838        assert_eq!(
1839            stability
1840                .median(NumericalStabilityLevel::Quad)
1841                .map(|median| median.formatted_relative_accuracy()),
1842            Some("<=1.0e-34".to_string())
1843        );
1844        assert_eq!(stability.median(NumericalStabilityLevel::ArbPrec), None);
1845    }
1846
1847    #[test]
1848    fn arb_stability_median_renders_unrepresentable_relative_accuracy() {
1849        for accuracy in [0.0_f64, 1.0e-320] {
1850            let mut result = EvaluationResult::zero();
1851            result
1852                .evaluation_metadata
1853                .stability_results
1854                .push(StabilityResult {
1855                    precision: Precision::Arb,
1856                    estimated_relative_accuracy: Some(accuracy.into()),
1857                    estimated_decimal_digits: (accuracy != 0.0).then(|| (-accuracy.log10()).into()),
1858                    status: StabilityStatus::Stable(1),
1859                    total_time: Duration::ZERO,
1860                });
1861            let statistics = StatisticsCounter::from_evaluation_results(&[result]);
1862            let rendered = statistics
1863                .numerical_stability_snapshot()
1864                .median(NumericalStabilityLevel::ArbPrec)
1865                .unwrap()
1866                .formatted_relative_accuracy();
1867            if accuracy == 0.0 {
1868                assert_eq!(rendered, "<=1.0e-1000");
1869            } else {
1870                let (mantissa, exponent) = rendered.split_once('e').unwrap();
1871                assert!(mantissa.parse::<f64>().unwrap() > 0.0, "{rendered}");
1872                assert!(
1873                    (-330..=-310).contains(&exponent.parse::<i32>().unwrap()),
1874                    "{rendered}"
1875                );
1876            }
1877        }
1878    }
1879
1880    #[test]
1881    fn numerical_stability_overflow_includes_exact_upper_boundary() {
1882        for (precision, level, digits, expected) in [
1883            (
1884                Precision::Double,
1885                NumericalStabilityLevel::Double,
1886                17,
1887                "<=1.0e-17",
1888            ),
1889            (
1890                Precision::Quad,
1891                NumericalStabilityLevel::Quad,
1892                34,
1893                "<=1.0e-34",
1894            ),
1895            (
1896                Precision::Arb,
1897                NumericalStabilityLevel::ArbPrec,
1898                1000,
1899                "<=1.0e-1000",
1900            ),
1901        ] {
1902            let mut result = EvaluationResult::zero();
1903            result
1904                .evaluation_metadata
1905                .stability_results
1906                .push(StabilityResult {
1907                    precision,
1908                    estimated_relative_accuracy: Some(10.0_f64.powi(-digits).into()),
1909                    estimated_decimal_digits: Some(f64::from(digits).into()),
1910                    status: StabilityStatus::Stable(2),
1911                    total_time: Duration::ZERO,
1912                });
1913            let statistics = StatisticsCounter::from_evaluation_results(&[result]);
1914            let rendered = statistics
1915                .numerical_stability_snapshot()
1916                .median(level)
1917                .unwrap()
1918                .formatted_relative_accuracy();
1919            assert_eq!(rendered, expected);
1920        }
1921    }
1922
1923    #[test]
1924    fn batch_result_omits_numerical_stability_when_minimal() {
1925        let rendered = BatchSampleEvaluationResult {
1926            samples: Vec::new(),
1927            observables: Default::default(),
1928            numerical_stability: None,
1929        }
1930        .to_string();
1931
1932        assert!(!rendered.contains("Numerical stability"), "{rendered}");
1933    }
1934
1935    #[test]
1936    fn zero_eval_statistics_snapshot_and_render_are_sanitized() {
1937        let stats = StatisticsCounter::new_empty();
1938        let snapshot = stats.snapshot();
1939
1940        assert_eq!(snapshot.num_evals, 0);
1941        assert_eq!(snapshot.average_total_time_seconds, 0.0);
1942        assert_eq!(snapshot.average_parameterization_time_seconds, 0.0);
1943        assert_eq!(snapshot.average_integrand_time_seconds, 0.0);
1944        assert_eq!(snapshot.average_evaluator_time_seconds, 0.0);
1945        assert_eq!(snapshot.average_observable_time_seconds, 0.0);
1946        assert_eq!(snapshot.average_integrator_time_seconds, 0.0);
1947        assert_eq!(snapshot.f64_percentage, 0.0);
1948        assert_eq!(snapshot.f128_percentage, 0.0);
1949        assert_eq!(snapshot.arb_percentage, 0.0);
1950        assert_eq!(snapshot.nan_percentage, 0.0);
1951        assert_eq!(snapshot.nan_or_unstable_percentage, 0.0);
1952        assert_eq!(snapshot.selection_efficiency_percentage, None);
1953
1954        let rendered = stats.render_status_table();
1955        assert!(rendered.contains("sel. % :     N/A"), "{rendered}");
1956        assert!(rendered.contains("f64 :     0.00%"), "{rendered}");
1957        assert!(rendered.contains("nans+unstable :     0.0%"), "{rendered}");
1958        assert!(rendered.contains("integrator :     0.00 µs"), "{rendered}");
1959    }
1960}