Skip to main content

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/// A symmetric 2x2 matrix -- posterior/information matrices in this module, and (0.33.0
307/// decision-support Task 10, MBA-1347) an impact-space covariance in the `error_budget` module.
308///
309/// `pub(crate)` together with its three fields and `add_assign`: `error_budget` needs to
310/// accumulate several declared sources' contributions into one covariance and then read its
311/// eigenvalues, reusing this type's existing, reviewed eigenvalue arithmetic (see
312/// `largest_smallest_eigenvalues` below) rather than writing a third copy of it
313/// (`crate::monte_carlo::calculate_confidence_ellipse` already has its own, sample-based copy).
314/// This is the same precedent as `kernel_solve_error`/`wind_reference_of` in
315/// `crate::perturbation`, widened from private for the identical reason in earlier tasks on this
316/// branch. Widening is behavior-preserving: every existing use of this type is within this same
317/// module, where field/method privacy was never enforced against it anyway.
318#[derive(Debug, Clone, Copy, Default)]
319pub(crate) struct Symmetric2 {
320    pub(crate) a00: f64,
321    pub(crate) a01: f64,
322    pub(crate) a11: f64,
323}
324
325impl Symmetric2 {
326    fn determinant(self) -> f64 {
327        self.a00 * self.a11 - self.a01 * self.a01
328    }
329
330    pub(crate) fn add_assign(&mut self, rhs: Self) {
331        self.a00 += rhs.a00;
332        self.a01 += rhs.a01;
333        self.a11 += rhs.a11;
334    }
335
336    /// Eigenvalues of this symmetric 2x2 matrix, largest first, each clamped to non-negative.
337    /// Never fails.
338    ///
339    /// `pub(crate)` (0.33.0 decision-support Task 10, MBA-1347): shares `inverse_with_condition`
340    /// below's eigenvalue arithmetic (trace, `hypot`-based discriminant, and the
341    /// `determinant = largest * smallest` division -- more accurate than subtracting two nearly
342    /// equal values in the naive quadratic formula) but with different error semantics.
343    /// `inverse_with_condition` treats a singular or non-positive-definite matrix as a hard
344    /// failure, because it specifically inverts an information matrix that this module's MAP fit
345    /// needs to be well-conditioned. An error-budget impact covariance is routinely and
346    /// legitimately singular (exactly one nonzero-sigma source produces an exact rank-1
347    /// covariance, since it is a single outer product `sigma^2 * v * v^T`) or exactly zero
348    /// (every declared sigma is zero) -- both normal, expected inputs there, not failures. This
349    /// method never errors: a negative discriminant or determinant can only arise here from
350    /// floating-point rounding on a matrix that is mathematically positive-semidefinite by
351    /// construction (a sum of such outer products), so it is clamped to zero rather than
352    /// reported as a failure.
353    pub(crate) fn largest_smallest_eigenvalues(self) -> (f64, f64) {
354        let trace = self.a00 + self.a11;
355        let discriminant = (self.a00 - self.a11).hypot(2.0 * self.a01);
356        let largest = (0.5 * (trace + discriminant)).max(0.0);
357        let determinant = self.determinant();
358        let smallest = if largest > 0.0 {
359            (determinant / largest).max(0.0)
360        } else {
361            0.0
362        };
363        (largest, smallest)
364    }
365
366    fn inverse_with_condition(self) -> Result<(Self, f64), TruingApproximationFailureCodeV1> {
367        if !self.a00.is_finite() || !self.a01.is_finite() || !self.a11.is_finite() {
368            return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
369        }
370        let trace = self.a00 + self.a11;
371        let discriminant = (self.a00 - self.a11).hypot(2.0 * self.a01);
372        let largest = 0.5 * (trace + discriminant);
373        let determinant = self.determinant();
374        if !largest.is_finite() || !determinant.is_finite() {
375            return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
376        }
377        if largest <= 0.0 || determinant <= 0.0 {
378            return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
379        }
380        // det = lambda_max * lambda_min is more accurate than subtracting two
381        // nearly equal values from the quadratic formula.
382        let smallest = determinant / largest;
383        if !smallest.is_finite()
384            || smallest <= 0.0
385            || smallest / largest <= INFORMATION_RELATIVE_EIGEN_TOLERANCE
386        {
387            return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
388        }
389        let inverse = Self {
390            a00: self.a11 / determinant,
391            a01: -self.a01 / determinant,
392            a11: self.a00 / determinant,
393        };
394        if !inverse.a00.is_finite() || !inverse.a01.is_finite() || !inverse.a11.is_finite() {
395            return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
396        }
397        Ok((inverse, largest / smallest))
398    }
399}
400
401struct Evaluation {
402    data_information: Symmetric2,
403    posterior_information: Symmetric2,
404    gradient: [f64; 2],
405    observation_results: Vec<WeightedTruingObservationResultV1>,
406    chi_square: f64,
407    prior_penalty: f64,
408    bc_sensitivity_ratio: f64,
409    data_condition_number: f64,
410}
411
412#[derive(Debug, Clone, Copy)]
413struct MapFitResult {
414    mv: f64,
415    bc: f64,
416    iterations: usize,
417    convergence_criterion: Option<TruingMapConvergenceCriterionV1>,
418    objective_poll_radius: Option<f64>,
419    max_objective_poll_improvement: Option<f64>,
420    objective_poll_evaluations: usize,
421}
422
423#[derive(Debug, Clone, Copy)]
424struct ObjectivePollResult {
425    mv: f64,
426    bc: f64,
427    converged: bool,
428    final_radius: f64,
429    max_final_improvement: f64,
430    evaluations: usize,
431}
432
433impl Evaluation {
434    fn penalized_chi_square(&self) -> f64 {
435        self.chi_square + self.prior_penalty
436    }
437}
438
439/// Run the opt-in uncertainty-aware joint MV+BC truing path.
440///
441/// Observation sigmas are absolute and known.  Consequently posterior
442/// covariance is the inverse likelihood-plus-prior information matrix and is
443/// **not** rescaled by residual RMS.  No identifiability heuristic changes the
444/// requested two-parameter fit; weak or collinear data instead produce broad
445/// uncertainty, warnings, or a structured approximation failure.
446pub fn run_uncertainty_truing_v1(
447    request: &UncertaintyTruingRequestV1,
448) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
449    validate_request(request)?;
450
451    request
452        .model
453        .with_forward_model(request.drop_unit, |model| run_with_model(request, model))
454}
455
456fn run_with_model(
457    request: &UncertaintyTruingRequestV1,
458    model: &TruingForwardModel<'_>,
459) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
460    let fit = fit_map(request, model)?;
461    let map_mv = fit.mv;
462    let map_bc = fit.bc;
463    let iterations = fit.iterations;
464    let converged = fit.convergence_criterion.is_some();
465    let evaluation = evaluate(request, model, map_mv, map_bc)?;
466    let mut warnings = Vec::new();
467
468    if !converged {
469        let gradient_norm = evaluation.gradient[0]
470            .abs()
471            .max(evaluation.gradient[1].abs());
472        push_warning(
473            &mut warnings,
474            TruingUncertaintyWarningCodeV1::OptimizerDidNotConverge,
475            format!(
476                "joint MV+BC MAP optimizer stopped after {iterations} iterations with scaled gradient {gradient_norm:.3e} above {MAP_SCALED_GRADIENT_TOLERANCE:.1e}"
477            ),
478        );
479    } else if fit.convergence_criterion == Some(TruingMapConvergenceCriterionV1::ObjectiveMesh) {
480        let radius = fit.objective_poll_radius.unwrap_or(f64::NAN);
481        let improvement = fit.max_objective_poll_improvement.unwrap_or(f64::NAN);
482        push_warning(
483            &mut warnings,
484            TruingUncertaintyWarningCodeV1::ObjectiveMeshConvergence,
485            format!(
486                "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})",
487                evaluation.gradient[0]
488                    .abs()
489                    .max(evaluation.gradient[1].abs()),
490                MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE,
491            ),
492        );
493    }
494    if evaluation.bc_sensitivity_ratio < TRUING_MIN_BC_SENSITIVITY_RATIO {
495        push_warning(
496            &mut warnings,
497            TruingUncertaintyWarningCodeV1::WeakBcSensitivity,
498            format!(
499                "observations weakly constrain BC: fractional sensitivity ratio {:.4} is below {:.2}",
500                evaluation.bc_sensitivity_ratio, TRUING_MIN_BC_SENSITIVITY_RATIO
501            ),
502        );
503    }
504    if !evaluation.data_condition_number.is_finite()
505        || evaluation.data_condition_number > TRUING_MAX_CONDITION_NUMBER
506    {
507        push_warning(
508            &mut warnings,
509            TruingUncertaintyWarningCodeV1::IllConditionedData,
510            format!(
511                "weighted observation Jacobian cannot cleanly separate MV from BC (condition {:.3e})",
512                evaluation.data_condition_number
513            ),
514        );
515    }
516
517    let at_bound = parameter_at_bound(map_mv, map_bc);
518    let approximation_result = if !converged {
519        Err(TruingApproximationFailureV1 {
520            code: TruingApproximationFailureCodeV1::OptimizerDidNotConverge,
521            message: "MAP optimizer did not converge; covariance around an unverified stationary point is withheld".to_string(),
522        })
523    } else if at_bound {
524        Err(TruingApproximationFailureV1 {
525            code: TruingApproximationFailureCodeV1::MapAtParameterBound,
526            message: "MAP lies on or numerically near a fit bound; an unconstrained Gaussian approximation would be misleading".to_string(),
527        })
528    } else {
529        build_approximation(map_mv, map_bc, evaluation.posterior_information)
530    };
531
532    let (approximation, covariance_q) = match approximation_result {
533        Ok((gaussian, covariance_q)) => {
534            warn_prior_dominance(request, &gaussian, &mut warnings);
535            if gaussian.muzzle_velocity_interval_95.lower < TRUING_MV_MIN_FPS
536                || gaussian.muzzle_velocity_interval_95.upper > TRUING_MV_MAX_FPS
537                || gaussian.ballistic_coefficient_interval_95.lower < TRUING_BC_MIN
538                || gaussian.ballistic_coefficient_interval_95.upper > TRUING_BC_MAX
539            {
540                push_warning(
541                    &mut warnings,
542                    TruingUncertaintyWarningCodeV1::IntervalCrossesFitBounds,
543                    "local Gaussian interval crosses a constrained fit bound; interpret its tails cautiously".to_string(),
544                );
545            }
546            (
547                TruingApproximationV1::Available(gaussian),
548                Some(covariance_q),
549            )
550        }
551        Err(failure) => {
552            push_warning(
553                &mut warnings,
554                TruingUncertaintyWarningCodeV1::GaussianApproximationUnavailable,
555                failure.message.clone(),
556            );
557            (TruingApproximationV1::Unavailable(failure), None)
558        }
559    };
560
561    let effective_parameter_count = covariance_q.map(|covariance| {
562        (evaluation.data_information.a00 * covariance.a00
563            + 2.0 * evaluation.data_information.a01 * covariance.a01
564            + evaluation.data_information.a11 * covariance.a11)
565            .clamp(0.0, 2.0)
566    });
567    let effective_degrees_of_freedom =
568        effective_parameter_count.map(|count| request.observations.len() as f64 - count);
569    let reduced_chi_square = effective_degrees_of_freedom
570        .filter(|dof| *dof > f64::EPSILON)
571        .map(|dof| evaluation.chi_square / dof);
572    if effective_degrees_of_freedom.is_some_and(|dof| dof <= 1.0) {
573        push_warning(
574            &mut warnings,
575            TruingUncertaintyWarningCodeV1::LowEffectiveDegreesOfFreedom,
576            "one or fewer effective residual degrees of freedom: residual fit quality is weakly validated".to_string(),
577        );
578    }
579
580    let predictive_bands =
581        build_predictive_bands(request, model, map_mv, map_bc, covariance_q, &mut warnings)?;
582
583    let penalized_chi_square = evaluation.penalized_chi_square();
584
585    Ok(UncertaintyTruingReportV1 {
586        schema_version: TRUING_UNCERTAINTY_SCHEMA_VERSION_V1,
587        drop_unit: request.drop_unit,
588        map_muzzle_velocity_fps: map_mv,
589        map_ballistic_coefficient: map_bc,
590        iterations,
591        converged,
592        priors: request.priors,
593        observations: evaluation.observation_results,
594        diagnostics: TruingUncertaintyDiagnosticsV1 {
595            chi_square: evaluation.chi_square,
596            prior_penalty: evaluation.prior_penalty,
597            penalized_chi_square,
598            effective_parameter_count,
599            effective_degrees_of_freedom,
600            reduced_chi_square,
601            bc_sensitivity_ratio: evaluation.bc_sensitivity_ratio,
602            data_condition_number: evaluation.data_condition_number,
603            map_scaled_gradient_inf_norm: evaluation.gradient[0]
604                .abs()
605                .max(evaluation.gradient[1].abs()),
606            map_convergence_criterion: fit.convergence_criterion,
607            map_objective_poll_radius: fit.objective_poll_radius,
608            map_max_objective_poll_improvement: fit.max_objective_poll_improvement,
609            map_objective_poll_evaluations: fit.objective_poll_evaluations,
610        },
611        approximation,
612        predictive_bands,
613        warnings,
614    })
615}
616
617fn validate_request(request: &UncertaintyTruingRequestV1) -> Result<(), UncertaintyTruingErrorV1> {
618    request
619        .model
620        .validate()
621        .map_err(UncertaintyTruingErrorV1::InvalidInput)?;
622    if request.observations.len() < 2 {
623        return Err(UncertaintyTruingErrorV1::InvalidInput(
624            "at least two weighted observations are required for a joint MV+BC fit".to_string(),
625        ));
626    }
627    for (index, observation) in request.observations.iter().enumerate() {
628        if !observation.range_yd.is_finite() || observation.range_yd <= 0.0 {
629            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
630                "observation {} range must be positive and finite",
631                index + 1
632            )));
633        }
634        if !observation.drop.is_finite() {
635            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
636                "observation {} drop must be finite",
637                index + 1
638            )));
639        }
640        if !observation.sigma.is_finite() || observation.sigma <= 0.0 {
641            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
642                "observation {} sigma must be positive and finite",
643                index + 1
644            )));
645        }
646    }
647    validate_prior(
648        "muzzle-velocity",
649        request.priors.muzzle_velocity_fps,
650        TRUING_MV_MIN_FPS,
651        TRUING_MV_MAX_FPS,
652    )?;
653    validate_prior(
654        "ballistic-coefficient",
655        request.priors.ballistic_coefficient,
656        TRUING_BC_MIN,
657        TRUING_BC_MAX,
658    )?;
659    for (index, prediction) in request.predictions.iter().enumerate() {
660        if !prediction.range_yd.is_finite() || prediction.range_yd <= 0.0 {
661            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
662                "prediction {} range must be positive and finite",
663                index + 1
664            )));
665        }
666        if prediction
667            .future_observation_sigma
668            .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
669        {
670            return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
671                "prediction {} future-observation sigma must be positive and finite",
672                index + 1
673            )));
674        }
675    }
676    Ok(())
677}
678
679fn validate_prior(
680    name: &str,
681    prior: Option<NormalPriorV1>,
682    lower: f64,
683    upper: f64,
684) -> Result<(), UncertaintyTruingErrorV1> {
685    let Some(prior) = prior else {
686        return Ok(());
687    };
688    if !prior.mean.is_finite() || !(lower..=upper).contains(&prior.mean) {
689        return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
690            "{name} prior mean must be finite and within {lower}..={upper}"
691        )));
692    }
693    if !prior.sigma.is_finite() || prior.sigma <= 0.0 {
694        return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
695            "{name} prior sigma must be positive and finite"
696        )));
697    }
698    Ok(())
699}
700
701fn fit_map(
702    request: &UncertaintyTruingRequestV1,
703    model: &TruingForwardModel<'_>,
704) -> Result<MapFitResult, UncertaintyTruingErrorV1> {
705    let mut mv = request.model.muzzle_velocity_fps;
706    let mut bc = request.model.ballistic_coefficient;
707    let mut lambda = 1.0e-6;
708    let mut current = objective(request, model, mv, bc)?;
709    let mut iterations = 0;
710    let mut convergence_criterion = None;
711
712    for _ in 0..TRUING_UNCERTAINTY_MAX_ITERS_V1 {
713        iterations += 1;
714        let evaluation = evaluate(request, model, mv, bc)?;
715        if evaluation.gradient[0]
716            .abs()
717            .max(evaluation.gradient[1].abs())
718            <= MAP_SCALED_GRADIENT_TOLERANCE
719        {
720            convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
721            break;
722        }
723
724        let mut accepted = false;
725        for _ in 0..30 {
726            let information = evaluation.posterior_information;
727            let damped = Symmetric2 {
728                a00: information.a00 + lambda * information.a00.max(1.0e-12),
729                a01: information.a01,
730                a11: information.a11 + lambda * information.a11.max(1.0e-12),
731            };
732            let determinant = damped.determinant();
733            if !determinant.is_finite() || determinant.abs() < 1.0e-24 {
734                lambda *= 10.0;
735                continue;
736            }
737            let delta_mv_coordinate = -(damped.a11 * evaluation.gradient[0]
738                - damped.a01 * evaluation.gradient[1])
739                / determinant;
740            let delta_bc_coordinate = -(-damped.a01 * evaluation.gradient[0]
741                + damped.a00 * evaluation.gradient[1])
742                / determinant;
743            let next_mv = (mv + MV_COORDINATE_SCALE_FPS * delta_mv_coordinate)
744                .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
745            let next_bc = (bc + BC_COORDINATE_SCALE * delta_bc_coordinate)
746                .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
747            let next = objective(request, model, next_mv, next_bc)?;
748            if objective_improvement_is_material(current, next) {
749                mv = next_mv;
750                bc = next_bc;
751                current = next;
752                lambda = (lambda * 0.5).max(1.0e-12);
753                accepted = true;
754                break;
755            }
756            // If clamping or floating-point resolution leaves the candidate at
757            // the current optimum, increasing damping cannot reveal a lower
758            // point.  The direct-objective poll below still verifies the full
759            // two-dimensional neighborhood before accepting convergence.
760            if next_mv == mv
761                && next_bc == bc
762                && evaluation.gradient[0]
763                    .abs()
764                    .max(evaluation.gradient[1].abs())
765                    <= MAP_SCALED_GRADIENT_TOLERANCE
766            {
767                convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
768                break;
769            }
770            lambda *= 4.0;
771        }
772        if convergence_criterion.is_some() {
773            break;
774        }
775        if !accepted {
776            break;
777        }
778    }
779    if convergence_criterion.is_none() {
780        let final_evaluation = evaluate(request, model, mv, bc)?;
781        if final_evaluation.gradient[0]
782            .abs()
783            .max(final_evaluation.gradient[1].abs())
784            <= MAP_SCALED_GRADIENT_TOLERANCE
785        {
786            convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
787        }
788    }
789
790    let mut objective_poll_radius = None;
791    let mut max_objective_poll_improvement = None;
792    let mut objective_poll_evaluations = 0;
793    if convergence_criterion.is_none() {
794        let poll = polish_and_verify_objective_mesh(request, model, mv, bc, current)?;
795        mv = poll.mv;
796        bc = poll.bc;
797        objective_poll_evaluations = poll.evaluations;
798        if poll.converged {
799            convergence_criterion = Some(TruingMapConvergenceCriterionV1::ObjectiveMesh);
800            objective_poll_radius = Some(poll.final_radius);
801            max_objective_poll_improvement = Some(poll.max_final_improvement);
802        }
803    }
804
805    Ok(MapFitResult {
806        mv,
807        bc,
808        iterations,
809        convergence_criterion,
810        objective_poll_radius,
811        max_objective_poll_improvement,
812        objective_poll_evaluations,
813    })
814}
815
816fn objective_improvement_is_material(current: f64, candidate: f64) -> bool {
817    current - candidate > MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE
818}
819
820/// Polish an LM candidate against the actual penalized chi-square surface and
821/// verify local convergence independently of the finite-difference Jacobian.
822///
823/// The eight polling directions contain a positive spanning coordinate basis;
824/// the local information eigenvectors accelerate progress along the strongly
825/// correlated MV/BC valley.
826/// A point is accepted only after no direction yields a material improvement
827/// at the minimum mesh radius.
828fn polish_and_verify_objective_mesh(
829    request: &UncertaintyTruingRequestV1,
830    model: &TruingForwardModel<'_>,
831    mut mv: f64,
832    mut bc: f64,
833    mut current: f64,
834) -> Result<ObjectivePollResult, UncertaintyTruingErrorV1> {
835    let information = evaluate(request, model, mv, bc)?.posterior_information;
836    let angle = 0.5 * (2.0 * information.a01).atan2(information.a00 - information.a11);
837    let (sin, cos) = angle.sin_cos();
838    // (cos, sin) is the large-information direction and (-sin, cos) the weak
839    // direction.  Both are unit vectors in scaled optimizer coordinates.
840    let directions = [
841        (1.0, 0.0),
842        (-1.0, 0.0),
843        (0.0, 1.0),
844        (0.0, -1.0),
845        (cos, sin),
846        (-cos, -sin),
847        (-sin, cos),
848        (sin, -cos),
849    ];
850
851    let mut radius = MAP_OBJECTIVE_INITIAL_POLL_RADIUS;
852    let mut evaluations = 0;
853    let mut max_final_improvement = f64::INFINITY;
854    while evaluations < MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
855        let mut best = current;
856        let mut best_point = (mv, bc);
857        for (mv_direction, bc_direction) in directions {
858            if evaluations >= MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
859                break;
860            }
861            let candidate_mv = (mv + radius * mv_direction * MV_COORDINATE_SCALE_FPS)
862                .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
863            let candidate_bc = (bc + radius * bc_direction * BC_COORDINATE_SCALE)
864                .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
865            if candidate_mv == mv && candidate_bc == bc {
866                continue;
867            }
868            let candidate = objective(request, model, candidate_mv, candidate_bc)?;
869            evaluations += 1;
870            if candidate < best {
871                best = candidate;
872                best_point = (candidate_mv, candidate_bc);
873            }
874        }
875
876        let improvement = (current - best).max(0.0);
877        if objective_improvement_is_material(current, best) {
878            mv = best_point.0;
879            bc = best_point.1;
880            current = best;
881            continue;
882        }
883        max_final_improvement = improvement;
884        if radius <= MAP_OBJECTIVE_MIN_POLL_RADIUS {
885            return Ok(ObjectivePollResult {
886                mv,
887                bc,
888                converged: true,
889                final_radius: radius,
890                max_final_improvement,
891                evaluations,
892            });
893        }
894        radius = (radius * 0.25).max(MAP_OBJECTIVE_MIN_POLL_RADIUS);
895    }
896
897    Ok(ObjectivePollResult {
898        mv,
899        bc,
900        converged: false,
901        final_radius: radius,
902        max_final_improvement,
903        evaluations,
904    })
905}
906
907fn objective(
908    request: &UncertaintyTruingRequestV1,
909    model: &TruingForwardModel<'_>,
910    mv: f64,
911    bc: f64,
912) -> Result<f64, UncertaintyTruingErrorV1> {
913    let mut objective = 0.0;
914    let ranges: Vec<f64> = request
915        .observations
916        .iter()
917        .map(|observation| observation.range_yd)
918        .collect();
919    let predictions = model
920        .predict_many_in_unit(mv, bc, &ranges, request.drop_unit)
921        .map_err(forward_error)?;
922    for (observation, prediction) in request.observations.iter().zip(predictions) {
923        let prediction = prediction.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
924        let standardized = (prediction - observation.drop) / observation.sigma;
925        objective += standardized * standardized;
926    }
927    if let Some(prior) = request.priors.muzzle_velocity_fps {
928        let standardized = (mv - prior.mean) / prior.sigma;
929        objective += standardized * standardized;
930    }
931    if let Some(prior) = request.priors.ballistic_coefficient {
932        let standardized = (bc - prior.mean) / prior.sigma;
933        objective += standardized * standardized;
934    }
935    if objective.is_finite() {
936        Ok(objective)
937    } else {
938        Err(UncertaintyTruingErrorV1::ForwardModel(
939            "non-finite penalized chi-square".to_string(),
940        ))
941    }
942}
943
944fn evaluate(
945    request: &UncertaintyTruingRequestV1,
946    model: &TruingForwardModel<'_>,
947    mv: f64,
948    bc: f64,
949) -> Result<Evaluation, UncertaintyTruingErrorV1> {
950    let mut data_information = Symmetric2::default();
951    let mut gradient = [0.0, 0.0];
952    let mut results = Vec::with_capacity(request.observations.len());
953    let mut chi_square = 0.0;
954
955    // Fractional-sensitivity terms retain the physical parameter scaling even
956    // though the optimizer itself works in fixed numerical coordinates.
957    let (mut fractional_mv_norm2, mut fractional_bc_norm2): (f64, f64) = (0.0, 0.0);
958    let ranges: Vec<f64> = request
959        .observations
960        .iter()
961        .map(|observation| observation.range_yd)
962        .collect();
963    let rows =
964        truing_jacobian_rows(model, mv, bc, &ranges, request.drop_unit).map_err(forward_error)?;
965    for (observation, row) in request.observations.iter().zip(rows) {
966        let row = row.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
967        let residual = row.predicted_drop - observation.drop;
968        let standardized_residual = residual / observation.sigma;
969        let j_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS / observation.sigma;
970        let j_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE / observation.sigma;
971        data_information.a00 += j_mv * j_mv;
972        data_information.a01 += j_mv * j_bc;
973        data_information.a11 += j_bc * j_bc;
974        gradient[0] += j_mv * standardized_residual;
975        gradient[1] += j_bc * standardized_residual;
976        chi_square += standardized_residual * standardized_residual;
977        fractional_mv_norm2 += (row.d_drop_d_mv * mv / observation.sigma).powi(2);
978        fractional_bc_norm2 += (row.d_drop_d_bc * bc / observation.sigma).powi(2);
979        results.push(WeightedTruingObservationResultV1 {
980            range_yd: observation.range_yd,
981            observed_drop: observation.drop,
982            sigma: observation.sigma,
983            predicted_drop: row.predicted_drop,
984            residual,
985            standardized_residual,
986        });
987    }
988
989    let mut prior_information = Symmetric2::default();
990    let mut prior_penalty = 0.0;
991    if let Some(prior) = request.priors.muzzle_velocity_fps {
992        let j = MV_COORDINATE_SCALE_FPS / prior.sigma;
993        let standardized = (mv - prior.mean) / prior.sigma;
994        prior_information.a00 += j * j;
995        gradient[0] += j * standardized;
996        prior_penalty += standardized * standardized;
997    }
998    if let Some(prior) = request.priors.ballistic_coefficient {
999        let j = BC_COORDINATE_SCALE / prior.sigma;
1000        let standardized = (bc - prior.mean) / prior.sigma;
1001        prior_information.a11 += j * j;
1002        gradient[1] += j * standardized;
1003        prior_penalty += standardized * standardized;
1004    }
1005    let mut posterior_information = data_information;
1006    posterior_information.add_assign(prior_information);
1007
1008    let bc_sensitivity_ratio = if fractional_mv_norm2 > 0.0 {
1009        (fractional_bc_norm2 / fractional_mv_norm2).sqrt()
1010    } else {
1011        0.0
1012    };
1013    let data_condition_number = column_condition(data_information);
1014
1015    Ok(Evaluation {
1016        data_information,
1017        posterior_information,
1018        gradient,
1019        observation_results: results,
1020        chi_square,
1021        prior_penalty,
1022        bc_sensitivity_ratio,
1023        data_condition_number,
1024    })
1025}
1026
1027fn column_condition(information: Symmetric2) -> f64 {
1028    if information.a00 <= 0.0 || information.a11 <= 0.0 {
1029        return f64::INFINITY;
1030    }
1031    let correlation = (information.a01 / (information.a00 * information.a11).sqrt())
1032        .clamp(-1.0, 1.0)
1033        .abs();
1034    if 1.0 - correlation <= 1.0e-15 {
1035        f64::INFINITY
1036    } else {
1037        (1.0 + correlation) / (1.0 - correlation)
1038    }
1039}
1040
1041fn build_approximation(
1042    mv: f64,
1043    bc: f64,
1044    information: Symmetric2,
1045) -> Result<(TruingGaussianApproximationV1, Symmetric2), TruingApproximationFailureV1> {
1046    let (covariance_q, condition) = information
1047        .inverse_with_condition()
1048        .map_err(|code| TruingApproximationFailureV1 {
1049            code,
1050            message: match code {
1051                TruingApproximationFailureCodeV1::OptimizerDidNotConverge => {
1052                    "MAP optimizer did not converge".to_string()
1053                }
1054                TruingApproximationFailureCodeV1::MapAtParameterBound => {
1055                    "MAP is at a constrained parameter bound".to_string()
1056                }
1057                TruingApproximationFailureCodeV1::RankDeficientInformation => {
1058                    "likelihood-plus-prior information is rank deficient or numerically singular; collect more separated ranges or add an explicit prior".to_string()
1059                }
1060                TruingApproximationFailureCodeV1::NonFiniteInformation => {
1061                    "likelihood-plus-prior information or its inverse is non-finite".to_string()
1062                }
1063            },
1064        })?;
1065    let covariance = TruingCovarianceV1 {
1066        mv_variance_fps2: covariance_q.a00 * MV_COORDINATE_SCALE_FPS.powi(2),
1067        mv_bc_covariance_fps: covariance_q.a01 * MV_COORDINATE_SCALE_FPS * BC_COORDINATE_SCALE,
1068        bc_variance: covariance_q.a11 * BC_COORDINATE_SCALE.powi(2),
1069    };
1070    let mv_interval = GaussianIntervalV1::from_variance(mv, covariance.mv_variance_fps2)
1071        .ok_or_else(|| TruingApproximationFailureV1 {
1072            code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1073            message: "MV marginal variance is invalid".to_string(),
1074        })?;
1075    let bc_interval =
1076        GaussianIntervalV1::from_variance(bc, covariance.bc_variance).ok_or_else(|| {
1077            TruingApproximationFailureV1 {
1078                code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1079                message: "BC marginal variance is invalid".to_string(),
1080            }
1081        })?;
1082    let correlation = covariance.mv_bc_covariance_fps
1083        / (covariance.mv_variance_fps2 * covariance.bc_variance).sqrt();
1084    if !correlation.is_finite() {
1085        return Err(TruingApproximationFailureV1 {
1086            code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1087            message: "MV/BC posterior correlation is non-finite".to_string(),
1088        });
1089    }
1090    Ok((
1091        TruingGaussianApproximationV1 {
1092            covariance,
1093            muzzle_velocity_interval_95: mv_interval,
1094            ballistic_coefficient_interval_95: bc_interval,
1095            mv_bc_correlation: correlation.clamp(-1.0, 1.0),
1096            scaled_information_condition_number: condition,
1097        },
1098        covariance_q,
1099    ))
1100}
1101
1102fn build_predictive_bands(
1103    request: &UncertaintyTruingRequestV1,
1104    model: &TruingForwardModel<'_>,
1105    mv: f64,
1106    bc: f64,
1107    covariance_q: Option<Symmetric2>,
1108    warnings: &mut Vec<TruingUncertaintyWarningV1>,
1109) -> Result<Vec<TruingPredictiveBandV1>, UncertaintyTruingErrorV1> {
1110    let observed_min = request
1111        .observations
1112        .iter()
1113        .map(|observation| observation.range_yd)
1114        .fold(f64::INFINITY, f64::min);
1115    let observed_max = request
1116        .observations
1117        .iter()
1118        .map(|observation| observation.range_yd)
1119        .fold(f64::NEG_INFINITY, f64::max);
1120    let mut warned_extrapolation = false;
1121    let mut bands = Vec::with_capacity(request.predictions.len());
1122    let prediction_ranges: Vec<f64> = request
1123        .predictions
1124        .iter()
1125        .map(|prediction| prediction.range_yd)
1126        .collect();
1127    let rows = truing_jacobian_rows(model, mv, bc, &prediction_ranges, request.drop_unit)
1128        .map_err(forward_error)?;
1129    for (prediction, row) in request.predictions.iter().zip(rows) {
1130        let row = row.ok_or_else(|| unreachable_range_error(prediction.range_yd))?;
1131        if !warned_extrapolation
1132            && (prediction.range_yd < observed_min || prediction.range_yd > observed_max)
1133        {
1134            push_warning(
1135                warnings,
1136                TruingUncertaintyWarningCodeV1::PredictionOutsideObservedDomain,
1137                "one or more predictive ranges lie outside the observed range domain; local linear uncertainty may understate nonlinear extrapolation risk".to_string(),
1138            );
1139            warned_extrapolation = true;
1140        }
1141        let latent_interval_95 = covariance_q.and_then(|covariance| {
1142            let g_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS;
1143            let g_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE;
1144            let variance = g_mv * g_mv * covariance.a00
1145                + 2.0 * g_mv * g_bc * covariance.a01
1146                + g_bc * g_bc * covariance.a11;
1147            // Tiny negative roundoff can occur when the covariance is highly
1148            // correlated; a material negative variance is treated as unavailable.
1149            let tolerance = 1.0e-12
1150                * (g_mv * g_mv * covariance.a00)
1151                    .abs()
1152                    .max((g_bc * g_bc * covariance.a11).abs())
1153                    .max(1.0);
1154            let variance = if variance >= 0.0 {
1155                variance
1156            } else if variance >= -tolerance {
1157                0.0
1158            } else {
1159                return None;
1160            };
1161            GaussianIntervalV1::from_variance(row.predicted_drop, variance)
1162        });
1163        let future_observation_interval_95 =
1164            match (latent_interval_95, prediction.future_observation_sigma) {
1165                (Some(latent), Some(sigma)) => GaussianIntervalV1::from_variance(
1166                    row.predicted_drop,
1167                    latent.standard_deviation.powi(2) + sigma.powi(2),
1168                ),
1169                _ => None,
1170            };
1171        bands.push(TruingPredictiveBandV1 {
1172            range_yd: prediction.range_yd,
1173            predicted_drop: row.predicted_drop,
1174            latent_interval_95,
1175            future_observation_interval_95,
1176        });
1177    }
1178    Ok(bands)
1179}
1180
1181fn parameter_at_bound(mv: f64, bc: f64) -> bool {
1182    let mv_tolerance = 1.0e-6 * (TRUING_MV_MAX_FPS - TRUING_MV_MIN_FPS);
1183    let bc_tolerance = 1.0e-6 * (TRUING_BC_MAX - TRUING_BC_MIN);
1184    mv - TRUING_MV_MIN_FPS <= mv_tolerance
1185        || TRUING_MV_MAX_FPS - mv <= mv_tolerance
1186        || bc - TRUING_BC_MIN <= bc_tolerance
1187        || TRUING_BC_MAX - bc <= bc_tolerance
1188}
1189
1190fn warn_prior_dominance(
1191    request: &UncertaintyTruingRequestV1,
1192    approximation: &TruingGaussianApproximationV1,
1193    warnings: &mut Vec<TruingUncertaintyWarningV1>,
1194) {
1195    if let Some(prior) = request.priors.muzzle_velocity_fps {
1196        if approximation.covariance.mv_variance_fps2 >= 0.8 * prior.sigma.powi(2) {
1197            push_warning(
1198                warnings,
1199                TruingUncertaintyWarningCodeV1::MvPriorDominated,
1200                "MV posterior width remains close to its explicit prior width; observations add little marginal MV information".to_string(),
1201            );
1202        }
1203    }
1204    if let Some(prior) = request.priors.ballistic_coefficient {
1205        if approximation.covariance.bc_variance >= 0.8 * prior.sigma.powi(2) {
1206            push_warning(
1207                warnings,
1208                TruingUncertaintyWarningCodeV1::BcPriorDominated,
1209                "BC posterior width remains close to its explicit prior width; observations add little marginal BC information".to_string(),
1210            );
1211        }
1212    }
1213}
1214
1215fn push_warning(
1216    warnings: &mut Vec<TruingUncertaintyWarningV1>,
1217    code: TruingUncertaintyWarningCodeV1,
1218    message: String,
1219) {
1220    if warnings.iter().any(|warning| warning.code == code) {
1221        return;
1222    }
1223    warnings.push(TruingUncertaintyWarningV1 { code, message });
1224}
1225
1226fn forward_error(error: Box<dyn std::error::Error>) -> UncertaintyTruingErrorV1 {
1227    UncertaintyTruingErrorV1::ForwardModel(error.to_string())
1228}
1229
1230fn unreachable_range_error(range_yd: f64) -> UncertaintyTruingErrorV1 {
1231    UncertaintyTruingErrorV1::ForwardModel(format!(
1232        "trajectory did not reach requested range {range_yd:.3} yd"
1233    ))
1234}