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