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