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