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}