ballistics_engine/
truing_uncertainty.rs

1//! Uncertainty-aware joint muzzle-velocity / ballistic-coefficient truing.
2//!
3//! This is an opt-in companion to the historical truing API.  It performs a
4//! weighted two-parameter MAP fit using absolute, caller-supplied observation
5//! standard deviations and optional explicit independent normal priors.  The
6//! legacy point fitter and its output remain untouched.
7//!
8//! V1 uses a local Laplace/Gaussian approximation at the constrained MAP.  It
9//! is intentionally conservative about when that approximation is reported:
10//! the fitted MAP is still returned if the information matrix is singular or
11//! the optimum is on a parameter bound, but intervals and predictive bands are
12//! replaced by a structured approximation failure.
13
14use serde::Serialize;
15use thiserror::Error;
16
17use crate::truing::{
18    truing_jacobian_rows, DropUnit, TruingForwardModel, TruingModelInputsV1, TRUING_BC_MAX,
19    TRUING_BC_MIN, TRUING_MAX_CONDITION_NUMBER, TRUING_MIN_BC_SENSITIVITY_RATIO, TRUING_MV_MAX_FPS,
20    TRUING_MV_MIN_FPS,
21};
22
23/// Schema version for the uncertainty-aware library result.
24pub const TRUING_UNCERTAINTY_SCHEMA_VERSION_V1: u32 = 1;
25
26/// The interval probability reported by V1.
27pub const TRUING_UNCERTAINTY_INTERVAL_LEVEL_V1: f64 = 0.95;
28
29/// Iteration cap for the separately opt-in weighted joint MAP optimizer.
30///
31/// Weakly identifiable short-range likelihoods have long, shallow valleys and
32/// need more iterations than the legacy heuristic-gated point fitter, which
33/// refuses those joint fits entirely.
34pub const TRUING_UNCERTAINTY_MAX_ITERS_V1: usize = 100;
35
36const NORMAL_95_TWO_SIDED_Z: f64 = 1.959_963_984_540_054;
37// Fixed coordinate scales make the two columns of the normal equations
38// numerically comparable without changing the physical result.
39const MV_COORDINATE_SCALE_FPS: f64 = 100.0;
40const BC_COORDINATE_SCALE: f64 = 0.1;
41const INFORMATION_RELATIVE_EIGEN_TOLERANCE: f64 = 1.0e-12;
42const MAP_SCALED_GRADIENT_TOLERANCE: f64 = 1.0e-6;
43// The real trajectory surface has a much finer numerical texture than the
44// deliberately broad finite-difference steps used to estimate its covariance.
45// If LM cannot satisfy the gradient test, a direct-objective pattern poll must
46// reduce every scaled step to this radius and find no improvement larger than
47// the absolute chi-square tolerance before the MAP is accepted.
48const MAP_OBJECTIVE_INITIAL_POLL_RADIUS: f64 = 1.0e-2;
49const MAP_OBJECTIVE_MIN_POLL_RADIUS: f64 = 1.0e-7;
50const MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE: f64 = 1.0e-8;
51const MAP_OBJECTIVE_MAX_POLL_EVALUATIONS: usize = 1_024;
52
53/// One measured drop and its absolute one-standard-deviation uncertainty.
54///
55/// `drop` and `sigma` are both expressed in the request's [`DropUnit`].  The
56/// range is always in internal yards.  Repeated ranges are accepted: repeated
57/// shots tighten the same parameter combination and permit chi-square
58/// consistency checks, but they do not, by themselves, separate MV from BC.
59#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
60pub struct WeightedTruingObservationV1 {
61    pub range_yd: f64,
62    pub drop: f64,
63    pub sigma: f64,
64}
65
66/// An explicit independent normal prior, in the parameter's physical units.
67#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
68pub struct NormalPriorV1 {
69    pub mean: f64,
70    pub sigma: f64,
71}
72
73/// Optional independent priors used by the V1 MAP approximation.
74///
75/// There are no hidden priors.  `None` means that parameter contributes no
76/// prior precision or penalty.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
78pub struct TruingPriorsV1 {
79    pub muzzle_velocity_fps: Option<NormalPriorV1>,
80    pub ballistic_coefficient: Option<NormalPriorV1>,
81}
82
83/// A range at which to propagate parameter uncertainty into predicted drop.
84#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
85pub struct TruingPredictionRequestV1 {
86    pub range_yd: f64,
87    /// Optional absolute measurement sigma in the request's drop unit.  When
88    /// present, V1 reports both the latent model band and the wider band for a
89    /// future observation.  It is never inferred from residuals.
90    pub future_observation_sigma: Option<f64>,
91}
92
93/// Complete request for weighted joint MV+BC MAP truing.
94#[derive(Debug, Clone, Serialize)]
95pub struct UncertaintyTruingRequestV1 {
96    pub model: TruingModelInputsV1,
97    pub drop_unit: DropUnit,
98    pub observations: Vec<WeightedTruingObservationV1>,
99    pub priors: TruingPriorsV1,
100    pub predictions: Vec<TruingPredictionRequestV1>,
101}
102
103/// A two-sided interval from the local Gaussian approximation.
104#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
105pub struct GaussianIntervalV1 {
106    pub estimate: f64,
107    pub standard_deviation: f64,
108    pub lower: f64,
109    pub upper: f64,
110    pub probability: f64,
111}
112
113impl GaussianIntervalV1 {
114    fn from_variance(estimate: f64, variance: f64) -> Option<Self> {
115        if !estimate.is_finite() || !variance.is_finite() || variance < 0.0 {
116            return None;
117        }
118        let standard_deviation = variance.sqrt();
119        let half_width = NORMAL_95_TWO_SIDED_Z * standard_deviation;
120        Some(Self {
121            estimate,
122            standard_deviation,
123            lower: estimate - half_width,
124            upper: estimate + half_width,
125            probability: TRUING_UNCERTAINTY_INTERVAL_LEVEL_V1,
126        })
127    }
128}
129
130/// Physical-coordinate posterior covariance for `(MV fps, BC)`.
131#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
132pub struct TruingCovarianceV1 {
133    pub mv_variance_fps2: f64,
134    pub mv_bc_covariance_fps: f64,
135    pub bc_variance: f64,
136}
137
138/// Successful local Gaussian approximation around the MAP.
139#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
140pub struct TruingGaussianApproximationV1 {
141    pub covariance: TruingCovarianceV1,
142    pub muzzle_velocity_interval_95: GaussianIntervalV1,
143    pub ballistic_coefficient_interval_95: GaussianIntervalV1,
144    /// Posterior correlation between fitted MV and BC.
145    pub mv_bc_correlation: f64,
146    /// Condition number of the posterior information matrix in the documented
147    /// scaled coordinates (`100 fps`, `0.1 BC`).
148    pub scaled_information_condition_number: f64,
149}
150
151/// Why a local Gaussian approximation was withheld even though a MAP is
152/// available.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "snake_case")]
155pub enum TruingApproximationFailureCodeV1 {
156    OptimizerDidNotConverge,
157    MapAtParameterBound,
158    RankDeficientInformation,
159    NonFiniteInformation,
160}
161
162/// Structured approximation failure.  Consumers can branch on `code` while
163/// still presenting the explanatory `message`.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct TruingApproximationFailureV1 {
166    pub code: TruingApproximationFailureCodeV1,
167    pub message: String,
168}
169
170/// Availability of the Laplace/Gaussian approximation.
171#[derive(Debug, Clone, PartialEq, Serialize)]
172#[serde(rename_all = "snake_case", tag = "status", content = "details")]
173pub enum TruingApproximationV1 {
174    Available(TruingGaussianApproximationV1),
175    Unavailable(TruingApproximationFailureV1),
176}
177
178/// Input and residual diagnostics at one observation.
179#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
180pub struct WeightedTruingObservationResultV1 {
181    pub range_yd: f64,
182    pub observed_drop: f64,
183    pub sigma: f64,
184    pub predicted_drop: f64,
185    pub residual: f64,
186    pub standardized_residual: f64,
187}
188
189/// Parameter-propagated drop uncertainty at one requested range.
190#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
191pub struct TruingPredictiveBandV1 {
192    pub range_yd: f64,
193    pub predicted_drop: f64,
194    /// Band for the latent model prediction.  `None` means the Gaussian
195    /// approximation was unavailable.
196    pub latent_interval_95: Option<GaussianIntervalV1>,
197    /// Band for a future measured impact, including the explicitly supplied
198    /// `future_observation_sigma`.  This remains `None` when no future sigma was
199    /// requested or the Gaussian approximation was unavailable.
200    pub future_observation_interval_95: Option<GaussianIntervalV1>,
201}
202
203/// Machine-readable warning categories emitted alongside the fit.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
205#[serde(rename_all = "snake_case")]
206pub enum TruingUncertaintyWarningCodeV1 {
207    OptimizerDidNotConverge,
208    ObjectiveMeshConvergence,
209    WeakBcSensitivity,
210    IllConditionedData,
211    MvPriorDominated,
212    BcPriorDominated,
213    GaussianApproximationUnavailable,
214    IntervalCrossesFitBounds,
215    LowEffectiveDegreesOfFreedom,
216    PredictionOutsideObservedDomain,
217}
218
219/// Numerical criterion that verified the reported MAP.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "snake_case")]
222pub enum TruingMapConvergenceCriterionV1 {
223    /// The scaled Gauss-Newton half-gradient met its tolerance.
224    ScaledGradient,
225    /// A deterministic direct-objective pattern search found no material
226    /// improvement at its minimum mesh radius.
227    ObjectiveMesh,
228}
229
230/// A warning with stable code and human-readable context.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
232pub struct TruingUncertaintyWarningV1 {
233    pub code: TruingUncertaintyWarningCodeV1,
234    pub message: String,
235}
236
237/// Fit and approximation diagnostics.
238#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
239pub struct TruingUncertaintyDiagnosticsV1 {
240    /// Data chi-square, `sum((prediction - observation) / sigma)^2`.
241    pub chi_square: f64,
242    /// Explicit prior contribution to the penalized chi-square.
243    pub prior_penalty: f64,
244    /// `chi_square + prior_penalty`, minimized by the MAP fitter.
245    pub penalized_chi_square: f64,
246    /// Effective number of parameters learned from the observations,
247    /// `trace(I_data * I_posterior^-1)`.
248    pub effective_parameter_count: Option<f64>,
249    /// Effective residual degrees of freedom, `n - effective_parameter_count`.
250    pub effective_degrees_of_freedom: Option<f64>,
251    /// Data chi-square divided by effective residual degrees of freedom.
252    pub reduced_chi_square: Option<f64>,
253    /// Fractional BC sensitivity relative to fractional MV sensitivity.
254    pub bc_sensitivity_ratio: f64,
255    /// Collinearity condition diagnostic for the weighted data Jacobian.
256    pub data_condition_number: f64,
257    /// Infinity norm of the penalized-chi-square half-gradient in scaled
258    /// coordinates at the reported MAP.  This is an informative local-model
259    /// diagnostic.  It is the primary convergence test, but the deliberately
260    /// broad finite-difference stencil can disagree with the trajectory
261    /// solver's much finer numerical surface.  In that case
262    /// [`TruingMapConvergenceCriterionV1::ObjectiveMesh`] records a direct-
263    /// objective verification instead.
264    pub map_scaled_gradient_inf_norm: f64,
265    /// Criterion that verified the reported MAP, or `None` if the optimizer
266    /// did not converge.
267    pub map_convergence_criterion: Option<TruingMapConvergenceCriterionV1>,
268    /// Final scaled pattern-poll radius for objective-mesh convergence.
269    /// `100 fps` and `0.1 BC` are one scaled unit.  `None` when the gradient
270    /// criterion was used or the poll did not reach its resolution target.
271    pub map_objective_poll_radius: Option<f64>,
272    /// Largest direct chi-square improvement found in the final pattern poll.
273    /// Objective-mesh convergence requires this to be no greater than
274    /// `1e-8` in absolute penalized chi-square units.
275    pub map_max_objective_poll_improvement: Option<f64>,
276    /// Number of direct objective evaluations used by the fallback poll.
277    pub map_objective_poll_evaluations: usize,
278}
279
280/// Result of uncertainty-aware joint MV+BC truing.
281#[derive(Debug, Clone, PartialEq, Serialize)]
282pub struct UncertaintyTruingReportV1 {
283    pub schema_version: u32,
284    pub drop_unit: DropUnit,
285    pub map_muzzle_velocity_fps: f64,
286    pub map_ballistic_coefficient: f64,
287    pub iterations: usize,
288    pub converged: bool,
289    pub priors: TruingPriorsV1,
290    pub observations: Vec<WeightedTruingObservationResultV1>,
291    pub diagnostics: TruingUncertaintyDiagnosticsV1,
292    pub approximation: TruingApproximationV1,
293    pub predictive_bands: Vec<TruingPredictiveBandV1>,
294    pub warnings: Vec<TruingUncertaintyWarningV1>,
295}
296
297/// Input or forward-model failure before a report can be produced.
298#[derive(Debug, Error, Clone, PartialEq, Eq)]
299pub enum UncertaintyTruingErrorV1 {
300    #[error("invalid uncertainty-truing request: {0}")]
301    InvalidInput(String),
302    #[error("truing forward model failed: {0}")]
303    ForwardModel(String),
304}
305
306#[derive(Debug, Clone, Copy, Default)]
307struct Symmetric2 {
308    a00: f64,
309    a01: f64,
310    a11: f64,
311}
312
313impl Symmetric2 {
314    fn determinant(self) -> f64 {
315        self.a00 * self.a11 - self.a01 * self.a01
316    }
317
318    fn add_assign(&mut self, rhs: Self) {
319        self.a00 += rhs.a00;
320        self.a01 += rhs.a01;
321        self.a11 += rhs.a11;
322    }
323
324    fn inverse_with_condition(self) -> Result<(Self, f64), TruingApproximationFailureCodeV1> {
325        if !self.a00.is_finite() || !self.a01.is_finite() || !self.a11.is_finite() {
326            return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
327        }
328        let trace = self.a00 + self.a11;
329        let discriminant = (self.a00 - self.a11).hypot(2.0 * self.a01);
330        let largest = 0.5 * (trace + discriminant);
331        let determinant = self.determinant();
332        if !largest.is_finite() || !determinant.is_finite() {
333            return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
334        }
335        if largest <= 0.0 || determinant <= 0.0 {
336            return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
337        }
338        // det = lambda_max * lambda_min is more accurate than subtracting two
339        // nearly equal values from the quadratic formula.
340        let smallest = determinant / largest;
341        if !smallest.is_finite()
342            || smallest <= 0.0
343            || smallest / largest <= INFORMATION_RELATIVE_EIGEN_TOLERANCE
344        {
345            return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
346        }
347        let inverse = Self {
348            a00: self.a11 / determinant,
349            a01: -self.a01 / determinant,
350            a11: self.a00 / determinant,
351        };
352        if !inverse.a00.is_finite() || !inverse.a01.is_finite() || !inverse.a11.is_finite() {
353            return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
354        }
355        Ok((inverse, largest / smallest))
356    }
357}
358
359struct Evaluation {
360    data_information: Symmetric2,
361    posterior_information: Symmetric2,
362    gradient: [f64; 2],
363    observation_results: Vec<WeightedTruingObservationResultV1>,
364    chi_square: f64,
365    prior_penalty: f64,
366    bc_sensitivity_ratio: f64,
367    data_condition_number: f64,
368}
369
370#[derive(Debug, Clone, Copy)]
371struct MapFitResult {
372    mv: f64,
373    bc: f64,
374    iterations: usize,
375    convergence_criterion: Option<TruingMapConvergenceCriterionV1>,
376    objective_poll_radius: Option<f64>,
377    max_objective_poll_improvement: Option<f64>,
378    objective_poll_evaluations: usize,
379}
380
381#[derive(Debug, Clone, Copy)]
382struct ObjectivePollResult {
383    mv: f64,
384    bc: f64,
385    converged: bool,
386    final_radius: f64,
387    max_final_improvement: f64,
388    evaluations: usize,
389}
390
391impl Evaluation {
392    fn penalized_chi_square(&self) -> f64 {
393        self.chi_square + self.prior_penalty
394    }
395}
396
397/// Run the opt-in uncertainty-aware joint MV+BC truing path.
398///
399/// Observation sigmas are absolute and known.  Consequently posterior
400/// covariance is the inverse likelihood-plus-prior information matrix and is
401/// **not** rescaled by residual RMS.  No identifiability heuristic changes the
402/// requested two-parameter fit; weak or collinear data instead produce broad
403/// uncertainty, warnings, or a structured approximation failure.
404pub fn run_uncertainty_truing_v1(
405    request: &UncertaintyTruingRequestV1,
406) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
407    validate_request(request)?;
408
409    request
410        .model
411        .with_forward_model(request.drop_unit, |model| run_with_model(request, model))
412}
413
414fn run_with_model(
415    request: &UncertaintyTruingRequestV1,
416    model: &TruingForwardModel<'_>,
417) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
418    let fit = fit_map(request, model)?;
419    let map_mv = fit.mv;
420    let map_bc = fit.bc;
421    let iterations = fit.iterations;
422    let converged = fit.convergence_criterion.is_some();
423    let evaluation = evaluate(request, model, map_mv, map_bc)?;
424    let mut warnings = Vec::new();
425
426    if !converged {
427        let gradient_norm = evaluation.gradient[0]
428            .abs()
429            .max(evaluation.gradient[1].abs());
430        push_warning(
431            &mut warnings,
432            TruingUncertaintyWarningCodeV1::OptimizerDidNotConverge,
433            format!(
434                "joint MV+BC MAP optimizer stopped after {iterations} iterations with scaled gradient {gradient_norm:.3e} above {MAP_SCALED_GRADIENT_TOLERANCE:.1e}"
435            ),
436        );
437    } else if fit.convergence_criterion == Some(TruingMapConvergenceCriterionV1::ObjectiveMesh) {
438        let radius = fit.objective_poll_radius.unwrap_or(f64::NAN);
439        let improvement = fit.max_objective_poll_improvement.unwrap_or(f64::NAN);
440        push_warning(
441            &mut warnings,
442            TruingUncertaintyWarningCodeV1::ObjectiveMeshConvergence,
443            format!(
444                "LM's broad-stencil gradient test stalled (final norm {:.3e}); a direct-objective pattern search found no penalized-chi-square improvement above {:.1e} at scaled radius {radius:.1e} (largest observed {improvement:.3e})",
445                evaluation.gradient[0]
446                    .abs()
447                    .max(evaluation.gradient[1].abs()),
448                MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE,
449            ),
450        );
451    }
452    if evaluation.bc_sensitivity_ratio < TRUING_MIN_BC_SENSITIVITY_RATIO {
453        push_warning(
454            &mut warnings,
455            TruingUncertaintyWarningCodeV1::WeakBcSensitivity,
456            format!(
457                "observations weakly constrain BC: fractional sensitivity ratio {:.4} is below {:.2}",
458                evaluation.bc_sensitivity_ratio, TRUING_MIN_BC_SENSITIVITY_RATIO
459            ),
460        );
461    }
462    if !evaluation.data_condition_number.is_finite()
463        || evaluation.data_condition_number > TRUING_MAX_CONDITION_NUMBER
464    {
465        push_warning(
466            &mut warnings,
467            TruingUncertaintyWarningCodeV1::IllConditionedData,
468            format!(
469                "weighted observation Jacobian cannot cleanly separate MV from BC (condition {:.3e})",
470                evaluation.data_condition_number
471            ),
472        );
473    }
474
475    let at_bound = parameter_at_bound(map_mv, map_bc);
476    let approximation_result = if !converged {
477        Err(TruingApproximationFailureV1 {
478            code: TruingApproximationFailureCodeV1::OptimizerDidNotConverge,
479            message: "MAP optimizer did not converge; covariance around an unverified stationary point is withheld".to_string(),
480        })
481    } else if at_bound {
482        Err(TruingApproximationFailureV1 {
483            code: TruingApproximationFailureCodeV1::MapAtParameterBound,
484            message: "MAP lies on or numerically near a fit bound; an unconstrained Gaussian approximation would be misleading".to_string(),
485        })
486    } else {
487        build_approximation(map_mv, map_bc, evaluation.posterior_information)
488    };
489
490    let (approximation, covariance_q) = match approximation_result {
491        Ok((gaussian, covariance_q)) => {
492            warn_prior_dominance(request, &gaussian, &mut warnings);
493            if gaussian.muzzle_velocity_interval_95.lower < TRUING_MV_MIN_FPS
494                || gaussian.muzzle_velocity_interval_95.upper > TRUING_MV_MAX_FPS
495                || gaussian.ballistic_coefficient_interval_95.lower < TRUING_BC_MIN
496                || gaussian.ballistic_coefficient_interval_95.upper > TRUING_BC_MAX
497            {
498                push_warning(
499                    &mut warnings,
500                    TruingUncertaintyWarningCodeV1::IntervalCrossesFitBounds,
501                    "local Gaussian interval crosses a constrained fit bound; interpret its tails cautiously".to_string(),
502                );
503            }
504            (
505                TruingApproximationV1::Available(gaussian),
506                Some(covariance_q),
507            )
508        }
509        Err(failure) => {
510            push_warning(
511                &mut warnings,
512                TruingUncertaintyWarningCodeV1::GaussianApproximationUnavailable,
513                failure.message.clone(),
514            );
515            (TruingApproximationV1::Unavailable(failure), None)
516        }
517    };
518
519    let effective_parameter_count = covariance_q.map(|covariance| {
520        (evaluation.data_information.a00 * covariance.a00
521            + 2.0 * evaluation.data_information.a01 * covariance.a01
522            + evaluation.data_information.a11 * covariance.a11)
523            .clamp(0.0, 2.0)
524    });
525    let effective_degrees_of_freedom =
526        effective_parameter_count.map(|count| request.observations.len() as f64 - count);
527    let reduced_chi_square = effective_degrees_of_freedom
528        .filter(|dof| *dof > f64::EPSILON)
529        .map(|dof| evaluation.chi_square / dof);
530    if effective_degrees_of_freedom.is_some_and(|dof| dof <= 1.0) {
531        push_warning(
532            &mut warnings,
533            TruingUncertaintyWarningCodeV1::LowEffectiveDegreesOfFreedom,
534            "one or fewer effective residual degrees of freedom: residual fit quality is weakly validated".to_string(),
535        );
536    }
537
538    let predictive_bands =
539        build_predictive_bands(request, model, map_mv, map_bc, covariance_q, &mut warnings)?;
540
541    let penalized_chi_square = evaluation.penalized_chi_square();
542
543    Ok(UncertaintyTruingReportV1 {
544        schema_version: TRUING_UNCERTAINTY_SCHEMA_VERSION_V1,
545        drop_unit: request.drop_unit,
546        map_muzzle_velocity_fps: map_mv,
547        map_ballistic_coefficient: map_bc,
548        iterations,
549        converged,
550        priors: request.priors,
551        observations: evaluation.observation_results,
552        diagnostics: TruingUncertaintyDiagnosticsV1 {
553            chi_square: evaluation.chi_square,
554            prior_penalty: evaluation.prior_penalty,
555            penalized_chi_square,
556            effective_parameter_count,
557            effective_degrees_of_freedom,
558            reduced_chi_square,
559            bc_sensitivity_ratio: evaluation.bc_sensitivity_ratio,
560            data_condition_number: evaluation.data_condition_number,
561            map_scaled_gradient_inf_norm: evaluation.gradient[0]
562                .abs()
563                .max(evaluation.gradient[1].abs()),
564            map_convergence_criterion: fit.convergence_criterion,
565            map_objective_poll_radius: fit.objective_poll_radius,
566            map_max_objective_poll_improvement: fit.max_objective_poll_improvement,
567            map_objective_poll_evaluations: fit.objective_poll_evaluations,
568        },
569        approximation,
570        predictive_bands,
571        warnings,
572    })
573}
574
575fn validate_request(request: &UncertaintyTruingRequestV1) -> Result<(), UncertaintyTruingErrorV1> {
576    request
577        .model
578        .validate()
579        .map_err(UncertaintyTruingErrorV1::InvalidInput)?;
580    if request.observations.len() < 2 {
581        return Err(UncertaintyTruingErrorV1::InvalidInput(
582            "at least two weighted observations are required for a joint MV+BC fit".to_string(),
583        ));
584    }
585    for (index, observation) in request.observations.iter().enumerate() {
586        if !observation.range_yd.is_finite() || observation.range_yd <= 0.0 {
587            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
588                "observation {} range must be positive and finite",
589                index + 1
590            )));
591        }
592        if !observation.drop.is_finite() {
593            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
594                "observation {} drop must be finite",
595                index + 1
596            )));
597        }
598        if !observation.sigma.is_finite() || observation.sigma <= 0.0 {
599            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
600                "observation {} sigma must be positive and finite",
601                index + 1
602            )));
603        }
604    }
605    validate_prior(
606        "muzzle-velocity",
607        request.priors.muzzle_velocity_fps,
608        TRUING_MV_MIN_FPS,
609        TRUING_MV_MAX_FPS,
610    )?;
611    validate_prior(
612        "ballistic-coefficient",
613        request.priors.ballistic_coefficient,
614        TRUING_BC_MIN,
615        TRUING_BC_MAX,
616    )?;
617    for (index, prediction) in request.predictions.iter().enumerate() {
618        if !prediction.range_yd.is_finite() || prediction.range_yd <= 0.0 {
619            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
620                "prediction {} range must be positive and finite",
621                index + 1
622            )));
623        }
624        if prediction
625            .future_observation_sigma
626            .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
627        {
628            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
629                "prediction {} future-observation sigma must be positive and finite",
630                index + 1
631            )));
632        }
633    }
634    Ok(())
635}
636
637fn validate_prior(
638    name: &str,
639    prior: Option<NormalPriorV1>,
640    lower: f64,
641    upper: f64,
642) -> Result<(), UncertaintyTruingErrorV1> {
643    let Some(prior) = prior else {
644        return Ok(());
645    };
646    if !prior.mean.is_finite() || !(lower..=upper).contains(&prior.mean) {
647        return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
648            "{name} prior mean must be finite and within {lower}..={upper}"
649        )));
650    }
651    if !prior.sigma.is_finite() || prior.sigma <= 0.0 {
652        return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
653            "{name} prior sigma must be positive and finite"
654        )));
655    }
656    Ok(())
657}
658
659fn fit_map(
660    request: &UncertaintyTruingRequestV1,
661    model: &TruingForwardModel<'_>,
662) -> Result<MapFitResult, UncertaintyTruingErrorV1> {
663    let mut mv = request.model.muzzle_velocity_fps;
664    let mut bc = request.model.ballistic_coefficient;
665    let mut lambda = 1.0e-6;
666    let mut current = objective(request, model, mv, bc)?;
667    let mut iterations = 0;
668    let mut convergence_criterion = None;
669
670    for _ in 0..TRUING_UNCERTAINTY_MAX_ITERS_V1 {
671        iterations += 1;
672        let evaluation = evaluate(request, model, mv, bc)?;
673        if evaluation.gradient[0]
674            .abs()
675            .max(evaluation.gradient[1].abs())
676            <= MAP_SCALED_GRADIENT_TOLERANCE
677        {
678            convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
679            break;
680        }
681
682        let mut accepted = false;
683        for _ in 0..30 {
684            let information = evaluation.posterior_information;
685            let damped = Symmetric2 {
686                a00: information.a00 + lambda * information.a00.max(1.0e-12),
687                a01: information.a01,
688                a11: information.a11 + lambda * information.a11.max(1.0e-12),
689            };
690            let determinant = damped.determinant();
691            if !determinant.is_finite() || determinant.abs() < 1.0e-24 {
692                lambda *= 10.0;
693                continue;
694            }
695            let delta_mv_coordinate = -(damped.a11 * evaluation.gradient[0]
696                - damped.a01 * evaluation.gradient[1])
697                / determinant;
698            let delta_bc_coordinate = -(-damped.a01 * evaluation.gradient[0]
699                + damped.a00 * evaluation.gradient[1])
700                / determinant;
701            let next_mv = (mv + MV_COORDINATE_SCALE_FPS * delta_mv_coordinate)
702                .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
703            let next_bc = (bc + BC_COORDINATE_SCALE * delta_bc_coordinate)
704                .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
705            let next = objective(request, model, next_mv, next_bc)?;
706            if objective_improvement_is_material(current, next) {
707                mv = next_mv;
708                bc = next_bc;
709                current = next;
710                lambda = (lambda * 0.5).max(1.0e-12);
711                accepted = true;
712                break;
713            }
714            // If clamping or floating-point resolution leaves the candidate at
715            // the current optimum, increasing damping cannot reveal a lower
716            // point.  The direct-objective poll below still verifies the full
717            // two-dimensional neighborhood before accepting convergence.
718            if next_mv == mv
719                && next_bc == bc
720                && evaluation.gradient[0]
721                    .abs()
722                    .max(evaluation.gradient[1].abs())
723                    <= MAP_SCALED_GRADIENT_TOLERANCE
724            {
725                convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
726                break;
727            }
728            lambda *= 4.0;
729        }
730        if convergence_criterion.is_some() {
731            break;
732        }
733        if !accepted {
734            break;
735        }
736    }
737    if convergence_criterion.is_none() {
738        let final_evaluation = evaluate(request, model, mv, bc)?;
739        if final_evaluation.gradient[0]
740            .abs()
741            .max(final_evaluation.gradient[1].abs())
742            <= MAP_SCALED_GRADIENT_TOLERANCE
743        {
744            convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
745        }
746    }
747
748    let mut objective_poll_radius = None;
749    let mut max_objective_poll_improvement = None;
750    let mut objective_poll_evaluations = 0;
751    if convergence_criterion.is_none() {
752        let poll = polish_and_verify_objective_mesh(request, model, mv, bc, current)?;
753        mv = poll.mv;
754        bc = poll.bc;
755        objective_poll_evaluations = poll.evaluations;
756        if poll.converged {
757            convergence_criterion = Some(TruingMapConvergenceCriterionV1::ObjectiveMesh);
758            objective_poll_radius = Some(poll.final_radius);
759            max_objective_poll_improvement = Some(poll.max_final_improvement);
760        }
761    }
762
763    Ok(MapFitResult {
764        mv,
765        bc,
766        iterations,
767        convergence_criterion,
768        objective_poll_radius,
769        max_objective_poll_improvement,
770        objective_poll_evaluations,
771    })
772}
773
774fn objective_improvement_is_material(current: f64, candidate: f64) -> bool {
775    current - candidate > MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE
776}
777
778/// Polish an LM candidate against the actual penalized chi-square surface and
779/// verify local convergence independently of the finite-difference Jacobian.
780///
781/// The eight polling directions contain a positive spanning coordinate basis;
782/// the local information eigenvectors accelerate progress along the strongly
783/// correlated MV/BC valley.
784/// A point is accepted only after no direction yields a material improvement
785/// at the minimum mesh radius.
786fn polish_and_verify_objective_mesh(
787    request: &UncertaintyTruingRequestV1,
788    model: &TruingForwardModel<'_>,
789    mut mv: f64,
790    mut bc: f64,
791    mut current: f64,
792) -> Result<ObjectivePollResult, UncertaintyTruingErrorV1> {
793    let information = evaluate(request, model, mv, bc)?.posterior_information;
794    let angle = 0.5 * (2.0 * information.a01).atan2(information.a00 - information.a11);
795    let (sin, cos) = angle.sin_cos();
796    // (cos, sin) is the large-information direction and (-sin, cos) the weak
797    // direction.  Both are unit vectors in scaled optimizer coordinates.
798    let directions = [
799        (1.0, 0.0),
800        (-1.0, 0.0),
801        (0.0, 1.0),
802        (0.0, -1.0),
803        (cos, sin),
804        (-cos, -sin),
805        (-sin, cos),
806        (sin, -cos),
807    ];
808
809    let mut radius = MAP_OBJECTIVE_INITIAL_POLL_RADIUS;
810    let mut evaluations = 0;
811    let mut max_final_improvement = f64::INFINITY;
812    while evaluations < MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
813        let mut best = current;
814        let mut best_point = (mv, bc);
815        for (mv_direction, bc_direction) in directions {
816            if evaluations >= MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
817                break;
818            }
819            let candidate_mv = (mv + radius * mv_direction * MV_COORDINATE_SCALE_FPS)
820                .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
821            let candidate_bc = (bc + radius * bc_direction * BC_COORDINATE_SCALE)
822                .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
823            if candidate_mv == mv && candidate_bc == bc {
824                continue;
825            }
826            let candidate = objective(request, model, candidate_mv, candidate_bc)?;
827            evaluations += 1;
828            if candidate < best {
829                best = candidate;
830                best_point = (candidate_mv, candidate_bc);
831            }
832        }
833
834        let improvement = (current - best).max(0.0);
835        if objective_improvement_is_material(current, best) {
836            mv = best_point.0;
837            bc = best_point.1;
838            current = best;
839            continue;
840        }
841        max_final_improvement = improvement;
842        if radius <= MAP_OBJECTIVE_MIN_POLL_RADIUS {
843            return Ok(ObjectivePollResult {
844                mv,
845                bc,
846                converged: true,
847                final_radius: radius,
848                max_final_improvement,
849                evaluations,
850            });
851        }
852        radius = (radius * 0.25).max(MAP_OBJECTIVE_MIN_POLL_RADIUS);
853    }
854
855    Ok(ObjectivePollResult {
856        mv,
857        bc,
858        converged: false,
859        final_radius: radius,
860        max_final_improvement,
861        evaluations,
862    })
863}
864
865fn objective(
866    request: &UncertaintyTruingRequestV1,
867    model: &TruingForwardModel<'_>,
868    mv: f64,
869    bc: f64,
870) -> Result<f64, UncertaintyTruingErrorV1> {
871    let mut objective = 0.0;
872    let ranges: Vec<f64> = request
873        .observations
874        .iter()
875        .map(|observation| observation.range_yd)
876        .collect();
877    let predictions = model
878        .predict_many_in_unit(mv, bc, &ranges, request.drop_unit)
879        .map_err(forward_error)?;
880    for (observation, prediction) in request.observations.iter().zip(predictions) {
881        let prediction = prediction.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
882        let standardized = (prediction - observation.drop) / observation.sigma;
883        objective += standardized * standardized;
884    }
885    if let Some(prior) = request.priors.muzzle_velocity_fps {
886        let standardized = (mv - prior.mean) / prior.sigma;
887        objective += standardized * standardized;
888    }
889    if let Some(prior) = request.priors.ballistic_coefficient {
890        let standardized = (bc - prior.mean) / prior.sigma;
891        objective += standardized * standardized;
892    }
893    if objective.is_finite() {
894        Ok(objective)
895    } else {
896        Err(UncertaintyTruingErrorV1::ForwardModel(
897            "non-finite penalized chi-square".to_string(),
898        ))
899    }
900}
901
902fn evaluate(
903    request: &UncertaintyTruingRequestV1,
904    model: &TruingForwardModel<'_>,
905    mv: f64,
906    bc: f64,
907) -> Result<Evaluation, UncertaintyTruingErrorV1> {
908    let mut data_information = Symmetric2::default();
909    let mut gradient = [0.0, 0.0];
910    let mut results = Vec::with_capacity(request.observations.len());
911    let mut chi_square = 0.0;
912
913    // Fractional-sensitivity terms retain the physical parameter scaling even
914    // though the optimizer itself works in fixed numerical coordinates.
915    let (mut fractional_mv_norm2, mut fractional_bc_norm2): (f64, f64) = (0.0, 0.0);
916    let ranges: Vec<f64> = request
917        .observations
918        .iter()
919        .map(|observation| observation.range_yd)
920        .collect();
921    let rows =
922        truing_jacobian_rows(model, mv, bc, &ranges, request.drop_unit).map_err(forward_error)?;
923    for (observation, row) in request.observations.iter().zip(rows) {
924        let row = row.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
925        let residual = row.predicted_drop - observation.drop;
926        let standardized_residual = residual / observation.sigma;
927        let j_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS / observation.sigma;
928        let j_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE / observation.sigma;
929        data_information.a00 += j_mv * j_mv;
930        data_information.a01 += j_mv * j_bc;
931        data_information.a11 += j_bc * j_bc;
932        gradient[0] += j_mv * standardized_residual;
933        gradient[1] += j_bc * standardized_residual;
934        chi_square += standardized_residual * standardized_residual;
935        fractional_mv_norm2 += (row.d_drop_d_mv * mv / observation.sigma).powi(2);
936        fractional_bc_norm2 += (row.d_drop_d_bc * bc / observation.sigma).powi(2);
937        results.push(WeightedTruingObservationResultV1 {
938            range_yd: observation.range_yd,
939            observed_drop: observation.drop,
940            sigma: observation.sigma,
941            predicted_drop: row.predicted_drop,
942            residual,
943            standardized_residual,
944        });
945    }
946
947    let mut prior_information = Symmetric2::default();
948    let mut prior_penalty = 0.0;
949    if let Some(prior) = request.priors.muzzle_velocity_fps {
950        let j = MV_COORDINATE_SCALE_FPS / prior.sigma;
951        let standardized = (mv - prior.mean) / prior.sigma;
952        prior_information.a00 += j * j;
953        gradient[0] += j * standardized;
954        prior_penalty += standardized * standardized;
955    }
956    if let Some(prior) = request.priors.ballistic_coefficient {
957        let j = BC_COORDINATE_SCALE / prior.sigma;
958        let standardized = (bc - prior.mean) / prior.sigma;
959        prior_information.a11 += j * j;
960        gradient[1] += j * standardized;
961        prior_penalty += standardized * standardized;
962    }
963    let mut posterior_information = data_information;
964    posterior_information.add_assign(prior_information);
965
966    let bc_sensitivity_ratio = if fractional_mv_norm2 > 0.0 {
967        (fractional_bc_norm2 / fractional_mv_norm2).sqrt()
968    } else {
969        0.0
970    };
971    let data_condition_number = column_condition(data_information);
972
973    Ok(Evaluation {
974        data_information,
975        posterior_information,
976        gradient,
977        observation_results: results,
978        chi_square,
979        prior_penalty,
980        bc_sensitivity_ratio,
981        data_condition_number,
982    })
983}
984
985fn column_condition(information: Symmetric2) -> f64 {
986    if information.a00 <= 0.0 || information.a11 <= 0.0 {
987        return f64::INFINITY;
988    }
989    let correlation = (information.a01 / (information.a00 * information.a11).sqrt())
990        .clamp(-1.0, 1.0)
991        .abs();
992    if 1.0 - correlation <= 1.0e-15 {
993        f64::INFINITY
994    } else {
995        (1.0 + correlation) / (1.0 - correlation)
996    }
997}
998
999fn build_approximation(
1000    mv: f64,
1001    bc: f64,
1002    information: Symmetric2,
1003) -> Result<(TruingGaussianApproximationV1, Symmetric2), TruingApproximationFailureV1> {
1004    let (covariance_q, condition) = information
1005        .inverse_with_condition()
1006        .map_err(|code| TruingApproximationFailureV1 {
1007            code,
1008            message: match code {
1009                TruingApproximationFailureCodeV1::OptimizerDidNotConverge => {
1010                    "MAP optimizer did not converge".to_string()
1011                }
1012                TruingApproximationFailureCodeV1::MapAtParameterBound => {
1013                    "MAP is at a constrained parameter bound".to_string()
1014                }
1015                TruingApproximationFailureCodeV1::RankDeficientInformation => {
1016                    "likelihood-plus-prior information is rank deficient or numerically singular; collect more separated ranges or add an explicit prior".to_string()
1017                }
1018                TruingApproximationFailureCodeV1::NonFiniteInformation => {
1019                    "likelihood-plus-prior information or its inverse is non-finite".to_string()
1020                }
1021            },
1022        })?;
1023    let covariance = TruingCovarianceV1 {
1024        mv_variance_fps2: covariance_q.a00 * MV_COORDINATE_SCALE_FPS.powi(2),
1025        mv_bc_covariance_fps: covariance_q.a01 * MV_COORDINATE_SCALE_FPS * BC_COORDINATE_SCALE,
1026        bc_variance: covariance_q.a11 * BC_COORDINATE_SCALE.powi(2),
1027    };
1028    let mv_interval = GaussianIntervalV1::from_variance(mv, covariance.mv_variance_fps2)
1029        .ok_or_else(|| TruingApproximationFailureV1 {
1030            code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1031            message: "MV marginal variance is invalid".to_string(),
1032        })?;
1033    let bc_interval =
1034        GaussianIntervalV1::from_variance(bc, covariance.bc_variance).ok_or_else(|| {
1035            TruingApproximationFailureV1 {
1036                code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1037                message: "BC marginal variance is invalid".to_string(),
1038            }
1039        })?;
1040    let correlation = covariance.mv_bc_covariance_fps
1041        / (covariance.mv_variance_fps2 * covariance.bc_variance).sqrt();
1042    if !correlation.is_finite() {
1043        return Err(TruingApproximationFailureV1 {
1044            code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1045            message: "MV/BC posterior correlation is non-finite".to_string(),
1046        });
1047    }
1048    Ok((
1049        TruingGaussianApproximationV1 {
1050            covariance,
1051            muzzle_velocity_interval_95: mv_interval,
1052            ballistic_coefficient_interval_95: bc_interval,
1053            mv_bc_correlation: correlation.clamp(-1.0, 1.0),
1054            scaled_information_condition_number: condition,
1055        },
1056        covariance_q,
1057    ))
1058}
1059
1060fn build_predictive_bands(
1061    request: &UncertaintyTruingRequestV1,
1062    model: &TruingForwardModel<'_>,
1063    mv: f64,
1064    bc: f64,
1065    covariance_q: Option<Symmetric2>,
1066    warnings: &mut Vec<TruingUncertaintyWarningV1>,
1067) -> Result<Vec<TruingPredictiveBandV1>, UncertaintyTruingErrorV1> {
1068    let observed_min = request
1069        .observations
1070        .iter()
1071        .map(|observation| observation.range_yd)
1072        .fold(f64::INFINITY, f64::min);
1073    let observed_max = request
1074        .observations
1075        .iter()
1076        .map(|observation| observation.range_yd)
1077        .fold(f64::NEG_INFINITY, f64::max);
1078    let mut warned_extrapolation = false;
1079    let mut bands = Vec::with_capacity(request.predictions.len());
1080    let prediction_ranges: Vec<f64> = request
1081        .predictions
1082        .iter()
1083        .map(|prediction| prediction.range_yd)
1084        .collect();
1085    let rows = truing_jacobian_rows(model, mv, bc, &prediction_ranges, request.drop_unit)
1086        .map_err(forward_error)?;
1087    for (prediction, row) in request.predictions.iter().zip(rows) {
1088        let row = row.ok_or_else(|| unreachable_range_error(prediction.range_yd))?;
1089        if !warned_extrapolation
1090            && (prediction.range_yd < observed_min || prediction.range_yd > observed_max)
1091        {
1092            push_warning(
1093                warnings,
1094                TruingUncertaintyWarningCodeV1::PredictionOutsideObservedDomain,
1095                "one or more predictive ranges lie outside the observed range domain; local linear uncertainty may understate nonlinear extrapolation risk".to_string(),
1096            );
1097            warned_extrapolation = true;
1098        }
1099        let latent_interval_95 = covariance_q.and_then(|covariance| {
1100            let g_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS;
1101            let g_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE;
1102            let variance = g_mv * g_mv * covariance.a00
1103                + 2.0 * g_mv * g_bc * covariance.a01
1104                + g_bc * g_bc * covariance.a11;
1105            // Tiny negative roundoff can occur when the covariance is highly
1106            // correlated; a material negative variance is treated as unavailable.
1107            let tolerance = 1.0e-12
1108                * (g_mv * g_mv * covariance.a00)
1109                    .abs()
1110                    .max((g_bc * g_bc * covariance.a11).abs())
1111                    .max(1.0);
1112            let variance = if variance >= 0.0 {
1113                variance
1114            } else if variance >= -tolerance {
1115                0.0
1116            } else {
1117                return None;
1118            };
1119            GaussianIntervalV1::from_variance(row.predicted_drop, variance)
1120        });
1121        let future_observation_interval_95 =
1122            match (latent_interval_95, prediction.future_observation_sigma) {
1123                (Some(latent), Some(sigma)) => GaussianIntervalV1::from_variance(
1124                    row.predicted_drop,
1125                    latent.standard_deviation.powi(2) + sigma.powi(2),
1126                ),
1127                _ => None,
1128            };
1129        bands.push(TruingPredictiveBandV1 {
1130            range_yd: prediction.range_yd,
1131            predicted_drop: row.predicted_drop,
1132            latent_interval_95,
1133            future_observation_interval_95,
1134        });
1135    }
1136    Ok(bands)
1137}
1138
1139fn parameter_at_bound(mv: f64, bc: f64) -> bool {
1140    let mv_tolerance = 1.0e-6 * (TRUING_MV_MAX_FPS - TRUING_MV_MIN_FPS);
1141    let bc_tolerance = 1.0e-6 * (TRUING_BC_MAX - TRUING_BC_MIN);
1142    mv - TRUING_MV_MIN_FPS <= mv_tolerance
1143        || TRUING_MV_MAX_FPS - mv <= mv_tolerance
1144        || bc - TRUING_BC_MIN <= bc_tolerance
1145        || TRUING_BC_MAX - bc <= bc_tolerance
1146}
1147
1148fn warn_prior_dominance(
1149    request: &UncertaintyTruingRequestV1,
1150    approximation: &TruingGaussianApproximationV1,
1151    warnings: &mut Vec<TruingUncertaintyWarningV1>,
1152) {
1153    if let Some(prior) = request.priors.muzzle_velocity_fps {
1154        if approximation.covariance.mv_variance_fps2 >= 0.8 * prior.sigma.powi(2) {
1155            push_warning(
1156                warnings,
1157                TruingUncertaintyWarningCodeV1::MvPriorDominated,
1158                "MV posterior width remains close to its explicit prior width; observations add little marginal MV information".to_string(),
1159            );
1160        }
1161    }
1162    if let Some(prior) = request.priors.ballistic_coefficient {
1163        if approximation.covariance.bc_variance >= 0.8 * prior.sigma.powi(2) {
1164            push_warning(
1165                warnings,
1166                TruingUncertaintyWarningCodeV1::BcPriorDominated,
1167                "BC posterior width remains close to its explicit prior width; observations add little marginal BC information".to_string(),
1168            );
1169        }
1170    }
1171}
1172
1173fn push_warning(
1174    warnings: &mut Vec<TruingUncertaintyWarningV1>,
1175    code: TruingUncertaintyWarningCodeV1,
1176    message: String,
1177) {
1178    if warnings.iter().any(|warning| warning.code == code) {
1179        return;
1180    }
1181    warnings.push(TruingUncertaintyWarningV1 { code, message });
1182}
1183
1184fn forward_error(error: Box<dyn std::error::Error>) -> UncertaintyTruingErrorV1 {
1185    UncertaintyTruingErrorV1::ForwardModel(error.to_string())
1186}
1187
1188fn unreachable_range_error(range_yd: f64) -> UncertaintyTruingErrorV1 {
1189    UncertaintyTruingErrorV1::ForwardModel(format!(
1190        "trajectory did not reach requested range {range_yd:.3} yd"
1191    ))
1192}