Skip to main content

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}