ballistics_engine/error_budget.rs
1//! MBA-1347: a per-input error budget and measurement-priority report.
2//!
3//! A shooter usually has time to improve exactly ONE input before a shot: rezero the sight, get
4//! a better wind call, chronograph another string, and so on. This module propagates each
5//! DECLARED per-input uncertainty (a caller-supplied one-sigma value for one [`InputAxis`]) to
6//! impact covariance at the ranges of interest, via the same central-difference kernel the rest
7//! of the 0.33.0 decision-support train is built on ([`central_difference`]), then ranks the
8//! sources so the report can end with a concrete answer: which single input is worth improving
9//! here, and which ones are not.
10//!
11//! Sources are preserved individually and NEVER collapsed into an "other" bucket -- that is this
12//! report's whole reason to exist, as distinct from the existing WEZ (`monte-carlo --wez`)
13//! attribution, which lumps everything not explicitly modelled into dispersion the caller cannot
14//! attribute to a specific input at all. See [`error_budget`]'s doc comment for the full
15//! contract.
16//!
17//! On top of the covariance/ranking above, [`error_budget_with_target`] (Task 11) answers the
18//! decision a shooter actually faces: given a target size, what is the hit probability, and how
19//! much would it improve if one particular source were measured perfectly? See
20//! [`p_hit_bivariate`]'s doc comment for the hit-probability math and
21//! [`TargetGeometryV1`] for the target shapes it accepts.
22//!
23//! # Unavailable sources are recorded, never silently dropped
24//!
25//! [`central_difference`] can legitimately refuse to differentiate a declared axis:
26//! [`KernelError::AxisUnsupportedForRequest`] (`Altitude` under a QNH-referenced atmosphere,
27//! `ShotAzimuth` under compass-referenced wind), [`KernelError::AxisAbsent`] (a wind axis under
28//! segmented wind), [`KernelError::CategoricalAxis`] (an effect toggle), or
29//! [`KernelError::StepOutOfDomain`] (both perturbed sides, using the axis's OWN default step --
30//! `error_budget` always requests [`central_difference`]'s `None`-step formula, never a custom
31//! one, so the declared sigma has no bearing on whether this fires -- left the axis's physical
32//! domain). Every one of these is recorded as an
33//! [`UnavailableSourceV1`] (axis, declared sigma, a machine-readable
34//! [`UnavailableReasonCodeV1`], and a human-readable reason) in
35//! [`ErrorBudgetReportV1::unavailable_sources`], and the rest of the report is still produced
36//! from whatever sources DID evaluate.
37//!
38//! **Silently skipping an unavailable axis would report "this input contributes no
39//! uncertainty," the one wrong answer this ticket exists to prevent** -- a source that could not
40//! be measured must never look identical to a source that WAS measured and found to contribute
41//! exactly zero. [`SourceContributionV1`] is never constructed for an axis that failed; it only
42//! ever describes an axis [`central_difference`] actually evaluated.
43//!
44//! Any OTHER error `central_difference` reports ([`KernelError::Solve`],
45//! [`KernelError::Observation`], or the two defensive variants
46//! [`KernelError::TypeMismatch`]/[`KernelError::NonFinite`]) is a genuine solver or trajectory
47//! failure, not a normal "this input cannot be perturbed here" fact, and [`error_budget`]
48//! propagates it unchanged rather than folding it into `unavailable_sources`. The classification
49//! (see the private `unavailable_reason` below) is an exhaustive match with no wildcard arm, so
50//! a future [`KernelError`] variant fails to compile here until it is explicitly placed in one
51//! bucket or the other, rather than silently defaulting into whichever this match's last arm
52//! happens to be. [`KernelError::DuplicateAxis`] is classified there too even though
53//! `error_budget` itself constructs and returns it before that match ever runs (see "Sources are
54//! validated up front" below) -- the exhaustiveness is over the whole `KernelError` type, not
55//! only the subset `central_difference` can produce.
56//!
57//! One caller mistake is deliberately NOT laundered through this mechanism: a range in
58//! `ranges_m` that cannot actually be observed -- either beyond the declared
59//! `base.shot.max_range_m`, or (less obviously) still inside `max_range_m` but past where THIS
60//! trajectory actually terminates, e.g. a steep downward shot that strikes the ground at 95 m
61//! under a declared `max_range_m: 900` -- would otherwise fail BOTH perturbed sides of EVERY
62//! axis identically (an [`KernelError::Observation`] domain rejection on each side becomes
63//! [`KernelError::StepOutOfDomain`]), recording every declared source as unavailable with a
64//! misleading reason that blames the axis's own step when the real problem is that the caller
65//! queried past the trajectory. See "Sources and ranges are validated up front" below --
66//! `error_budget` rejects both forms of that mistake directly instead.
67//!
68//! # Sources and ranges are validated up front
69//!
70//! Before any [`central_difference`] call, in two stages:
71//!
72//! 1. Every `range_m` in `ranges_m` must be finite and in `[0, base.shot.max_range_m]`, or
73//! [`error_budget`] returns [`KernelError::Observation`] immediately -- a cheap check against
74//! the DECLARED bound, requiring no solve, that rejects the unambiguous cases (negative,
75//! non-finite, or past the caller's own stated `max_range_m`).
76//! 2. `base` is then solved once via [`evaluate`], over the whole of `ranges_m` at once -- the
77//! same nominal-reference-point pattern `crate::tolerance::tolerance_envelope` and
78//! `crate::explain::explain_difference` already use. A range that passed stage 1 but lies
79//! past where THIS trajectory actually terminates is rejected here with an honest
80//! [`KernelError::Observation`] naming the REAL computed trajectory extent, not a per-axis
81//! "unavailable" that blames the wrong cause. The observations `evaluate` returns are
82//! otherwise unused in this function -- this call exists for its validation, not its output;
83//! see "Cost" below for what it adds.
84//!
85//! Every declared `sigma` must also be finite and non-negative, and the same [`InputAxis`] must
86//! not appear twice in `sources` (two entries would double-count that axis's variance and make
87//! its own leave-one-out counterfactual ambiguous), or [`error_budget`] returns
88//! [`KernelError::NonFinite`] / [`KernelError::DuplicateAxis`] respectively -- checked
89//! immediately after the two range stages above, still before any [`central_difference`] call.
90//!
91//! # Ranking is deterministic
92//!
93//! Sources are ranked by [`SourceContributionV1::variance_share`], descending. Ties (equal
94//! shares) break on a fixed, declaration-order-independent key (the axis's own `Debug` name), so
95//! `error_budget(base, &[(A, sa), (B, sb)], ranges)` and
96//! `error_budget(base, &[(B, sb), (A, sa)], ranges)` produce IDENTICAL orderings even when two
97//! sources happen to contribute exactly the same share. A real-physics fixture essentially never
98//! produces an exact tie in floating point, so a declaration-order test alone (the brief's own
99//! `ranking_is_invariant_to_declaration_order`) would still pass with NO tie-break at all, as
100//! long as Rust's sort remains stable and the two shares genuinely differ -- see
101//! `tied_variance_shares_break_deterministically_regardless_of_input_order` in this module's
102//! tests, which constructs a genuine tie directly against the sort function itself, and would
103//! fail without the tie-break.
104//!
105//! # Cost
106//!
107//! Two parts: one fixed pre-check, then one [`central_difference`] call per DECLARED source.
108//!
109//! **The pre-check** (added by the F1 fix, 0.33.0 final-review wave): one call to [`evaluate`]
110//! on the nominal, unperturbed `base` request, covering every range in `ranges_m` at once -- see
111//! "Sources and ranges are validated up front" above. This costs exactly ONE real trajectory
112//! solve, paid once per call to [`error_budget`]/[`error_budget_with_target`] regardless of how
113//! many sources are declared or how many ranges are requested, and regardless of whether `base`
114//! carries a `zero_distance_m`: `base`'s own `muzzle_angle_rad` is already the resolved angle
115//! (`request_roundtrip`'s `From<&ResolvedSolveRequestV1> for SolveRequestV1` always carries it
116//! alongside `zero_distance_m`), so `build_zeroed_solver`'s `(Some, Some)` arm applies only a
117//! cheap windage bias rather than re-running the elevation search -- unlike the per-source
118//! solves below, which perturb an axis away from where `base` was resolved and so, for a
119//! `requires_rezero` axis, must re-search from scratch.
120//!
121//! **Per declared source**, that is 2 real trajectory solves in the common (central-difference)
122//! case, or 3 if one side fell outside the axis's physical domain and the kernel fell back to a
123//! one-sided difference (see [`DifferenceScheme`]). If the axis `requires_rezero`
124//! (`crate::perturbation::axis_meta(axis).requires_rezero`) and the request
125//! carries a `zero_distance_m`, each of those solves is itself preceded by a fresh elevation
126//! search of up to 60 trial solves (`find_zero_angle`, `src/cli_api.rs`) -- unavoidable, and not
127//! something this module changes; see `crate::perturbation::derive`'s own module doc for where
128//! that number comes from. `ranges_m` is passed through to the kernel unchanged and the
129//! resulting `Vec<Derivative>` is indexed by range when building each row, so this part of the
130//! cost, like the pre-check, is independent of how many ranges are requested and scales only
131//! with the number of DECLARED sources.
132//!
133//! Measured, not guessed (a previous task's cost doc on this branch understated its own number
134//! by 5x before being corrected by direct measurement, so this number was obtained the same
135//! way): a temporary instrumented low-level solve counter was added to `TrajectorySolver::solve`,
136//! run once against this module's own three-source test fixture
137//! (`every_declared_source_appears_individually`: `MuzzleVelocityMps` and
138//! `BallisticCoefficient`, both `requires_rezero`, plus `WindSpeed`, which is not), then removed
139//! (the working tree was diffed against the pre-instrumentation state afterward to confirm a
140//! byte-for-byte revert of `src/cli_api.rs`). Measured results, with the F1 pre-check included:
141//!
142//! - All three sources together at a single range: **80 low-level solves** (79 before the F1
143//! fix added the pre-check -- exactly the expected `+1`).
144//! - The IDENTICAL three sources requested over FOUR ranges instead of one: **80 again** --
145//! confirming the per-range independence above still holds with the pre-check included (the
146//! pre-check itself covers all four ranges from that same one solve).
147//! - Decomposed by declaring each source alone (also at one range): `WindSpeed` (not
148//! `requires_rezero`) now costs 3; `MuzzleVelocityMps` costs 38; `BallisticCoefficient` costs
149//! 41 -- each exactly one more than its pre-F1 figure, since every SEPARATE call now pays its
150//! own copy of the fixed pre-check. Summing these three single-source figures
151//! (`38 + 41 + 3 = 82`) therefore OVERCOUNTS the combined three-source total (80) by 2: the
152//! pre-check is a fixed cost per CALL, not per source, so declaring the three sources as three
153//! separate calls pays it three times, while declaring them together in one call pays it once.
154//! Subtracting the pre-check from each isolated figure recovers the pre-F1 per-source numbers
155//! exactly (`38 - 1 = 37`, `41 - 1 = 40`, `3 - 1 = 2`), which still sum additively
156//! (`37 + 40 + 2 = 79`) and, with the combined call's own single pre-check added back once
157//! (`79 + 1 = 80`), match its measured total exactly -- confirming each declared source's cost
158//! remains independent of, and additive with, every other declared source's, exactly as before
159//! F1; only the fixed one-time pre-check is new, and it does not multiply with source count.
160//! The two `requires_rezero` axes each still cost roughly 17-19 trial solves per perturbed side
161//! beyond their one real solve (`(37 - 2) / 2 = 17.5` average, `(40 - 2) / 2 = 19` average) --
162//! well under the 60-iteration cap, not close to it, for this fixture's zero geometry.
163//!
164//! # Why the differencing step ignores the declared sigma
165//!
166//! `error_budget` always calls [`central_difference`] with `step: None` -- the axis's own small
167//! default, never `Some(sigma)`. The delta method's Jacobian is the local SLOPE of impact at the
168//! nominal point; the declared sigma only enters afterward, scaling that slope via
169//! `J * Sigma * J^T`. The consequence: a large declared sigma is linearly extrapolated from a
170//! slope measured over a much SMALLER window (`WindSpeed`'s own default step is 0.05 m/s,
171//! regardless of whether the caller declared a 1 m/s or a 10 m/s wind-call sigma) -- this is
172//! exactly the local-linearity limitation the `assumptions` payload already discloses, not a
173//! separate concern. Using `Some(sigma)` instead was considered and rejected: it would push
174//! `WindSpeed`/`RelativeHumidity`-style sigmas straight out of their physical domain on an
175//! ordinary still-air or dry-air request (see the "One-sided fallback" section in
176//! `crate::perturbation::derive`'s own module doc), making sources vanish into one-sided
177//! fallbacks or `StepOutOfDomain` far more often -- the opposite of what a report whose whole
178//! purpose is surfacing every declared source should do.
179
180use serde::{Deserialize, Serialize};
181
182use crate::perturbation::access::KernelError;
183use crate::perturbation::derive::{central_difference, Derivative, DifferenceScheme};
184use crate::perturbation::evaluate;
185use crate::perturbation::taxonomy::InputAxis;
186use crate::solve_json::ResolvedSolveRequestV1;
187use crate::special::normal_cdf;
188use crate::trajectory_observation::TrajectoryObservationError;
189use crate::truing_uncertainty::Symmetric2;
190
191/// Schema version for [`ErrorBudgetReportV1`].
192pub const ERROR_BUDGET_SCHEMA_VERSION_V1: u32 = 1;
193
194/// The chi-square critical value for a 95% confidence region with 2 degrees of freedom -- the
195/// same constant `crate::monte_carlo::calculate_confidence_ellipse` uses for its own,
196/// sample-based ellipse.
197const CHI2_95_2DOF: f64 = 5.991;
198
199/// 95% confidence ellipse for a 2-dof (drop, windage) impact covariance.
200#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
201pub struct Ellipse95V1 {
202 pub semi_major_m: f64,
203 pub semi_minor_m: f64,
204 /// Radians, measured from the drop axis toward the windage axis.
205 pub rotation_rad: f64,
206 pub area_m2: f64,
207}
208
209/// A target shape for [`p_hit_bivariate`] / [`error_budget_with_target`], always centred on the
210/// nominal (zero-mean) impact point -- there is no separate "offset from point of aim" field, so
211/// the reported hit probability implicitly assumes a well-zeroed rifle aimed at the target's own
212/// centre. `width_m`/`height_m`/`radius_m` are clamped to non-negative internally by
213/// [`p_hit_bivariate`]; a negative value is treated as zero rather than producing an inverted or
214/// NaN result.
215#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum TargetGeometryV1 {
218 /// Drop extent `height_m`, windage extent `width_m` -- matching this module's
219 /// (drop, windage) axis order, not (x, y) or (width, height) screen convention.
220 Rect {
221 width_m: f64,
222 height_m: f64,
223 },
224 Circle {
225 radius_m: f64,
226 },
227}
228
229/// One declared source's contribution to impact variance at one range.
230///
231/// Constructed only for a source [`central_difference`] actually evaluated -- an axis it refused
232/// is recorded in [`UnavailableSourceV1`] instead, never here with a fabricated zero.
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct SourceContributionV1 {
235 pub axis: InputAxis,
236 /// The caller-declared one-sigma uncertainty for this axis, in the axis's own physical unit
237 /// (`crate::perturbation::axis_meta(axis).kind`).
238 pub sigma: f64,
239 pub d_drop_d_x: f64,
240 pub d_windage_d_x: f64,
241 /// Which finite-difference scheme produced the two derivatives above. A one-sided scheme
242 /// (`ForwardOneSided`/`BackwardOneSided`) has larger truncation error than `Central` -- see
243 /// this module's "Unavailable sources" doc section and [`DifferenceScheme`]'s own doc for
244 /// when the kernel falls back to one. Still air (`wind.speed_mps: 0.0`) is the ordinary,
245 /// ROUTINE case for `WindSpeed`, not an exotic one -- wind-call uncertainty is this report's
246 /// flagship use case, so a non-`Central` scheme here is expected, not a warning sign by
247 /// itself.
248 pub scheme: DifferenceScheme,
249 /// Fraction of total impact variance (`sigma_drop_m^2 + sigma_windage_m^2`) attributable to
250 /// this source. Sums to 1.0 across a row's `sources` whenever at least one source has a
251 /// nonzero contribution.
252 pub variance_share: f64,
253 /// Reduction in the row's 95% ellipse area if this source alone were measured perfectly
254 /// (its sigma set to zero, every other source unchanged). Always `>= 0.0`.
255 ///
256 /// **Degenerate with two or fewer sources.** The remaining covariance after removing any ONE
257 /// of exactly two declared sources is an outer product (rank <= 1, area exactly 0 -- see
258 /// `Symmetric2::largest_smallest_eigenvalues`'s doc for why that is normal, not a
259 /// bug), so with exactly two sources BOTH report a reduction equal to the FULL ellipse area,
260 /// even when one dominates the variance share overwhelmingly (e.g. a 99%/1% split). That is
261 /// literally true (perfecting either one alone does leave a zero-area ellipse) but reads, next
262 /// to a ranking, as a tie where none exists on `variance_share`. A declaration of two sources
263 /// (e.g. muzzle velocity and wind call, the most likely pair this ticket sees) always has this
264 /// property; prefer `variance_share` to distinguish sources when `sources.len() <= 2`. With
265 /// three or more sources the reduction is generically discriminating.
266 pub ellipse_area_reduction_m2: f64,
267 /// The hit-probability gain over the row's target if THIS source alone were measured
268 /// perfectly (its sigma set to zero, every other source unchanged), i.e.
269 /// `p_hit(without this source) - p_hit(with every declared source)`. `Some` exactly when
270 /// [`error_budget_with_target`] was given a target; `None` (never a fabricated `0.0`) when
271 /// no target was supplied, matching [`ErrorBudgetRowV1::p_hit`].
272 ///
273 /// **Always `>= 0.0`** (see [`p_hit_bivariate`]'s doc: shrinking a target-centred impact
274 /// covariance in the Loewner order cannot reduce the mass of a symmetric normal over a
275 /// symmetric convex target -- Anderson's theorem). The unclamped value is checked against a
276 /// small negative tolerance before being clamped to zero (a `debug_assert!` in
277 /// [`error_budget_with_target`]), so a bug that made it SYSTEMATICALLY negative -- as
278 /// opposed to a few ULP of quadrature noise -- fails a debug/test build loudly rather than
279 /// being silently laundered into `0.0`.
280 ///
281 /// **Does NOT share `ellipse_area_reduction_m2`'s two-source degeneracy above -- it is one of
282 /// the few fields on this type that DOES discriminate at `sources.len() == 2`,** the
283 /// muzzle-velocity/wind-call pair this ticket names as the most likely declaration. The
284 /// ellipse-area field is degenerate there because AREA is exactly zero for ANY rank-1
285 /// covariance, blind to which specific rank-1 covariance it is -- removing either of two
286 /// sources leaves a *different* rank-1 covariance (one source's own outer product, not the
287 /// other's), but both have zero area, so both report the same "full area" reduction
288 /// regardless of the real variance split. Hit probability is not blind that way: it depends
289 /// on the SHAPE of the remaining rank-1 covariance (which is oriented along the surviving
290 /// source's own `(d_drop_d_x, d_windage_d_x)` direction), not merely on whether it is
291 /// singular. On this module's own flagship two-source fixture (`MuzzleVelocityMps` +
292 /// `WindSpeed`, `resolved()`, range 600 m, target 0.5 m x 0.75 m), measured
293 /// `p_hit_gain_if_perfect` values are `~0.4416` (perfecting `WindSpeed`, which nearly
294 /// dominates windage) and `~0.00008` (perfecting `MuzzleVelocityMps`) -- a difference of
295 /// three and a half orders of magnitude, not a tie. See
296 /// `p_hit_gain_if_perfect_discriminates_with_only_two_sources` in this module's tests.
297 pub p_hit_gain_if_perfect: Option<f64>,
298}
299
300/// Which structural refusal made a source unavailable -- the machine-readable counterpart to
301/// [`UnavailableSourceV1::reason`]'s prose. Named identically to the [`KernelError`] variant it
302/// comes from. Added at the same time as the rest of this `V1` type (not held back for a later
303/// schema revision) specifically because adding a field to an already-shipped `V1` wire type
304/// would be a breaking change; this crate had not shipped `error_budget` on any released version
305/// when this field was added, so there is no such constraint yet.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum UnavailableReasonCodeV1 {
309 AxisUnsupportedForRequest,
310 AxisAbsent,
311 CategoricalAxis,
312 StepOutOfDomain,
313}
314
315/// A declared source [`error_budget`] could not evaluate for this request, and why.
316///
317/// See this module's "Unavailable sources" doc section. An axis appearing here never also
318/// appears in any row's `sources` -- the two are disjoint by construction. This list is the same
319/// for every row for the three refusals that depend only on `axis` and `base`'s OTHER fields
320/// (`code` other than `StepOutOfDomain`): those never depend on which ranges were requested.
321/// `StepOutOfDomain` specifically COULD in principle depend on range (a query near a perturbed
322/// request's own shrunk domain -- see `crate::perturbation::derive`'s
323/// `target_distance_falls_back_to_one_sided_when_queried_near_its_own_max_range`), but
324/// `error_budget` rejects the common, obvious version of that (a range beyond the BASE request's
325/// own `max_range_m`) up front instead of letting it reach this list at all -- see
326/// `error_budget`'s "Sources and ranges are validated up front" doc section.
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328pub struct UnavailableSourceV1 {
329 pub axis: InputAxis,
330 pub sigma: f64,
331 pub code: UnavailableReasonCodeV1,
332 pub reason: String,
333}
334
335/// The impact covariance and ranked sources at one requested range.
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337pub struct ErrorBudgetRowV1 {
338 pub range_m: f64,
339 pub sigma_drop_m: f64,
340 pub sigma_windage_m: f64,
341 pub covariance_m2: f64,
342 pub ellipse_95: Ellipse95V1,
343 /// Probability the impact (drop, windage) falls inside the row's target, computed by
344 /// [`p_hit_bivariate`] from this row's own `sigma_drop_m`/`sigma_windage_m`/`covariance_m2`.
345 /// `Some` exactly when [`error_budget_with_target`] was given a target; `None` (never a
346 /// fabricated number) when no target was supplied -- see that function's "Hit probability"
347 /// doc section.
348 pub p_hit: Option<f64>,
349 /// Ranked by [`SourceContributionV1::variance_share`], descending, most-informative first --
350 /// see this module's "Ranking is deterministic" doc section.
351 pub sources: Vec<SourceContributionV1>,
352 /// A plain-language statement of which single input is most worth improving at this range,
353 /// or that none of the declared sources contributes (or that none could be evaluated at
354 /// all -- worded differently; see [`error_budget`]).
355 pub priority_statement: String,
356}
357
358/// Per-input uncertainty propagation and measurement-priority report (MBA-1347).
359///
360/// Carries [`method`](ErrorBudgetReportV1#structfield.method) and
361/// [`assumptions`](ErrorBudgetReportV1#structfield.assumptions) in the payload itself, not only
362/// in prose documentation -- see [`error_budget`]'s "Honesty" doc section and the
363/// `the_report_declares_independence_and_linearity` test.
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
365pub struct ErrorBudgetReportV1 {
366 pub schema_version: u32,
367 pub method: String,
368 pub assumptions: Vec<String>,
369 /// Declared sources this report could not evaluate. See this module's "Unavailable sources"
370 /// doc section -- never silently omitted.
371 pub unavailable_sources: Vec<UnavailableSourceV1>,
372 pub rows: Vec<ErrorBudgetRowV1>,
373}
374
375/// One declared source's central-difference Jacobian across every requested range, computed
376/// exactly once -- see this module's "Cost" doc section.
377struct AxisJacobian {
378 axis: InputAxis,
379 sigma: f64,
380 /// One entry per `ranges_m`, same order -- `central_difference`'s own contract.
381 derivatives: Vec<Derivative>,
382}
383
384/// One axis's contribution at a single range, gathered from an `AxisJacobian` for row-building.
385#[derive(Debug, Clone, Copy)]
386struct Entry {
387 axis: InputAxis,
388 sigma: f64,
389 scheme: DifferenceScheme,
390 d_drop_d_x: f64,
391 d_windage_d_x: f64,
392}
393
394/// Classify a `central_difference` failure as either "this source is unavailable, but the rest
395/// of the report should still be produced" (`Some((code, reason))`) or "this is a genuine
396/// failure the caller must see" (`None`).
397///
398/// `Some` for the four structural refusals this module's doc names explicitly
399/// (`AxisUnsupportedForRequest`, `AxisAbsent`, `CategoricalAxis`, `StepOutOfDomain`); `None` for
400/// everything else (`Solve`, `Observation`, `TypeMismatch`, `NonFinite`, `DuplicateAxis`,
401/// `InvalidDomain`), which the caller must propagate. Exhaustive over every `KernelError` variant
402/// with no wildcard arm, so a future variant fails to compile here until it is explicitly placed
403/// in one bucket or the other -- this is what forced `DuplicateAxis` to be classified here even
404/// though `error_budget`'s own up-front validation constructs and returns it directly, never
405/// through `central_difference` at all.
406///
407/// `pub(crate)` (0.33.0 decision-support Task 12, MBA-1350): `crate::tolerance::tolerance_envelope`
408/// reuses this SAME classification verbatim (including its exact reason text) for the identical
409/// four `bisect_axis`/`with_axis` refusals, rather than defining a second, independently
410/// maintained copy of the same four-way split -- a future `KernelError` variant now only needs
411/// to be placed in one bucket here, once, for both features to agree on it. Two of the four
412/// reason strings below read as if written for differentiation/uncertainty specifically
413/// (`CategoricalAxis`'s "cannot be assigned a one-sigma uncertainty or differentiated" and
414/// `StepOutOfDomain`'s "central differencing needs to perturb..."); `tolerance_envelope` never
415/// actually reaches `StepOutOfDomain` (`bisect_axis` cannot produce it -- only
416/// `central_difference` can), and accepts the `CategoricalAxis` wording being slightly
417/// off-context (still true and informative) as the cost of sharing one classifier rather than
418/// forking it.
419pub(crate) fn unavailable_reason(e: &KernelError) -> Option<(UnavailableReasonCodeV1, String)> {
420 match e {
421 KernelError::AxisUnsupportedForRequest { reason, .. } => Some((
422 UnavailableReasonCodeV1::AxisUnsupportedForRequest,
423 reason.to_string(),
424 )),
425 KernelError::AxisAbsent(_) => Some((
426 UnavailableReasonCodeV1::AxisAbsent,
427 "this axis has no single scalar value on this request (for the three wind axes, \
428 this means the wind is declared as a segmented profile rather than a constant \
429 speed/direction)"
430 .to_string(),
431 )),
432 KernelError::CategoricalAxis(_) => Some((
433 UnavailableReasonCodeV1::CategoricalAxis,
434 "this axis is categorical (a toggle or an enumerated choice), not a continuous \
435 quantity, and cannot be assigned a one-sigma uncertainty or differentiated"
436 .to_string(),
437 )),
438 KernelError::StepOutOfDomain { attempted, .. } => Some((
439 UnavailableReasonCodeV1::StepOutOfDomain,
440 format!(
441 "central differencing needs to perturb this axis by {attempted:.6} in both \
442 directions from its nominal value, using the axis's own default step (this does \
443 not depend on the declared sigma), and both directions left its physical \
444 domain; its sensitivity could not be measured at this operating point"
445 ),
446 )),
447 KernelError::Solve { .. }
448 | KernelError::Observation(_)
449 | KernelError::TypeMismatch(_)
450 | KernelError::NonFinite(_)
451 | KernelError::DuplicateAxis(_)
452 | KernelError::InvalidDomain { .. } => None,
453 }
454}
455
456/// Sum of `sigma^2 * [d_drop, d_windage] * [d_drop, d_windage]^T` over `entries`, as a
457/// [`Symmetric2`] (`a00`/`a11` are drop/windage variance, `a01` is their covariance) --
458/// optionally skipping one axis entirely (equivalent to, but cheaper than, substituting a zero
459/// sigma for it), used to compute the "if this source were measured perfectly" ellipse.
460fn accumulate(entries: &[Entry], exclude: Option<InputAxis>) -> Symmetric2 {
461 let mut total = Symmetric2::default();
462 for e in entries {
463 if Some(e.axis) == exclude {
464 continue;
465 }
466 let s2 = e.sigma * e.sigma;
467 total.add_assign(Symmetric2 {
468 a00: e.d_drop_d_x * e.d_drop_d_x * s2,
469 a01: e.d_drop_d_x * e.d_windage_d_x * s2,
470 a11: e.d_windage_d_x * e.d_windage_d_x * s2,
471 });
472 }
473 total
474}
475
476/// The 95% ellipse for an impact covariance `cov` (`a00`/`a11` = drop/windage variance, `a01` =
477/// their covariance). Never fails -- see `Symmetric2::largest_smallest_eigenvalues`.
478fn ellipse_95(cov: Symmetric2) -> Ellipse95V1 {
479 let (largest, smallest) = cov.largest_smallest_eigenvalues();
480 let semi_major_m = (CHI2_95_2DOF * largest).sqrt();
481 let semi_minor_m = (CHI2_95_2DOF * smallest).sqrt();
482 // Standard closed form for a symmetric 2x2 matrix's eigenvector angle -- equivalent to (and
483 // more robust at cov.a01 == 0 than) solving tan(theta) = (largest - a00) / a01 directly.
484 let rotation_rad = 0.5 * (2.0 * cov.a01).atan2(cov.a00 - cov.a11);
485 Ellipse95V1 {
486 semi_major_m,
487 semi_minor_m,
488 rotation_rad,
489 area_m2: std::f64::consts::PI * semi_major_m * semi_minor_m,
490 }
491}
492
493/// Rank by [`SourceContributionV1::variance_share`], descending, breaking ties on the axis's own
494/// `Debug` name so the order never depends on how the caller declared `sources` -- see this
495/// module's "Ranking is deterministic" doc section.
496///
497/// Uses `f64::total_cmp` rather than `partial_cmp` -- `variance_share` is finite and
498/// non-negative for any ordinarily-sized declared sigma, but an astronomically large (still
499/// finite) sigma can overflow `sigma * sigma` to infinity and produce a NaN share
500/// (`inf / inf`); this crate ships a fuzz suite, and a library function must not panic on an
501/// extreme-but-technically-valid input. `total_cmp` gives NaN a well-defined (if not physically
502/// meaningful) place in the order instead.
503fn sort_by_variance_share_desc(sources: &mut [SourceContributionV1]) {
504 sources.sort_by(|x, y| {
505 y.variance_share
506 .total_cmp(&x.variance_share)
507 .then_with(|| format!("{:?}", x.axis).cmp(&format!("{:?}", y.axis)))
508 });
509}
510
511/// Compose the row's plain-language measurement-priority statement.
512fn build_priority_statement(
513 sources: &[SourceContributionV1],
514 unavailable: &[UnavailableSourceV1],
515 range_m: f64,
516) -> String {
517 let unavailable_note = || -> String {
518 if unavailable.is_empty() {
519 String::new()
520 } else {
521 format!(
522 " {} declared source{} could not be evaluated at all -- see \
523 unavailable_sources.",
524 unavailable.len(),
525 if unavailable.len() == 1 { "" } else { "s" },
526 )
527 }
528 };
529 match sources.first() {
530 Some(top) if top.variance_share > 0.0 => {
531 let caveat = if top.scheme != DifferenceScheme::Central {
532 " (from a one-sided approximation, not a central difference -- see assumptions)"
533 } else {
534 ""
535 };
536 format!(
537 "{:?} dominates at {range_m:.0} m ({:.1}% of impact variance){caveat}. \
538 Measuring it better is the highest-value single improvement here.{}{}",
539 top.axis,
540 top.variance_share * 100.0,
541 gain_divergence_note(sources, top),
542 unavailable_note(),
543 )
544 }
545 Some(_) => format!(
546 "No declared source contributes uncertainty at {range_m:.0} m (every evaluated \
547 sigma is zero).{}",
548 unavailable_note(),
549 ),
550 None if !unavailable.is_empty() => format!(
551 "None of the declared sources could be evaluated at {range_m:.0} m -- see \
552 unavailable_sources for why."
553 ),
554 None => format!("No sources were declared for this report at {range_m:.0} m."),
555 }
556}
557
558/// (F3, 0.33.0 final-review fix wave) The variance leader (`top`, `sources[0]` after
559/// [`sort_by_variance_share_desc`]) is the source that contributes the most to the impact
560/// ELLIPSE'S SIZE -- but with a target supplied, that is not necessarily the source that most
561/// improves the odds of hitting THIS target: a source can dominate variance while displacing
562/// impact mostly in a direction the target is wide in (so perfecting it barely moves `p_hit`),
563/// while a lower-variance source displaces impact across the target's NARROW dimension (so
564/// perfecting it moves `p_hit` substantially). Left unremarked, the statement above would
565/// recommend measuring a provably near-worthless input for the shooter's actual goal --
566/// hitting THIS target -- while staying silent about a source that would help far more.
567///
568/// Returns `""` (the statement above is left exactly as it was before a target argument
569/// existed) in the two cases where there is nothing to add: no target was supplied at all (no
570/// source in `sources` carries a `Some` `p_hit_gain_if_perfect`), or the variance leader and the
571/// gain leader are the SAME axis (measuring the top-variance source is already the right advice
572/// for hitting the target too). Only ever compares sources whose gain is `Some`, and only
573/// speaks up when the gain leader's gain is STRICTLY greater than `top`'s own -- a tie (most
574/// concretely, every declared source having an identical, often zero, gain) breaks
575/// deterministically in [`top_by_gain`] but is not a genuine "this one is better" claim, and the
576/// sentence below asserts exactly that comparison, so a mere tie-break must not trigger it. See
577/// `the_priority_statement_names_the_gain_leader_when_it_diverges_from_the_variance_leader` and
578/// `the_priority_statement_is_unchanged_when_the_leaders_coincide` in this module's tests.
579fn gain_divergence_note(sources: &[SourceContributionV1], top: &SourceContributionV1) -> String {
580 let Some(gain_leader) = top_by_gain(sources) else {
581 return String::new(); // no target was supplied at all
582 };
583 let Some(top_gain) = top.p_hit_gain_if_perfect else {
584 debug_assert!(
585 false,
586 "a target is supplied (top_by_gain found a source with Some gain) so EVERY source, \
587 including the variance leader, must also carry a Some gain -- see \
588 error_budget_with_target's own doc comment"
589 );
590 return String::new();
591 };
592 let gain_leader_gain = gain_leader
593 .p_hit_gain_if_perfect
594 .expect("top_by_gain only ever returns a source with a Some gain");
595 if gain_leader.axis == top.axis || gain_leader_gain <= top_gain {
596 return String::new();
597 }
598 format!(
599 " However, perfecting {:?} yields the larger hit-probability gain (+{:.1}% vs +{:.1}%); \
600 if hitting this target is the goal, improve {:?} first.",
601 gain_leader.axis,
602 gain_leader_gain * 100.0,
603 top_gain * 100.0,
604 gain_leader.axis,
605 )
606}
607
608/// Among `sources` with a `Some` [`SourceContributionV1::p_hit_gain_if_perfect`], the one with
609/// the LARGEST gain -- ties break on the axis's own `Debug` name (the alphabetically-first name
610/// wins), the same declaration-order-independent key [`sort_by_variance_share_desc`] uses, for
611/// the same reason: two sources with an identical gain must resolve to the same answer
612/// regardless of the order the caller declared them in. `None` iff no source has a gain at all
613/// (no target was supplied), or `sources` is empty.
614fn top_by_gain(sources: &[SourceContributionV1]) -> Option<&SourceContributionV1> {
615 sources
616 .iter()
617 .filter(|s| s.p_hit_gain_if_perfect.is_some())
618 .max_by(|x, y| {
619 x.p_hit_gain_if_perfect
620 .expect("filtered to Some above")
621 .total_cmp(&y.p_hit_gain_if_perfect.expect("filtered to Some above"))
622 .then_with(|| format!("{:?}", y.axis).cmp(&format!("{:?}", x.axis)))
623 })
624}
625
626// ---------------------------------------------------------------------------------------------
627// Hit probability (MBA-1347, Task 11): the mass of a bivariate normal impact distribution over a
628// target, and the value-of-information question built on it -- see `p_hit_bivariate`'s own doc
629// comment for the math and `error_budget_with_target`'s "Hit probability" doc section for how a
630// report uses it.
631// ---------------------------------------------------------------------------------------------
632
633/// 20-node Gauss-Legendre quadrature on `[-1, 1]`: the positive half of the standard abscissas
634/// and weights (the negative half is generated by symmetry in [`gauss_legendre_20`]). Fixed
635/// order, so [`p_hit_bivariate`]'s result is deterministic and reproducible across platforms --
636/// the order (and the panel-splitting built on it below) is named in
637/// [`ErrorBudgetReportV1::method`] whenever a target is supplied (the
638/// `"_gl20_panelled_pm6sigma"` suffix).
639const GL20_X: [f64; 10] = [
640 0.0765265211334973,
641 0.2277858511416451,
642 0.3737060887154195,
643 0.5108670019508271,
644 0.636_053_680_726_515,
645 0.7463319064601508,
646 0.8391169718222188,
647 0.912_234_428_251_326,
648 0.9639719272779138,
649 0.9931285991850949,
650];
651
652/// Weights matching [`GL20_X`], same order.
653const GL20_W: [f64; 10] = [
654 0.1527533871307258,
655 0.1491729864726037,
656 0.142_096_109_318_382,
657 0.1316886384491766,
658 0.1181945319615184,
659 0.1019301198172404,
660 0.0832767415767048,
661 0.0626720483341091,
662 0.0406014298003869,
663 0.0176140071391521,
664];
665
666/// Integrate `f` over `[lo, hi]` with the fixed 20-node Gauss-Legendre rule above.
667///
668/// [`p_hit_bivariate`] calls this once per SMOOTH panel of its integration domain, never once
669/// over the whole domain in a single call -- see that function's doc comment for why a single
670/// panel is not accurate enough here. Returns `0.0` for an empty or inverted interval (`hi <=
671/// lo`), which the caller legitimately produces whenever two panel boundaries coincide (e.g. a
672/// correlation-crossing point that lands exactly on the target's own edge).
673fn gauss_legendre_20(lo: f64, hi: f64, f: impl Fn(f64) -> f64) -> f64 {
674 if hi <= lo {
675 return 0.0;
676 }
677 let mid = 0.5 * (hi + lo);
678 let half = 0.5 * (hi - lo);
679 let mut acc = 0.0;
680 for k in 0..10 {
681 for sign in [-1.0f64, 1.0f64] {
682 let u = mid + sign * half * GL20_X[k];
683 acc += GL20_W[k] * half * f(u);
684 }
685 }
686 acc
687}
688
689/// The windage interval `[a, b]` `target` admits at drop-offset `u`: constant for a rectangle,
690/// the chord of the circle at that height for a circle. Total and well-defined (never NaN) for
691/// any `u`, including `|u|` beyond a circle's radius (returns `(0.0, 0.0)`, an empty interval,
692/// rather than requiring the caller to pre-filter).
693fn windage_bounds_at(u: f64, target: TargetGeometryV1) -> (f64, f64) {
694 match target {
695 TargetGeometryV1::Rect { width_m, .. } => {
696 let half_w = width_m.max(0.0) / 2.0;
697 (-half_w, half_w)
698 }
699 TargetGeometryV1::Circle { radius_m } => {
700 let r = radius_m.max(0.0);
701 let x = (r * r - u * u).max(0.0).sqrt();
702 (-x, x)
703 }
704 }
705}
706
707/// P(impact falls inside `target`) for a bivariate normal impact distribution centred at the
708/// origin -- the nominal (zero-mean) trajectory solution -- with drop variance `var_drop`,
709/// windage variance `var_wind`, and drop/windage covariance `cov`. `target` is always centred on
710/// that same origin; see [`TargetGeometryV1`]'s doc for why there is no separate aim-point
711/// offset.
712///
713/// # The math (pinned, MBA-1347 spec section 6.2)
714///
715/// A correlated covariance does NOT let the rectangle probability separate into a product of two
716/// [`normal_cdf`] differences (`uncorrelated_rectangle_matches_the_separable_closed_form` in this
717/// module's tests exists specifically to demonstrate the separable form is only valid at zero
718/// correlation, and `a_strongly_correlated_case_differs_materially_from_the_wrong_separable_approximation`
719/// shows how far a real, moderately-correlated case departs from it). Instead this integrates
720/// over the drop axis and, at each drop value `u`, applies the CONDITIONAL normal distribution of
721/// windage given that drop:
722///
723/// `P = integral of phi(u) * [Phi(beta(u)) - Phi(alpha(u))] du`,
724///
725/// where `phi` is the drop marginal's density, `Phi` is [`normal_cdf`], and `alpha(u)`/`beta(u)`
726/// are the target's windage bounds at drop `u` (constant for a rectangle; the circle's chord for
727/// a circle), expressed in units of the conditional windage standard deviation and offset by the
728/// conditional mean `rho * (sigma_windage / sigma_drop) * u`.
729///
730/// # Two degenerate covariances, handled explicitly (not merely "does not panic")
731///
732/// - **Zero total variance** (`var_drop <= 0.0 && var_wind <= 0.0`): a deterministic impact
733/// exactly at the origin, which is always the target's own centre here -- inside by
734/// definition, so this returns `1.0` outright without touching the quadrature below.
735/// - **Zero drop variance alone** (`var_drop <= 0.0`, `var_wind > 0.0`): drop is deterministic at
736/// 0 but windage is not. The quadrature above integrates OVER the drop axis, which cannot
737/// represent a Dirac delta; instead of letting `sd -> 0` silently zero out every quadrature
738/// node's density (which the naive translation of the formula above does, and which would
739/// wrongly report `0.0` regardless of target size -- caught by
740/// `drop_deterministic_windage_random_matches_closed_form_not_hardcoded_zero` in this module's
741/// tests), this evaluates the windage marginal directly at drop `= 0`.
742///
743/// # A degenerate target
744///
745/// Checked FIRST, before either degenerate-covariance branch above: a target with no positive
746/// area (`width_m <= 0.0 || height_m <= 0.0` for a rectangle; `radius_m <= 0.0` for a circle --
747/// after the same `.max(0.0)` treatment negative dimensions get elsewhere) can never be hit,
748/// returning `0.0` unconditionally, REGARDLESS of the covariance. Without this guard, a
749/// zero-size target combined with zero total variance would fall into the "deterministic impact"
750/// branch above and report `1.0` -- technically defensible under a boundary-inclusive convention
751/// (a point impact exactly at a zero-size target's own centre), but indistinguishable from a
752/// caller's degenerate-target bug silently reading as total confidence, the single most
753/// misleading number this report could produce. `error_budget_with_target` reaches this with an
754/// empty `sources` list (zero total variance from having nothing to accumulate) more easily than
755/// it might seem, so this is checked unconditionally rather than only when `sources` happens to
756/// be empty.
757///
758/// A single declared source (one nonzero-sigma axis) produces a RANK-1 covariance -- `cov`
759/// exactly `+-sigma_drop * sigma_windage` before the correlation clamp below -- which is the
760/// routine case [`error_budget_with_target`] hits every time it prices "if this source alone
761/// were perfected" against a row with exactly one OTHER remaining source (see
762/// `SourceContributionV1::p_hit_gain_if_perfect`'s doc). `rho` is clamped to
763/// `[-0.999_999, 0.999_999]` so the conditional variance below is never exactly zero, avoiding a
764/// division by zero without needing a third special case for perfect correlation.
765///
766/// # Why the quadrature is PANELLED, not a single 20-node call over the whole domain
767///
768/// The spec pins "fixed-order Gauss-Legendre quadrature over a truncated +/-6 sigma domain," but
769/// a single 20-node rule spread across the WHOLE `[-6 sigma_drop, 6 sigma_drop]` interval is not
770/// merely imprecise, it is badly wrong for realistic inputs, for two DIFFERENT reasons, both
771/// found by comparing that naive translation against an independent fine (4000-point-per-smooth-
772/// piece composite Simpson) reference over a broad sweep of target sizes and correlations, not
773/// guessed:
774///
775/// **First: the target boundary is a genuine discontinuity in the naive formulation.** Written
776/// as "integrate over the whole +/-6 sigma domain, contributing zero outside the target's own
777/// drop extent," the integrand jumps from a generic nonzero value to zero exactly at the
778/// target's edge. Gauss-Legendre quadrature assumes smoothness across its whole panel; a hidden
779/// jump degrades it to first-order accuracy. Measured on this module's own pinned
780/// zero-correlation rectangle test (drop/windage sigma 0.10 m/0.20 m, target 0.30 m x 0.40 m):
781/// the naive single-panel translation is wrong by **8.6e-3** against the closed form (the test
782/// requires `< 1e-6`) -- for a SMALL circular target relative to sigma (radius one-fifth of
783/// sigma: radius 0.02 with sigma 0.1 in both axes) the naive version places every one of its 20
784/// nodes outside the target entirely and returns exactly **0.0** against a true value near
785/// 0.020.
786///
787/// Fix: restrict the integration domain to the target's own drop extent intersected with the
788/// +/-6 sigma truncation (`[lo, hi]` below) -- outside that range the contribution is EXACTLY
789/// zero, not approximately zero, so there is nothing to lose by not integrating there at all.
790/// This alone brings the zero-correlation rectangle case to ~1e-16 (machine precision, since the
791/// integrand reduces to a constant windage factor times a plain Gaussian bump, which 20-point
792/// Gauss-Legendre integrates essentially exactly).
793///
794/// **Second: near-perfect correlation creates a separate, INTERNAL sharp transition the target
795/// boundary fix does not touch.** As `|rho| -> 1`, the conditional windage standard deviation
796/// `sigma_w * sqrt(1 - rho^2) -> 0`, so the bracketed `[Phi(beta(u)) - Phi(alpha(u))]` factor
797/// above becomes an increasingly steep (though still, short of exactly `rho = +-1`, smooth)
798/// sigmoid in `u`, centred wherever the conditional mean crosses the window bound -- a location
799/// that can fall anywhere inside the domain, not just at its edges. This is not a rare input:
800/// EVERY "if this source alone were perfected" comparison in [`error_budget_with_target`]
801/// evaluates a covariance with exactly one remaining source, which is exactly rank-1 (`rho` at
802/// the +-0.999_999 clamp). Measured on that exact shape (a real single-source covariance,
803/// `sigma_drop` = 18.5, `sigma_windage` = 6.0, `rho` clamped to -0.999_999, swept over target
804/// heights/widths from 0.05x to 20x each sigma): the boundary-restricted-but-still-single-panel
805/// quadrature is wrong by up to **0.28** against the fine reference at the swept extremes
806/// (height 20x sigma_drop, width 1x sigma_windage), and by up to **7.4e-2** even restricted to
807/// height/width within 0.5x-4x of the natural (sigma_drop, sigma_windage) scale -- nowhere near a
808/// contrived corner.
809///
810/// Fix: when `|rho|` is non-negligible, ALSO split the domain at the (closed-form) drop value(s)
811/// where the conditional mean crosses the window's bound -- `+-half_width * sigma_drop / (rho *
812/// sigma_windage)` for a rectangle (the window bound is constant), or `+-radius / sqrt(1 + (rho *
813/// sigma_windage / sigma_drop)^2)` for a circle (from solving the circle's own chord equation) --
814/// giving the sigmoid its own smooth sub-panel instead of sharing one with the flat shoulder on
815/// either side. This brings the worst case measured over a broad synthetic stress sweep (several
816/// (sigma_drop, sigma_windage) magnitudes including the realistic 18.5/6.0 pair above, target
817/// heights/widths from 0.05x to 20x sigma, `|rho|` up to 0.999_999, both shapes) down under
818/// **1e-3** (6.1e-4 for rectangles, 2.3e-4 for circles) -- and realistic (roughly comparable
819/// width/height, moderate correlation) target shapes measured one to two further orders of
820/// magnitude better than that worst case.
821///
822/// This is a more careful IMPLEMENTATION of the pinned formula, not a different one: the number
823/// of panel BOUNDARIES is bounded at compile time (at most 4: the two domain edges -- the
824/// target's own edge only ever contributes 0 extra boundaries since it already bounds `[lo,
825/// hi]` -- plus up to two correlation-crossing points), so there are at most 3 panels, each
826/// integrated by the exact same 20-node rule named in the spec. The result therefore stays
827/// deterministic and its cost stays bounded (at most 3 panels * 20 nodes * 2 [`normal_cdf`]
828/// calls per node = 120 evaluations of [`normal_cdf`], negligible next to the real trajectory
829/// solves [`error_budget_with_target`] needs to build the covariance in the first place).
830///
831/// # Bounded and monotone
832///
833/// Always clamped to `[0.0, 1.0]` before returning. The TRUE integral is monotone non-decreasing
834/// in target size for a fixed covariance (a bigger rectangle or circle strictly contains a
835/// smaller one centred at the same origin, so the region of integration only grows) -- but that
836/// is a property of the exact mathematical integral, not something the panelled quadrature gets
837/// for free: growing the target moves the panel boundaries (the domain-restriction edge, and, at
838/// nonzero `rho`, the correlation-crossing points), which relocates every one of the 20 nodes
839/// within the affected panels, so the COMPUTED value is not automatically a monotone functional
840/// of target size the way the true integral is. This is verified BY TEST across a range of
841/// target sizes and shapes and, since the near-degenerate correlated regime is exactly where
842/// panel boundaries move the most, across `rho` in `{0.0, 0.9, 0.999_999}` too -- see
843/// `p_hit_is_bounded_and_grows_with_target_size` (circle, verbatim from the spec),
844/// `p_hit_grows_with_target_size_for_a_rectangle_too`, and
845/// `p_hit_is_monotone_in_target_size_across_a_sweep_including_near_rank_one_correlation` in this
846/// module's tests -- not guaranteed by construction of the floating-point implementation.
847pub fn p_hit_bivariate(var_drop: f64, var_wind: f64, cov: f64, target: TargetGeometryV1) -> f64 {
848 // A degenerate (non-positive-area) target can never be hit -- see doc comment's "A degenerate
849 // target" section above. Checked before either covariance branch below, and unconditionally
850 // (not only when the covariance also happens to be degenerate).
851 let target_has_positive_area = match target {
852 TargetGeometryV1::Rect { width_m, height_m } => width_m > 0.0 && height_m > 0.0,
853 TargetGeometryV1::Circle { radius_m } => radius_m > 0.0,
854 };
855 if !target_has_positive_area {
856 return 0.0;
857 }
858
859 let sd = var_drop.max(0.0).sqrt();
860 let sw = var_wind.max(0.0).sqrt();
861
862 // Zero total variance: see doc comment above.
863 if sd <= 0.0 && sw <= 0.0 {
864 return 1.0;
865 }
866
867 // Zero drop variance alone: see doc comment above -- the windage marginal evaluated directly
868 // at drop = 0, not routed through a quadrature that cannot represent a Dirac delta.
869 if sd <= 0.0 {
870 let (a, b) = windage_bounds_at(0.0, target);
871 return (normal_cdf(b / sw) - normal_cdf(a / sw)).clamp(0.0, 1.0);
872 }
873
874 let rho = if sw > 0.0 {
875 (cov / (sd * sw)).clamp(-0.999_999, 0.999_999)
876 } else {
877 0.0
878 };
879 let cond_sw = sw * (1.0 - rho * rho).max(0.0).sqrt();
880
881 // Restrict the domain to the target's own drop extent intersected with the +/-6 sigma
882 // truncation -- outside it the contribution is exactly zero, not approximately zero (see
883 // doc comment's "First: the target boundary..." section above).
884 let edge = match target {
885 TargetGeometryV1::Rect { height_m, .. } => height_m.max(0.0) / 2.0,
886 TargetGeometryV1::Circle { radius_m } => radius_m.max(0.0),
887 };
888 let hi = (6.0 * sd).min(edge);
889 if hi <= 0.0 {
890 return 0.0;
891 }
892 let lo = -hi;
893
894 // Extra panel boundaries at the conditional-mean/window-bound crossing point(s), needed only
895 // when |rho| is non-negligible -- see doc comment's "Second: near-perfect correlation..."
896 // section above.
897 let mut panel_bounds = vec![lo, hi];
898 if rho.abs() > 1e-9 {
899 let k = rho * (sw / sd); // conditional mean of windage given drop is k * u
900 let c = match target {
901 TargetGeometryV1::Rect { width_m, .. } => (width_m.max(0.0) / 2.0) / k,
902 TargetGeometryV1::Circle { radius_m } => radius_m.max(0.0) / (1.0 + k * k).sqrt(),
903 };
904 for candidate in [c, -c] {
905 if candidate.is_finite() && candidate > lo && candidate < hi {
906 panel_bounds.push(candidate);
907 }
908 }
909 }
910 panel_bounds.sort_by(f64::total_cmp);
911 panel_bounds.dedup();
912
913 let sqrt_2pi = (2.0 * std::f64::consts::PI).sqrt();
914 let mut acc = 0.0;
915 for w in panel_bounds.windows(2) {
916 acc += gauss_legendre_20(w[0], w[1], |u| {
917 let density = (-0.5 * (u / sd) * (u / sd)).exp() / (sd * sqrt_2pi);
918 let (a, b) = windage_bounds_at(u, target);
919 let mean = rho * (sw / sd) * u;
920 let p = if cond_sw > 0.0 {
921 normal_cdf((b - mean) / cond_sw) - normal_cdf((a - mean) / cond_sw)
922 } else if mean >= a && mean <= b {
923 1.0
924 } else {
925 0.0
926 };
927 density * p
928 });
929 }
930 acc.clamp(0.0, 1.0)
931}
932
933/// Propagate each declared per-input uncertainty in `sources` to impact covariance at every
934/// range in `ranges_m`, via central differences through the real solver
935/// ([`central_difference`]), and rank the sources by their share of impact variance.
936///
937/// `sources` is `(axis, sigma)` pairs: `sigma` is the caller's one-sigma uncertainty for that
938/// axis, in the axis's own physical unit (`crate::perturbation::axis_meta(axis).kind`). Every
939/// `sigma` must be finite and non-negative and every axis must appear at most once -- both are
940/// validated up front, before any solve; see "Sources and ranges are validated up front" and
941/// `# Errors` below. (Declaring the same axis twice would double-count its variance and make its
942/// own leave-one-out counterfactual ambiguous -- which of the two entries would "removing this
943/// source" mean? -- so it is rejected rather than given an arbitrary answer.)
944///
945/// # Honesty
946///
947/// [`ErrorBudgetReportV1::method`] and [`ErrorBudgetReportV1::assumptions`] state, in the
948/// payload itself (not only in prose documentation), that: sources are treated as INDEPENDENT
949/// (no correlation between them is modelled); propagation is FIRST-ORDER/local-linear about the
950/// nominal solution, using the axis's own small default differencing step regardless of the
951/// declared sigma (not exact for large or non-Gaussian input uncertainty -- see "Why the
952/// differencing step ignores the declared sigma" above); the 95% ellipse assumes an
953/// approximately Gaussian impact distribution; a source's derivative may be one-sided rather
954/// than central; and an unavailable source is not the same fact as a zero-contribution one. See
955/// `the_report_declares_independence_and_linearity` in this module's tests.
956///
957/// # Unavailable sources
958///
959/// See this module's top-level "Unavailable sources" doc section.
960///
961/// # Errors
962///
963/// - [`KernelError::Observation`] immediately, before any solve, if any `range_m` in `ranges_m`
964/// is not finite or falls outside `[0, base.shot.max_range_m]` -- see "Sources and ranges are
965/// validated up front" above.
966/// - [`KernelError::NonFinite`] immediately if any declared `sigma` is not finite or is
967/// negative.
968/// - [`KernelError::DuplicateAxis`] immediately if the same axis appears more than once in
969/// `sources`.
970/// - Otherwise, propagates any [`KernelError`] from [`central_difference`] that is not one of
971/// the four structural refusals recorded in [`ErrorBudgetReportV1::unavailable_sources`]
972/// instead -- a genuine solver or trajectory failure, not a normal "this input cannot be
973/// perturbed here" fact.
974///
975/// A thin wrapper over [`error_budget_with_target`] passing `None` -- every row's `p_hit` and
976/// every source's `p_hit_gain_if_perfect` come back `None` (never a fabricated number), and
977/// `method`/`assumptions` say nothing about hit probability. Call
978/// [`error_budget_with_target`] directly to also get those.
979pub fn error_budget(
980 base: &ResolvedSolveRequestV1,
981 sources: &[(InputAxis, f64)],
982 ranges_m: &[f64],
983) -> Result<ErrorBudgetReportV1, KernelError> {
984 error_budget_with_target(base, sources, ranges_m, None)
985}
986
987/// As [`error_budget`], but additionally reports hit probability over `target` when it is
988/// `Some`: each row's [`ErrorBudgetRowV1::p_hit`], and each of its sources'
989/// [`SourceContributionV1::p_hit_gain_if_perfect`] -- the hit-probability gain if that source
990/// alone were measured perfectly, the value-of-information number this ticket exists to answer.
991/// [`error_budget`] is a thin wrapper passing `None`. Validation, ranking, unavailable-source
992/// handling, and cost are otherwise IDENTICAL to [`error_budget`] and documented on it and this
993/// module's top-level doc comment; this doc comment covers only what `target` adds.
994///
995/// # Hit probability
996///
997/// When `target` is `Some`, each row's `p_hit` is [`p_hit_bivariate`] evaluated at that row's own
998/// impact covariance (`sigma_drop_m`, `sigma_windage_m`, `covariance_m2` -- the same numbers the
999/// row already reports, not a separately recomputed covariance). Each source's
1000/// `p_hit_gain_if_perfect` is the SAME row's `p_hit` if that one source's sigma were zero instead
1001/// (every other declared source unchanged) minus the row's actual `p_hit`, clamped to `>= 0.0`
1002/// after a `debug_assert!` that the unclamped value is not more than a small tolerance below
1003/// zero -- see [`SourceContributionV1::p_hit_gain_if_perfect`]'s own doc for why a systematically
1004/// negative raw value must fail loudly rather than be silently clamped away.
1005/// [`ErrorBudgetReportV1::method`] gains a `"_gl20_panelled_pm6sigma"` suffix and
1006/// [`ErrorBudgetReportV1::assumptions`] gains one more entry naming the quadrature and the
1007/// target-centred-on-aim-point assumption -- see
1008/// `the_report_names_the_quadrature_and_the_aim_point_assumption_only_when_a_target_is_supplied`
1009/// in this module's tests.
1010pub fn error_budget_with_target(
1011 base: &ResolvedSolveRequestV1,
1012 sources: &[(InputAxis, f64)],
1013 ranges_m: &[f64],
1014 target: Option<TargetGeometryV1>,
1015) -> Result<ErrorBudgetReportV1, KernelError> {
1016 // Validate ranges_m up front (review I3): an out-of-range query would otherwise fail BOTH
1017 // perturbed sides of EVERY axis identically (a genuine Observation domain rejection on each
1018 // side collapses to StepOutOfDomain), recording every declared source as unavailable with a
1019 // reason that blames the axis's own step -- laundering a caller mistake (a range beyond the
1020 // trajectory) into a plausible-looking per-axis explanation. Reject it directly instead, the
1021 // same way `evaluate`'s own `observation_at_range_checked` would once a solve actually ran.
1022 for &range_m in ranges_m {
1023 if !range_m.is_finite() {
1024 return Err(KernelError::Observation(
1025 TrajectoryObservationError::NonFiniteQuery {
1026 distance_m: range_m,
1027 },
1028 ));
1029 }
1030 if range_m < 0.0 || range_m > base.shot.max_range_m {
1031 return Err(KernelError::Observation(
1032 TrajectoryObservationError::OutOfRange {
1033 requested_m: range_m,
1034 minimum_m: 0.0,
1035 maximum_m: base.shot.max_range_m,
1036 },
1037 ));
1038 }
1039 }
1040
1041 // Evaluate the nominal (unperturbed) request over ranges_m once, before touching any
1042 // source. The loop above only rejects a range against the DECLARED base.shot.max_range_m,
1043 // which says nothing about where THIS bullet actually lands: a request that terminates
1044 // early (a ground strike, a velocity floor, any other cause short of max_range_m) can have
1045 // every range in `ranges_m` pass that check while still lying past the real trajectory.
1046 // Left unchecked, such a range would fail BOTH perturbed sides of EVERY axis identically (a
1047 // genuine Observation::OutOfRange on each side collapsing to StepOutOfDomain), reporting
1048 // "successfully" that every source is unavailable for a step-size reason that has nothing to
1049 // do with the real cause -- exactly the failure mode the loop above's own comment already
1050 // disclaims. `evaluate` solves once and checks every range against the REAL computed
1051 // trajectory via `observation_at_range_checked`, so an out-of-range query fails here with an
1052 // honest `Observation(OutOfRange { .. })` naming the actual trajectory extent, before any
1053 // axis is perturbed -- the identical nominal-reference-point pattern
1054 // `crate::tolerance::tolerance_envelope` and `crate::explain::explain_difference` already
1055 // use. The returned observations are otherwise unused here: this call exists for its
1056 // validation, not its output. See "Sources and ranges are validated up front" above and
1057 // "Cost" below for what this adds.
1058 evaluate(&base.into(), ranges_m)?;
1059
1060 // Validate sources up front (review I4 + pre-existing sigma check): every sigma finite and
1061 // non-negative, and no axis declared twice. Checked together, before any solve.
1062 for (i, &(axis, sigma)) in sources.iter().enumerate() {
1063 if !(sigma.is_finite() && sigma >= 0.0) {
1064 return Err(KernelError::NonFinite(axis));
1065 }
1066 if sources[..i]
1067 .iter()
1068 .any(|&(earlier_axis, _)| earlier_axis == axis)
1069 {
1070 return Err(KernelError::DuplicateAxis(axis));
1071 }
1072 }
1073
1074 let mut jac: Vec<AxisJacobian> = Vec::with_capacity(sources.len());
1075 let mut unavailable: Vec<UnavailableSourceV1> = Vec::new();
1076
1077 for &(axis, sigma) in sources {
1078 // One central_difference call per DECLARED source, covering every range in ranges_m at
1079 // once -- never re-derived per range. See this module's "Cost" doc section.
1080 match central_difference(base, axis, ranges_m, None) {
1081 Ok(derivatives) => jac.push(AxisJacobian {
1082 axis,
1083 sigma,
1084 derivatives,
1085 }),
1086 Err(e) => match unavailable_reason(&e) {
1087 Some((code, reason)) => unavailable.push(UnavailableSourceV1 {
1088 axis,
1089 sigma,
1090 code,
1091 reason,
1092 }),
1093 None => return Err(e),
1094 },
1095 }
1096 }
1097
1098 let mut rows = Vec::with_capacity(ranges_m.len());
1099 for (i, &range_m) in ranges_m.iter().enumerate() {
1100 let entries: Vec<Entry> = jac
1101 .iter()
1102 .map(|j| {
1103 let d = &j.derivatives[i];
1104 debug_assert_eq!(
1105 d.range_m, range_m,
1106 "central_difference's Nth derivative must be tagged with ranges_m's Nth range"
1107 );
1108 Entry {
1109 axis: j.axis,
1110 sigma: j.sigma,
1111 scheme: d.scheme,
1112 d_drop_d_x: d.d_drop_d_x,
1113 d_windage_d_x: d.d_windage_d_x,
1114 }
1115 })
1116 .collect();
1117
1118 let total_cov = accumulate(&entries, None);
1119 let total_var = total_cov.a00 + total_cov.a11;
1120 let full_ellipse = ellipse_95(total_cov);
1121 // `Some(p_hit)` iff a target was supplied -- see "Hit probability" doc section above.
1122 let p_hit = target.map(|t| p_hit_bivariate(total_cov.a00, total_cov.a11, total_cov.a01, t));
1123
1124 let mut sources_out: Vec<SourceContributionV1> = entries
1125 .iter()
1126 .map(|e| {
1127 let s2 = e.sigma * e.sigma;
1128 let this_var =
1129 e.d_drop_d_x * e.d_drop_d_x * s2 + e.d_windage_d_x * e.d_windage_d_x * s2;
1130 let reduced = accumulate(&entries, Some(e.axis));
1131 let reduced_ellipse = ellipse_95(reduced);
1132 // `target.zip(p_hit)` is `Some` exactly when `target` is (`p_hit` is computed
1133 // from `target` a few lines up), so this never diverges from `p_hit`'s own
1134 // Some-ness.
1135 let p_hit_gain_if_perfect = target.zip(p_hit).map(|(t, base_p_hit)| {
1136 let raw =
1137 p_hit_bivariate(reduced.a00, reduced.a11, reduced.a01, t) - base_p_hit;
1138 debug_assert!(
1139 raw > -2e-3,
1140 "perfecting {:?} at range {range_m} produced a meaningfully negative raw \
1141 p_hit gain ({raw}) before clamping -- perfecting a source shrinks the \
1142 impact covariance in the Loewner order, and shrinking a target-centred \
1143 covariance that way cannot reduce the mass of a symmetric normal over a \
1144 symmetric convex target (Anderson's theorem), so a value this far below \
1145 zero means the quadrature or the excluded-source covariance is wrong, \
1146 not ordinary numerical noise (measured worst-case quadrature error is \
1147 under 1e-3 across a broad stress sweep, and orders of magnitude better \
1148 for realistic target shapes -- see p_hit_bivariate's doc comment; -2e-3 \
1149 keeps roughly 3x headroom over that worst case rather than the 10x+ a \
1150 looser threshold would give, so a systematically wrong excluded-source \
1151 covariance is less likely to hide under the clamp)",
1152 e.axis
1153 );
1154 raw.max(0.0)
1155 });
1156 SourceContributionV1 {
1157 axis: e.axis,
1158 sigma: e.sigma,
1159 d_drop_d_x: e.d_drop_d_x,
1160 d_windage_d_x: e.d_windage_d_x,
1161 scheme: e.scheme,
1162 variance_share: if total_var > 0.0 {
1163 this_var / total_var
1164 } else {
1165 0.0
1166 },
1167 ellipse_area_reduction_m2: (full_ellipse.area_m2 - reduced_ellipse.area_m2)
1168 .max(0.0),
1169 p_hit_gain_if_perfect,
1170 }
1171 })
1172 .collect();
1173
1174 sort_by_variance_share_desc(&mut sources_out);
1175 let priority_statement = build_priority_statement(&sources_out, &unavailable, range_m);
1176
1177 rows.push(ErrorBudgetRowV1 {
1178 range_m,
1179 sigma_drop_m: total_cov.a00.sqrt(),
1180 sigma_windage_m: total_cov.a11.sqrt(),
1181 covariance_m2: total_cov.a01,
1182 ellipse_95: full_ellipse,
1183 p_hit,
1184 sources: sources_out,
1185 priority_statement,
1186 });
1187 }
1188
1189 let mut method = "central_difference_first_order_propagation".to_string();
1190 let mut assumptions = vec![
1191 "Declared sources are treated as independent; correlations between them are not \
1192 modelled."
1193 .to_string(),
1194 "Propagation is first-order (local linear) about the nominal solution, evaluated by \
1195 central differences through the real solver using each axis's own small default \
1196 step -- independent of the declared sigma, never a step scaled to it. A large \
1197 declared sigma is therefore a linear extrapolation from a slope measured over a \
1198 much smaller window, which is not exact for large or non-Gaussian input \
1199 uncertainty."
1200 .to_string(),
1201 "The 95% ellipse uses the chi-square 2-dof critical value 5.991 and assumes an \
1202 approximately Gaussian impact distribution."
1203 .to_string(),
1204 "A source's derivative may come from a one-sided (forward- or backward-only) \
1205 difference rather than a central one when its nominal value sits at a physical \
1206 domain boundary (for example, still air for wind speed); see that source's scheme \
1207 field. A one-sided difference has larger truncation error than a central one."
1208 .to_string(),
1209 "A source listed in unavailable_sources could not be evaluated for this request and \
1210 is excluded from every row's variance and ranking. That is not the same fact as a \
1211 source contributing zero -- it means this report cannot currently measure that \
1212 source's effect at all."
1213 .to_string(),
1214 ];
1215 if target.is_some() {
1216 method.push_str("_gl20_panelled_pm6sigma");
1217 assumptions.push(
1218 "Hit probability is the bivariate-normal mass over the target, computed by 20-point \
1219 Gauss-Legendre quadrature per smooth sub-interval, truncated at +/-6 sigma and \
1220 split at the target's own edge and (when two sources are strongly correlated) at \
1221 the drop values where the conditional windage window is crossed, so the fixed-order \
1222 rule is never applied across a hidden discontinuity or an unresolved sharp \
1223 transition. It reflects the declared input uncertainty only -- not model error -- \
1224 and assumes the target is centred on the aim point (the nominal trajectory's own \
1225 impact point), not offset from it."
1226 .to_string(),
1227 );
1228 }
1229
1230 Ok(ErrorBudgetReportV1 {
1231 schema_version: ERROR_BUDGET_SCHEMA_VERSION_V1,
1232 method,
1233 assumptions,
1234 unavailable_sources: unavailable,
1235 rows,
1236 })
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use super::*;
1242 use crate::perturbation::InputAxis;
1243
1244 fn resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
1245 let json = serde_json::json!({
1246 "schema_version": 1,
1247 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1248 "ballistic_coefficient": 0.243},
1249 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1250 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
1251 "atmosphere": {}, "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
1252 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
1253 }).to_string();
1254 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1255 crate::solve_v1::solve_v1(req).unwrap().resolved_request
1256 }
1257
1258 /// A QNH-referenced atmosphere fixture: `Altitude` is refused by `with_axis`
1259 /// (`AxisUnsupportedForRequest`) and must show up as unavailable, not silently vanish.
1260 fn qnh_resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
1261 let json = serde_json::json!({
1262 "schema_version": 1,
1263 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1264 "ballistic_coefficient": 0.243},
1265 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1266 "shot": {"max_range_m": 900.0},
1267 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1268 "pressure_reference": "qnh"},
1269 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1270 })
1271 .to_string();
1272 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1273 crate::solve_v1::solve_v1(req).unwrap().resolved_request
1274 }
1275
1276 // ---- Step 1 tests, verbatim from the brief (fixture literal fixed: FRAC_PI_2 instead of a
1277 // hardcoded pi/2 float, which clippy's approx_constant lint rejects -- see the task report).
1278
1279 /// Acceptance criterion: a zero-uncertainty source contributes exactly zero.
1280 #[test]
1281 fn a_zero_sigma_source_contributes_exactly_zero() {
1282 let r = resolved();
1283 let rep = error_budget(
1284 &r,
1285 &[
1286 (InputAxis::MuzzleVelocityMps, 0.0),
1287 (InputAxis::WindSpeed, 1.0),
1288 ],
1289 &[600.0],
1290 )
1291 .unwrap();
1292 let mv = rep.rows[0]
1293 .sources
1294 .iter()
1295 .find(|s| s.axis == InputAxis::MuzzleVelocityMps)
1296 .unwrap();
1297 assert_eq!(mv.variance_share, 0.0);
1298 assert_eq!(mv.sigma, 0.0);
1299 // Beyond the brief: a zero-sigma source must not fabricate a zero derivative either, and
1300 // its ellipse-area reduction (a second, independently-computed quantity derived from the
1301 // SAME sigma=0) must also be exactly zero, not merely small.
1302 assert!(
1303 mv.d_drop_d_x != 0.0,
1304 "the real derivative must still be reported"
1305 );
1306 assert_eq!(mv.ellipse_area_reduction_m2, 0.0);
1307 }
1308
1309 /// Sources are preserved individually -- never collapsed into an "other" bucket.
1310 #[test]
1311 fn every_declared_source_appears_individually() {
1312 let r = resolved();
1313 let declared = [
1314 (InputAxis::MuzzleVelocityMps, 5.0),
1315 (InputAxis::WindSpeed, 1.0),
1316 (InputAxis::BallisticCoefficient, 0.005),
1317 ];
1318 let rep = error_budget(&r, &declared, &[600.0]).unwrap();
1319 assert_eq!(rep.rows[0].sources.len(), declared.len());
1320 for (axis, _) in declared {
1321 assert!(
1322 rep.rows[0].sources.iter().any(|s| s.axis == axis),
1323 "{axis:?} missing"
1324 );
1325 }
1326 }
1327
1328 /// Ranking must not depend on the order sources were declared in.
1329 #[test]
1330 fn ranking_is_invariant_to_declaration_order() {
1331 let r = resolved();
1332 let a = error_budget(
1333 &r,
1334 &[
1335 (InputAxis::MuzzleVelocityMps, 5.0),
1336 (InputAxis::WindSpeed, 1.0),
1337 ],
1338 &[600.0],
1339 )
1340 .unwrap();
1341 let b = error_budget(
1342 &r,
1343 &[
1344 (InputAxis::WindSpeed, 1.0),
1345 (InputAxis::MuzzleVelocityMps, 5.0),
1346 ],
1347 &[600.0],
1348 )
1349 .unwrap();
1350 let order_a: Vec<_> = a.rows[0].sources.iter().map(|s| s.axis).collect();
1351 let order_b: Vec<_> = b.rows[0].sources.iter().map(|s| s.axis).collect();
1352 assert_eq!(order_a, order_b);
1353 }
1354
1355 /// Variance shares are normalised.
1356 #[test]
1357 fn variance_shares_sum_to_one() {
1358 let r = resolved();
1359 let rep = error_budget(
1360 &r,
1361 &[
1362 (InputAxis::MuzzleVelocityMps, 5.0),
1363 (InputAxis::WindSpeed, 1.0),
1364 ],
1365 &[600.0],
1366 )
1367 .unwrap();
1368 let sum: f64 = rep.rows[0].sources.iter().map(|s| s.variance_share).sum();
1369 assert!((sum - 1.0).abs() < 1e-9, "shares summed to {sum}");
1370 }
1371
1372 /// (F3, 0.33.0 final-review fix wave) A tall, narrow target (`height_m` generous, `width_m`
1373 /// tight) constructed so `MuzzleVelocityMps`'s large drop-only variance dominates the
1374 /// ellipse's SIZE (91.5% of variance share) while barely touching `p_hit` (the target's
1375 /// generous height absorbs almost all of it: perfecting it gains only ~4.1 points), and
1376 /// `WindSpeed`'s much smaller windage variance (8.5% share) is exactly what the target's
1377 /// narrow width makes precious (perfecting it gains ~39.5 points -- nearly ten times more).
1378 /// Before this fix, the statement named only the variance leader, `MuzzleVelocityMps`,
1379 /// recommending a measurement that is nearly worthless for actually hitting this target
1380 /// while staying silent about `WindSpeed`, which would help far more.
1381 #[test]
1382 fn the_priority_statement_names_the_gain_leader_when_it_diverges_from_the_variance_leader() {
1383 let r = resolved();
1384 let declared = [
1385 (InputAxis::MuzzleVelocityMps, 40.0),
1386 (InputAxis::WindSpeed, 0.5),
1387 ];
1388 let target = TargetGeometryV1::Rect {
1389 width_m: 0.15,
1390 height_m: 3.0,
1391 };
1392 let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
1393 let row = &rep.rows[0];
1394
1395 let mv = row
1396 .sources
1397 .iter()
1398 .find(|s| s.axis == InputAxis::MuzzleVelocityMps)
1399 .unwrap();
1400 let ws = row
1401 .sources
1402 .iter()
1403 .find(|s| s.axis == InputAxis::WindSpeed)
1404 .unwrap();
1405 // Fixture assumptions, pinned so a future change to the physics or the quadrature that
1406 // breaks the divergence this test relies on fails HERE with a clear message, not with a
1407 // confusing failure deeper in the statement assertions below.
1408 assert!(
1409 mv.variance_share > ws.variance_share,
1410 "fixture assumption: MuzzleVelocityMps must be the variance leader; got MV={} \
1411 WS={}",
1412 mv.variance_share,
1413 ws.variance_share
1414 );
1415 assert_eq!(
1416 row.sources[0].axis,
1417 InputAxis::MuzzleVelocityMps,
1418 "sorted by variance share"
1419 );
1420 let (mv_gain, ws_gain) = (
1421 mv.p_hit_gain_if_perfect.unwrap(),
1422 ws.p_hit_gain_if_perfect.unwrap(),
1423 );
1424 assert!(
1425 ws_gain > mv_gain + 0.1,
1426 "fixture assumption: WindSpeed's gain must diverge sharply from the variance \
1427 leader's own -- got MV gain={mv_gain} WS gain={ws_gain}"
1428 );
1429
1430 let statement = &row.priority_statement;
1431 assert!(
1432 statement.starts_with("MuzzleVelocityMps dominates"),
1433 "the variance leader's own sentence must still be stated first: {statement}"
1434 );
1435 assert!(
1436 statement.contains("perfecting WindSpeed yields the larger hit-probability gain"),
1437 "expected the statement to name the gain leader when it diverges from the variance \
1438 leader: {statement}"
1439 );
1440 assert!(
1441 statement.contains("improve WindSpeed first"),
1442 "expected a concrete recommendation naming the gain leader: {statement}"
1443 );
1444 }
1445
1446 /// (F3, continued) When a target is supplied but the two leaders COINCIDE (the variance
1447 /// leader is also the gain leader, the ordinary case), the statement must read exactly as it
1448 /// did before this ticket -- no added sentence, nothing implying a divergence that is not
1449 /// there.
1450 #[test]
1451 fn the_priority_statement_is_unchanged_when_the_leaders_coincide() {
1452 let r = resolved();
1453 let declared = [
1454 (InputAxis::MuzzleVelocityMps, 5.0),
1455 (InputAxis::WindSpeed, 1.5),
1456 ];
1457 let target = TargetGeometryV1::Rect {
1458 width_m: 0.5,
1459 height_m: 0.75,
1460 };
1461 let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
1462 let row = &rep.rows[0];
1463 assert_eq!(
1464 row.sources[0].axis,
1465 InputAxis::WindSpeed,
1466 "fixture assumption: WindSpeed is both the variance leader and (per \
1467 p_hit_gain_if_perfect_discriminates_with_only_two_sources) the gain leader here"
1468 );
1469 assert_eq!(
1470 row.priority_statement,
1471 "WindSpeed dominates at 600 m (98.2% of impact variance). Measuring it better is \
1472 the highest-value single improvement here."
1473 );
1474 }
1475
1476 /// (F3, continued) Without a target at all, the statement must be completely unaffected by
1477 /// this ticket -- `gain_divergence_note` must return `""` whenever no source carries a
1478 /// `p_hit_gain_if_perfect` at all, exactly reproducing every pre-existing, no-target
1479 /// statement. Same declared sigmas as the divergence fixture above (so this covers the
1480 /// identical numeric case with the target simply omitted), asserted structurally rather than
1481 /// by hardcoding the variance percentage: the statement must end exactly where it always did
1482 /// and must never contain the new sentence's tell -- "However".
1483 #[test]
1484 fn the_priority_statement_is_unchanged_with_no_target_supplied() {
1485 let r = resolved();
1486 let rep = error_budget(
1487 &r,
1488 &[
1489 (InputAxis::MuzzleVelocityMps, 40.0),
1490 (InputAxis::WindSpeed, 0.5),
1491 ],
1492 &[600.0],
1493 )
1494 .unwrap();
1495 let statement = &rep.rows[0].priority_statement;
1496 assert!(
1497 statement.starts_with("MuzzleVelocityMps dominates at 600 m ("),
1498 "{statement}"
1499 );
1500 assert!(
1501 statement
1502 .ends_with("Measuring it better is the highest-value single improvement here."),
1503 "the statement must end exactly where it always did when no target is supplied (no \
1504 extra sentence appended): {statement}"
1505 );
1506 assert!(
1507 !statement.contains("However"),
1508 "no target was supplied, so no divergence sentence should ever be appended: \
1509 {statement}"
1510 );
1511 }
1512
1513 #[test]
1514 fn the_report_declares_independence_and_linearity() {
1515 let r = resolved();
1516 let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
1517 assert_eq!(rep.method, "central_difference_first_order_propagation");
1518 assert!(rep.assumptions.iter().any(|s| s.contains("independent")));
1519 assert!(rep
1520 .assumptions
1521 .iter()
1522 .any(|s| s.to_lowercase().contains("linear")));
1523 }
1524
1525 /// The honesty requirement names three specific claims the payload must carry: independence,
1526 /// first-order/local-linearity, AND the Gaussian-ellipse assumption. The test above (verbatim
1527 /// from the brief) only pins down the first two; this pins down the third explicitly, plus
1528 /// the two extra assumptions this implementation adds (non-central schemes,
1529 /// unavailable-is-not-zero) -- so all five payload strings are independently checked for their
1530 /// OWN specific content, not just "the list is non-empty."
1531 #[test]
1532 fn the_report_declares_the_gaussian_ellipse_assumption_and_the_two_added_caveats() {
1533 let r = resolved();
1534 let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
1535 assert!(
1536 rep.assumptions.iter().any(|s| s.contains("5.991") && s.to_lowercase().contains("gaussian")),
1537 "no assumption states the chi-square constant and the Gaussian-ellipse assumption: {:#?}",
1538 rep.assumptions
1539 );
1540 assert!(
1541 rep.assumptions
1542 .iter()
1543 .any(|s| s.to_lowercase().contains("one-sided")),
1544 "no assumption warns that a source's derivative may be one-sided: {:#?}",
1545 rep.assumptions
1546 );
1547 assert!(
1548 rep.assumptions
1549 .iter()
1550 .any(|s| s.contains("unavailable_sources")
1551 && s.to_lowercase().contains("not the same fact as")),
1552 "no assumption distinguishes an unavailable source from a zero-contribution one: {:#?}",
1553 rep.assumptions
1554 );
1555 }
1556
1557 // ---- Beyond the brief: the four blind spots the task explicitly calls out, plus the
1558 // determinism/cost/honesty requirements it names as "things the brief does not know."
1559
1560 /// (1) A one-sided scheme must be visible in the report, not silently indistinguishable from
1561 /// a central one. Still air is this report's flagship scenario (a wind-call sigma with
1562 /// `speed_mps: 0.0`), and it deterministically forces `ForwardOneSided` (see
1563 /// `crate::perturbation::derive`'s own `wind_speed_falls_back_to_one_sided_in_still_air`).
1564 #[test]
1565 fn a_non_central_scheme_is_surfaced_in_the_source_and_the_priority_statement() {
1566 let json = serde_json::json!({
1567 "schema_version": 1,
1568 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1569 "ballistic_coefficient": 0.243},
1570 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1571 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
1572 "atmosphere": {},
1573 "wind": {"speed_mps": 0.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
1574 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
1575 })
1576 .to_string();
1577 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1578 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1579
1580 let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
1581 let ws = &rep.rows[0].sources[0];
1582 assert_eq!(ws.axis, InputAxis::WindSpeed);
1583 assert_eq!(ws.scheme, DifferenceScheme::ForwardOneSided);
1584 assert!(
1585 rep.rows[0].priority_statement.contains("one-sided"),
1586 "priority_statement should flag a one-sided dominant source: {}",
1587 rep.rows[0].priority_statement
1588 );
1589 assert!(rep
1590 .assumptions
1591 .iter()
1592 .any(|s| s.to_lowercase().contains("one-sided")));
1593 }
1594
1595 /// (2) An unavailable source must be RECORDED, not silently dropped -- and must be
1596 /// distinguishable from a source that evaluated and contributed zero. Also confirms the rest
1597 /// of the report (a source that DID evaluate) is unaffected by a sibling's unavailability.
1598 #[test]
1599 fn an_unavailable_source_is_recorded_not_silently_dropped() {
1600 let r = qnh_resolved();
1601 let rep = error_budget(
1602 &r,
1603 &[
1604 (InputAxis::MuzzleVelocityMps, 5.0),
1605 (InputAxis::Altitude, 50.0),
1606 ],
1607 &[300.0],
1608 )
1609 .unwrap();
1610
1611 // Altitude must NEVER appear as an evaluated source...
1612 assert!(rep.rows[0]
1613 .sources
1614 .iter()
1615 .all(|s| s.axis != InputAxis::Altitude));
1616 // ...but MUST appear, explicitly, as unavailable, with a reason naming the mechanism.
1617 let skipped = rep
1618 .unavailable_sources
1619 .iter()
1620 .find(|u| u.axis == InputAxis::Altitude)
1621 .expect("Altitude must be recorded as unavailable, not dropped");
1622 assert_eq!(skipped.sigma, 50.0);
1623 assert_eq!(
1624 skipped.code,
1625 UnavailableReasonCodeV1::AxisUnsupportedForRequest
1626 );
1627 assert!(
1628 skipped.reason.to_lowercase().contains("qnh"),
1629 "{}",
1630 skipped.reason
1631 );
1632 // The unavailable list is not merely non-empty by accident -- it must be EXACTLY the one
1633 // axis that was actually refused, not every declared axis.
1634 assert_eq!(rep.unavailable_sources.len(), 1);
1635
1636 // The sibling source that DID evaluate must be reported normally and dominate (it is the
1637 // only evaluated source), not be suppressed or zeroed by Altitude's unavailability.
1638 assert_eq!(rep.rows[0].sources.len(), 1);
1639 let mv = &rep.rows[0].sources[0];
1640 assert_eq!(mv.axis, InputAxis::MuzzleVelocityMps);
1641 assert!(mv.variance_share > 0.0);
1642 assert!(
1643 rep.rows[0]
1644 .priority_statement
1645 .contains("could not be evaluated"),
1646 "priority_statement should mention the unavailable source too: {}",
1647 rep.rows[0].priority_statement
1648 );
1649 }
1650
1651 /// (2), continued: when EVERY declared source is unavailable, the report must still succeed
1652 /// (not error out) with an empty `sources` list, a priority statement that says so plainly
1653 /// (never confused with "every sigma is zero"), and a well-defined (zero) ellipse.
1654 #[test]
1655 fn every_source_unavailable_still_produces_a_well_formed_report() {
1656 let r = qnh_resolved();
1657 let rep = error_budget(&r, &[(InputAxis::Altitude, 50.0)], &[300.0]).unwrap();
1658 assert!(rep.rows[0].sources.is_empty());
1659 assert_eq!(rep.unavailable_sources.len(), 1);
1660 assert_eq!(rep.unavailable_sources[0].axis, InputAxis::Altitude);
1661 assert_eq!(
1662 rep.unavailable_sources[0].code,
1663 UnavailableReasonCodeV1::AxisUnsupportedForRequest
1664 );
1665 assert_eq!(rep.rows[0].ellipse_95.area_m2, 0.0);
1666 assert!(rep.rows[0]
1667 .priority_statement
1668 .contains("None of the declared sources"));
1669 }
1670
1671 /// (2), continued once more: the classification itself, tested directly and exhaustively
1672 /// against every `KernelError` variant, independent of any physics fixture. This is the
1673 /// authoritative proof that a genuine (non-structural) failure is classified as "propagate,"
1674 /// not "unavailable" -- constructing a REAL end-to-end fixture that makes
1675 /// `central_difference` return a genuine `Solve`/`Observation` failure through
1676 /// `error_budget`'s fixed (default-step) call is only reachable via a knife-edge convergence
1677 /// boundary that would make the test fragile against unrelated solver changes; the
1678 /// exhaustive match in production code (no wildcard arm) is the stronger guarantee here,
1679 /// since it also protects a FUTURE `KernelError` variant at compile time. See the task
1680 /// report for the full reasoning.
1681 ///
1682 /// (Review I4): `DuplicateAxis` is in the GENUINE bucket, not the structural one --
1683 /// `error_budget` constructs and returns it directly from its own up-front validation
1684 /// (never through `central_difference`), so by the time anything reaches this
1685 /// classification it must propagate, exactly like a malformed request.
1686 ///
1687 /// Also verifies the SPECIFIC `UnavailableReasonCodeV1` returned for each structural
1688 /// refusal, not just that one was returned -- `is_some()` alone would not catch two codes
1689 /// swapped between variants (e.g. `AxisAbsent` mistakenly tagged `CategoricalAxis`).
1690 #[test]
1691 fn unavailable_reason_classifies_every_kernel_error_variant() {
1692 use crate::solve_json::SolveErrorCodeV1;
1693 use crate::trajectory_observation::TrajectoryObservationError;
1694
1695 let structural = [
1696 (
1697 KernelError::AxisUnsupportedForRequest {
1698 axis: InputAxis::Altitude,
1699 reason: "x",
1700 },
1701 UnavailableReasonCodeV1::AxisUnsupportedForRequest,
1702 ),
1703 (
1704 KernelError::AxisAbsent(InputAxis::WindSpeed),
1705 UnavailableReasonCodeV1::AxisAbsent,
1706 ),
1707 (
1708 KernelError::CategoricalAxis(InputAxis::CoriolisEnabled),
1709 UnavailableReasonCodeV1::CategoricalAxis,
1710 ),
1711 (
1712 KernelError::StepOutOfDomain {
1713 axis: InputAxis::RelativeHumidity,
1714 attempted: 2.0,
1715 },
1716 UnavailableReasonCodeV1::StepOutOfDomain,
1717 ),
1718 ];
1719 for (e, expected_code) in &structural {
1720 match unavailable_reason(e) {
1721 Some((code, _)) => assert_eq!(
1722 code, *expected_code,
1723 "{e:?} classified with the wrong UnavailableReasonCodeV1"
1724 ),
1725 None => {
1726 panic!("{e:?} must be classified as unavailable (recorded), not propagated")
1727 }
1728 }
1729 }
1730
1731 let genuine = [
1732 KernelError::Solve {
1733 code: SolveErrorCodeV1::SolveFailed,
1734 message: "x".into(),
1735 },
1736 KernelError::Observation(TrajectoryObservationError::NonMonotonicTrajectory {
1737 index: 3,
1738 previous_distance_m: 10.0,
1739 distance_m: 9.0,
1740 }),
1741 KernelError::TypeMismatch(InputAxis::Mass),
1742 KernelError::NonFinite(InputAxis::Mass),
1743 KernelError::DuplicateAxis(InputAxis::Mass),
1744 // 0.33.0 decision-support Task 12 (MBA-1350): tolerance_envelope's own
1745 // domain-validation variant. Not constructed by central_difference, but this
1746 // classifier is exhaustive over the whole KernelError type (see its doc comment),
1747 // so it belongs in this list on the same footing as DuplicateAxis above.
1748 KernelError::InvalidDomain {
1749 axis: InputAxis::WindSpeed,
1750 reason: "x",
1751 },
1752 ];
1753 for e in &genuine {
1754 assert!(
1755 unavailable_reason(e).is_none(),
1756 "{e:?} must be classified as a genuine failure (propagated), not recorded"
1757 );
1758 }
1759 }
1760
1761 /// (3) Ranking must be deterministic even when two sources GENUINELY tie -- a real physics
1762 /// fixture cannot reliably produce a bit-exact tie, so this constructs one directly against
1763 /// the sort function itself. Without the tie-break (comparing only `variance_share`), Rust's
1764 /// stable sort would preserve each input's own relative order for the tied pair, so the two
1765 /// differently-ordered inputs below would disagree -- this test fails under that
1766 /// implementation (verified while developing it) and passes only because the tie is broken
1767 /// on a fixed key.
1768 #[test]
1769 fn tied_variance_shares_break_deterministically_regardless_of_input_order() {
1770 fn stub(axis: InputAxis, variance_share: f64) -> SourceContributionV1 {
1771 SourceContributionV1 {
1772 axis,
1773 sigma: 1.0,
1774 d_drop_d_x: 0.0,
1775 d_windage_d_x: 0.0,
1776 scheme: DifferenceScheme::Central,
1777 variance_share,
1778 ellipse_area_reduction_m2: 0.0,
1779 p_hit_gain_if_perfect: None,
1780 }
1781 }
1782 let mut a = vec![
1783 stub(InputAxis::WindSpeed, 0.5),
1784 stub(InputAxis::MuzzleVelocityMps, 0.5),
1785 ];
1786 let mut b = vec![
1787 stub(InputAxis::MuzzleVelocityMps, 0.5),
1788 stub(InputAxis::WindSpeed, 0.5),
1789 ];
1790 sort_by_variance_share_desc(&mut a);
1791 sort_by_variance_share_desc(&mut b);
1792 let order_a: Vec<_> = a.iter().map(|s| s.axis).collect();
1793 let order_b: Vec<_> = b.iter().map(|s| s.axis).collect();
1794 assert_eq!(
1795 order_a, order_b,
1796 "a tie must break the same way regardless of input order"
1797 );
1798 // Pin down WHICH order, not just "some order both agree on": "MuzzleVelocityMps" sorts
1799 // before "WindSpeed" as a Debug string.
1800 assert_eq!(
1801 order_a,
1802 vec![InputAxis::MuzzleVelocityMps, InputAxis::WindSpeed]
1803 );
1804 }
1805
1806 /// (3), continued: a three-way tie (beyond a simple pairwise swap) stays fully deterministic
1807 /// across every rotation of the input order.
1808 #[test]
1809 fn a_three_way_tie_is_fully_deterministic_across_every_rotation() {
1810 fn stub(axis: InputAxis) -> SourceContributionV1 {
1811 SourceContributionV1 {
1812 axis,
1813 sigma: 1.0,
1814 d_drop_d_x: 0.0,
1815 d_windage_d_x: 0.0,
1816 scheme: DifferenceScheme::Central,
1817 variance_share: 1.0 / 3.0,
1818 ellipse_area_reduction_m2: 0.0,
1819 p_hit_gain_if_perfect: None,
1820 }
1821 }
1822 let axes = [
1823 InputAxis::WindSpeed,
1824 InputAxis::MuzzleVelocityMps,
1825 InputAxis::Mass,
1826 ];
1827 let mut orders = Vec::new();
1828 for rotation in 0..axes.len() {
1829 let mut rotated: Vec<SourceContributionV1> = (0..axes.len())
1830 .map(|k| stub(axes[(k + rotation) % axes.len()]))
1831 .collect();
1832 sort_by_variance_share_desc(&mut rotated);
1833 orders.push(rotated.iter().map(|s| s.axis).collect::<Vec<_>>());
1834 }
1835 for w in orders.windows(2) {
1836 assert_eq!(
1837 w[0], w[1],
1838 "every rotation of a full tie must sort identically"
1839 );
1840 }
1841 }
1842
1843 /// Would a test notice two sources' contributions transposed? Verified against
1844 /// INDEPENDENTLY computed quantities (direct `central_difference` calls made in this test,
1845 /// not `error_budget`'s own internal numbers) for two axes with genuinely different
1846 /// sensitivities, rather than only checking self-consistency.
1847 #[test]
1848 fn two_sources_contributions_are_not_transposed() {
1849 let r = resolved();
1850 let mv_sigma = 5.0_f64;
1851 let ws_sigma = 1.0_f64;
1852 let rep = error_budget(
1853 &r,
1854 &[
1855 (InputAxis::MuzzleVelocityMps, mv_sigma),
1856 (InputAxis::WindSpeed, ws_sigma),
1857 ],
1858 &[600.0],
1859 )
1860 .unwrap();
1861
1862 let mv_deriv =
1863 central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None).unwrap()[0];
1864 let ws_deriv = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap()[0];
1865
1866 let mv_var =
1867 (mv_deriv.d_drop_d_x * mv_sigma).powi(2) + (mv_deriv.d_windage_d_x * mv_sigma).powi(2);
1868 let ws_var =
1869 (ws_deriv.d_drop_d_x * ws_sigma).powi(2) + (ws_deriv.d_windage_d_x * ws_sigma).powi(2);
1870 let independent_total = mv_var + ws_var;
1871
1872 let mv_row = rep.rows[0]
1873 .sources
1874 .iter()
1875 .find(|s| s.axis == InputAxis::MuzzleVelocityMps)
1876 .unwrap();
1877 let ws_row = rep.rows[0]
1878 .sources
1879 .iter()
1880 .find(|s| s.axis == InputAxis::WindSpeed)
1881 .unwrap();
1882
1883 // Raw derivatives and sigma must match the independently-computed kernel call exactly --
1884 // this is what a transposition (assigning WindSpeed's numbers to the MuzzleVelocityMps
1885 // row or vice versa) would break.
1886 assert_eq!(mv_row.d_drop_d_x, mv_deriv.d_drop_d_x);
1887 assert_eq!(mv_row.d_windage_d_x, mv_deriv.d_windage_d_x);
1888 assert_eq!(mv_row.sigma, mv_sigma);
1889 assert_eq!(ws_row.d_drop_d_x, ws_deriv.d_drop_d_x);
1890 assert_eq!(ws_row.d_windage_d_x, ws_deriv.d_windage_d_x);
1891 assert_eq!(ws_row.sigma, ws_sigma);
1892
1893 // variance_share compared against a total computed OUTSIDE error_budget entirely.
1894 assert!((mv_row.variance_share - mv_var / independent_total).abs() < 1e-9);
1895 assert!((ws_row.variance_share - ws_var / independent_total).abs() < 1e-9);
1896
1897 // Sanity: the two shares are not (nearly) equal, or a transposition would be invisible
1898 // to the assertions above.
1899 assert!(
1900 (mv_row.variance_share - ws_row.variance_share).abs() > 0.05,
1901 "fixture must give the two sources distinguishably different shares: mv={}, ws={}",
1902 mv_row.variance_share,
1903 ws_row.variance_share
1904 );
1905 }
1906
1907 /// A second independent check that shares sum to one, using a total computed OUTSIDE
1908 /// `error_budget`'s own arithmetic (three sources this time, not two, and reusing the
1909 /// independent per-source variances rather than re-deriving `error_budget`'s own `total_var`
1910 /// field).
1911 #[test]
1912 fn variance_shares_sum_to_one_against_an_independently_recomputed_total() {
1913 let r = resolved();
1914 let declared = [
1915 (InputAxis::MuzzleVelocityMps, 5.0),
1916 (InputAxis::WindSpeed, 1.0),
1917 (InputAxis::BallisticCoefficient, 0.005),
1918 ];
1919 let rep = error_budget(&r, &declared, &[600.0]).unwrap();
1920
1921 let mut independent_total = 0.0;
1922 let mut independent_var = std::collections::HashMap::new();
1923 for &(axis, sigma) in &declared {
1924 let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
1925 let v = (d.d_drop_d_x * sigma).powi(2) + (d.d_windage_d_x * sigma).powi(2);
1926 independent_total += v;
1927 independent_var.insert(axis, v);
1928 }
1929
1930 let mut share_sum = 0.0;
1931 for s in &rep.rows[0].sources {
1932 let expected_share = independent_var[&s.axis] / independent_total;
1933 assert!(
1934 (s.variance_share - expected_share).abs() < 1e-9,
1935 "{:?}: report said {}, independently expected {}",
1936 s.axis,
1937 s.variance_share,
1938 expected_share
1939 );
1940 share_sum += s.variance_share;
1941 }
1942 assert!(
1943 (share_sum - 1.0).abs() < 1e-9,
1944 "shares summed to {share_sum}"
1945 );
1946 }
1947
1948 /// (I6, review round) FOUR previously-unasserted public payload fields --
1949 /// `sigma_drop_m`, `sigma_windage_m`, `covariance_m2`, and `ellipse_95.rotation_rad` -- each
1950 /// checked against an INDEPENDENTLY computed value, never against `error_budget`'s own
1951 /// internal `Symmetric2`/`accumulate` bookkeeping. Each of these mutations passes all of this
1952 /// module's OTHER tests: swapping `sigma_drop_m`/`sigma_windage_m`; forcing `rotation_rad` to
1953 /// `0.0` unconditionally; forcing `covariance_m2` to `0.0` unconditionally. (The fifth
1954 /// previously-unasserted field, `ellipse_area_reduction_m2`, is covered by the next test,
1955 /// which needs 3+ sources to be discriminating at all -- see that field's own doc comment.)
1956 ///
1957 /// `Cant` is declared at a NONZERO baseline (`cant_angle_rad: 0.5`, ~29 degrees) specifically
1958 /// so its derivative has comparable, clearly nonzero components in BOTH drop and windage: at
1959 /// a baseline cant of exactly zero, canting the rifle by an infinitesimal `dtheta` rotates a
1960 /// purely-vertical drop vector into windage to FIRST order while changing its own magnitude
1961 /// only to SECOND order (`d(drop)/d(cant) ~ 0`, `d(windage)/d(cant) ~ drop`), which would
1962 /// make the covariance term a floating-point-noise-sized artifact rather than a robustly
1963 /// nonzero cross term -- at a nonzero baseline, both `sin(cant)` and `cos(cant)` are
1964 /// appreciable, giving `Cant` a genuinely mixed drop/windage sensitivity.
1965 #[test]
1966 fn sigma_covariance_and_rotation_are_verified_independently_not_against_themselves() {
1967 let json = serde_json::json!({
1968 "schema_version": 1,
1969 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1970 "ballistic_coefficient": 0.243},
1971 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1972 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "cant_angle_rad": 0.5},
1973 "atmosphere": {},
1974 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
1975 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
1976 })
1977 .to_string();
1978 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1979 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1980
1981 let declared = [
1982 (InputAxis::MuzzleVelocityMps, 5.0),
1983 (InputAxis::WindSpeed, 1.0),
1984 (InputAxis::Cant, 0.01),
1985 ];
1986 let rep = error_budget(&r, &declared, &[600.0]).unwrap();
1987
1988 // Independent oracle: direct central_difference calls, summed by hand right here -- not
1989 // error_budget's own accumulate()/Symmetric2 code path.
1990 let mut var_drop = 0.0_f64;
1991 let mut var_wind = 0.0_f64;
1992 let mut cov = 0.0_f64;
1993 for &(axis, sigma) in &declared {
1994 let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
1995 let s2 = sigma * sigma;
1996 var_drop += d.d_drop_d_x * d.d_drop_d_x * s2;
1997 var_wind += d.d_windage_d_x * d.d_windage_d_x * s2;
1998 cov += d.d_drop_d_x * d.d_windage_d_x * s2;
1999 }
2000
2001 let row = &rep.rows[0];
2002
2003 // sigma_drop_m / sigma_windage_m: independently computed AND distinguishably different
2004 // (measured ~0.045 vs ~0.217 for this fixture), so a swap between them is not invisible.
2005 assert!(
2006 (row.sigma_drop_m - var_drop.sqrt()).abs() < 1e-9,
2007 "sigma_drop_m: got {}, expected {}",
2008 row.sigma_drop_m,
2009 var_drop.sqrt()
2010 );
2011 assert!(
2012 (row.sigma_windage_m - var_wind.sqrt()).abs() < 1e-9,
2013 "sigma_windage_m: got {}, expected {}",
2014 row.sigma_windage_m,
2015 var_wind.sqrt()
2016 );
2017 assert!(
2018 (row.sigma_drop_m - row.sigma_windage_m).abs()
2019 > 0.1 * row.sigma_drop_m.max(row.sigma_windage_m),
2020 "fixture must give sigma_drop_m and sigma_windage_m distinguishably different \
2021 values, or a swap between them would be invisible here: drop={}, wind={}",
2022 row.sigma_drop_m,
2023 row.sigma_windage_m
2024 );
2025
2026 // covariance_m2: independently computed, and clearly nonzero (measured ~ -1.5e-4, about
2027 // 7.7% of var_drop -- not a floating-point-noise-sized artifact).
2028 assert!(
2029 (row.covariance_m2 - cov).abs() < 1e-9 * cov.abs().max(1.0),
2030 "covariance_m2: got {}, expected {}",
2031 row.covariance_m2,
2032 cov
2033 );
2034 assert!(
2035 cov.abs() > 1e-5,
2036 "fixture must give a clearly nonzero covariance: {cov}"
2037 );
2038
2039 // rotation_rad: verified via the DEFINITION of the major-axis angle (a Rayleigh-quotient
2040 // check), not by re-deriving the same atan2 formula error_budget itself uses to compute
2041 // it -- the variance PROJECTED along the direction rotation_rad points must equal the
2042 // ellipse's own major-axis eigenvalue (semi_major_m^2 / CHI2_95_2DOF). Forcing
2043 // rotation_rad to 0.0 would project onto the drop axis instead, giving var_drop
2044 // (~0.002) rather than the true lambda_max (~0.047 for this fixture) -- clearly caught.
2045 let (s, c) = row.ellipse_95.rotation_rad.sin_cos();
2046 let var_along_major = c * c * var_drop + 2.0 * c * s * cov + s * s * var_wind;
2047 let lambda_max = row.ellipse_95.semi_major_m.powi(2) / CHI2_95_2DOF;
2048 assert!(
2049 (var_along_major - lambda_max).abs() < 1e-9 * lambda_max.max(1.0),
2050 "rotation_rad ({}) does not point along the major axis: variance projected along it \
2051 is {}, but the major-axis eigenvalue is {}",
2052 row.ellipse_95.rotation_rad,
2053 var_along_major,
2054 lambda_max
2055 );
2056 assert_ne!(
2057 row.ellipse_95.rotation_rad, 0.0,
2058 "fixture must give a genuinely nonzero rotation"
2059 );
2060 }
2061
2062 /// (I7, review round) `ellipse_area_reduction_m2` is DEGENERATE with two or fewer declared
2063 /// sources (documented on the field: removing either of exactly two leaves a rank-1
2064 /// covariance, area exactly zero, so BOTH report the full ellipse area as their own
2065 /// reduction even at a lopsided variance split). With three or more sources it is
2066 /// generically discriminating; this proves that, and checks every source's value against an
2067 /// INDEPENDENT oracle that does not call `Symmetric2`/`ellipse_95` at all (a hand-rolled
2068 /// trace/determinant formula -- the same shape `monte_carlo::calculate_confidence_ellipse`
2069 /// uses, deliberately a DIFFERENT numerical path than this module's own).
2070 #[test]
2071 fn ellipse_area_reduction_is_nonzero_and_discriminating_with_three_or_more_sources() {
2072 fn independent_ellipse_area(var_drop: f64, var_wind: f64, cov: f64) -> f64 {
2073 let trace = var_drop + var_wind;
2074 let det = (var_drop * var_wind - cov * cov).max(0.0);
2075 let disc = ((trace * trace / 4.0) - det).max(0.0).sqrt();
2076 let l1 = (trace / 2.0 + disc).max(0.0);
2077 let l2 = (trace / 2.0 - disc).max(0.0);
2078 std::f64::consts::PI * (CHI2_95_2DOF * l1).sqrt() * (CHI2_95_2DOF * l2).sqrt()
2079 }
2080
2081 let r = resolved();
2082 // One dominant source (MV) plus two much smaller ones -- a realistic "mostly one input
2083 // matters" declaration, exactly the shape I7 warns reads as a false tie under the
2084 // two-source degeneracy above.
2085 let declared = [
2086 (InputAxis::MuzzleVelocityMps, 5.0),
2087 (InputAxis::WindSpeed, 0.3),
2088 (InputAxis::BallisticCoefficient, 0.001),
2089 ];
2090 let rep = error_budget(&r, &declared, &[600.0]).unwrap();
2091 assert_eq!(rep.rows[0].sources.len(), 3);
2092
2093 let mut derivs = std::collections::HashMap::new();
2094 for &(axis, sigma) in &declared {
2095 let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
2096 derivs.insert(axis, (sigma, d.d_drop_d_x, d.d_windage_d_x));
2097 }
2098 let variance_excluding = |exclude: Option<InputAxis>| -> (f64, f64, f64) {
2099 let mut vd = 0.0;
2100 let mut vw = 0.0;
2101 let mut cv = 0.0;
2102 for (&axis, &(sigma, dd, dw)) in &derivs {
2103 if Some(axis) == exclude {
2104 continue;
2105 }
2106 let s2 = sigma * sigma;
2107 vd += dd * dd * s2;
2108 vw += dw * dw * s2;
2109 cv += dd * dw * s2;
2110 }
2111 (vd, vw, cv)
2112 };
2113 let (fvd, fvw, fcv) = variance_excluding(None);
2114 let full_area = independent_ellipse_area(fvd, fvw, fcv);
2115
2116 let mut reductions = Vec::new();
2117 for s in &rep.rows[0].sources {
2118 let (vd, vw, cv) = variance_excluding(Some(s.axis));
2119 let reduced_area = independent_ellipse_area(vd, vw, cv);
2120 let expected_reduction = (full_area - reduced_area).max(0.0);
2121 assert!(
2122 (s.ellipse_area_reduction_m2 - expected_reduction).abs()
2123 < 1e-9 * expected_reduction.max(1.0),
2124 "{:?}: got {}, independently expected {}",
2125 s.axis,
2126 s.ellipse_area_reduction_m2,
2127 expected_reduction
2128 );
2129 reductions.push((s.axis, s.ellipse_area_reduction_m2));
2130 }
2131
2132 // Discriminating, not a "0.0 unconditionally" mutation (which would fail the exact
2133 // check above already) nor a same-for-everyone tie (the I7 degeneracy, which no longer
2134 // applies with 3+ sources).
2135 assert!(
2136 reductions.iter().all(|&(_, red)| red > 0.0),
2137 "every reduction should be positive with 3+ sources: {reductions:?}"
2138 );
2139 let first = reductions[0].1;
2140 assert!(
2141 reductions
2142 .iter()
2143 .any(|&(_, red)| (red - first).abs() > 1e-6 * first.max(1.0)),
2144 "reductions must discriminate between sources with 3+ declared, not all be equal: \
2145 {reductions:?}"
2146 );
2147 }
2148
2149 /// (4) Jacobian reuse across ranges: each row must carry its OWN correctly-indexed
2150 /// derivative (checked against an independent `central_difference` call at that same single
2151 /// range), and different ranges must give genuinely different numbers -- catching a bug that
2152 /// indexed every row from row 0's derivative instead of `derivatives[i]`.
2153 #[test]
2154 fn error_budget_computes_a_correctly_indexed_row_per_requested_range() {
2155 let r = resolved();
2156 let ranges = [300.0_f64, 600.0_f64, 850.0_f64];
2157 let rep = error_budget(&r, &[(InputAxis::MuzzleVelocityMps, 5.0)], &ranges).unwrap();
2158 assert_eq!(rep.rows.len(), ranges.len());
2159
2160 for (i, &range_m) in ranges.iter().enumerate() {
2161 assert_eq!(rep.rows[i].range_m, range_m);
2162 let expected =
2163 central_difference(&r, InputAxis::MuzzleVelocityMps, &[range_m], None).unwrap()[0];
2164 let got = &rep.rows[i].sources[0];
2165 assert_eq!(got.d_drop_d_x, expected.d_drop_d_x, "range {range_m}");
2166 assert_eq!(got.d_windage_d_x, expected.d_windage_d_x, "range {range_m}");
2167 }
2168 // Sensitivity to muzzle velocity grows with range: row 2 must differ meaningfully from
2169 // row 0, or a bug that copied row 0 into every row would pass the per-row checks above
2170 // (they'd coincidentally still match central_difference at range 300 for EVERY row only
2171 // if 300 were queried each time -- it is not, so this also guards indexing directly).
2172 assert!(
2173 rep.rows[2].sources[0].d_drop_d_x.abs() > rep.rows[0].sources[0].d_drop_d_x.abs() * 2.0
2174 );
2175 }
2176
2177 /// Declaring a non-finite or negative sigma is rejected up front, before any solve -- it is
2178 /// not a real one-sigma uncertainty regardless of whether anything downstream would panic on
2179 /// it. (An earlier revision of this comment cited `variance_share`'s sort comparator
2180 /// panicking on NaN as the reason; that specific mechanism no longer applies now that the
2181 /// sort uses `f64::total_cmp` (review M1), but the input is still nonsensical and still
2182 /// rejected -- see `an_astronomically_large_sigma_does_not_panic_the_sort` below for the
2183 /// remaining, more extreme case `total_cmp` exists to handle instead of panicking on.)
2184 #[test]
2185 fn a_non_finite_or_negative_sigma_is_rejected() {
2186 let r = resolved();
2187 let nan = error_budget(&r, &[(InputAxis::WindSpeed, f64::NAN)], &[600.0]);
2188 assert!(matches!(
2189 nan,
2190 Err(KernelError::NonFinite(InputAxis::WindSpeed))
2191 ));
2192 let neg = error_budget(&r, &[(InputAxis::WindSpeed, -1.0)], &[600.0]);
2193 assert!(matches!(
2194 neg,
2195 Err(KernelError::NonFinite(InputAxis::WindSpeed))
2196 ));
2197 let inf = error_budget(&r, &[(InputAxis::WindSpeed, f64::INFINITY)], &[600.0]);
2198 assert!(matches!(
2199 inf,
2200 Err(KernelError::NonFinite(InputAxis::WindSpeed))
2201 ));
2202 }
2203
2204 /// (M1, review round) An astronomically large (but finite, non-negative -- so it PASSES the
2205 /// validation above) declared sigma can overflow `sigma * sigma` to infinity, making
2206 /// `variance_share` a NaN (`inf / inf`). `sort_by_variance_share_desc` must not panic on
2207 /// that: this crate ships a fuzz suite, and a library function panicking on an
2208 /// extreme-but-technically-valid input is a real failure mode.
2209 #[test]
2210 fn an_astronomically_large_sigma_does_not_panic_the_sort() {
2211 let r = resolved();
2212 let huge = 1e200_f64;
2213 assert!(
2214 huge.is_finite() && (huge * huge).is_infinite(),
2215 "fixture assumption: sigma^2 must overflow to infinity"
2216 );
2217 let rep = error_budget(
2218 &r,
2219 &[
2220 (InputAxis::MuzzleVelocityMps, huge),
2221 (InputAxis::WindSpeed, 1.0),
2222 ],
2223 &[600.0],
2224 )
2225 .unwrap();
2226 // Does not panic; the NaN share's exact placement by total_cmp is not physically
2227 // meaningful for this pathological input and is not asserted, only that the call
2228 // returns normally with every declared source still present.
2229 assert_eq!(rep.rows[0].sources.len(), 2);
2230 }
2231
2232 /// (I4, review round) The same axis declared twice must be rejected up front, not silently
2233 /// double-counted -- with duplicates, `accumulate(&entries, Some(axis))` (keyed on axis)
2234 /// would exclude BOTH entries when pricing "if this source were measured perfectly" for
2235 /// EITHER one, computing a counterfactual against a baseline where the caller's OTHER
2236 /// declaration of the same axis was ALSO perfected -- not what either individual declaration
2237 /// means.
2238 #[test]
2239 fn a_duplicate_axis_declaration_is_rejected() {
2240 let r = resolved();
2241 let e = error_budget(
2242 &r,
2243 &[(InputAxis::WindSpeed, 1.0), (InputAxis::WindSpeed, 2.0)],
2244 &[600.0],
2245 );
2246 assert!(matches!(
2247 e,
2248 Err(KernelError::DuplicateAxis(InputAxis::WindSpeed))
2249 ));
2250 }
2251
2252 /// (I4, continued) The duplicate check finds a repeat anywhere in the list, not just
2253 /// adjacent entries, and names the axis that was actually repeated.
2254 #[test]
2255 fn a_duplicate_axis_is_found_even_when_not_adjacent() {
2256 let r = resolved();
2257 let e = error_budget(
2258 &r,
2259 &[
2260 (InputAxis::MuzzleVelocityMps, 5.0),
2261 (InputAxis::BallisticCoefficient, 0.005),
2262 (InputAxis::MuzzleVelocityMps, 6.0),
2263 ],
2264 &[600.0],
2265 );
2266 assert!(matches!(
2267 e,
2268 Err(KernelError::DuplicateAxis(InputAxis::MuzzleVelocityMps))
2269 ));
2270 }
2271
2272 /// (I3, review round) A range beyond the BASE request's own `max_range_m` must be rejected
2273 /// directly as `Observation(OutOfRange)`, not silently converted into "every declared source
2274 /// is unavailable" -- without this check, `central_difference` would fail BOTH perturbed
2275 /// sides of EVERY axis identically on the same out-of-range observation
2276 /// (`StepOutOfDomain`), recording each one as unavailable with a reason blaming that axis's
2277 /// own step, when the real cause is the query itself and has nothing to do with any axis.
2278 #[test]
2279 fn a_range_beyond_max_range_m_is_rejected_directly_not_laundered_per_axis() {
2280 let r = resolved(); // max_range_m: 900.0
2281 let e = error_budget(
2282 &r,
2283 &[
2284 (InputAxis::MuzzleVelocityMps, 5.0),
2285 (InputAxis::WindSpeed, 1.0),
2286 ],
2287 &[600.0, 5000.0],
2288 );
2289 match e {
2290 Err(KernelError::Observation(TrajectoryObservationError::OutOfRange {
2291 requested_m,
2292 maximum_m,
2293 ..
2294 })) => {
2295 assert_eq!(requested_m, 5000.0);
2296 assert_eq!(maximum_m, 900.0);
2297 }
2298 other => panic!(
2299 "expected Err(KernelError::Observation(OutOfRange {{ .. }})) naming the \
2300 out-of-range query, got {other:?}"
2301 ),
2302 }
2303 }
2304
2305 /// (F1, 0.33.0 final-review fix wave) A request that terminates well short of its own
2306 /// declared `max_range_m` -- here, a steep downward `muzzle_angle_rad` that strikes the
2307 /// ground at roughly 17 m -- must be rejected the same way a range beyond `max_range_m`
2308 /// already is: an honest `Observation(OutOfRange)` naming the REAL computed trajectory
2309 /// extent, not a per-axis `StepOutOfDomain` report. Before this fix, the manual
2310 /// `range_m > base.shot.max_range_m` check up front let `900.0` through unchallenged (it IS
2311 /// `<= max_range_m`), so `central_difference` went on to fail BOTH perturbed sides of every
2312 /// declared source identically on the same real out-of-range observation, and
2313 /// `error_budget` returned `Ok(..)` with every source in `unavailable_sources` blaming its
2314 /// own differencing step -- a "successful" report with a fabricated per-axis explanation for
2315 /// what was actually just a query past where the bullet landed.
2316 #[test]
2317 fn a_range_within_max_range_m_but_beyond_the_actual_trajectory_is_rejected_with_the_real_extent(
2318 ) {
2319 let json = serde_json::json!({
2320 "schema_version": 1,
2321 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2322 "ballistic_coefficient": 0.243},
2323 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
2324 // Steeply downward and unzeroed: strikes the ground at roughly 17 m, far short of
2325 // the 900 m declared below.
2326 "shot": {"max_range_m": 900.0, "muzzle_angle_rad": -1.4},
2327 "atmosphere": {}, "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
2328 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
2329 })
2330 .to_string();
2331 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
2332 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
2333
2334 let e = error_budget(
2335 &r,
2336 &[
2337 (InputAxis::MuzzleVelocityMps, 5.0),
2338 (InputAxis::WindSpeed, 1.0),
2339 ],
2340 &[900.0], // == max_range_m, so the cheap declared-bound check alone lets it through
2341 );
2342 match e {
2343 Err(KernelError::Observation(
2344 inner @ TrajectoryObservationError::OutOfRange {
2345 requested_m,
2346 maximum_m,
2347 ..
2348 },
2349 )) => {
2350 assert_eq!(requested_m, 900.0);
2351 // Names the REAL trajectory extent (~17 m), not the declared max_range_m (900).
2352 assert!(
2353 maximum_m < 900.0 && maximum_m > 0.0,
2354 "expected the actual short trajectory's extent, got maximum_m = {maximum_m}"
2355 );
2356 let msg = inner.to_string();
2357 assert!(
2358 msg.contains("outside the computed trajectory"),
2359 "message did not name the computed trajectory: {msg}"
2360 );
2361 assert!(
2362 !msg.to_lowercase().contains("step"),
2363 "message must not blame a per-axis differencing step: {msg}"
2364 );
2365 }
2366 other => panic!(
2367 "expected Err(KernelError::Observation(OutOfRange {{ .. }})) naming the actual \
2368 computed trajectory extent -- got {other:?} (an Ok(..) here would mean every \
2369 declared source was laundered into a fabricated per-axis StepOutOfDomain \
2370 explanation instead)"
2371 ),
2372 }
2373 }
2374
2375 /// (I3, continued) A negative range is likewise rejected directly, matching
2376 /// `observation_at_range_checked`'s own `distance_m < minimum_m` check.
2377 #[test]
2378 fn a_negative_range_is_rejected_directly() {
2379 let r = resolved();
2380 let e = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[-1.0]);
2381 assert!(matches!(
2382 e,
2383 Err(KernelError::Observation(TrajectoryObservationError::OutOfRange {
2384 requested_m,
2385 ..
2386 })) if requested_m == -1.0
2387 ));
2388 }
2389
2390 /// (I1, review round) The `StepOutOfDomain` reason names the REAL trigger (the axis's own
2391 /// default differencing step) and does not blame the declared sigma, which has zero
2392 /// influence on whether this fires -- `error_budget` always requests `central_difference`'s
2393 /// default step, never a custom one.
2394 #[test]
2395 fn step_out_of_domain_reason_blames_the_default_step_not_the_sigma() {
2396 let e = KernelError::StepOutOfDomain {
2397 axis: InputAxis::RelativeHumidity,
2398 attempted: 2.0,
2399 };
2400 let (code, reason) = unavailable_reason(&e).unwrap();
2401 assert_eq!(code, UnavailableReasonCodeV1::StepOutOfDomain);
2402 // Names the real trigger (the axis's own default step)...
2403 assert!(reason.to_lowercase().contains("default step"), "{reason}");
2404 // ...and explicitly disclaims sigma as the cause, rather than implying sigma's
2405 // MAGNITUDE is what pushed the step out of domain (the false claim I1 found in this
2406 // module's top-of-file doc comment, now corrected there too). The word "sigma" appearing
2407 // at all is fine and expected here -- the message says "does not depend on the declared
2408 // sigma" precisely to rule that out -- so this checks for the wrong-causation PHRASING,
2409 // not for the word's mere presence.
2410 assert!(
2411 !reason.to_lowercase().contains("sigma larger"),
2412 "reason should not claim the declared sigma's SIZE caused this: {reason}"
2413 );
2414 assert!(
2415 reason
2416 .to_lowercase()
2417 .contains("does not depend on the declared sigma"),
2418 "{reason}"
2419 );
2420 }
2421
2422 /// Declaring zero sources is well-defined, not a panic or a spurious error.
2423 #[test]
2424 fn declaring_no_sources_is_well_defined() {
2425 let r = resolved();
2426 let rep = error_budget(&r, &[], &[600.0]).unwrap();
2427 assert!(rep.rows[0].sources.is_empty());
2428 assert!(rep.unavailable_sources.is_empty());
2429 assert_eq!(rep.rows[0].ellipse_95.area_m2, 0.0);
2430 assert!(rep.rows[0]
2431 .priority_statement
2432 .contains("No sources were declared"));
2433 }
2434
2435 /// The 95% ellipse for a single dominant source matches a hand-computable closed form: with
2436 /// only one source, the impact covariance is an exact rank-1 outer product
2437 /// `sigma^2 * [dd, dw] * [dd, dw]^T`, whose only nonzero eigenvalue is
2438 /// `sigma^2 * (dd^2 + dw^2)` -- an independent check on `Symmetric2::largest_smallest_eigenvalues`
2439 /// and `ellipse_95` together, not just on `error_budget`'s own bookkeeping.
2440 ///
2441 /// (Review I5): the minor axis and area are zero in EXACT arithmetic for a rank-1 covariance,
2442 /// but NOT bit-exactly in IEEE-754: `determinant = fl(dd^2 s^2 * dw^2 s^2) -
2443 /// fl((dd * dw * s^2)^2)` is two differently-rounded computations of the same mathematical
2444 /// product and can land a few ULP to either side of zero depending on `dd`/`dw`'s exact bit
2445 /// pattern (a catastrophic-cancellation difference of two near-equal drops, hence
2446 /// libm/compiler/platform sensitive) -- this file's own tests run on Darwin, CI's
2447 /// `ci.yml` runs on `ubuntu-latest`/glibc. A prior version of this test asserted bit-exact
2448 /// `== 0.0`, which is a genuine, unverified CI coin-flip, not a property this code actually
2449 /// guarantees (`largest_smallest_eigenvalues` clamps a NEGATIVE determinant to exactly zero,
2450 /// but a tiny POSITIVE one survives as a tiny nonzero `smallest`). Bound both RELATIVE to the
2451 /// major axis instead.
2452 #[test]
2453 fn single_source_ellipse_matches_the_closed_form_rank_one_case() {
2454 let r = resolved();
2455 let sigma = 1.0_f64;
2456 let rep = error_budget(&r, &[(InputAxis::WindSpeed, sigma)], &[600.0]).unwrap();
2457 let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap()[0];
2458
2459 let expected_largest = sigma * sigma * (d.d_drop_d_x.powi(2) + d.d_windage_d_x.powi(2));
2460 let expected_major = (CHI2_95_2DOF * expected_largest).sqrt();
2461 let ellipse = rep.rows[0].ellipse_95;
2462 assert!(
2463 ellipse.semi_minor_m <= 1e-9 * expected_major,
2464 "semi_minor_m should be negligible relative to the major axis for a rank-1 \
2465 covariance, got minor={}, major={}",
2466 ellipse.semi_minor_m,
2467 expected_major
2468 );
2469 assert!(
2470 ellipse.area_m2 <= 1e-9 * std::f64::consts::PI * expected_major * expected_major,
2471 "area_m2 should be negligible relative to a major-axis-radius circle for a rank-1 \
2472 covariance, got area={}, major={}",
2473 ellipse.area_m2,
2474 expected_major
2475 );
2476 assert!(
2477 (ellipse.semi_major_m - expected_major).abs() < 1e-9,
2478 "got {}, expected {}",
2479 ellipse.semi_major_m,
2480 expected_major
2481 );
2482 }
2483
2484 /// These are wire-shaped `V1` report types (matching the crate's `solve_json` convention):
2485 /// the honesty requirement's payload claims are only real if they actually survive a JSON
2486 /// round trip, not merely present on the in-memory Rust struct. Also pins the wire spelling
2487 /// of `DifferenceScheme` (snake_case, matching `InputAxis`/`InputGroup`'s existing
2488 /// convention) and of an unavailable source, since both are newly serializable additions
2489 /// this task made to the kernel's public types.
2490 #[test]
2491 fn the_report_round_trips_through_json_including_unavailable_sources_and_scheme() {
2492 let r = qnh_resolved();
2493 let rep = error_budget(
2494 &r,
2495 &[
2496 (InputAxis::MuzzleVelocityMps, 5.0),
2497 (InputAxis::Altitude, 50.0),
2498 ],
2499 &[300.0],
2500 )
2501 .unwrap();
2502
2503 let json = serde_json::to_string(&rep).expect("report must serialize");
2504 assert!(json.contains("\"unavailable_sources\""));
2505 assert!(
2506 json.contains("\"scheme\":\"central\"") || json.contains("\"scheme\": \"central\""),
2507 "DifferenceScheme must serialize snake_case, got: {json}"
2508 );
2509
2510 let round_tripped: ErrorBudgetReportV1 =
2511 serde_json::from_str(&json).expect("report must deserialize back");
2512 assert_eq!(round_tripped, rep);
2513 }
2514
2515 // ---- Task 11 (MBA-1347) Step 1 tests, verbatim from the brief.
2516
2517 /// With zero correlation the rectangle probability separates, so we can check the
2518 /// quadrature against the closed-form product of two normal CDFs.
2519 #[test]
2520 fn uncorrelated_rectangle_matches_the_separable_closed_form() {
2521 use crate::special::normal_cdf;
2522 let (sd, sw) = (0.10_f64, 0.20_f64);
2523 let (w, h) = (0.30_f64, 0.40_f64);
2524 let want = (normal_cdf(h / 2.0 / sd) - normal_cdf(-h / 2.0 / sd))
2525 * (normal_cdf(w / 2.0 / sw) - normal_cdf(-w / 2.0 / sw));
2526 let got = p_hit_bivariate(
2527 sd * sd,
2528 sw * sw,
2529 0.0,
2530 TargetGeometryV1::Rect {
2531 width_m: w,
2532 height_m: h,
2533 },
2534 );
2535 assert!((got - want).abs() < 1e-6, "got {got} want {want}");
2536 }
2537
2538 #[test]
2539 fn p_hit_is_bounded_and_grows_with_target_size() {
2540 let small = p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Circle { radius_m: 0.1 });
2541 let big = p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Circle { radius_m: 0.5 });
2542 assert!((0.0..=1.0).contains(&small) && (0.0..=1.0).contains(&big));
2543 assert!(big > small);
2544 }
2545
2546 /// (Review, round 2 -- F2) There was no independent-accuracy oracle anywhere in this module
2547 /// for a circle with both variances positive -- every existing circle test either uses
2548 /// `p_hit_bivariate` as its own oracle, or (the degenerate-covariance tests) only ever
2549 /// evaluates the chord at `u = 0`, where `sqrt(r^2 - 0^2) == r` makes a bare `r` and the real
2550 /// chord formula indistinguishable. This closes that gap with the exact closed form for an
2551 /// isotropic, uncorrelated circular target: `(X, Y)` iid `N(0, sigma^2)` gives `R =
2552 /// sqrt(X^2+Y^2)` a Rayleigh distribution, `P(R <= r) = 1 - exp(-r^2 / (2*sigma^2))`.
2553 /// Measured agreement ~2e-5 (the quadrature's own residual near the chord's endpoint
2554 /// singularity -- see `p_hit_bivariate`'s doc); replacing the chord `sqrt(r^2 - u^2)` with a
2555 /// bare `r` (i.e. silently treating the circle as its own bounding square) is wrong by
2556 /// ~7.3e-2 on the first case below, three orders of magnitude past this test's tolerance.
2557 #[test]
2558 fn circle_matches_the_rayleigh_closed_form_when_uncorrelated_and_isotropic() {
2559 for (sigma, r) in [(0.1_f64, 0.1_f64), (0.1, 0.15), (1.0, 1.2)] {
2560 let got = p_hit_bivariate(
2561 sigma * sigma,
2562 sigma * sigma,
2563 0.0,
2564 TargetGeometryV1::Circle { radius_m: r },
2565 );
2566 let want = 1.0 - (-(r * r) / (2.0 * sigma * sigma)).exp();
2567 assert!(
2568 (got - want).abs() < 1e-4,
2569 "sigma={sigma} r={r}: got={got} want={want} diff={}",
2570 (got - want).abs()
2571 );
2572 }
2573 }
2574
2575 /// (Review, round 2 -- F2) A circle centred at the origin is rotationally symmetric, so its
2576 /// hit probability can only depend on the covariance's EIGENVALUES, not on which
2577 /// (drop, windage) basis it happens to be expressed in: `p_hit_bivariate(vd, vw, cov, Circle)`
2578 /// must equal `p_hit_bivariate(lambda1, lambda2, 0.0, Circle)`. This pins the chord formula
2579 /// AND the correlated (correlation-split) code path at once -- a bare-`r` chord mutation
2580 /// breaks rotational invariance directly, since it silently treats the circle as an
2581 /// axis-aligned square, which is NOT rotation invariant (measured diff up to ~1.1e-2 under
2582 /// that mutation, against ~2e-5 for the real chord). Reuses `Symmetric2`'s own
2583 /// already-tested eigenvalue arithmetic rather than re-deriving it by hand here.
2584 #[test]
2585 fn circle_probability_is_rotation_invariant() {
2586 for (vd, vw, cov, r) in [
2587 (0.02_f64, 0.05_f64, 0.015_f64, 0.15_f64),
2588 (0.3, 0.1, -0.12, 0.4),
2589 ] {
2590 let (l1, l2) = Symmetric2 {
2591 a00: vd,
2592 a01: cov,
2593 a11: vw,
2594 }
2595 .largest_smallest_eigenvalues();
2596 let direct = p_hit_bivariate(vd, vw, cov, TargetGeometryV1::Circle { radius_m: r });
2597 let rotated = p_hit_bivariate(l1, l2, 0.0, TargetGeometryV1::Circle { radius_m: r });
2598 assert!(
2599 (direct - rotated).abs() < 1e-4,
2600 "vd={vd} vw={vw} cov={cov} r={r}: direct={direct} rotated={rotated} diff={}",
2601 (direct - rotated).abs()
2602 );
2603 }
2604 }
2605
2606 /// Perfecting a source can never reduce hit probability.
2607 #[test]
2608 fn perfecting_a_source_never_lowers_p_hit() {
2609 let r = resolved();
2610 let rep = error_budget_with_target(
2611 &r,
2612 &[
2613 (InputAxis::MuzzleVelocityMps, 5.0),
2614 (InputAxis::WindSpeed, 1.5),
2615 ],
2616 &[600.0],
2617 Some(TargetGeometryV1::Rect {
2618 width_m: 0.5,
2619 height_m: 0.75,
2620 }),
2621 )
2622 .unwrap();
2623 for s in &rep.rows[0].sources {
2624 let gain = s.p_hit_gain_if_perfect.expect("target supplied");
2625 assert!(
2626 gain >= -1e-9,
2627 "{:?} reported a negative gain {gain}",
2628 s.axis
2629 );
2630 }
2631 }
2632
2633 /// (Review, round 2) `p_hit_gain_if_perfect` does NOT share `ellipse_area_reduction_m2`'s
2634 /// two-source degeneracy -- an earlier revision of this field's own doc comment claimed it
2635 /// did, which was simply wrong (see the corrected doc comment). Same exact fixture as
2636 /// `perfecting_a_source_never_lowers_p_hit` immediately above (the flagship
2637 /// `MuzzleVelocityMps` + `WindSpeed` pair), but checked against an INDEPENDENT oracle (direct
2638 /// `central_difference` calls, summed by hand) and required to discriminate by orders of
2639 /// magnitude, not merely "not exactly equal."
2640 #[test]
2641 fn p_hit_gain_if_perfect_discriminates_with_only_two_sources() {
2642 let r = resolved();
2643 let declared = [
2644 (InputAxis::MuzzleVelocityMps, 5.0),
2645 (InputAxis::WindSpeed, 1.5),
2646 ];
2647 let target = TargetGeometryV1::Rect {
2648 width_m: 0.5,
2649 height_m: 0.75,
2650 };
2651 let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
2652 let row = &rep.rows[0];
2653 assert_eq!(row.sources.len(), 2);
2654 let base_p_hit = row.p_hit.expect("target supplied");
2655
2656 let mut derivs = std::collections::HashMap::new();
2657 for &(axis, sigma) in &declared {
2658 let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
2659 derivs.insert(axis, (sigma, d.d_drop_d_x, d.d_windage_d_x));
2660 }
2661 let variance_excluding = |exclude: InputAxis| -> (f64, f64, f64) {
2662 let mut vd = 0.0;
2663 let mut vw = 0.0;
2664 let mut cv = 0.0;
2665 for (&axis, &(sigma, dd, dw)) in &derivs {
2666 if axis == exclude {
2667 continue;
2668 }
2669 let s2 = sigma * sigma;
2670 vd += dd * dd * s2;
2671 vw += dw * dw * s2;
2672 cv += dd * dw * s2;
2673 }
2674 (vd, vw, cv)
2675 };
2676
2677 let mut gains = std::collections::HashMap::new();
2678 for s in &row.sources {
2679 let (vd, vw, cv) = variance_excluding(s.axis);
2680 let expected = (p_hit_bivariate(vd, vw, cv, target) - base_p_hit).max(0.0);
2681 let got = s.p_hit_gain_if_perfect.expect("target supplied");
2682 assert!(
2683 (got - expected).abs() < 1e-9,
2684 "{:?}: got {got}, independently expected {expected}",
2685 s.axis
2686 );
2687 gains.insert(s.axis, got);
2688 }
2689
2690 let gain_ws = gains[&InputAxis::WindSpeed];
2691 let gain_mv = gains[&InputAxis::MuzzleVelocityMps];
2692 // Measured: ~0.4416 (WindSpeed) vs ~0.00008 (MuzzleVelocityMps) -- three and a half
2693 // orders of magnitude apart, not the tie ellipse_area_reduction_m2 would show here.
2694 assert!(
2695 (gain_ws - gain_mv).abs() > 0.05,
2696 "expected the two gains to discriminate sharply at n=2, got WindSpeed={gain_ws} \
2697 MuzzleVelocityMps={gain_mv}"
2698 );
2699 }
2700
2701 // ---- Task 11: beyond the brief. The brief pins the math and gives exactly the three tests
2702 // above; the task additionally names four correctness requirements (bounded+monotone,
2703 // gain-never-negative, degenerate-covariance-no-NaN, unavailable-distinguishable-from-zero)
2704 // and requires the quadrature itself to be checked against an independent oracle at zero
2705 // correlation AND shown to differ materially from the (wrong) separable approximation once
2706 // correlated. Every new public field also gets at least one assertion that would fail if
2707 // that field were replaced by a constant or a copy of a neighbouring field -- see the task
2708 // report for the full field-to-test mapping.
2709
2710 /// Requirement: the quadrature must actually be doing something, not silently degenerating
2711 /// to the (wrong) separable approximation once the two axes are correlated. `rho = 0.9` on
2712 /// the SAME (sd, sw, w, h) as the zero-correlation test above -- measured difference from the
2713 /// wrong separable product is ~0.025, comfortably over the 0.01 threshold asserted here.
2714 #[test]
2715 fn a_strongly_correlated_case_differs_materially_from_the_wrong_separable_approximation() {
2716 let (sd, sw) = (0.10_f64, 0.20_f64);
2717 let (w, h) = (0.30_f64, 0.40_f64);
2718 let rho = 0.9_f64;
2719 let cov = rho * sd * sw;
2720 let got = p_hit_bivariate(
2721 sd * sd,
2722 sw * sw,
2723 cov,
2724 TargetGeometryV1::Rect {
2725 width_m: w,
2726 height_m: h,
2727 },
2728 );
2729 let wrong_separable = (normal_cdf(h / 2.0 / sd) - normal_cdf(-h / 2.0 / sd))
2730 * (normal_cdf(w / 2.0 / sw) - normal_cdf(-w / 2.0 / sw));
2731 assert!((0.0..=1.0).contains(&got), "got={got} out of bounds");
2732 assert!(
2733 (got - wrong_separable).abs() > 0.01,
2734 "a correlated case (rho={rho}) should differ materially from the separable \
2735 approximation, proving the conditional decomposition changes the answer: got={got} \
2736 wrong_separable={wrong_separable} diff={}",
2737 (got - wrong_separable).abs()
2738 );
2739 }
2740
2741 /// (Review, round 2 -- F1) Proves the correlation-crossing panel split is load-bearing, not
2742 /// merely accuracy polish: this is exactly the realistic single-source rank-1 covariance
2743 /// shape documented on `p_hit_bivariate` (`sigma_drop = 18.5`, `sigma_windage = 6.0`, `rho`
2744 /// clamped to `-0.999_999`), at the specific target size (height 20x sigma_drop, width 1x
2745 /// sigma_windage) measured to be off by 0.275 WITHOUT the split.
2746 ///
2747 /// Independent oracle: as `|rho| -> 1`, windage collapses onto the deterministic line
2748 /// `windage = k * drop` (`k = rho * sigma_windage / sigma_drop`), so hitting the rectangle
2749 /// reduces to a ONE-DIMENSIONAL question about drop alone: `|drop| <= half_height` AND
2750 /// `|k * drop| <= half_width`, i.e. `|drop| <= min(half_height, half_width / |k|)`, giving
2751 /// `P = 2 * Phi(min(half_height, half_width / |k|) / sigma_drop) - 1`. This is a LIMIT (exact
2752 /// only at `rho = +-1` exactly, not the clamped `0.999_999`), so it is checked to a loose
2753 /// (2e-3) tolerance, not machine precision -- still two orders of magnitude tighter than the
2754 /// 0.275 error the mutation below produces.
2755 #[test]
2756 fn correlation_crossing_split_matches_the_near_degenerate_line_limit() {
2757 let (dd, dw, sigma) = (3.7_f64, -1.2_f64, 5.0_f64);
2758 let var_drop = (dd * sigma).powi(2);
2759 let var_wind = (dw * sigma).powi(2);
2760 let cov = dd * dw * sigma * sigma;
2761 let sd = var_drop.sqrt();
2762 let sw = var_wind.sqrt();
2763 let target = TargetGeometryV1::Rect {
2764 width_m: sw,
2765 height_m: 20.0 * sd,
2766 };
2767
2768 let got = p_hit_bivariate(var_drop, var_wind, cov, target);
2769
2770 let rho = (cov / (sd * sw)).clamp(-0.999_999, 0.999_999);
2771 let k = rho * (sw / sd);
2772 let half_h = 10.0 * sd;
2773 let half_w = sw / 2.0;
2774 let oracle = 2.0 * normal_cdf(half_h.min(half_w / k.abs()) / sd) - 1.0;
2775
2776 assert!(
2777 (got - oracle).abs() < 2e-3,
2778 "got={got} oracle={oracle} diff={}",
2779 (got - oracle).abs()
2780 );
2781 }
2782
2783 /// Requirement 1 (bounded + monotone), rectangle variant -- the brief's own pinned test only
2784 /// covers the circle branch.
2785 #[test]
2786 fn p_hit_grows_with_target_size_for_a_rectangle_too() {
2787 let small = p_hit_bivariate(
2788 0.01,
2789 0.01,
2790 0.0,
2791 TargetGeometryV1::Rect {
2792 width_m: 0.1,
2793 height_m: 0.1,
2794 },
2795 );
2796 let big = p_hit_bivariate(
2797 0.01,
2798 0.01,
2799 0.0,
2800 TargetGeometryV1::Rect {
2801 width_m: 0.5,
2802 height_m: 0.5,
2803 },
2804 );
2805 assert!((0.0..=1.0).contains(&small) && (0.0..=1.0).contains(&big));
2806 assert!(big > small, "small={small} big={big}");
2807 // Also pins width_m/height_m against a transposition: growing ONLY the width, or ONLY
2808 // the height, must each independently grow p_hit.
2809 let wider = p_hit_bivariate(
2810 0.01,
2811 0.01,
2812 0.0,
2813 TargetGeometryV1::Rect {
2814 width_m: 0.5,
2815 height_m: 0.1,
2816 },
2817 );
2818 let taller = p_hit_bivariate(
2819 0.01,
2820 0.01,
2821 0.0,
2822 TargetGeometryV1::Rect {
2823 width_m: 0.1,
2824 height_m: 0.5,
2825 },
2826 );
2827 assert!(wider > small, "wider={wider} small={small}");
2828 assert!(taller > small, "taller={taller} small={small}");
2829 }
2830
2831 /// (Review, round 2 -- F4) The two monotonicity tests above check only 2-4 hand-picked
2832 /// points, all at `rho = 0` with isotropic sigma -- exactly the regime where the quadrature
2833 /// is simplest (a single panel, no correlation-crossing split) and least likely to expose a
2834 /// real non-monotonicity. Sweeps 20 log-spaced target sizes (both shapes) at `rho` in `{0.0,
2835 /// 0.9, 0.999_999}`, the same three correlations the accuracy tests elsewhere in this module
2836 /// exercise, so the near-rank-1 regime where panel boundaries move the most as the target
2837 /// grows is actually covered.
2838 ///
2839 /// The scale range (0.05x to 3x a baseline target size) is deliberately capped short of deep
2840 /// saturation (`p_hit` within a few `1e-8` of `1.0`): a target many sigma across on every
2841 /// axis genuinely can, and empirically does on this exact sweep at `rho = 0.999_999`, produce
2842 /// a sub-`1e-8` NON-monotone wiggle -- confirmed by extending this sweep's range during
2843 /// development and observing exactly that, which is why `p_hit_bivariate`'s own doc comment
2844 /// states monotonicity as a property of the true integral verified by test over a range, not
2845 /// a guarantee of the floating-point implementation at every conceivable input. The `1e-9`
2846 /// tolerance below is chosen to comfortably clear ordinary quadrature rounding while still
2847 /// catching a real regression (a transposition or dropped term would violate monotonicity by
2848 /// orders of magnitude more than `1e-9`, as the other tests in this module demonstrate).
2849 #[test]
2850 fn p_hit_is_monotone_in_target_size_across_a_sweep_including_near_rank_one_correlation() {
2851 let (sd, sw) = (0.1_f64, 0.2_f64);
2852 let (w0, h0) = (0.3_f64, 0.4_f64);
2853 let r0 = 0.25_f64;
2854 // 20 log-spaced scale factors from 0.05 to 3.0.
2855 let scales: Vec<f64> = (0..20)
2856 .map(|i| 0.05_f64 * 60.0_f64.powf(i as f64 / 19.0))
2857 .collect();
2858
2859 for &rho in &[0.0_f64, 0.9, 0.999_999] {
2860 let cov = rho * sd * sw;
2861
2862 let rect_vals: Vec<f64> = scales
2863 .iter()
2864 .map(|&s| {
2865 p_hit_bivariate(
2866 sd * sd,
2867 sw * sw,
2868 cov,
2869 TargetGeometryV1::Rect {
2870 width_m: s * w0,
2871 height_m: s * h0,
2872 },
2873 )
2874 })
2875 .collect();
2876 for w in rect_vals.windows(2) {
2877 assert!(
2878 w[1] >= w[0] - 1e-9,
2879 "rho={rho}: rectangle p_hit decreased as target grew: {} -> {}",
2880 w[0],
2881 w[1]
2882 );
2883 }
2884 assert!(
2885 *rect_vals.last().unwrap() > *rect_vals.first().unwrap() + 0.1,
2886 "rho={rho}: rectangle sweep should show substantial net growth, got {:?}",
2887 rect_vals
2888 );
2889
2890 // Isotropic sigma for the circle sweep (a circle has one size parameter, not two).
2891 let circle_vals: Vec<f64> = scales
2892 .iter()
2893 .map(|&s| {
2894 p_hit_bivariate(
2895 sd * sd,
2896 sd * sd,
2897 cov,
2898 TargetGeometryV1::Circle { radius_m: s * r0 },
2899 )
2900 })
2901 .collect();
2902 for w in circle_vals.windows(2) {
2903 assert!(
2904 w[1] >= w[0] - 1e-9,
2905 "rho={rho}: circle p_hit decreased as target grew: {} -> {}",
2906 w[0],
2907 w[1]
2908 );
2909 }
2910 assert!(
2911 *circle_vals.last().unwrap() > *circle_vals.first().unwrap() + 0.1,
2912 "rho={rho}: circle sweep should show substantial net growth, got {:?}",
2913 circle_vals
2914 );
2915 }
2916 }
2917
2918 /// Requirement 3 (degenerate covariance, no NaN), the case beyond what the brief's pinned
2919 /// `p_hit_bivariate` code actually gets right: `var_drop == 0.0` with `var_wind > 0.0` is a
2920 /// deterministic drop (always exactly at the nominal point) with real windage uncertainty.
2921 /// The general quadrature integrates OVER the drop axis and cannot represent a Dirac delta,
2922 /// so a naive translation that just lets `sd -> 0` zero out the density at every quadrature
2923 /// node returns a hardcoded `0.0` regardless of target size -- this pins the actual (correct)
2924 /// closed-form behaviour instead, for both target shapes, and confirms it is NOT stuck at a
2925 /// constant.
2926 #[test]
2927 fn drop_deterministic_windage_random_matches_closed_form_not_hardcoded_zero() {
2928 let sw = 0.2_f64;
2929 // Tall enough that the deterministic drop = 0 is always inside the rectangle.
2930 let target = TargetGeometryV1::Rect {
2931 width_m: 0.3,
2932 height_m: 10.0,
2933 };
2934 let got = p_hit_bivariate(0.0, sw * sw, 0.0, target);
2935 let want = normal_cdf(0.15 / sw) - normal_cdf(-0.15 / sw);
2936 assert!((got - want).abs() < 1e-9, "got={got} want={want}");
2937 assert!(
2938 got > 0.0 && got < 1.0,
2939 "fixture must give a non-degenerate probability: {got}"
2940 );
2941
2942 let wider = p_hit_bivariate(
2943 0.0,
2944 sw * sw,
2945 0.0,
2946 TargetGeometryV1::Rect {
2947 width_m: 1.0,
2948 height_m: 10.0,
2949 },
2950 );
2951 assert!(
2952 wider > got,
2953 "a hardcoded-zero bug would make wider == got == 0.0: {wider} {got}"
2954 );
2955
2956 let r = 0.15_f64;
2957 let got_circle =
2958 p_hit_bivariate(0.0, sw * sw, 0.0, TargetGeometryV1::Circle { radius_m: r });
2959 let want_circle = normal_cdf(r / sw) - normal_cdf(-r / sw);
2960 assert!(
2961 (got_circle - want_circle).abs() < 1e-9,
2962 "got={got_circle} want={want_circle}"
2963 );
2964 }
2965
2966 /// The symmetric degenerate case (`var_wind == 0.0`, `var_drop > 0.0`): this one is already
2967 /// handled correctly by the general quadrature without a special branch (see
2968 /// `p_hit_bivariate`'s doc comment), but is pinned here directly for completeness rather than
2969 /// only indirectly through `error_budget_with_target`.
2970 #[test]
2971 fn windage_deterministic_drop_random_matches_closed_form() {
2972 let sd = 0.15_f64;
2973 let target = TargetGeometryV1::Rect {
2974 width_m: 10.0,
2975 height_m: 0.4,
2976 };
2977 let got = p_hit_bivariate(sd * sd, 0.0, 0.0, target);
2978 let want = normal_cdf(0.2 / sd) - normal_cdf(-0.2 / sd);
2979 assert!((got - want).abs() < 1e-9, "got={got} want={want}");
2980
2981 let r = 0.2_f64;
2982 let got_circle =
2983 p_hit_bivariate(sd * sd, 0.0, 0.0, TargetGeometryV1::Circle { radius_m: r });
2984 let want_circle = normal_cdf(r / sd) - normal_cdf(-r / sd);
2985 assert!(
2986 (got_circle - want_circle).abs() < 1e-9,
2987 "got={got_circle} want={want_circle}"
2988 );
2989 assert!(!got.is_nan() && !got_circle.is_nan());
2990 }
2991
2992 /// Requirement 3, continued: `var_drop == var_wind == 0.0` (zero total variance) must give
2993 /// exactly `1.0` regardless of target size or shape (the nominal point is always the
2994 /// target's own centre) -- and a nonsensical nonzero `cov` supplied alongside two zero
2995 /// variances (an inconsistent, invalid covariance a caller should never construct) must not
2996 /// leak through into a NaN.
2997 #[test]
2998 fn zero_total_variance_means_a_deterministic_impact_at_the_nominal_point() {
2999 for target in [
3000 TargetGeometryV1::Rect {
3001 width_m: 0.001,
3002 height_m: 0.001,
3003 },
3004 TargetGeometryV1::Rect {
3005 width_m: 5.0,
3006 height_m: 5.0,
3007 },
3008 TargetGeometryV1::Circle { radius_m: 0.001 },
3009 TargetGeometryV1::Circle { radius_m: 5.0 },
3010 ] {
3011 let got = p_hit_bivariate(0.0, 0.0, 0.0, target);
3012 assert_eq!(
3013 got, 1.0,
3014 "{target:?}: a deterministic impact at the nominal point (always the target's \
3015 own centre here) must be inside with probability exactly 1.0, got {got}"
3016 );
3017 }
3018 let got = p_hit_bivariate(0.0, 0.0, 999.0, TargetGeometryV1::Circle { radius_m: 1.0 });
3019 assert_eq!(got, 1.0);
3020 assert!(!got.is_nan());
3021 }
3022
3023 /// (Review, round 2 -- F6) A target with no positive area can never be hit, REGARDLESS of
3024 /// the covariance. Before this guard, `p_hit_bivariate(0.0, 0.0, 0.0, Rect { 0.0, 0.0 })`
3025 /// returned `1.0` (falling into the zero-total-variance branch above, which does not look at
3026 /// the target at all) -- technically defensible under a boundary-inclusive convention, but
3027 /// indistinguishable from a degenerate-target bug reading as total confidence. Checked with
3028 /// BOTH zero and positive declared variance, and for a negative dimension too (already
3029 /// clamped to zero elsewhere in this function, so should be treated identically to an
3030 /// explicit zero).
3031 #[test]
3032 fn a_degenerate_target_can_never_be_hit_regardless_of_covariance() {
3033 let degenerate_targets = [
3034 TargetGeometryV1::Rect {
3035 width_m: 0.0,
3036 height_m: 0.0,
3037 },
3038 TargetGeometryV1::Rect {
3039 width_m: 0.0,
3040 height_m: 5.0,
3041 },
3042 TargetGeometryV1::Rect {
3043 width_m: 5.0,
3044 height_m: 0.0,
3045 },
3046 TargetGeometryV1::Rect {
3047 width_m: -1.0,
3048 height_m: 5.0,
3049 },
3050 TargetGeometryV1::Circle { radius_m: 0.0 },
3051 TargetGeometryV1::Circle { radius_m: -1.0 },
3052 ];
3053 for target in degenerate_targets {
3054 let deterministic = p_hit_bivariate(0.0, 0.0, 0.0, target);
3055 assert_eq!(
3056 deterministic, 0.0,
3057 "{target:?}: a degenerate (non-positive-area) target must be unhittable even \
3058 when the impact is otherwise deterministic at its centre, got {deterministic}"
3059 );
3060 let with_real_uncertainty = p_hit_bivariate(0.01, 0.05, 0.0, target);
3061 assert_eq!(
3062 with_real_uncertainty, 0.0,
3063 "{target:?}: a degenerate target must be unhittable with real declared \
3064 uncertainty too, got {with_real_uncertainty}"
3065 );
3066 }
3067 }
3068
3069 /// Requirement 3, continued once more: a SINGLE declared source through the real
3070 /// `error_budget_with_target` path gives an exact rank-1 covariance (this is the routine
3071 /// case, not an exception -- see `p_hit_bivariate`'s doc comment). Must not NaN or panic, and
3072 /// the resulting gain (perfecting the only evaluated source collapses the covariance to
3073 /// exactly zero) must still be non-negative.
3074 #[test]
3075 fn a_single_declared_source_gives_a_well_formed_rank_one_p_hit_not_nan_or_negative() {
3076 let r = resolved();
3077 let target = TargetGeometryV1::Rect {
3078 width_m: 0.5,
3079 height_m: 0.75,
3080 };
3081 let rep =
3082 error_budget_with_target(&r, &[(InputAxis::WindSpeed, 1.5)], &[600.0], Some(target))
3083 .unwrap();
3084 let row = &rep.rows[0];
3085 let p_hit = row.p_hit.expect("target supplied");
3086 assert!(
3087 !p_hit.is_nan(),
3088 "single-source rank-1 covariance must not produce NaN"
3089 );
3090 assert!((0.0..=1.0).contains(&p_hit), "p_hit out of bounds: {p_hit}");
3091 let gain = row.sources[0]
3092 .p_hit_gain_if_perfect
3093 .expect("target supplied");
3094 assert!(!gain.is_nan());
3095 assert!(gain >= 0.0, "gain={gain}");
3096 }
3097
3098 /// Requirement 4: an unavailable source must remain distinguishable from an evaluated source
3099 /// that happens to have zero gain -- it has NO `p_hit_gain_if_perfect` at all (a different
3100 /// TYPE, `UnavailableSourceV1`, with no such field), never a fabricated `Some(0.0)`. The
3101 /// sibling that DID evaluate must still report a real, independently-checked gain, unaffected
3102 /// by the other source's unavailability -- extending Task 10's
3103 /// `an_unavailable_source_is_recorded_not_silently_dropped` to the new target/p_hit
3104 /// machinery specifically.
3105 #[test]
3106 fn an_unavailable_source_has_no_gain_field_while_the_evaluated_sibling_does_when_a_target_is_supplied(
3107 ) {
3108 let r = qnh_resolved();
3109 let target = TargetGeometryV1::Circle { radius_m: 0.5 };
3110 let rep = error_budget_with_target(
3111 &r,
3112 &[
3113 (InputAxis::MuzzleVelocityMps, 5.0),
3114 (InputAxis::Altitude, 50.0),
3115 ],
3116 &[300.0],
3117 Some(target),
3118 )
3119 .unwrap();
3120
3121 assert_eq!(rep.unavailable_sources.len(), 1);
3122 assert_eq!(rep.unavailable_sources[0].axis, InputAxis::Altitude);
3123 // `UnavailableSourceV1` has no `p_hit_gain_if_perfect` field at all -- this line would
3124 // simply fail to COMPILE if one were mistakenly added there, which is a stronger
3125 // guarantee than any runtime assertion could give.
3126
3127 assert_eq!(rep.rows[0].sources.len(), 1);
3128 let mv = &rep.rows[0].sources[0];
3129 assert_eq!(mv.axis, InputAxis::MuzzleVelocityMps);
3130 let gain = mv
3131 .p_hit_gain_if_perfect
3132 .expect("target supplied, and MV evaluated");
3133 // With exactly one EVALUATED source, excluding it leaves a zero covariance (Altitude
3134 // never contributes any variance at all, evaluated or not), so the independent oracle
3135 // for "perfecting the only evaluated source" is exactly `1.0 - the row's own p_hit`.
3136 let base_p_hit = rep.rows[0].p_hit.expect("target supplied");
3137 let expected = (1.0 - base_p_hit).max(0.0);
3138 assert!(
3139 (gain - expected).abs() < 1e-9,
3140 "gain={gain} expected={expected}"
3141 );
3142 assert!(
3143 gain > 0.0,
3144 "a real muzzle-velocity uncertainty against a finite target should show a strictly \
3145 positive gain, not merely >= 0: {gain}"
3146 );
3147 }
3148
3149 /// `p_hit`/`p_hit_gain_if_perfect` field pin (would fail if either were replaced by a
3150 /// constant): `None` on both the row and its sources when no target is supplied, mirroring
3151 /// `error_budget`'s documented "thin wrapper passing None" contract.
3152 #[test]
3153 fn p_hit_fields_are_none_when_no_target_is_supplied() {
3154 let r = resolved();
3155 let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
3156 assert_eq!(rep.rows[0].p_hit, None);
3157 assert_eq!(rep.rows[0].sources[0].p_hit_gain_if_perfect, None);
3158 }
3159
3160 /// Field pin for `ErrorBudgetRowV1::p_hit`: checked against an INDEPENDENT
3161 /// `p_hit_bivariate` call built from this row's own `sigma_drop_m`/`sigma_windage_m`/
3162 /// `covariance_m2` (not `error_budget_with_target`'s own internal bookkeeping), with the
3163 /// target deliberately sized relative to this fixture's own dispersion so `p_hit` lands
3164 /// strictly between 0 and 1 -- neither saturated value would distinguish a real computation
3165 /// from a `0.0`/`1.0` constant.
3166 #[test]
3167 fn p_hit_is_computed_from_the_rows_own_covariance_not_a_constant() {
3168 let r = resolved();
3169 let declared = [
3170 (InputAxis::MuzzleVelocityMps, 5.0),
3171 (InputAxis::WindSpeed, 1.0),
3172 ];
3173 let probe = error_budget(&r, &declared, &[600.0]).unwrap();
3174 let row0 = &probe.rows[0];
3175 let target = TargetGeometryV1::Rect {
3176 width_m: 2.0 * row0.sigma_windage_m,
3177 height_m: 2.0 * row0.sigma_drop_m,
3178 };
3179
3180 let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
3181 let row = &rep.rows[0];
3182 let got = row.p_hit.expect("target supplied");
3183
3184 assert!(
3185 (0.01..0.99).contains(&got),
3186 "fixture should give a non-degenerate p_hit: {got}"
3187 );
3188 let oracle = p_hit_bivariate(
3189 row.sigma_drop_m * row.sigma_drop_m,
3190 row.sigma_windage_m * row.sigma_windage_m,
3191 row.covariance_m2,
3192 target,
3193 );
3194 assert!(
3195 (got - oracle).abs() < 1e-9,
3196 "p_hit ({got}) does not match an independent p_hit_bivariate call on this row's own \
3197 covariance ({oracle})"
3198 );
3199 }
3200
3201 /// Field pin for `SourceContributionV1::p_hit_gain_if_perfect`: three declared sources (so
3202 /// the field is generically discriminating, not degenerate the way it would be with only
3203 /// two -- same reasoning as `ellipse_area_reduction_is_nonzero_and_discriminating_with_three_or_more_sources`
3204 /// above), each checked against an independent oracle built from direct `central_difference`
3205 /// calls summed by hand, never `error_budget_with_target`'s own `accumulate`/`Symmetric2`
3206 /// path.
3207 #[test]
3208 fn p_hit_gain_if_perfect_matches_an_independent_oracle_and_discriminates_with_three_sources() {
3209 let r = resolved();
3210 let declared = [
3211 (InputAxis::MuzzleVelocityMps, 5.0),
3212 (InputAxis::WindSpeed, 0.3),
3213 (InputAxis::BallisticCoefficient, 0.001),
3214 ];
3215 let probe = error_budget(&r, &declared, &[600.0]).unwrap();
3216 let row0 = &probe.rows[0];
3217 let target = TargetGeometryV1::Rect {
3218 width_m: 2.0 * row0.sigma_windage_m,
3219 height_m: 2.0 * row0.sigma_drop_m,
3220 };
3221
3222 let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
3223 let row = &rep.rows[0];
3224 assert_eq!(row.sources.len(), 3);
3225 let base_p_hit = row.p_hit.expect("target supplied");
3226
3227 let mut derivs = std::collections::HashMap::new();
3228 for &(axis, sigma) in &declared {
3229 let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
3230 derivs.insert(axis, (sigma, d.d_drop_d_x, d.d_windage_d_x));
3231 }
3232 let variance_excluding = |exclude: Option<InputAxis>| -> (f64, f64, f64) {
3233 let mut vd = 0.0;
3234 let mut vw = 0.0;
3235 let mut cv = 0.0;
3236 for (&axis, &(sigma, dd, dw)) in &derivs {
3237 if Some(axis) == exclude {
3238 continue;
3239 }
3240 let s2 = sigma * sigma;
3241 vd += dd * dd * s2;
3242 vw += dw * dw * s2;
3243 cv += dd * dw * s2;
3244 }
3245 (vd, vw, cv)
3246 };
3247 let (fvd, fvw, fcv) = variance_excluding(None);
3248 let oracle_base_p_hit = p_hit_bivariate(fvd, fvw, fcv, target);
3249 assert!((base_p_hit - oracle_base_p_hit).abs() < 1e-9);
3250
3251 let mut gains = Vec::new();
3252 for s in &row.sources {
3253 let (vd, vw, cv) = variance_excluding(Some(s.axis));
3254 let expected = (p_hit_bivariate(vd, vw, cv, target) - oracle_base_p_hit).max(0.0);
3255 let got = s.p_hit_gain_if_perfect.expect("target supplied");
3256 assert!(
3257 (got - expected).abs() < 1e-9,
3258 "{:?}: got {got}, independently expected {expected}",
3259 s.axis
3260 );
3261 gains.push((s.axis, got));
3262 }
3263 assert!(
3264 gains.iter().any(|&(_, g)| g > 0.0),
3265 "at least one source should show a positive gain: {gains:?}"
3266 );
3267 let first = gains[0].1;
3268 assert!(
3269 gains
3270 .iter()
3271 .any(|&(_, g)| (g - first).abs() > 1e-6 * first.max(1.0)),
3272 "gains must discriminate between sources, not all be equal: {gains:?}"
3273 );
3274 }
3275
3276 /// `method`/`assumptions` field pin: the `"_gl20_panelled_pm6sigma"` suffix and the new
3277 /// hit-probability assumption must appear ONLY when a target is supplied, never when it is
3278 /// not (which would also break `the_report_declares_independence_and_linearity`'s
3279 /// exact-equality check on `method`). The suffix says "panelled" specifically (not the
3280 /// brief's own suggested `"_gl20_pm6sigma"`) so a machine consumer can tell this report used
3281 /// the piecewise quadrature, not a single 20-node call over the whole domain -- see
3282 /// `p_hit_bivariate`'s doc comment for why the difference matters.
3283 #[test]
3284 fn the_report_names_the_quadrature_and_the_aim_point_assumption_only_when_a_target_is_supplied()
3285 {
3286 let r = resolved();
3287 let sources = [(InputAxis::WindSpeed, 1.0)];
3288
3289 let without = error_budget_with_target(&r, &sources, &[600.0], None).unwrap();
3290 assert_eq!(without.method, "central_difference_first_order_propagation");
3291 assert!(!without
3292 .assumptions
3293 .iter()
3294 .any(|s| s.to_lowercase().contains("gauss-legendre")));
3295
3296 let with_target = error_budget_with_target(
3297 &r,
3298 &sources,
3299 &[600.0],
3300 Some(TargetGeometryV1::Circle { radius_m: 0.3 }),
3301 )
3302 .unwrap();
3303 assert_eq!(
3304 with_target.method,
3305 "central_difference_first_order_propagation_gl20_panelled_pm6sigma"
3306 );
3307 assert!(
3308 with_target
3309 .assumptions
3310 .iter()
3311 .any(|s| s.to_lowercase().contains("gauss-legendre") && s.contains("aim point")),
3312 "{:#?}",
3313 with_target.assumptions
3314 );
3315 }
3316
3317 /// Wire-shape pin for the new `TargetGeometryV1` type: externally-tagged, snake_case variant
3318 /// names, matching this module's existing `DifferenceScheme`/`UnavailableReasonCodeV1`
3319 /// convention.
3320 #[test]
3321 fn target_geometry_serializes_snake_case_externally_tagged() {
3322 let rect = TargetGeometryV1::Rect {
3323 width_m: 0.5,
3324 height_m: 0.75,
3325 };
3326 let json = serde_json::to_string(&rect).unwrap();
3327 assert!(json.contains("\"rect\""), "{json}");
3328 assert!(
3329 json.contains("\"width_m\":0.5") || json.contains("\"width_m\": 0.5"),
3330 "{json}"
3331 );
3332 assert!(
3333 json.contains("\"height_m\":0.75") || json.contains("\"height_m\": 0.75"),
3334 "{json}"
3335 );
3336 let back: TargetGeometryV1 = serde_json::from_str(&json).expect("must deserialize");
3337 assert_eq!(back, rect);
3338
3339 let circle = TargetGeometryV1::Circle { radius_m: 0.3 };
3340 let json2 = serde_json::to_string(&circle).unwrap();
3341 assert!(json2.contains("\"circle\""), "{json2}");
3342 assert!(
3343 json2.contains("\"radius_m\":0.3") || json2.contains("\"radius_m\": 0.3"),
3344 "{json2}"
3345 );
3346 let back2: TargetGeometryV1 = serde_json::from_str(&json2).expect("must deserialize");
3347 assert_eq!(back2, circle);
3348 }
3349
3350 /// The honesty requirement's payload claims for hit probability are only real if `p_hit`/
3351 /// `p_hit_gain_if_perfect` survive a JSON round trip as actual present (`Some`) values, not
3352 /// only the already-covered `None` case in
3353 /// `the_report_round_trips_through_json_including_unavailable_sources_and_scheme` above.
3354 ///
3355 /// Deliberately does NOT assert full-struct `assert_eq!(round_tripped, rep)` the way that
3356 /// other test does: this fixture's `WindSpeed` source has a `d_drop_d_x` of
3357 /// `3.7609987435516246e-6` (a near-zero value -- wind primarily affects windage, not drop, so
3358 /// a small residual drop sensitivity is ordinary, not a red flag), and serde_json 1.0.149's
3359 /// float parser round-trips that SPECIFIC value one ULP off (`3.7609987435516246e-6`
3360 /// serializes correctly via `ryu`,
3361 /// but re-parsing that exact string back yields bit pattern `...ece00001` instead of the
3362 /// original `...ece00000`, confirmed with a standalone minimal reproduction outside this
3363 /// crate) -- a pre-existing float-formatting edge case in a dependency, unrelated to this
3364 /// task and already latent in `d_drop_d_x` before this task added anything. A bit-exact
3365 /// struct comparison here would make THIS test flaky on a fact that has nothing to do with
3366 /// what it is actually checking, so it compares only the fields it cares about, with a
3367 /// tolerance many orders of magnitude looser than one ULP.
3368 #[test]
3369 fn p_hit_and_gain_round_trip_through_json_when_present() {
3370 let r = resolved();
3371 let target = TargetGeometryV1::Rect {
3372 width_m: 0.5,
3373 height_m: 0.75,
3374 };
3375 let rep = error_budget_with_target(
3376 &r,
3377 &[
3378 (InputAxis::MuzzleVelocityMps, 5.0),
3379 (InputAxis::WindSpeed, 1.5),
3380 ],
3381 &[600.0],
3382 Some(target),
3383 )
3384 .unwrap();
3385
3386 let json = serde_json::to_string(&rep).expect("report must serialize");
3387 assert!(json.contains("\"p_hit\":"), "{json}");
3388 assert!(json.contains("\"p_hit_gain_if_perfect\":"), "{json}");
3389
3390 let round_tripped: ErrorBudgetReportV1 =
3391 serde_json::from_str(&json).expect("report must deserialize back");
3392
3393 let want_p_hit = rep.rows[0].p_hit.expect("target supplied");
3394 let got_p_hit = round_tripped.rows[0]
3395 .p_hit
3396 .expect("must round-trip as Some");
3397 assert!(
3398 (got_p_hit - want_p_hit).abs() < 1e-9,
3399 "got={got_p_hit} want={want_p_hit}"
3400 );
3401
3402 assert_eq!(
3403 round_tripped.rows[0].sources.len(),
3404 rep.rows[0].sources.len()
3405 );
3406 for (got_s, want_s) in round_tripped.rows[0]
3407 .sources
3408 .iter()
3409 .zip(rep.rows[0].sources.iter())
3410 {
3411 assert_eq!(got_s.axis, want_s.axis);
3412 let want_gain = want_s.p_hit_gain_if_perfect.expect("target supplied");
3413 let got_gain = got_s
3414 .p_hit_gain_if_perfect
3415 .expect("must round-trip as Some");
3416 assert!(
3417 (got_gain - want_gain).abs() < 1e-9,
3418 "{:?}: got={got_gain} want={want_gain}",
3419 got_s.axis
3420 );
3421 }
3422 }
3423}