ballistics_engine/explain.rs
1//! MBA-1345: explain why two fully resolved solutions differ.
2//!
3//! For each input group the difference is attributed by a SYMMETRIC counterfactual: swap the
4//! group from B into A, and independently from A into B, and average. That makes the answer
5//! independent of replacement order, which a one-directional swap is not. Whatever the group
6//! contributions fail to explain is reported as an explicit interaction remainder and is never
7//! distributed across groups: for correlated inputs there is no unique causal attribution, and
8//! pretending otherwise is the failure this design exists to avoid.
9//!
10//! # The derived-value exclusion rule
11//!
12//! A resolved request echoes some axes as plain, independent inputs, but a few are actually
13//! COMPUTED from a different axis's value. A group swap must exclude any axis whose resolved
14//! value is computed from another axis whenever that source axis is:
15//!
16//! (a) in a DIFFERENT group, or
17//! (b) itself excluded (from the SAME group).
18//!
19//! Same-group derivation where the source is ALSO swapped normally is self-consistent and
20//! needs no special handling -- both values move together, so the pairing the derivation
21//! depends on is preserved. That is why, for instance, `Temperature`'s ICAO-standard default
22//! from `Altitude` is ordinarily harmless: swapping the whole `Atmosphere` group moves both
23//! (when `Altitude` is not itself excluded -- see the QNH case below, which is exactly the
24//! exception).
25//!
26//! Three axes are known to violate this, found one at a time by successive reviews, each
27//! documented below as its own subsection: `MuzzleAngle` (case (a): derived from
28//! `MuzzleVelocity` and `Atmosphere`, both different groups), `WindDirection` under compass
29//! wind (case (a): derived from `ShotGeometry`'s `ShotAzimuth`), and `Pressure` under a QNH
30//! atmosphere (case (b): derived from `Atmosphere`'s own `Altitude`, which is excluded rather
31//! than swapped). There is no taxonomy metadata recording "derived from" relationships -- each
32//! is handled as an explicit, documented special case in `plan_exclusions` rather than a
33//! generic mechanism, since three known instances do not justify inventing a
34//! derivation-tracking data model the taxonomy does not otherwise have. A future instance
35//! should be diagnosed against this same rule and added the same way.
36//!
37//! ## Instance 1: `MuzzleAngle` is derived, not independent, once a zero distance is present
38//!
39//! `ZeroSightGeometry` lists `MuzzleAngle` -- but on a RESOLVED request, `muzzle_angle_rad` is
40//! not an independent input whenever `zero_distance_m` is also present: it is the
41//! ALREADY-SEARCHED elevation, a function of muzzle velocity and atmosphere as much as of any
42//! sight/zero knob in this group (review C1; rule case (a) -- `MuzzleVelocity` and `Atmosphere`
43//! are different groups). Naively applying it as an ordinary axis would import muzzle-velocity's
44//! and atmosphere's own differences into `ZeroSightGeometry`'s bucket -- concretely, swapping a
45//! group in which every visible sight/zero setting is byte-identical between the two requests
46//! would still report a non-zero contribution, because the group also carried the OTHER
47//! request's fully-baked elevation. A shooter reading "5 cm of this is your zero/sight geometry"
48//! would go check a scope that never moved.
49//!
50//! `taxonomy.rs`'s `axes_in_group(ZeroSightGeometry)` lists `MuzzleAngle` FIRST specifically to
51//! fix this: `SightHeight` (always present, `requires_rezero`) is applied second, and its own
52//! re-resolve clears and re-derives the elevation for the DESTINATION's own muzzle velocity and
53//! atmosphere, overwriting whatever `MuzzleAngle`'s write introduced -- see that match arm's own
54//! comment for the full mechanism, and `every_groups_contribution_matches_an_independent_recomputation`
55//! below for the acceptance test (identical sight/zero inputs must report an EXACTLY zero
56//! `ZeroSightGeometry`, not merely a small one). For an angle-only request (no `zero_distance_m`
57//! on the destination), nothing later in the group clears the angle, so `MuzzleAngle`'s own
58//! write correctly stands: there it genuinely IS the independent input, and it must still be
59//! swapped in that case -- see `muzzle_angle_is_still_swapped_for_an_angle_only_request` (review
60//! round 3, N3): the fix above is only correct BECAUSE the later clear-gate is conditioned on
61//! `zero_distance_m` being present, not unconditionally on axis order, and there was no test
62//! pinning that down until this one.
63//!
64//! ## Instance 2: `WindDirection` is derived from `ShotAzimuth` under compass-referenced wind
65//!
66//! Under `wind_reference: "compass"`, the resolved (shooter-relative) `direction_from_rad` is
67//! `compass_bearing_to_shooter_relative_rad(bearing, shot_azimuth_rad)` =
68//! `(bearing - shot_azimuth_rad).rem_euclid(2*pi)` (`solve_v1.rs`'s `resolve_wind`, `wind.rs`) --
69//! a function of `ShotAzimuth`, which lives in `ShotGeometry`, a DIFFERENT group (review round
70//! 3, N1; rule case (a)). [`with_axis`] already refuses to swap `ShotAzimuth` itself under
71//! compass wind, protecting `ShotGeometry`'s own number, but nothing stopped `WindDirection`
72//! from moving on ITS OWN: two compass-referenced requests with an IDENTICAL raw wind bearing
73//! but different `shot_azimuth_rad` resolve to DIFFERENT `direction_from_rad` values purely
74//! because of the azimuth difference, and swapping `WindDirection` would report that as a `Wind`
75//! contribution -- the exact `MuzzleAngle` shape, relabelled onto a different pair of groups.
76//! `plan_exclusions` now excludes `WindDirection` (both legs, both directions) whenever EITHER
77//! `a` or `b` is compass-referenced, regardless of whether the raw bearing or the azimuth
78//! actually differ -- the same unconditional-on-values pattern the `Altitude`/`ShotAzimuth`
79//! guards already use. See `wind_direction_is_excluded_under_compass_wind_even_with_an_identical_raw_bearing`.
80//!
81//! ## Instance 3: `Pressure` is derived from `Altitude` under a QNH atmosphere
82//!
83//! Under `pressure_reference: "qnh"`, the resolved `pressure_pa` is
84//! `reduce_qnh_to_station_pressure(qnh_value, altitude_m)` (`solve_v1.rs`'s `resolve_atmosphere`)
85//! -- a function of `Altitude`, which lives in the SAME group, `Atmosphere` (review round 3, N2;
86//! rule case (b)). Same-group derivation is ordinarily fine (see the rule above), but `Altitude`
87//! itself is excluded whenever `pressure_reference` is QNH -- so the pairing breaks: `Pressure`
88//! would still move while its source, `Altitude`, stays fixed on both legs, leaking a value that
89//! is only physically valid at the OTHER request's (unswapped) altitude into `Atmosphere`'s
90//! contribution, in violation of the third `assumptions` entry's promise that an excluded axis's
91//! effect lands in the remainder, not partially in its group. `plan_exclusions` now excludes
92//! `Pressure` too, using the exact same QNH condition that already excludes `Altitude`. See
93//! `pressure_exclusion_does_not_leak_a_partial_effect_into_atmosphere` (a QNH-vs-QNH fixture,
94//! added alongside the existing QNH-vs-absolute `altitude_exclusion_does_not_leak_a_partial_effect_into_atmosphere`,
95//! which cannot exercise this specific hazard because a plain absolute pressure has no altitude
96//! dependency to leak in the first place).
97//!
98//! **Known limitation, not fixed:** `Temperature` has the identical hazard when it is OMITTED
99//! from the raw request under a QNH atmosphere -- its ICAO-standard default
100//! (`calculate_icao_standard_atmosphere(altitude_m)`) is ALSO a function of `Altitude`. Unlike
101//! `Pressure`'s dependency, this one is not detectable from a resolved request alone: the
102//! resolved `temperature_k` echo carries only the concrete numeric value, with no record of
103//! whether it came from an explicit input or from this default, and the assumption notice that
104//! WOULD say so is not part of `ResolvedSolveRequestV1` at all. Excluding `Temperature`
105//! unconditionally whenever `Altitude` is QNH-excluded would trade this rare, real leak for a
106//! much more common one (silently discarding a genuinely independent temperature difference on
107//! every ordinary QNH request that happens to also specify a temperature), which is a worse
108//! trade. Left undetected and undocumented anywhere else but here.
109//!
110//! # Symmetric exclusion: an axis unusable on either side must be excluded on BOTH legs
111//!
112//! [`with_axis`] can refuse an axis for a request-specific structural reason:
113//! `KernelError::AxisUnsupportedForRequest` for `Altitude` under a QNH-referenced atmosphere or
114//! `ShotAzimuth` under compass-referenced wind, and `KernelError::AxisAbsent` for the three wind
115//! axes when the request being written TO uses segmented wind (`access.rs`'s module doc).
116//! [`read_axis`] separately returns `None` for the same three wind axes when the request being
117//! read FROM is segmented, and also for a handful of ordinarily-optional scalar fields
118//! (`length_m`, `latitude_rad`, the two zero-POI offsets, the lateral sight offset, and -- true
119//! of the code though not named in `read_axis`'s own doc comment -- `zero_distance_m` on a
120//! request zeroed purely by an explicit angle) that just were not supplied on a request.
121//!
122//! An earlier revision of this module decided this PER LEG: forward excluded an axis only if
123//! `A` itself refused it (or lacked a value to read from `B`), and backward only if `B` did.
124//! That is wrong (review I1): if only ONE leg excludes an axis, the two legs stop measuring the
125//! same counterfactual. A QNH-referenced `A` compared against a non-QNH `B`, for instance, has
126//! forward keep `A`'s own altitude (refused) while backward hands `B` the whole of `A`'s
127//! altitude (not refused) -- so `contribution(Atmosphere)` ends up as the full
128//! temperature/pressure/humidity effect PLUS HALF of a completely separate altitude difference,
129//! while the report's own `assumptions` claim that effect went to the interaction remainder.
130//!
131//! `plan_exclusions` fixes this by deciding exclusions for a WHOLE group ONCE, from the two
132//! ORIGINAL requests together, before either swap direction runs: an axis is excluded from BOTH
133//! legs if EITHER `a` or `b` would refuse it as a destination, or if it is present on exactly
134//! one of `a`/`b` (review I2 -- the same splitting hazard, via `read_axis` instead of
135//! `with_axis`: an ordinarily-optional field supplied on one saved profile and omitted on the
136//! other, such as `latitude_rad`, would otherwise have forward keep `A`'s own value while
137//! backward overwrites `B` with it, again half-attributing a real difference with no record of
138//! it at all). Both cases are recorded in [`SolutionDiffReportV1::skipped_axes`] -- ALWAYS as a
139//! pair, one entry per direction, so the report always explains both legs' identical treatment
140//! of that axis -- with a `reason` reworded for this whole-group context rather than reused
141//! verbatim from `with_axis`'s own per-axis-perturbation wording (review I1's second point: a
142//! reason like "perturbing altitude would move air density without moving pressure" is
143//! misleading here, where `Pressure` is a SEPARATE axis in the same group and IS copied
144//! normally). An axis absent on BOTH `a` and `b` is not reported at all: there is nothing to
145//! attribute either way, the same as if neither request had ever mentioned it.
146//!
147//! Either way, an exclusion never aborts the comparison -- only that one axis (or, for the
148//! magnus/enhanced_spin_drift pair below, both together) is left out of that one group, on both
149//! legs. Two cross-axis conflicts are excluded this same way even though neither is a "derived
150//! value" in the sense above (F2, 0.33.0 final-review fix wave): `CoriolisEnabled` differing
151//! while either request lacks `latitude_rad` (`solve_v1` requires it whenever coriolis is
152//! enabled), and `MagnusEnabled`/`EnhancedSpinDriftEnabled` swapping between two requests with
153//! opposite flags set (`solve_v1` rejects both true on one request, `taxonomy.rs`'s Known
154//! Limitation (d)) -- see `plan_exclusions`'s own two guards for the full mechanism, and for why
155//! the magnus/ESD case excludes BOTH axes together rather than just the one whose write would
156//! fail first. A GENUINE failure -- some OTHER `solve_v1` re-resolve failure partway through
157//! applying a group, not one of the cases `plan_exclusions` already recognizes -- is NOT one of
158//! these cases and propagates as an error, uncaught, exactly as `central_difference` and
159//! `bisect_axis` already do for their own non-refusal failures.
160//!
161//! # Why exclusions are decided from the original requests, not the accumulated one
162//!
163//! `plan_exclusions` probes [`with_axis`] against `a` and `b` exactly as passed into
164//! [`explain_difference`] -- never a request `swap_group` has already partly rewritten. This
165//! matters because a group with more than one axis applies its writes one at a time,
166//! re-resolving between them (`swap_group`, via a real `solve_v1` call) so each subsequent
167//! [`with_axis`] call sees the previous write. That re-resolve goes through the reverse
168//! conversion (`request_roundtrip.rs`), which ALWAYS clears `pressure_reference` and
169//! `wind_reference` back to the omitted-field default (that module's own doc: the resolved
170//! values already have the transform baked in, so echoing the mode back would apply it a second
171//! time). That is correct for solving, but it means the very fact `with_axis`'s guards key on --
172//! "was this request originally QNH- or compass-referenced" -- would be erased by the FIRST
173//! re-resolve within a group, not preserved across it, if it were checked against that
174//! progressively-modified request instead.
175//!
176//! `ShotGeometry` lists `TargetDistance`, `ShootingAngle` and `Cant` before `ShotAzimuth`: three
177//! unrelated axes, each triggering a re-resolve, would be applied before it. Checking the
178//! compass guard against a progressively-modified request would have it silently pass by the
179//! time `ShotAzimuth` is reached, building exactly the physically-inverted counterfactual the
180//! guard exists to prevent -- with no error and no skip recorded, because from `with_axis`'s
181//! point of view at that moment the request no longer looks compass-referenced at all. Deciding
182//! every axis's exclusion up front, from `a`/`b` as originally given, makes this impossible by
183//! construction: there is no accumulated, partly-laundered request for the check to see in the
184//! first place. `swap_group` itself never re-derives a refusal at all -- it trusts the
185//! `excluded` list `plan_exclusions` already computed, safely, because a refusal can only become
186//! MORE permissive as a group's later axes are applied (round-tripping only ever clears a
187//! reference-mode echo, it never introduces one): an axis `plan_exclusions` already cleared for
188//! both `a` and `b` is guaranteed to still be writable on `swap_group`'s running request,
189//! however many earlier axes in the same group have already re-resolved it. The test
190//! `shot_azimuth_is_refused_even_when_other_shot_geometry_axes_are_applied_first` pins this down
191//! by exercising exactly that four-axis ordering.
192//!
193//! # The drag description travels with ProjectileDrag (MBA-1603)
194//!
195//! Three resolved inputs describe the projectile's drag without being taxonomy axes:
196//! `projectile.drag_table` (MBA-1597), `projectile.bc_segments` (MBA-1598) and
197//! `corrections.bc5d_table_path`. They are deliberately not axes, so `error-budget` and
198//! `tolerance` never perturb them (except that the BC axis moves a one-band ladder's band with
199//! the scalar, which it must equal), but they are projectile drag all the same. Before MBA-1603 no
200//! group swapped them: every counterfactual kept the destination's own copy, so a difference in
201//! any of them landed whole in `interaction_remainder` with nothing in `skipped_axes` to say why.
202//! Worse, when `drag_model` or the BC also differed, the swap flew one request's band BCs against
203//! the other request's reference curve, so the `ProjectileDrag` contribution described a
204//! projectile neither request had.
205//!
206//! `swap_group` therefore carries the three as one unit with `ProjectileDrag` whenever the two
207//! requests' descriptions differ (`drag_descriptions_differ`). It clears them from the
208//! destination BEFORE the scalar axes are written and writes the source's AFTER them, in one
209//! final re-resolve under `with_axis`'s re-zero rule. Clearing first keeps every intermediate
210//! re-resolve legal: a BC5D table never meets a foreign diameter, which its caliber check
211//! refuses, and no ladder-plus-table request is ever formed, which `solve_v1` refuses as
212//! `conflicting_fields`. Writing last puts the ladder or table onto a projectile whose
213//! `drag_model`, BC, mass and diameter are already the source's, so band BCs always fly against
214//! the curve they were measured against. When the descriptions are equal nothing changes, and
215//! the comparison is exactly what it was before.
216//!
217//! A BC5D table is not a derived-value hazard in the sense of the rule above. The resolved
218//! request echoes only the table's path; `solve_v1` regenerates the table's ladder on every
219//! re-solve from that request's own BC, drag model, mass and muzzle velocity, and never copies a
220//! ladder from the other request.
221//!
222//! Other inputs that belong to no group are still swapped by none: `effects.aerodynamic_jump`,
223//! `effects.wind_shear_model`, `solver.*` and `shot.ground_threshold_m`. A difference in one of
224//! them lands in `interaction_remainder`.
225
226use serde::{Deserialize, Serialize};
227
228use crate::perturbation::access::{read_axis, with_axis, KernelError};
229use crate::perturbation::taxonomy::{axes_in_group, InputAxis, InputGroup};
230use crate::perturbation::{evaluate, kernel_solve_error, Observation};
231use crate::solve_json::{
232 CorrectionsV1, PressureReferenceV1, ResolvedProjectileV1, ResolvedSolveRequestV1,
233 SolveRequestV1, WindReferenceV1,
234};
235
236/// Schema version for [`SolutionDiffReportV1`].
237pub const EXPLAIN_SCHEMA_VERSION_V1: u32 = 1;
238
239/// The difference between two [`Observation`]s at one range, `b - a`. SI throughout, same sign
240/// convention as [`Observation`] itself.
241#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
242pub struct DeltaV1 {
243 pub drop_m: f64,
244 pub windage_m: f64,
245 pub time_s: f64,
246 pub velocity_mps: f64,
247}
248
249impl DeltaV1 {
250 fn between(a: &Observation, b: &Observation) -> Self {
251 DeltaV1 {
252 drop_m: b.drop_m - a.drop_m,
253 windage_m: b.windage_m - a.windage_m,
254 time_s: b.time_s - a.time_s,
255 velocity_mps: b.velocity_mps - a.velocity_mps,
256 }
257 }
258 fn mean(x: Self, y: Self) -> Self {
259 DeltaV1 {
260 drop_m: 0.5 * (x.drop_m + y.drop_m),
261 windage_m: 0.5 * (x.windage_m + y.windage_m),
262 time_s: 0.5 * (x.time_s + y.time_s),
263 velocity_mps: 0.5 * (x.velocity_mps + y.velocity_mps),
264 }
265 }
266 fn neg(self) -> Self {
267 DeltaV1 {
268 drop_m: -self.drop_m,
269 windage_m: -self.windage_m,
270 time_s: -self.time_s,
271 velocity_mps: -self.velocity_mps,
272 }
273 }
274 fn add(self, o: Self) -> Self {
275 DeltaV1 {
276 drop_m: self.drop_m + o.drop_m,
277 windage_m: self.windage_m + o.windage_m,
278 time_s: self.time_s + o.time_s,
279 velocity_mps: self.velocity_mps + o.velocity_mps,
280 }
281 }
282 fn sub(self, o: Self) -> Self {
283 DeltaV1 {
284 drop_m: self.drop_m - o.drop_m,
285 windage_m: self.windage_m - o.windage_m,
286 time_s: self.time_s - o.time_s,
287 velocity_mps: self.velocity_mps - o.velocity_mps,
288 }
289 }
290}
291
292/// Which leg of a group's symmetric counterfactual swap a [`SkippedAxisV1`] occurred on.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295pub enum SwapDirectionV1 {
296 /// Building A with the group's axes replaced by B's values (measured against A).
297 Forward,
298 /// Building B with the group's axes replaced by A's values (measured against B, negated).
299 Backward,
300}
301
302/// One taxonomy axis that could not be carried across during a group's counterfactual swap, and
303/// why -- see the module doc's "Symmetric exclusion" section. Always recorded in pairs, one per
304/// [`SwapDirectionV1`], even when only one direction independently hit the refusal or absence:
305/// the OTHER direction is excluded too, to keep both legs measuring the same counterfactual.
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct SkippedAxisV1 {
308 pub group: InputGroup,
309 pub axis: InputAxis,
310 pub direction: SwapDirectionV1,
311 pub reason: String,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct GroupContributionV1 {
316 pub group: InputGroup,
317 pub delta: DeltaV1,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct SolutionDiffRowV1 {
322 pub range_m: f64,
323 pub total: DeltaV1,
324 pub contributions: Vec<GroupContributionV1>,
325 pub interaction_remainder: DeltaV1,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SolutionDiffReportV1 {
330 pub schema_version: u32,
331 pub method: String,
332 pub assumptions: Vec<String>,
333 /// Axes excluded from a group's swap because one or both requests could not carry them --
334 /// see the module doc's "Symmetric exclusion". Always excluded from BOTH swap directions,
335 /// and reported as a pair (one `SkippedAxisV1` per direction), even when only one direction
336 /// independently hit the refusal or absence. Never distributed or hidden: whatever effect a
337 /// skipped axis would have had is left inside `SolutionDiffRowV1::interaction_remainder`,
338 /// not silently absorbed into that axis's own group.
339 pub skipped_axes: Vec<SkippedAxisV1>,
340 pub rows: Vec<SolutionDiffRowV1>,
341}
342
343/// Word an axis's exclusion reason for the whole-group context, rather than reusing
344/// `with_axis`'s own per-axis-perturbation wording verbatim (review I1): `with_axis`'s own
345/// `Altitude` reason talks about perturbing altitude "without moving" pressure, which is
346/// misleading here, where `Pressure` is a SEPARATE axis in the same group that DOES get copied
347/// normally. `own_refusal` is this DIRECTION's own refusal (if any); `other_refusal` is the
348/// OTHER direction's, used to explain a "sympathetic" exclusion -- one leg that would not have
349/// refused on its own, excluded anyway to keep both legs measuring the same counterfactual.
350fn describe_refusal(
351 axis: InputAxis,
352 own_refusal: &Option<String>,
353 other_refusal: &Option<String>,
354) -> String {
355 let context = match axis {
356 InputAxis::Altitude => {
357 "altitude is tied to pressure under a QNH-referenced atmosphere; this group swap \
358 cannot re-derive that relationship for a different altitude. \
359 Temperature/RelativeHumidity/Latitude in the same group ARE copied normally; \
360 Pressure is ALSO excluded whenever the two requests' altitudes actually differ \
361 (see Pressure's own skipped_axes entry when that applies), but is copied normally \
362 alongside Altitude's exclusion when the altitudes happen to match"
363 }
364 InputAxis::ShotAzimuth => {
365 "the shot azimuth is tied to an earth-fixed wind bearing under compass-referenced \
366 wind; this group swap cannot re-derive that bearing for a different azimuth, even \
367 though TargetDistance/ShootingAngle/Cant/AimAzimuth/TargetHeight in the same group \
368 ARE copied normally"
369 }
370 _ => "this axis cannot be swapped for one of the two requests being compared",
371 };
372 match own_refusal {
373 Some(reason) => format!("{context} ({reason})"),
374 None => {
375 format!(
376 "excluded to match the other swap direction, which cannot swap this axis: {context} \
377 ({})",
378 other_refusal.as_deref().unwrap_or("no further detail available")
379 )
380 }
381 }
382}
383
384/// Word a wind axis's exclusion reason (taxonomy.rs Known Limitation (c)): `own_segmented` is
385/// whether THIS direction's destination is segmented (blocking the write); `other_segmented` is
386/// whether the OTHER direction's destination is segmented (which, as this direction's source,
387/// blocks reading a value to copy).
388fn describe_wind_absence(own_segmented: bool, other_segmented: bool) -> String {
389 match (own_segmented, other_segmented) {
390 (true, true) => "both requests being compared use segmented wind; neither has a \
391 single scalar value for this axis"
392 .to_string(),
393 (true, false) => "this request's wind is segmented, so there is no single scalar \
394 field to write this axis into"
395 .to_string(),
396 (false, true) => "the other request's wind is segmented, so there is no single \
397 scalar value to read for this axis"
398 .to_string(),
399 (false, false) => {
400 unreachable!("describe_wind_absence called when neither side is segmented")
401 }
402 }
403}
404
405/// True when `r`'s wind was entered compass-referenced. Shares `access.rs`'s own
406/// `wind_reference_of` (review round 4 -- widened to `pub(crate)` for exactly this reuse)
407/// rather than re-matching the two wind shapes a second time -- the same fact [`with_axis`]
408/// checks to refuse `ShotAzimuth` there, because `WindDirection`'s OWN resolved value depends on
409/// it too (module doc, "The derived-value exclusion rule", Instance 2).
410fn is_compass_referenced(r: &ResolvedSolveRequestV1) -> bool {
411 crate::perturbation::access::wind_reference_of(&r.wind) == Some(WindReferenceV1::Compass)
412}
413
414/// True when `r`'s atmosphere was entered as a QNH altimeter setting. Read directly from the
415/// public `ResolvedAtmosphereV1::pressure_reference` echo -- the same fact [`with_axis`] checks
416/// to refuse `Altitude` (`access.rs`) -- because `Pressure`'s OWN resolved value depends on it
417/// too (module doc, "The derived-value exclusion rule", Instance 3).
418fn is_qnh_referenced(r: &ResolvedSolveRequestV1) -> bool {
419 r.atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh)
420}
421
422/// Decide, for one group, which axes must be excluded from BOTH legs of the symmetric swap
423/// between `a` and `b`, and produce the [`SkippedAxisV1`] entries explaining why -- see the
424/// module doc's "Symmetric exclusion" section for why a per-leg decision is not good enough,
425/// and "The derived-value exclusion rule" for the two checks that are not about refusal or
426/// absence at all.
427///
428/// Five structurally different reasons exclude an axis, each checked directly against `a` and
429/// `b` as originally given (see the module doc's "Why exclusions are decided from the original
430/// requests"):
431///
432/// - One of the three wind axes, where either request's wind is segmented (taxonomy.rs Known
433/// Limitation (c)) -- detected via [`read_axis`] returning `None`.
434/// - `WindDirection`, derived from `ShotGeometry`'s `ShotAzimuth` whenever either request is
435/// compass-referenced (review round 3, N1) -- the derived-value rule's case (a).
436/// - `Pressure`, derived from this SAME group's `Altitude` whenever either request is
437/// QNH-referenced, and `Altitude` is itself excluded in that case (review round 3, N2) -- the
438/// derived-value rule's case (b).
439/// - An ordinarily-optional axis present on exactly one of `a`/`b` (review I2).
440/// - [`with_axis`] refusing the axis as a destination for `a` XOR `b` (review I1) -- a
441/// QNH-referenced `Altitude` or a compass-referenced `ShotAzimuth`. Probed with each
442/// request's OWN current value for that axis (guaranteed well-typed and finite), since
443/// `with_axis`'s guards depend only on `axis` and the request's own structural fields, never
444/// on the value being written.
445///
446/// An axis absent on BOTH `a` and `b` triggers none of these and is silently left out of both
447/// the exclusion list and the report: there is nothing to attribute either way.
448///
449/// # Errors
450///
451/// Only an UNEXPECTED `with_axis` failure (not `AxisUnsupportedForRequest`) propagates -- for
452/// instance a `TypeMismatch`/`NonFinite`, which should not be reachable here at all, since every
453/// probed value came from `read_axis` for the identical axis on the identical request.
454fn plan_exclusions(
455 a: &ResolvedSolveRequestV1,
456 b: &ResolvedSolveRequestV1,
457 group: InputGroup,
458) -> Result<(Vec<InputAxis>, Vec<SkippedAxisV1>), KernelError> {
459 let mut excluded = Vec::new();
460 let mut skipped = Vec::new();
461
462 for &axis in axes_in_group(group) {
463 // F2 (0.33.0 final-review fix wave): an axis can be pre-excluded by an EARLIER axis in
464 // this same group's iteration -- currently only EnhancedSpinDriftEnabled, excluded
465 // alongside MagnusEnabled below when the two conflict -- in which case its own turn has
466 // nothing left to decide or record.
467 if excluded.contains(&axis) {
468 continue;
469 }
470
471 let a_value = read_axis(a, axis);
472 let b_value = read_axis(b, axis);
473
474 if matches!(
475 axis,
476 InputAxis::WindSpeed | InputAxis::WindDirection | InputAxis::WindVertical
477 ) {
478 if a_value.is_none() || b_value.is_none() {
479 excluded.push(axis);
480 skipped.push(SkippedAxisV1 {
481 group,
482 axis,
483 direction: SwapDirectionV1::Forward,
484 reason: describe_wind_absence(a_value.is_none(), b_value.is_none()),
485 });
486 skipped.push(SkippedAxisV1 {
487 group,
488 axis,
489 direction: SwapDirectionV1::Backward,
490 reason: describe_wind_absence(b_value.is_none(), a_value.is_none()),
491 });
492 continue;
493 }
494 // N1 (review round 3, narrowed by F1): WindDirection's resolved value is ALSO
495 // derived from ShotAzimuth -- ShotGeometry, a DIFFERENT group -- whenever either
496 // side is compass-referenced (module doc, derived-value rule, Instance 2). But the
497 // derivation only actually CONTAMINATES the swap when the azimuths differ: if
498 // a.shot.shot_azimuth_rad == b.shot.shot_azimuth_rad, the compass-to-shooter-relative
499 // shift is the SAME on both sides, so the difference between the two resolved
500 // directions is exactly the difference between the two raw bearings -- a genuine
501 // Wind-group effect, not azimuth contamination (F1: excluding unconditionally on the
502 // mode flag over-fires, under-attributing Wind on the most natural same-shot-
503 // direction compass comparison). `with_axis`'s OWN Altitude/ShotAzimuth guards do
504 // not need this refinement -- they only ever see ONE request, so they cannot compare
505 // -- but `plan_exclusions` has both and must.
506 if axis == InputAxis::WindDirection
507 && (is_compass_referenced(a) || is_compass_referenced(b))
508 && a.shot.shot_azimuth_rad != b.shot.shot_azimuth_rad
509 {
510 excluded.push(axis);
511 let reason = "the resolved wind direction is derived from shot_azimuth_rad (a \
512 ShotGeometry axis) under compass-referenced wind; swapping it \
513 here would attribute part of a shot-azimuth difference to Wind \
514 instead of leaving it in the interaction remainder"
515 .to_string();
516 skipped.push(SkippedAxisV1 {
517 group,
518 axis,
519 direction: SwapDirectionV1::Forward,
520 reason: reason.clone(),
521 });
522 skipped.push(SkippedAxisV1 {
523 group,
524 axis,
525 direction: SwapDirectionV1::Backward,
526 reason,
527 });
528 }
529 continue;
530 }
531
532 // N2 (review round 3, narrowed by F1): Pressure's resolved value is derived from
533 // Altitude -- the SAME group, but Altitude is itself excluded whenever either side is
534 // QNH-referenced, so the pairing that would ordinarily make same-group derivation
535 // harmless is broken (module doc, derived-value rule, Instance 3). But this only
536 // actually breaks the pairing when the altitudes differ: if
537 // a.atmosphere.altitude_m == b.atmosphere.altitude_m, the QNH-reduced pressures differ
538 // only by the raw altimeter setting, and the swapped value stays physically valid at
539 // the (unmoved) destination altitude -- excluding unconditionally on the mode flag
540 // over-fires, under-attributing Atmosphere on the most natural same-location QNH
541 // comparison (F1). See the WindDirection guard above for why `with_axis`'s own
542 // unconditional guards do not need (and cannot use) this same refinement.
543 if axis == InputAxis::Pressure
544 && (is_qnh_referenced(a) || is_qnh_referenced(b))
545 && a.atmosphere.altitude_m != b.atmosphere.altitude_m
546 {
547 excluded.push(axis);
548 let reason = "the resolved station pressure is derived from altitude_m under a \
549 QNH-referenced atmosphere, and Altitude is excluded from this same \
550 comparison; swapping Pressure alone would move a value that is only \
551 physically valid at the OTHER request's (unswapped) altitude"
552 .to_string();
553 skipped.push(SkippedAxisV1 {
554 group,
555 axis,
556 direction: SwapDirectionV1::Forward,
557 reason: reason.clone(),
558 });
559 skipped.push(SkippedAxisV1 {
560 group,
561 axis,
562 direction: SwapDirectionV1::Backward,
563 reason,
564 });
565 continue;
566 }
567
568 // F2 (0.33.0 final-review fix wave): magnus and enhanced_spin_drift cannot both be true
569 // on any ONE resolved request (`solve_v1`'s `validate_effects` rejects that combination
570 // outright, `taxonomy.rs`'s Known Limitation (d)), so two requests that each solve fine
571 // alone -- one with magnus on, the other with enhanced_spin_drift on -- make
572 // `swap_group`'s fixed axis-at-a-time order (`MagnusEnabled` always applied before
573 // `EnhancedSpinDriftEnabled`, `axes_in_group` above) walk straight into that conflict:
574 // writing the destination's NEW magnus value lands on a request that still carries its
575 // OWN unswapped enhanced_spin_drift (not yet overwritten), transiently combining two
576 // "true"s whenever the source's magnus and the destination's enhanced_spin_drift are
577 // both true. Excluding only ONE of the two axes does not fix this in general -- it
578 // merely relocates the identical hazard to the OTHER axis's write on the OTHER leg
579 // (confirmed by direct construction, not assumed: excluding just `MagnusEnabled` leaves
580 // `EnhancedSpinDriftEnabled`'s own later write to collide with the untouched destination
581 // magnus on whichever leg was previously safe), so both axes are excluded together
582 // whenever EITHER leg could conflict (`(b.magnus && a.esd) || (a.magnus && b.esd)`; at
583 // most one of the two can ever be true at once, since neither `a` nor `b` can have both
584 // flags true individually, but either alone is enough to require excluding both axes) --
585 // their real difference falls to the interaction remainder instead of aborting the
586 // report. Decided once, from both ORIGINALS, when the loop reaches `MagnusEnabled`
587 // (first in this group's fixed axis order); the `excluded.contains` guard at the top of
588 // this loop skips `EnhancedSpinDriftEnabled`'s own turn once this fires.
589 if axis == InputAxis::MagnusEnabled
590 && ((a.effects.magnus && b.effects.enhanced_spin_drift)
591 || (b.effects.magnus && a.effects.enhanced_spin_drift))
592 {
593 let reason = "magnus and enhanced_spin_drift cannot both be enabled on one resolved \
594 request, and one of these two requests has magnus enabled while the \
595 other has enhanced_spin_drift enabled; swapping either flag alone \
596 would transiently combine a newly-written true with the destination's \
597 own still-unswapped true on the other flag, which solve_v1 rejects as \
598 a conflict, so both effects are excluded together rather than aborting \
599 the comparison"
600 .to_string();
601 for excluded_axis in [
602 InputAxis::MagnusEnabled,
603 InputAxis::EnhancedSpinDriftEnabled,
604 ] {
605 excluded.push(excluded_axis);
606 skipped.push(SkippedAxisV1 {
607 group,
608 axis: excluded_axis,
609 direction: SwapDirectionV1::Forward,
610 reason: reason.clone(),
611 });
612 skipped.push(SkippedAxisV1 {
613 group,
614 axis: excluded_axis,
615 direction: SwapDirectionV1::Backward,
616 reason: reason.clone(),
617 });
618 }
619 continue;
620 }
621
622 // F2 (0.33.0 final-review fix wave): `with_axis` performs no cross-field validation at
623 // all -- it only ever builds the DTO -- so writing `CoriolisEnabled` onto a request that
624 // lacks `latitude_rad` does not fail HERE; it fails later, inside `swap_group`'s own
625 // re-resolve (`solve_v1`'s "latitude_rad is required when the Coriolis effect is
626 // enabled"), a genuine `KernelError::Solve`, not one of the refusal cases this function
627 // otherwise catches -- so, uncaught, it aborts `explain_difference` ENTIRELY, even when
628 // `a` and `b` each solve fine on their own. Only a risk when the flags actually DIFFER
629 // (swapping an identical flag is a no-op regardless of latitude_rad) and either side
630 // lacks `latitude_rad` -- a request that itself already has coriolis enabled is
631 // guaranteed, by its own prior validation, to already carry `latitude_rad`, so the only
632 // way this validation can newly fail is turning the flag ON for a destination that never
633 // supplied one.
634 if axis == InputAxis::CoriolisEnabled
635 && a.effects.coriolis != b.effects.coriolis
636 && (a.atmosphere.latitude_rad.is_none() || b.atmosphere.latitude_rad.is_none())
637 {
638 excluded.push(axis);
639 let reason = "the coriolis flag differs between the two requests and at least one \
640 of them has no latitude_rad; solve_v1 requires latitude_rad whenever \
641 the coriolis effect is enabled, so swapping this flag onto the request \
642 that lacks one would fail re-resolution rather than build a valid \
643 counterfactual"
644 .to_string();
645 skipped.push(SkippedAxisV1 {
646 group,
647 axis,
648 direction: SwapDirectionV1::Forward,
649 reason: reason.clone(),
650 });
651 skipped.push(SkippedAxisV1 {
652 group,
653 axis,
654 direction: SwapDirectionV1::Backward,
655 reason,
656 });
657 continue;
658 }
659
660 if a_value.is_some() != b_value.is_some() {
661 excluded.push(axis);
662 let reason = "this axis is present on only one of the two requests being compared \
663 (absent on the other), so there is no symmetric value to swap in \
664 either direction"
665 .to_string();
666 skipped.push(SkippedAxisV1 {
667 group,
668 axis,
669 direction: SwapDirectionV1::Forward,
670 reason: reason.clone(),
671 });
672 skipped.push(SkippedAxisV1 {
673 group,
674 axis,
675 direction: SwapDirectionV1::Backward,
676 reason,
677 });
678 continue;
679 }
680 let (Some(a_value), Some(b_value)) = (a_value, b_value) else {
681 continue; // absent on BOTH sides -- nothing to attribute, not reported.
682 };
683
684 let a_refusal = match with_axis(a, axis, a_value) {
685 Ok(_) => None,
686 Err(KernelError::AxisUnsupportedForRequest { reason, .. }) => Some(reason.to_string()),
687 Err(other) => return Err(other),
688 };
689 let b_refusal = match with_axis(b, axis, b_value) {
690 Ok(_) => None,
691 Err(KernelError::AxisUnsupportedForRequest { reason, .. }) => Some(reason.to_string()),
692 Err(other) => return Err(other),
693 };
694 if a_refusal.is_some() || b_refusal.is_some() {
695 excluded.push(axis);
696 skipped.push(SkippedAxisV1 {
697 group,
698 axis,
699 direction: SwapDirectionV1::Forward,
700 reason: describe_refusal(axis, &a_refusal, &b_refusal),
701 });
702 skipped.push(SkippedAxisV1 {
703 group,
704 axis,
705 direction: SwapDirectionV1::Backward,
706 reason: describe_refusal(axis, &b_refusal, &a_refusal),
707 });
708 }
709 }
710 Ok((excluded, skipped))
711}
712
713/// True when `a` and `b` describe the projectile's drag differently in a way no
714/// `ProjectileDrag` taxonomy axis carries: a caller drag curve (`projectile.drag_table`,
715/// MBA-1597), a BC ladder (`projectile.bc_segments`, MBA-1598), or an offline BC correction
716/// table (`corrections.bc5d_table_path`). `swap_group` carries these three as one unit with
717/// `ProjectileDrag` whenever this is true (MBA-1603); see the module doc.
718///
719/// The destructuring is exhaustive on purpose (no `..`): a new member of
720/// `ResolvedProjectileV1` or `CorrectionsV1` fails to compile here until someone decides
721/// whether it is part of the drag description, instead of silently landing in
722/// `interaction_remainder` the way these three did.
723fn drag_descriptions_differ(a: &ResolvedSolveRequestV1, b: &ResolvedSolveRequestV1) -> bool {
724 let ResolvedProjectileV1 {
725 mass_kg: _,
726 diameter_m: _,
727 length_m: _,
728 drag_model: _,
729 ballistic_coefficient: _,
730 drag_table: _,
731 bc_segments: _,
732 } = &a.projectile;
733 fn correction_table(r: &ResolvedSolveRequestV1) -> Option<&str> {
734 r.corrections
735 .as_ref()
736 .and_then(|CorrectionsV1 { bc5d_table_path }| bc5d_table_path.as_deref())
737 }
738 a.projectile.drag_table != b.projectile.drag_table
739 || a.projectile.bc_segments != b.projectile.bc_segments
740 || correction_table(a) != correction_table(b)
741}
742
743/// Build the [`SolveRequestV1`] representing `dst` with every NON-EXCLUDED axis of `group`
744/// copied from `src`, applying each axis one at a time and re-resolving between writes (via a
745/// real `solve_v1` call) so a later axis in the same group sees the effect of an earlier one --
746/// most importantly, so `ZeroSightGeometry`'s `SightHeight` re-derives the elevation for the
747/// destination's own physics after `MuzzleAngle` (listed first specifically for this) has
748/// written the source's (see the module doc's "`MuzzleAngle` is derived" section). Does not
749/// itself evaluate a trajectory -- the caller does that once, over the whole `ranges_m` slice,
750/// exactly as [`central_difference`](crate::perturbation::central_difference) already does for
751/// its own pair of counterfactual requests.
752///
753/// `excluded` is computed ONCE per group by `plan_exclusions`, from `a`/`b` exactly as passed
754/// into [`explain_difference`], before either swap direction runs -- never re-derived here. See
755/// the module doc's "Why exclusions are decided from the original requests" for why that
756/// ordering is required for correctness, not just convenience: every axis reaching this loop is
757/// guaranteed still writable on the running request, however many earlier axes in this same
758/// group have already re-resolved it.
759///
760/// # Errors
761///
762/// Any [`with_axis`] or `solve_v1` failure propagates via `?` unchanged: by the time an axis
763/// reaches this loop it is not supposed to be refusable -- `plan_exclusions` has already
764/// filtered out every conflict known to reach here, including the magnus/enhanced-spin-drift
765/// pair (`taxonomy.rs`'s Known Limitation (d)) and the coriolis/latitude dependency (F2, 0.33.0
766/// final-review fix wave) -- so a failure here is either a genuine bug (a `read_axis`/
767/// `with_axis` mismatch) or a new, not-yet-recognized conflict, never a structurally
768/// unrepresentable axis or one of the cases already known and excluded.
769fn swap_group(
770 dst: &ResolvedSolveRequestV1,
771 src: &ResolvedSolveRequestV1,
772 group: InputGroup,
773 excluded: &[InputAxis],
774) -> Result<SolveRequestV1, KernelError> {
775 let mut current = dst.clone();
776 // MBA-1603: the drag description travels with ProjectileDrag as one unit, cleared from the
777 // destination BEFORE the scalar axes are written and replaced by the source's AFTER them.
778 // Clearing first keeps every intermediate re-resolve legal (a BC5D table never sees a
779 // foreign diameter, which its caliber check refuses); writing last means the ladder or
780 // table arrives on a projectile whose drag_model, BC, mass and diameter are already the
781 // source's, so band BCs never fly against the wrong reference curve. Skipped entirely when
782 // the two descriptions are equal, which leaves every such comparison exactly as before.
783 let carry_drag_description =
784 group == InputGroup::ProjectileDrag && drag_descriptions_differ(dst, src);
785 if carry_drag_description {
786 current.projectile.drag_table = None;
787 current.projectile.bc_segments = None;
788 current.corrections = None;
789 }
790 for &axis in axes_in_group(group) {
791 if excluded.contains(&axis) {
792 continue;
793 }
794 let Some(v) = read_axis(src, axis) else {
795 // plan_exclusions already excludes any axis present on exactly one side; a `None`
796 // reaching here means it is absent on BOTH sides -- a plain no-op, nothing to copy.
797 continue;
798 };
799 let req = with_axis(¤t, axis, v)?;
800 current = crate::solve_v1::solve_v1(req)
801 .map_err(kernel_solve_error)?
802 .resolved_request;
803 }
804 if carry_drag_description {
805 let mut req: SolveRequestV1 = (¤t).into();
806 req.projectile.drag_table = src.projectile.drag_table.clone();
807 req.projectile.bc_segments = src.projectile.bc_segments.clone();
808 req.corrections = src.corrections.clone();
809 // The same re-zero rule `with_axis` applies to a requires_rezero axis: a new drag
810 // description invalidates the destination's searched elevation.
811 if req.shot.zero_distance_m.is_some() {
812 req.shot.muzzle_angle_rad = None;
813 }
814 current = crate::solve_v1::solve_v1(req)
815 .map_err(kernel_solve_error)?
816 .resolved_request;
817 }
818 Ok((¤t).into())
819}
820
821/// Attribute the difference between two fully resolved solve results to the seven input groups
822/// (MBA-1345).
823///
824/// For each [`InputGroup`] `g`, the forward leg replaces `g`'s axes on `a` with `b`'s values and
825/// measures the change against `a`; the backward leg replaces `g`'s axes on `b` with `a`'s
826/// values and measures the change against `b`, negated. The group's reported contribution is
827/// the MEAN of the two, which is what makes the decomposition independent of which request is
828/// treated as the "before" and which as the "after". Whatever the seven groups do not explain
829/// -- genuine nonlinear interaction between them -- is reported once per range, as
830/// `SolutionDiffRowV1::interaction_remainder`, and is never distributed across the groups: for
831/// correlated inputs there is no unique causal attribution, and pretending otherwise is the
832/// failure this design exists to avoid.
833///
834/// `ranges_m` is passed straight through to two calls to [`evaluate`] per group (one per swap
835/// direction), plus two more for `a`/`b` themselves -- 16 solves for the fixed seven-group
836/// taxonomy, each covering every range in `ranges_m` at once rather than once per range (see
837/// [`evaluate`]'s own cost note). That 16 is NOT the total, though: building each group's
838/// counterfactual (`swap_group`) re-resolves via a full `solve_v1` call after EVERY applied
839/// axis, regardless of whether that specific axis is `requires_rezero` -- not just the ones that
840/// invalidate a zero -- because [`with_axis`] always needs a freshly resolved request to apply
841/// its NEXT write onto. For a request with N present axes (up to the full 32; 5-6 are commonly
842/// absent, e.g. `length_m`/`latitude_rad`/the POI offsets, so N is often closer to 27), that is
843/// up to N solves per direction, 2N total, ON TOP of the 16 -- so a typical call costs on the
844/// order of 16 + 2*27 = 70 solves, not 16. `plan_exclusions` itself adds no solves (it only
845/// calls [`read_axis`]/[`with_axis`], never `solve_v1`), so excluding an axis only ever reduces
846/// this total, never increases it. When the two requests' drag descriptions differ (a drag
847/// table, BC ladder or correction table; see the module doc's MBA-1603 section),
848/// `ProjectileDrag`'s swap re-resolves once more per direction to write the source's
849/// description, so such a call costs 2 solves more.
850///
851/// # Axes that cannot be swapped
852///
853/// See the module doc's "Symmetric exclusion" section for the full explanation. In short:
854/// [`with_axis`] can refuse an axis for a structural reason specific to `a` or `b` (a
855/// QNH-referenced altitude, a compass-referenced shot azimuth, one of the three wind axes under
856/// segmented wind), and an ordinarily-optional axis can be present on only one of `a`/`b`. An
857/// axis affected by either is recorded in the returned report's `skipped_axes` -- one entry per
858/// swap direction, always both together, even though only one direction may have hit the
859/// refusal or absence directly -- and excluded from BOTH legs of that group's swap, so the two
860/// legs keep measuring the same counterfactual; the rest of that axis's group is still
861/// attributed normally, and a refusal never aborts the comparison. An axis absent from BOTH `a`
862/// and `b` (no `length_m` supplied on either, for instance) is skipped without being recorded:
863/// there is nothing there to attribute either way.
864///
865/// # Errors
866///
867/// Returns whatever [`evaluate`], [`with_axis`], or `plan_exclusions` error on `a`, `b`, or
868/// either swapped counterfactual, MINUS the refusal/absence cases above
869/// (`KernelError::AxisUnsupportedForRequest`, the presence-asymmetry case, which never even
870/// reaches a `KernelError`, and the two cross-axis conflicts `plan_exclusions` now also catches
871/// up front -- `CoriolisEnabled` differing while either request lacks `latitude_rad`, and
872/// `MagnusEnabled`/`EnhancedSpinDriftEnabled` swapping between requests with opposite flags,
873/// `taxonomy.rs`'s Known Limitation (d) -- both F2, 0.33.0 final-review fix wave), which are
874/// caught and recorded rather than returned. Every OTHER error -- some other `solve_v1`
875/// re-resolve failing validation partway through building a group's counterfactual, not one of
876/// the cases `plan_exclusions` already recognizes -- propagates unchanged: it is a genuine
877/// failure, not a structurally unrepresentable axis, and must not be silently absorbed into a
878/// skip or a zero contribution.
879///
880/// `a` and `b` are read-only throughout: every counterfactual request is built from a clone
881/// (`swap_group`), never a mutation of either input.
882pub fn explain_difference(
883 a: &ResolvedSolveRequestV1,
884 b: &ResolvedSolveRequestV1,
885 ranges_m: &[f64],
886) -> Result<SolutionDiffReportV1, KernelError> {
887 let obs_a = evaluate(&a.into(), ranges_m)?;
888 let obs_b = evaluate(&b.into(), ranges_m)?;
889
890 let mut skipped_axes = Vec::new();
891 let mut per_group: Vec<(InputGroup, Vec<DeltaV1>)> = Vec::with_capacity(InputGroup::ALL.len());
892 for &group in InputGroup::ALL {
893 let (excluded, mut group_skips) = plan_exclusions(a, b, group)?;
894 skipped_axes.append(&mut group_skips);
895
896 // Each swapped request is evaluated exactly ONCE over the whole `ranges_m` slice below
897 // -- never once per range -- so N ranges do not multiply the cost of a
898 // `requires_rezero` axis's zero search inside `swap_group`.
899 let fwd_req = swap_group(a, b, group, &excluded)?;
900 let bwd_req = swap_group(b, a, group, &excluded)?;
901 let fwd_obs = evaluate(&fwd_req, ranges_m)?;
902 let bwd_obs = evaluate(&bwd_req, ranges_m)?;
903
904 let mut deltas = Vec::with_capacity(ranges_m.len());
905 for i in 0..ranges_m.len() {
906 let forward = DeltaV1::between(&obs_a[i], &fwd_obs[i]); // A with g<-B, vs A
907 let backward = DeltaV1::between(&obs_b[i], &bwd_obs[i]).neg(); // B with g<-A, vs B, negated
908 deltas.push(DeltaV1::mean(forward, backward));
909 }
910 per_group.push((group, deltas));
911 }
912
913 let mut rows = Vec::with_capacity(ranges_m.len());
914 for (i, &range_m) in ranges_m.iter().enumerate() {
915 let total = DeltaV1::between(&obs_a[i], &obs_b[i]);
916 let contributions: Vec<GroupContributionV1> = per_group
917 .iter()
918 .map(|(g, d)| GroupContributionV1 {
919 group: *g,
920 delta: d[i],
921 })
922 .collect();
923 let summed = contributions
924 .iter()
925 .fold(DeltaV1::default(), |acc, c| acc.add(c.delta));
926 rows.push(SolutionDiffRowV1 {
927 range_m,
928 total,
929 contributions,
930 interaction_remainder: total.sub(summed),
931 });
932 }
933
934 Ok(SolutionDiffReportV1 {
935 schema_version: EXPLAIN_SCHEMA_VERSION_V1,
936 method: "symmetric_group_counterfactual".to_string(),
937 assumptions: vec![
938 "Group contributions are symmetric counterfactuals: the mean of swapping the group \
939 in each direction, so the result does not depend on replacement order."
940 .to_string(),
941 "Nonlinear interaction between groups is reported as an explicit interaction \
942 remainder and is NOT distributed across groups. For correlated inputs no unique \
943 causal attribution exists."
944 .to_string(),
945 "An axis that could not be swapped for one or both requests (see skipped_axes) is \
946 excluded from BOTH directions of that axis's group, so the two directions keep \
947 measuring the same counterfactual; any real effect the axis would have had is \
948 folded into the interaction remainder, not silently dropped and not partially \
949 attributed to its group."
950 .to_string(),
951 ],
952 skipped_axes,
953 rows,
954 })
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960
961 fn resolved(mv: f64, temp: f64) -> crate::solve_json::ResolvedSolveRequestV1 {
962 let json = serde_json::json!({
963 "schema_version": 1,
964 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
965 "ballistic_coefficient": 0.243},
966 "rifle": {"muzzle_velocity_mps": mv, "sight_height_m": 0.05},
967 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
968 "atmosphere": {"temperature_k": temp}, "wind": {"speed_mps": 3.0,
969 "direction_from_rad": std::f64::consts::FRAC_PI_2},
970 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
971 })
972 .to_string();
973 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
974 crate::solve_v1::solve_v1(req).unwrap().resolved_request
975 }
976
977 /// Varies ONLY `ballistic_coefficient` (ProjectileDrag) and wind speed (Wind), holding
978 /// muzzle velocity, temperature, and every sight/zero knob identical to `resolved()`'s own
979 /// baseline -- see `every_groups_contribution_matches_an_independent_recomputation_bc_and_wind_fixture`.
980 fn resolved_with_bc_and_wind_speed(
981 ballistic_coefficient: f64,
982 wind_speed_mps: f64,
983 ) -> crate::solve_json::ResolvedSolveRequestV1 {
984 let json = serde_json::json!({
985 "schema_version": 1,
986 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
987 "ballistic_coefficient": ballistic_coefficient},
988 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
989 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
990 "atmosphere": {"temperature_k": 288.0},
991 "wind": {"speed_mps": wind_speed_mps,
992 "direction_from_rad": std::f64::consts::FRAC_PI_2},
993 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
994 })
995 .to_string();
996 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
997 crate::solve_v1::solve_v1(req).unwrap().resolved_request
998 }
999
1000 fn resolved_with_effects(
1001 mv: f64,
1002 magnus: bool,
1003 enhanced_spin_drift: bool,
1004 ) -> crate::solve_json::ResolvedSolveRequestV1 {
1005 let json = serde_json::json!({
1006 "schema_version": 1,
1007 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1008 "ballistic_coefficient": 0.243},
1009 "rifle": {"muzzle_velocity_mps": mv, "sight_height_m": 0.05},
1010 "shot": {"max_range_m": 900.0},
1011 "atmosphere": {"temperature_k": 288.0},
1012 "wind": {},
1013 "solver": {},
1014 "effects": {"magnus": magnus, "enhanced_spin_drift": enhanced_spin_drift},
1015 "sampling": {"interval_m": 50.0}
1016 })
1017 .to_string();
1018 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1019 crate::solve_v1::solve_v1(req).unwrap().resolved_request
1020 }
1021
1022 /// Acceptance criterion: identical requests produce zero deltas and zero contributions, and
1023 /// nothing is reported as skipped (both requests are ordinary constant-wind, absolute-
1024 /// pressure, shooter-relative requests, so no refusal or structural absence should fire).
1025 #[test]
1026 fn identical_requests_produce_zero_everything() {
1027 let a = resolved(823.0, 288.0);
1028 let rep = explain_difference(&a, &a, &[300.0, 600.0]).unwrap();
1029 assert!(
1030 rep.skipped_axes.is_empty(),
1031 "expected no skipped axes for two ordinary, identical requests, got {:?}",
1032 rep.skipped_axes
1033 );
1034 for row in &rep.rows {
1035 assert!(
1036 row.total.drop_m.abs() < 1e-9,
1037 "total drop {}",
1038 row.total.drop_m
1039 );
1040 assert!(row.interaction_remainder.drop_m.abs() < 1e-9);
1041 for c in &row.contributions {
1042 assert!(
1043 c.delta.drop_m.abs() < 1e-9,
1044 "{:?} contributed {}",
1045 c.group,
1046 c.delta.drop_m
1047 );
1048 }
1049 }
1050 }
1051
1052 /// Swapping the two requests negates every reported quantity.
1053 #[test]
1054 fn the_decomposition_is_antisymmetric() {
1055 let a = resolved(823.0, 288.0);
1056 let b = resolved(870.0, 300.0);
1057 let ab = explain_difference(&a, &b, &[600.0]).unwrap();
1058 let ba = explain_difference(&b, &a, &[600.0]).unwrap();
1059 assert!((ab.rows[0].total.drop_m + ba.rows[0].total.drop_m).abs() < 1e-6);
1060 for (x, y) in ab.rows[0]
1061 .contributions
1062 .iter()
1063 .zip(ba.rows[0].contributions.iter())
1064 {
1065 assert_eq!(x.group, y.group);
1066 assert!(
1067 (x.delta.drop_m + y.delta.drop_m).abs() < 1e-6,
1068 "{:?} not antisymmetric",
1069 x.group
1070 );
1071 }
1072 }
1073
1074 /// Contributions plus the remainder must reconstruct the total exactly.
1075 #[test]
1076 fn contributions_plus_remainder_equal_the_total() {
1077 let a = resolved(823.0, 288.0);
1078 let b = resolved(870.0, 300.0);
1079 let rep = explain_difference(&a, &b, &[600.0]).unwrap();
1080 let row = &rep.rows[0];
1081 let sum: f64 = row.contributions.iter().map(|c| c.delta.drop_m).sum();
1082 assert!((sum + row.interaction_remainder.drop_m - row.total.drop_m).abs() < 1e-9);
1083 }
1084
1085 #[test]
1086 fn the_report_states_its_method_and_assumptions() {
1087 let a = resolved(823.0, 288.0);
1088 let rep = explain_difference(&a, &a, &[300.0]).unwrap();
1089 assert_eq!(rep.method, "symmetric_group_counterfactual");
1090 assert!(rep.assumptions.iter().any(|s| s.contains("interaction")));
1091 }
1092
1093 /// (S8, 0.33.0 final-review fix wave) `schema_version` must be the crate's declared
1094 /// constant, not a stray literal that could silently drift from it -- see
1095 /// `crate::tolerance`'s identical `schema_version_matches_the_declared_constant`.
1096 #[test]
1097 fn schema_version_matches_the_declared_constant() {
1098 let a = resolved(823.0, 288.0);
1099 let rep = explain_difference(&a, &a, &[300.0]).unwrap();
1100 assert_eq!(rep.schema_version, EXPLAIN_SCHEMA_VERSION_V1);
1101 }
1102
1103 /// The two source requests must never be mutated by the comparison -- `explain_difference`
1104 /// takes `&ResolvedSolveRequestV1`, and every counterfactual is built from a clone
1105 /// (`swap_group`), never a mutation of `a`/`b` themselves.
1106 #[test]
1107 fn source_requests_are_not_mutated_by_the_comparison() {
1108 let a = resolved(823.0, 288.0);
1109 let b = resolved(870.0, 300.0);
1110 let a_before = a.clone();
1111 let b_before = b.clone();
1112 let _ = explain_difference(&a, &b, &[300.0, 600.0]).unwrap();
1113 assert_eq!(a, a_before, "the first source request changed");
1114 assert_eq!(b, b_before, "the second source request changed");
1115 }
1116
1117 /// A forward-only (or backward-only) implementation would report either endpoint instead of
1118 /// their mean. Recompute the MuzzleVelocity group's forward and backward legs directly,
1119 /// using the same building blocks `explain_difference` uses internally, and confirm (a) they
1120 /// genuinely differ here -- or averaging them would be indistinguishable from reporting
1121 /// either alone -- and (b) the reported contribution is exactly their mean, not either one.
1122 ///
1123 /// The sanity gate below requires `|forward - backward| > 2e-6`, not `1e-6`: since
1124 /// `mean - forward == (backward - forward) / 2` (and symmetrically for `mean - backward`),
1125 /// a gate of exactly `1e-6` on `|forward - backward|` would only guarantee
1126 /// `|mean - forward| > 5e-7`, half of the `1e-6` margin the two endpoint assertions below
1127 /// actually need -- a value that just cleared the old, looser gate could still trip a
1128 /// false failure on those. `2e-6` is the smallest gate that genuinely implies both.
1129 #[test]
1130 fn contribution_is_the_mean_of_a_genuinely_different_forward_and_backward() {
1131 let a = resolved(823.0, 288.0);
1132 let b = resolved(870.0, 300.0);
1133 let ranges = [600.0];
1134
1135 let obs_a = evaluate(&(&a).into(), &ranges).unwrap();
1136 let obs_b = evaluate(&(&b).into(), &ranges).unwrap();
1137 let (excluded, _skipped) = plan_exclusions(&a, &b, InputGroup::MuzzleVelocity).unwrap();
1138 let fwd_req = swap_group(&a, &b, InputGroup::MuzzleVelocity, &excluded).unwrap();
1139 let bwd_req = swap_group(&b, &a, InputGroup::MuzzleVelocity, &excluded).unwrap();
1140 let fwd_obs = evaluate(&fwd_req, &ranges).unwrap();
1141 let bwd_obs = evaluate(&bwd_req, &ranges).unwrap();
1142 let forward = fwd_obs[0].drop_m - obs_a[0].drop_m;
1143 let backward = obs_b[0].drop_m - bwd_obs[0].drop_m;
1144
1145 assert!(
1146 (forward - backward).abs() > 2e-6,
1147 "forward ({forward}) and backward ({backward}) must genuinely differ here, or this \
1148 test cannot distinguish 'the mean of the two' from 'either endpoint alone'"
1149 );
1150
1151 let rep = explain_difference(&a, &b, &ranges).unwrap();
1152 let mv = rep.rows[0]
1153 .contributions
1154 .iter()
1155 .find(|c| c.group == InputGroup::MuzzleVelocity)
1156 .unwrap();
1157 let expected_mean = 0.5 * (forward + backward);
1158 assert!(
1159 (mv.delta.drop_m - expected_mean).abs() < 1e-9,
1160 "reported {} expected mean {}",
1161 mv.delta.drop_m,
1162 expected_mean
1163 );
1164 assert!(
1165 (mv.delta.drop_m - forward).abs() > 1e-6,
1166 "reported value must not equal the forward leg alone"
1167 );
1168 assert!(
1169 (mv.delta.drop_m - backward).abs() > 1e-6,
1170 "reported value must not equal the backward leg alone"
1171 );
1172 }
1173
1174 /// Cross-checks EVERY group's reported contribution against an independent recomputation
1175 /// (same primitives -- `plan_exclusions` + `swap_group` + `evaluate` -- but the mean is
1176 /// taken here, outside `explain_difference`'s own code path) and the remainder against the
1177 /// same total-minus-sum formula computed independently. This is the check that would fail
1178 /// under: a forward-only computation (the independent mean would disagree with a
1179 /// forward-only report), a remainder silently zeroed or redistributed into the groups (the
1180 /// independent remainder would disagree with a tampered report), or two groups'
1181 /// contributions swapped (checked BY GROUP IDENTITY). Returns the report so callers can
1182 /// layer their own fixture-specific "this group must be zero/non-negligible" assertions on
1183 /// top, since which groups should be non-negligible depends on which axes the caller's own
1184 /// fixture actually varies.
1185 fn assert_matches_independent_recomputation(
1186 a: &crate::solve_json::ResolvedSolveRequestV1,
1187 b: &crate::solve_json::ResolvedSolveRequestV1,
1188 range_m: f64,
1189 ) -> SolutionDiffReportV1 {
1190 let ranges = [range_m];
1191 let rep = explain_difference(a, b, &ranges).unwrap();
1192 let row = &rep.rows[0];
1193
1194 let obs_a = evaluate(&a.into(), &ranges).unwrap();
1195 let obs_b = evaluate(&b.into(), &ranges).unwrap();
1196
1197 let mut independent_sum = DeltaV1::default();
1198 for &group in InputGroup::ALL {
1199 let (excluded, _skipped) = plan_exclusions(a, b, group).unwrap();
1200 let fwd_req = swap_group(a, b, group, &excluded).unwrap();
1201 let bwd_req = swap_group(b, a, group, &excluded).unwrap();
1202 let fwd_obs = evaluate(&fwd_req, &ranges).unwrap();
1203 let bwd_obs = evaluate(&bwd_req, &ranges).unwrap();
1204 let forward = DeltaV1::between(&obs_a[0], &fwd_obs[0]);
1205 let backward = DeltaV1::between(&obs_b[0], &bwd_obs[0]).neg();
1206 let expected = DeltaV1::mean(forward, backward);
1207
1208 let reported = row
1209 .contributions
1210 .iter()
1211 .find(|c| c.group == group)
1212 .unwrap_or_else(|| panic!("{group:?} missing from the report"));
1213 assert!(
1214 (reported.delta.drop_m - expected.drop_m).abs() < 1e-9,
1215 "{group:?}: reported drop {} independent {}",
1216 reported.delta.drop_m,
1217 expected.drop_m
1218 );
1219 assert!(
1220 (reported.delta.windage_m - expected.windage_m).abs() < 1e-9,
1221 "{group:?}: windage mismatch"
1222 );
1223 assert!(
1224 (reported.delta.time_s - expected.time_s).abs() < 1e-9,
1225 "{group:?}: time mismatch"
1226 );
1227 assert!(
1228 (reported.delta.velocity_mps - expected.velocity_mps).abs() < 1e-9,
1229 "{group:?}: velocity mismatch"
1230 );
1231
1232 independent_sum = independent_sum.add(expected);
1233 }
1234
1235 let total = DeltaV1::between(&obs_a[0], &obs_b[0]);
1236 let expected_remainder = total.sub(independent_sum);
1237 assert!(
1238 (row.interaction_remainder.drop_m - expected_remainder.drop_m).abs() < 1e-9,
1239 "reported remainder {} independent remainder {}",
1240 row.interaction_remainder.drop_m,
1241 expected_remainder.drop_m
1242 );
1243
1244 rep
1245 }
1246
1247 /// `resolved()` only ever varies `mv` and `temp`, so a group whose axes are entirely
1248 /// independent of both -- ProjectileDrag, Wind, ShotGeometry, Effects, AND (review C1)
1249 /// ZeroSightGeometry -- must come back EXACTLY zero: nothing to swap, since every one of its
1250 /// axis VALUES is identical on `a` and `b`. Before the C1 fix, `ZeroSightGeometry` was NOT
1251 /// zero here despite that: it also carried `MuzzleAngle`, the RESOLVED elevation, which is a
1252 /// function of muzzle velocity and air density -- so it silently imported the OTHER
1253 /// request's baked-in elevation even though every explicit sight/zero knob was identical.
1254 /// `axes_in_group(ZeroSightGeometry)` now lists `MuzzleAngle` first specifically so a
1255 /// following `requires_rezero` axis re-derives the elevation for the destination's own
1256 /// physics instead (see the module doc). MuzzleVelocity and Atmosphere differ directly and
1257 /// must be non-negligible and pairwise distinguishable, so a label swap involving either
1258 /// would be an unmissable mismatch against the per-group check inside
1259 /// `assert_matches_independent_recomputation`.
1260 #[test]
1261 fn every_groups_contribution_matches_an_independent_recomputation() {
1262 let a = resolved(823.0, 288.0);
1263 let b = resolved(870.0, 300.0);
1264 let rep = assert_matches_independent_recomputation(&a, &b, 600.0);
1265 let row = &rep.rows[0];
1266
1267 for g in [
1268 InputGroup::ProjectileDrag,
1269 InputGroup::ZeroSightGeometry,
1270 InputGroup::Wind,
1271 InputGroup::ShotGeometry,
1272 InputGroup::Effects,
1273 ] {
1274 let c = row.contributions.iter().find(|x| x.group == g).unwrap();
1275 assert!(
1276 c.delta.drop_m.abs() < 1e-9,
1277 "{g:?} does not differ between a and b in this fixture at all, expected \
1278 exactly 0, got {}",
1279 c.delta.drop_m
1280 );
1281 }
1282 let mv = row
1283 .contributions
1284 .iter()
1285 .find(|c| c.group == InputGroup::MuzzleVelocity)
1286 .unwrap();
1287 let atmosphere = row
1288 .contributions
1289 .iter()
1290 .find(|c| c.group == InputGroup::Atmosphere)
1291 .unwrap();
1292 for (name, c) in [("MuzzleVelocity", mv), ("Atmosphere", atmosphere)] {
1293 assert!(
1294 c.delta.drop_m.abs() > 0.01,
1295 "{name} should have a substantial, non-negligible contribution here, got {}",
1296 c.delta.drop_m
1297 );
1298 }
1299 assert!(
1300 (mv.delta.drop_m - atmosphere.delta.drop_m).abs() > 0.01,
1301 "MuzzleVelocity ({}) and Atmosphere ({}) should be distinguishable, not just both \
1302 'non-negligible'",
1303 mv.delta.drop_m,
1304 atmosphere.delta.drop_m
1305 );
1306
1307 // The remainder itself must also be genuinely nonzero here (real second-order
1308 // interaction between muzzle velocity and temperature/elevation) -- not identically
1309 // zero, and not silently redistributed into the groups above (which would make THIS
1310 // remainder read back as zero while the groups' sum still matched `total`). Measured at
1311 // ~9.2e-4 after the C1 fix (it was ~4.9e-2 before: MuzzleVelocity's own re-zero and
1312 // Atmosphere's own "not re-zeroed" treatment now correctly explain almost all of the
1313 // elevation response themselves, instead of a chunk of it being force-fed through a
1314 // ZeroSightGeometry that should have been zero) -- 1e-4 stays comfortably below that
1315 // measured value while remaining far above floating-point noise (~1e-9-1e-12).
1316 assert!(
1317 row.interaction_remainder.drop_m.abs() > 1e-4,
1318 "expected a non-negligible interaction remainder in this fixture, got {}",
1319 row.interaction_remainder.drop_m
1320 );
1321 }
1322
1323 /// Review minor: `resolved()`'s fixture never varies `ballistic_coefficient` or wind speed,
1324 /// so a mislabeling among ProjectileDrag/Wind (or between either of them and any of the
1325 /// other always-zero groups) would be INVISIBLE to the test above -- every one of those
1326 /// groups reports zero there either way. A second fixture, varying only BC and wind speed
1327 /// (holding mv/temp/sight/zero geometry identical), exercises ProjectileDrag and Wind with a
1328 /// substantial, distinguishable value instead, closing that gap.
1329 #[test]
1330 fn every_groups_contribution_matches_an_independent_recomputation_bc_and_wind_fixture() {
1331 let a = resolved_with_bc_and_wind_speed(0.243, 3.0);
1332 let b = resolved_with_bc_and_wind_speed(0.300, 6.0);
1333 let rep = assert_matches_independent_recomputation(&a, &b, 600.0);
1334 let row = &rep.rows[0];
1335
1336 for g in [
1337 InputGroup::MuzzleVelocity,
1338 InputGroup::ZeroSightGeometry,
1339 InputGroup::Atmosphere,
1340 InputGroup::ShotGeometry,
1341 InputGroup::Effects,
1342 ] {
1343 let c = row.contributions.iter().find(|x| x.group == g).unwrap();
1344 assert!(
1345 c.delta.drop_m.abs() < 1e-9,
1346 "{g:?} does not differ between a and b in this fixture at all, expected \
1347 exactly 0, got {}",
1348 c.delta.drop_m
1349 );
1350 }
1351 let drag = row
1352 .contributions
1353 .iter()
1354 .find(|c| c.group == InputGroup::ProjectileDrag)
1355 .unwrap();
1356 let wind = row
1357 .contributions
1358 .iter()
1359 .find(|c| c.group == InputGroup::Wind)
1360 .unwrap();
1361 // ProjectileDrag (a higher BC) is measured on drop, its dominant effect; Wind (a
1362 // stronger crosswind) is measured on windage, ITS dominant effect -- checking each
1363 // group in the quantity it actually moves, rather than requiring both to be large in
1364 // the SAME quantity, so this does not depend on the two effects happening to be
1365 // comparable in magnitude.
1366 assert!(
1367 drag.delta.drop_m.abs() > 0.01,
1368 "ProjectileDrag should have a substantial drop contribution here, got {}",
1369 drag.delta.drop_m
1370 );
1371 assert!(
1372 wind.delta.windage_m.abs() > 0.01,
1373 "Wind should have a substantial windage contribution here, got {}",
1374 wind.delta.windage_m
1375 );
1376 // And each group must not be silently carrying the OTHER's signature: ProjectileDrag's
1377 // own windage move and Wind's own drop move should both be comparatively small, which
1378 // is what a labeling swap between these two specific groups would violate.
1379 assert!(
1380 drag.delta.windage_m.abs() < wind.delta.windage_m.abs(),
1381 "ProjectileDrag's windage move ({}) should be smaller than Wind's own ({})",
1382 drag.delta.windage_m,
1383 wind.delta.windage_m
1384 );
1385 assert!(
1386 wind.delta.drop_m.abs() < drag.delta.drop_m.abs(),
1387 "Wind's drop move ({}) should be smaller than ProjectileDrag's own ({})",
1388 wind.delta.drop_m,
1389 drag.delta.drop_m
1390 );
1391 }
1392
1393 /// Review round 3, N3: moving `MuzzleAngle` to the front of `ZeroSightGeometry` (C1) is
1394 /// only safe because a LATER `requires_rezero` axis's clear-gate fires and re-derives the
1395 /// angle -- and that gate is conditioned on `zero_distance_m` being present. For an
1396 /// angle-only request (`zero_distance_m == None`), the gate never fires, so `MuzzleAngle`
1397 /// genuinely IS the independent input and must still be swapped. Two angle-only requests
1398 /// with distinct `muzzle_angle_rad` values, both with no `zero_distance_m` at all, must
1399 /// still show a substantial `ZeroSightGeometry` contribution -- confirming the C1 reorder
1400 /// did not silently turn `MuzzleAngle` into a no-op for the one case it must still cover.
1401 #[test]
1402 fn muzzle_angle_is_still_swapped_for_an_angle_only_request() {
1403 let build = |muzzle_angle_rad: f64| {
1404 serde_json::json!({
1405 "schema_version": 1,
1406 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1407 "ballistic_coefficient": 0.243},
1408 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1409 "shot": {"max_range_m": 900.0, "muzzle_angle_rad": muzzle_angle_rad},
1410 "atmosphere": {"temperature_k": 288.0},
1411 "wind": {},
1412 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
1413 })
1414 .to_string()
1415 };
1416 let a = crate::solve_v1::solve_v1(
1417 crate::solve_json::decode_solve_request_v1(&build(0.010)).unwrap(),
1418 )
1419 .unwrap()
1420 .resolved_request;
1421 let b = crate::solve_v1::solve_v1(
1422 crate::solve_json::decode_solve_request_v1(&build(0.030)).unwrap(),
1423 )
1424 .unwrap()
1425 .resolved_request;
1426 assert_eq!(
1427 a.shot.zero_distance_m, None,
1428 "fixture assumption: angle-only, no zero distance at all"
1429 );
1430 assert_eq!(
1431 b.shot.zero_distance_m, None,
1432 "fixture assumption: angle-only, no zero distance at all"
1433 );
1434
1435 // Uses the shared helper (same primitives as explain_difference, mean taken outside its
1436 // own code path) so this ALSO confirms the swapped value matches an independent
1437 // recomputation, not just that it is non-zero.
1438 let rep = assert_matches_independent_recomputation(&a, &b, 600.0);
1439 let row = &rep.rows[0];
1440 let zero_sight_geometry = row
1441 .contributions
1442 .iter()
1443 .find(|c| c.group == InputGroup::ZeroSightGeometry)
1444 .unwrap();
1445 assert!(
1446 zero_sight_geometry.delta.drop_m.abs() > 0.01,
1447 "MuzzleAngle must still be swapped for an angle-only request -- expected a \
1448 substantial ZeroSightGeometry contribution, got {}",
1449 zero_sight_geometry.delta.drop_m
1450 );
1451 }
1452
1453 /// Requirement (1): `with_axis` refuses `Altitude` under a QNH-referenced atmosphere. The
1454 /// refusal must be recorded, not silently dropped, and must not abort the comparison. Per
1455 /// review I1, exclusion must apply to BOTH swap directions (`b` is not itself QNH-referenced,
1456 /// so only the Forward leg would refuse Altitude on its own -- Backward must be excluded
1457 /// anyway, to keep the two legs measuring the same counterfactual).
1458 #[test]
1459 fn altitude_is_skipped_and_recorded_under_qnh_pressure() {
1460 let qnh_json = serde_json::json!({
1461 "schema_version": 1,
1462 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1463 "ballistic_coefficient": 0.243},
1464 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1465 "shot": {"max_range_m": 900.0},
1466 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1467 "pressure_reference": "qnh"},
1468 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1469 })
1470 .to_string();
1471 let a = crate::solve_v1::solve_v1(
1472 crate::solve_json::decode_solve_request_v1(&qnh_json).unwrap(),
1473 )
1474 .unwrap()
1475 .resolved_request;
1476 let b = resolved(870.0, 300.0);
1477 assert_eq!(
1478 b.atmosphere.pressure_reference, None,
1479 "fixture assumption: b is NOT QNH-referenced, so only a's QNH-ness drives this test"
1480 );
1481
1482 let rep = explain_difference(&a, &b, &[300.0])
1483 .expect("a refused axis must not abort the whole comparison");
1484 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
1485 let hit = rep
1486 .skipped_axes
1487 .iter()
1488 .find(|s| {
1489 s.group == InputGroup::Atmosphere
1490 && s.axis == InputAxis::Altitude
1491 && s.direction == direction
1492 })
1493 .unwrap_or_else(|| {
1494 panic!(
1495 "expected a {direction:?}-direction Altitude skip under QNH pressure \
1496 (review I1: both directions must be excluded, not just the one that \
1497 independently refused), got {:?}",
1498 rep.skipped_axes
1499 )
1500 });
1501 assert!(
1502 hit.reason.to_lowercase().contains("qnh"),
1503 "reason should name QNH: {}",
1504 hit.reason
1505 );
1506 }
1507 }
1508
1509 /// Review I1's explicit ask: not just that Altitude is recorded as skipped, but that the
1510 /// resulting Atmosphere CONTRIBUTION is actually correct -- reflecting ONLY the other axes,
1511 /// with no partial leakage from the excluded Altitude difference. Proven by showing the
1512 /// reported contribution is UNCHANGED when b's altitude is moved from one value to ANOTHER:
1513 /// if Altitude's difference were still (even partially) leaking into the contribution,
1514 /// moving it would change the reported number; since Altitude is excluded from BOTH legs
1515 /// either way, it must not.
1516 ///
1517 /// Deliberately compares TWO altitudes that BOTH differ from `a`'s (1200 and 900), neither
1518 /// equal to `a`'s own 500 -- review round 4, F1 excludes `Pressure` only when the two
1519 /// requests' altitudes actually differ, so comparing against an altitude that happens to
1520 /// MATCH `a`'s would stop excluding `Pressure` in that one leg and confound this test with a
1521 /// second, unrelated effect (see `pressure_exclusion_does_not_leak_a_partial_effect_into_atmosphere`'s
1522 /// own doc comment for that failure mode in detail).
1523 ///
1524 /// This does NOT mean `altitude_m` is inert once the station temperature and pressure are
1525 /// both given directly (review round 5): `calculate_atmosphere` (`atmosphere.rs`) only
1526 /// skips altitude for the ONE-TIME station-level resolution in that case -- it is not the
1527 /// only channel. `TrajectorySolver::calculate_acceleration` (`cli_api.rs`, MBA-1136)
1528 /// separately recomputes a LOCAL atmosphere at every RK4/RK45 substep from the station
1529 /// altitude and the bullet's current height via `shot_frame_altitude`/
1530 /// `get_local_atmosphere_humid`, unconditionally, regardless of pressure-reference mode or
1531 /// whether temperature/pressure were explicit -- `perturbation::evaluate` goes through this
1532 /// same solver, so it is live here too. Because the geopotential-height conversion this
1533 /// lapse computation uses is mildly nonlinear, comparing two DIFFERENT absolute station
1534 /// altitudes (as this test's two `explain_difference` calls do) is not perfectly
1535 /// scale-invariant even when the seed temperature/pressure are identical. Measured directly
1536 /// (temporary, uncommitted probe) rather than assumed away: on this exact fixture (1200 m
1537 /// vs 900 m, 300 m range), the residual is `~3.7e-12` -- about 270x below the `1e-9`
1538 /// tolerance below, and about 9-10 significant digits of agreement on a ~4.3e-3 m baseline,
1539 /// consistent with ordinary floating-point accumulation through an RK4 integration rather
1540 /// than a physically resolvable effect. Pushing the gap to an unrealistic 900 m vs 5000 m
1541 /// (still the same short 300 m query range) only grows it to `~5.0e-11` -- still comfortably
1542 /// below `1e-9`. `< 1e-9` is kept as an effectively-exact bound for THIS fixture's altitude
1543 /// range, not because the channel doesn't exist.
1544 #[test]
1545 fn altitude_exclusion_does_not_leak_a_partial_effect_into_atmosphere() {
1546 let qnh_json = serde_json::json!({
1547 "schema_version": 1,
1548 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1549 "ballistic_coefficient": 0.243},
1550 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1551 "shot": {"max_range_m": 900.0},
1552 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1553 "pressure_reference": "qnh"},
1554 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1555 })
1556 .to_string();
1557 let a = crate::solve_v1::solve_v1(
1558 crate::solve_json::decode_solve_request_v1(&qnh_json).unwrap(),
1559 )
1560 .unwrap()
1561 .resolved_request;
1562
1563 let b_json = |altitude_m: f64| {
1564 serde_json::json!({
1565 "schema_version": 1,
1566 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1567 "ballistic_coefficient": 0.243},
1568 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1569 "shot": {"max_range_m": 900.0},
1570 "atmosphere": {"altitude_m": altitude_m, "temperature_k": 300.0,
1571 "pressure_pa": 98000.0},
1572 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1573 })
1574 .to_string()
1575 };
1576 let b_altitude_1200 = crate::solve_v1::solve_v1(
1577 crate::solve_json::decode_solve_request_v1(&b_json(1200.0)).unwrap(),
1578 )
1579 .unwrap()
1580 .resolved_request;
1581 let b_altitude_900 = crate::solve_v1::solve_v1(
1582 crate::solve_json::decode_solve_request_v1(&b_json(900.0)).unwrap(),
1583 )
1584 .unwrap()
1585 .resolved_request;
1586 // Fixture assumption: NEITHER b variant's altitude may equal a's 500 -- see the doc
1587 // comment above for why that specific coincidence would confound this test.
1588 assert_ne!(
1589 b_altitude_1200.atmosphere.altitude_m,
1590 a.atmosphere.altitude_m
1591 );
1592 assert_ne!(
1593 b_altitude_900.atmosphere.altitude_m,
1594 a.atmosphere.altitude_m
1595 );
1596
1597 let rep_1200 = explain_difference(&a, &b_altitude_1200, &[300.0]).unwrap();
1598 let rep_900 = explain_difference(&a, &b_altitude_900, &[300.0]).unwrap();
1599
1600 // Sanity: Altitude must be excluded on BOTH directions in BOTH comparisons, so this is
1601 // a fair, apples-to-apples check of the same code path (not, say, one comparison
1602 // exercising the exclusion and the other happening to skip it because the values
1603 // coincided).
1604 for rep in [&rep_1200, &rep_900] {
1605 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
1606 assert!(
1607 rep.skipped_axes
1608 .iter()
1609 .any(|s| s.group == InputGroup::Atmosphere
1610 && s.axis == InputAxis::Altitude
1611 && s.direction == direction),
1612 "expected Altitude excluded on {direction:?} in both comparisons"
1613 );
1614 }
1615 }
1616
1617 let atmosphere_1200 = rep_1200.rows[0]
1618 .contributions
1619 .iter()
1620 .find(|c| c.group == InputGroup::Atmosphere)
1621 .unwrap();
1622 let atmosphere_900 = rep_900.rows[0]
1623 .contributions
1624 .iter()
1625 .find(|c| c.group == InputGroup::Atmosphere)
1626 .unwrap();
1627 assert!(
1628 (atmosphere_1200.delta.drop_m - atmosphere_900.delta.drop_m).abs() < 1e-9,
1629 "Atmosphere's contribution changed when b's altitude changed (1200 -> 900 m) even \
1630 though Altitude is excluded from both comparisons -- {} vs {} -- a partial \
1631 altitude effect must be leaking through",
1632 atmosphere_1200.delta.drop_m,
1633 atmosphere_900.delta.drop_m
1634 );
1635 // And it must be genuinely non-zero (temperature alone is a real effect here), or the
1636 // equality above would be trivially true for the wrong reason (both sides zero).
1637 assert!(
1638 atmosphere_900.delta.drop_m.abs() > 0.001,
1639 "expected a non-negligible Atmosphere contribution from temperature alone, got {}",
1640 atmosphere_900.delta.drop_m
1641 );
1642 }
1643
1644 /// Review round 3, N2 (derived-value exclusion rule, Instance 3): the Altitude test above
1645 /// only exercises a QNH-vs-ABSOLUTE comparison, where `b`'s plain absolute pressure has no
1646 /// altitude dependency to leak in the first place -- it cannot catch Pressure itself still
1647 /// being swapped. This uses a QNH-vs-QNH comparison instead: both `b` variants share the
1648 /// SAME altitude (1200 m, always different from `a`'s 500 m -- review round 4, F1, the same
1649 /// reasoning as the Altitude test's own doc comment: comparing against an altitude that
1650 /// happens to match `a`'s would stop excluding `Pressure` in that leg and confound the
1651 /// comparison) but differ in their RAW QNH value, so their resolved `pressure_pa` differs
1652 /// too. If Pressure were still being copied (the bug this fix addresses), that resolved
1653 /// difference would move Atmosphere's contribution; since Pressure is excluded in BOTH
1654 /// comparisons here (both `b` variants sit at 1200 m, never 500 m), it must not.
1655 ///
1656 /// Unlike the Altitude test, this ONE needs a much looser tolerance, not just a non-exact
1657 /// one: `Altitude`/`Pressure` are excluded from being SWAPPED, but each `b` variant's
1658 /// baseline trajectory is still solved at ITS OWN real, unswapped (and, here,
1659 /// differing-by-QNH-value) pressure, and the swapped `Temperature` axis's effect on drop is
1660 /// itself (very slightly) a function of the ambient density it is evaluated in -- a genuine,
1661 /// small, second-order interaction between a held-fixed axis and a swapped one, not a leak.
1662 /// The Altitude test's sibling comparison has an analogous channel too (the MBA-1136 local
1663 /// altitude-lapse recompute, see that test's own doc comment, review round 5) -- it is not
1664 /// that no such channel exists there, only that it is roughly nine orders of magnitude
1665 /// smaller (measured `~3.7e-12` there vs `~1.8e-4` here), because THIS test's variation is a
1666 /// real, first-derivative pressure difference at a FIXED altitude, where that one is a
1667 /// second-derivative geopotential-conversion nonlinearity across two DIFFERENT altitudes.
1668 /// I measured both sides with a temporary, uncommitted probe before choosing this test's
1669 /// tolerance: with the fix disabled, this fixture's two comparisons differed by `~9.7e-3`
1670 /// (a first-order effect, the shape a real leak takes). With the fix applied, they differ by
1671 /// `~1.8e-4` -- over 50x
1672 /// smaller. `2e-3` sits with comfortable margin above the fixed residual and comfortably
1673 /// below the broken one.
1674 ///
1675 /// See `pressure_and_altitude_exclusion_give_an_exactly_zero_atmosphere_contribution_when_nothing_else_differs`
1676 /// below for a complementary, bit-EXACT version of this same property (the reviewer's own
1677 /// suggestion): when the two requests differ ONLY in altitude, every remaining Atmosphere
1678 /// axis is identical, so there is no second-order channel left and the contribution must be
1679 /// precisely `0.0`.
1680 #[test]
1681 fn pressure_exclusion_does_not_leak_a_partial_effect_into_atmosphere() {
1682 let qnh_json = |altitude_m: f64, temperature_k: f64, raw_qnh_pa: f64| {
1683 serde_json::json!({
1684 "schema_version": 1,
1685 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1686 "ballistic_coefficient": 0.243},
1687 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1688 "shot": {"max_range_m": 900.0},
1689 "atmosphere": {"altitude_m": altitude_m, "temperature_k": temperature_k,
1690 "pressure_pa": raw_qnh_pa, "pressure_reference": "qnh"},
1691 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1692 })
1693 .to_string()
1694 };
1695 let a = crate::solve_v1::solve_v1(
1696 crate::solve_json::decode_solve_request_v1(&qnh_json(500.0, 288.0, 101_325.0)).unwrap(),
1697 )
1698 .unwrap()
1699 .resolved_request;
1700 let b_qnh_101325 = crate::solve_v1::solve_v1(
1701 crate::solve_json::decode_solve_request_v1(&qnh_json(1200.0, 300.0, 101_325.0))
1702 .unwrap(),
1703 )
1704 .unwrap()
1705 .resolved_request;
1706 let b_qnh_105000 = crate::solve_v1::solve_v1(
1707 crate::solve_json::decode_solve_request_v1(&qnh_json(1200.0, 300.0, 105_000.0))
1708 .unwrap(),
1709 )
1710 .unwrap()
1711 .resolved_request;
1712
1713 // Fixture assumptions: both b variants sit at the SAME altitude (1200 m), which must
1714 // NOT equal a's (500 m) -- see the doc comment above -- and their resolved pressure_pa
1715 // must differ, purely from the raw QNH value, confirming this fixture actually
1716 // exercises "does the specific excluded Pressure value leak."
1717 assert_eq!(
1718 b_qnh_101325.atmosphere.altitude_m,
1719 b_qnh_105000.atmosphere.altitude_m
1720 );
1721 assert_ne!(b_qnh_101325.atmosphere.altitude_m, a.atmosphere.altitude_m);
1722 assert_ne!(
1723 b_qnh_101325.atmosphere.pressure_pa, b_qnh_105000.atmosphere.pressure_pa,
1724 "fixture assumption: different raw QNH at the same altitude must resolve to a \
1725 different station pressure"
1726 );
1727
1728 let rep_101325 = explain_difference(&a, &b_qnh_101325, &[300.0]).unwrap();
1729 let rep_105000 = explain_difference(&a, &b_qnh_105000, &[300.0]).unwrap();
1730
1731 for rep in [&rep_101325, &rep_105000] {
1732 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
1733 assert!(
1734 rep.skipped_axes
1735 .iter()
1736 .any(|s| s.group == InputGroup::Atmosphere
1737 && s.axis == InputAxis::Pressure
1738 && s.direction == direction),
1739 "expected Pressure excluded on {direction:?} under QNH-vs-QNH; got {:?}",
1740 rep.skipped_axes
1741 );
1742 }
1743 }
1744
1745 let atmosphere_101325 = rep_101325.rows[0]
1746 .contributions
1747 .iter()
1748 .find(|c| c.group == InputGroup::Atmosphere)
1749 .unwrap();
1750 let atmosphere_105000 = rep_105000.rows[0]
1751 .contributions
1752 .iter()
1753 .find(|c| c.group == InputGroup::Atmosphere)
1754 .unwrap();
1755 // See the doc comment above for exactly why this is 2e-3, not exact equality: measured
1756 // empirically at ~1.8e-4 with the fix applied vs. ~9.7e-3 with it disabled, on this
1757 // exact fixture.
1758 assert!(
1759 (atmosphere_101325.delta.drop_m - atmosphere_105000.delta.drop_m).abs() < 2e-3,
1760 "Atmosphere's contribution changed by more than the expected small second-order \
1761 residual when b's raw QNH value changed (101325 -> 105000 Pa) at the SAME altitude, \
1762 even though Pressure and Altitude are both excluded -- {} vs {} -- a first-order \
1763 pressure effect looks like it is leaking through",
1764 atmosphere_101325.delta.drop_m,
1765 atmosphere_105000.delta.drop_m
1766 );
1767 assert!(
1768 atmosphere_105000.delta.drop_m.abs() > 0.001,
1769 "expected a non-negligible Atmosphere contribution from temperature alone, got {}",
1770 atmosphere_105000.delta.drop_m
1771 );
1772 }
1773
1774 /// The reviewer's own suggestion (review round 4): a bit-EXACT complement to the tolerance
1775 /// test above. Two QNH-referenced requests differing ONLY in altitude (same explicit
1776 /// temperature, same everything else) exclude both `Altitude` and `Pressure`, leaving every
1777 /// OTHER `Atmosphere` axis byte-identical between them -- so `swap_group` reproduces the
1778 /// destination exactly, both legs are exact no-ops, and the contribution must be precisely
1779 /// `0.0`, not merely small. This is the same shape as
1780 /// `wind_direction_is_excluded_under_compass_wind_even_with_an_identical_raw_bearing` and the
1781 /// post-C1 `ZeroSightGeometry` assertion in `every_groups_contribution_matches_an_independent_recomputation`:
1782 /// a real leak would produce a large, first-order non-zero here, not a rounding-sized one.
1783 #[test]
1784 fn pressure_and_altitude_exclusion_give_an_exactly_zero_atmosphere_contribution_when_nothing_else_differs(
1785 ) {
1786 let qnh_json = |altitude_m: f64| {
1787 serde_json::json!({
1788 "schema_version": 1,
1789 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1790 "ballistic_coefficient": 0.243},
1791 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1792 "shot": {"max_range_m": 900.0},
1793 "atmosphere": {"altitude_m": altitude_m, "temperature_k": 288.0,
1794 "pressure_pa": 101_325.0, "pressure_reference": "qnh"},
1795 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1796 })
1797 .to_string()
1798 };
1799 let a = crate::solve_v1::solve_v1(
1800 crate::solve_json::decode_solve_request_v1(&qnh_json(500.0)).unwrap(),
1801 )
1802 .unwrap()
1803 .resolved_request;
1804 let b = crate::solve_v1::solve_v1(
1805 crate::solve_json::decode_solve_request_v1(&qnh_json(1200.0)).unwrap(),
1806 )
1807 .unwrap()
1808 .resolved_request;
1809 assert_ne!(
1810 a.atmosphere.altitude_m, b.atmosphere.altitude_m,
1811 "fixture assumption"
1812 );
1813 assert_eq!(
1814 a.atmosphere.temperature_k, b.atmosphere.temperature_k,
1815 "fixture assumption: temperature must be identical, or Atmosphere would have a \
1816 real, non-excluded axis to report and the contribution would not be zero"
1817 );
1818
1819 let rep = explain_difference(&a, &b, &[300.0])
1820 .expect("Altitude/Pressure exclusion must not abort the comparison");
1821 let atmosphere = rep.rows[0]
1822 .contributions
1823 .iter()
1824 .find(|c| c.group == InputGroup::Atmosphere)
1825 .unwrap();
1826 assert_eq!(
1827 atmosphere.delta.drop_m, 0.0,
1828 "Atmosphere's drop contribution must be exactly zero when altitude is the ONLY \
1829 thing that differs and both Altitude and Pressure are excluded, got {}",
1830 atmosphere.delta.drop_m
1831 );
1832 assert_eq!(
1833 atmosphere.delta.windage_m, 0.0,
1834 "Atmosphere's windage contribution must be exactly zero for the same reason, got {}",
1835 atmosphere.delta.windage_m
1836 );
1837
1838 // Non-triviality: unlike the other exact-zero tests in this file, a nonzero TOTAL isn't
1839 // the right guard here -- altitude alone, once excluded, is expected to affect nothing
1840 // at all, so a zero total is normal, not suspicious. What WOULD be suspicious is the
1841 // exclusion mechanism never actually engaging (e.g. a refactor that silently turned
1842 // plan_exclusions into a no-op, which would ALSO report an exact zero here, for the
1843 // wrong reason). Guard against that directly: both axes must actually appear in
1844 // skipped_axes.
1845 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
1846 for axis in [InputAxis::Altitude, InputAxis::Pressure] {
1847 assert!(
1848 rep.skipped_axes
1849 .iter()
1850 .any(|s| s.group == InputGroup::Atmosphere
1851 && s.axis == axis
1852 && s.direction == direction),
1853 "expected {axis:?} excluded on {direction:?}; got {:?}",
1854 rep.skipped_axes
1855 );
1856 }
1857 }
1858 }
1859
1860 /// Review round 4, F1: the Pressure exclusion must NOT fire when the two requests'
1861 /// altitudes MATCH -- only a differing altitude actually breaks the QNH-to-station-pressure
1862 /// pairing. Two QNH-referenced requests at the SAME altitude but DIFFERENT raw altimeter
1863 /// settings must attribute that difference to `Atmosphere` normally: excluding `Pressure`
1864 /// unconditionally on the QNH mode flag alone (the bug this test guards against) would
1865 /// UNDER-attribute a genuine altimeter-setting difference into the interaction remainder on
1866 /// the most natural same-location QNH comparison anyone would run.
1867 #[test]
1868 fn pressure_is_still_swapped_when_the_altitude_matches() {
1869 let build = |raw_qnh_pa: f64| {
1870 serde_json::json!({
1871 "schema_version": 1,
1872 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1873 "ballistic_coefficient": 0.243},
1874 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1875 "shot": {"max_range_m": 900.0},
1876 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0,
1877 "pressure_pa": raw_qnh_pa, "pressure_reference": "qnh"},
1878 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1879 })
1880 .to_string()
1881 };
1882 let a = crate::solve_v1::solve_v1(
1883 crate::solve_json::decode_solve_request_v1(&build(101_325.0)).unwrap(),
1884 )
1885 .unwrap()
1886 .resolved_request;
1887 let b = crate::solve_v1::solve_v1(
1888 crate::solve_json::decode_solve_request_v1(&build(98_000.0)).unwrap(),
1889 )
1890 .unwrap()
1891 .resolved_request;
1892 assert_eq!(
1893 a.atmosphere.altitude_m, b.atmosphere.altitude_m,
1894 "fixture assumption: identical altitude on both sides"
1895 );
1896 assert_ne!(
1897 a.atmosphere.pressure_pa, b.atmosphere.pressure_pa,
1898 "fixture assumption: different raw QNH must still resolve to different station \
1899 pressure at the same altitude"
1900 );
1901
1902 let rep = explain_difference(&a, &b, &[300.0]).unwrap();
1903
1904 assert!(
1905 !rep.skipped_axes
1906 .iter()
1907 .any(|s| s.group == InputGroup::Atmosphere && s.axis == InputAxis::Pressure),
1908 "Pressure must not be excluded when altitude matches on both sides; got {:?}",
1909 rep.skipped_axes
1910 );
1911
1912 let atmosphere = rep.rows[0]
1913 .contributions
1914 .iter()
1915 .find(|c| c.group == InputGroup::Atmosphere)
1916 .unwrap();
1917 assert!(
1918 atmosphere.delta.drop_m.abs() > 0.001,
1919 "Atmosphere's contribution must be substantial and non-zero here -- the raw QNH \
1920 genuinely differs and the shared altitude means Pressure is safe to swap -- got {}",
1921 atmosphere.delta.drop_m
1922 );
1923 }
1924
1925 /// Review I2: an ordinarily-optional axis present on exactly one of the two requests splits
1926 /// the two legs exactly like a `with_axis` refusal does -- forward would keep A's own value
1927 /// (nothing to copy from B) while backward would overwrite B with A's, silently
1928 /// half-attributing whatever that value's effect is, with NO record at all. `latitude_rad`
1929 /// is a convenient example: present here on `a`, absent on `b` -- an ordinary difference
1930 /// between two saved profiles, nothing to do with Coriolis specifically (Coriolis is left
1931 /// disabled on both, so this is not entangled with `resolve_effects`'s separate requirement
1932 /// that Coriolis needs a latitude on any request that enables it).
1933 #[test]
1934 fn an_axis_present_on_only_one_side_is_skipped_and_recorded_symmetrically() {
1935 let with_latitude_json = serde_json::json!({
1936 "schema_version": 1,
1937 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1938 "ballistic_coefficient": 0.243},
1939 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1940 "shot": {"max_range_m": 900.0},
1941 "atmosphere": {"temperature_k": 288.0, "latitude_rad": 0.7},
1942 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1943 })
1944 .to_string();
1945 let a = crate::solve_v1::solve_v1(
1946 crate::solve_json::decode_solve_request_v1(&with_latitude_json).unwrap(),
1947 )
1948 .unwrap()
1949 .resolved_request;
1950 assert_eq!(
1951 a.atmosphere.latitude_rad,
1952 Some(0.7),
1953 "fixture assumption: a supplies latitude_rad"
1954 );
1955
1956 let b = resolved(870.0, 300.0);
1957 assert_eq!(
1958 b.atmosphere.latitude_rad, None,
1959 "fixture assumption: b omits latitude_rad entirely"
1960 );
1961
1962 let rep = explain_difference(&a, &b, &[300.0])
1963 .expect("a presence-only-on-one-side axis must not abort the whole comparison");
1964
1965 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
1966 assert!(
1967 rep.skipped_axes
1968 .iter()
1969 .any(|s| s.group == InputGroup::Atmosphere
1970 && s.axis == InputAxis::Latitude
1971 && s.direction == direction),
1972 "expected a {direction:?} Latitude skip when it is present on only one side; \
1973 got {:?}",
1974 rep.skipped_axes
1975 );
1976 }
1977 }
1978
1979 /// (F2, 0.33.0 final-review fix wave) `a` and `b` each solve fine individually -- `a` has
1980 /// coriolis enabled with a supplied `latitude_rad`, `b` has coriolis left disabled and
1981 /// omits `latitude_rad` entirely, an ordinary difference between two saved profiles. Before
1982 /// this fix, comparing them aborted the WHOLE report with a bare `solve_v1` error
1983 /// ("latitude_rad is required when the Coriolis effect is enabled") the moment the backward
1984 /// leg tried to write coriolis=true onto `b`'s clone: `with_axis` performs no cross-field
1985 /// validation, so the write itself always "succeeds," and the failure only surfaces on the
1986 /// re-resolve inside `swap_group`, well after `plan_exclusions`'s existing refusal checks
1987 /// had already passed it through. `plan_exclusions` must now catch this up front instead.
1988 #[test]
1989 fn coriolis_conflict_with_missing_latitude_is_excluded_not_a_hard_abort() {
1990 let with_coriolis_json = serde_json::json!({
1991 "schema_version": 1,
1992 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1993 "ballistic_coefficient": 0.243},
1994 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1995 "shot": {"max_range_m": 900.0},
1996 "atmosphere": {"temperature_k": 288.0, "latitude_rad": 0.7},
1997 "wind": {}, "solver": {}, "effects": {"coriolis": true},
1998 "sampling": {"interval_m": 50.0}
1999 })
2000 .to_string();
2001 let a = crate::solve_v1::solve_v1(
2002 crate::solve_json::decode_solve_request_v1(&with_coriolis_json).unwrap(),
2003 )
2004 .unwrap()
2005 .resolved_request;
2006 assert!(
2007 a.effects.coriolis,
2008 "fixture assumption: a has coriolis enabled"
2009 );
2010 assert_eq!(
2011 a.atmosphere.latitude_rad,
2012 Some(0.7),
2013 "fixture assumption: a supplies latitude_rad"
2014 );
2015
2016 let b = resolved(823.0, 288.0);
2017 assert!(
2018 !b.effects.coriolis,
2019 "fixture assumption: b has coriolis disabled"
2020 );
2021 assert_eq!(
2022 b.atmosphere.latitude_rad, None,
2023 "fixture assumption: b omits latitude_rad entirely"
2024 );
2025
2026 let rep = explain_difference(&a, &b, &[600.0]).expect(
2027 "a coriolis/latitude conflict must be excluded, not abort the whole comparison",
2028 );
2029
2030 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
2031 assert!(
2032 rep.skipped_axes
2033 .iter()
2034 .any(|s| s.group == InputGroup::Effects
2035 && s.axis == InputAxis::CoriolisEnabled
2036 && s.direction == direction),
2037 "expected a {direction:?} CoriolisEnabled skip when the flags differ and one \
2038 side lacks latitude_rad; got {:?}",
2039 rep.skipped_axes
2040 );
2041 }
2042 let reason = &rep
2043 .skipped_axes
2044 .iter()
2045 .find(|s| s.group == InputGroup::Effects && s.axis == InputAxis::CoriolisEnabled)
2046 .unwrap()
2047 .reason;
2048 assert!(
2049 reason.to_lowercase().contains("latitude"),
2050 "reason must name latitude_rad as the cause: {reason}"
2051 );
2052
2053 assert_eq!(rep.rows.len(), 1);
2054 }
2055
2056 /// (F2, continued) The guard above must fire on the actual CONFLICT, not blanket-exclude
2057 /// `CoriolisEnabled` whenever the flags merely differ: here both `a` and `b` supply the
2058 /// identical `latitude_rad`, so turning coriolis on for either destination is always safe,
2059 /// and the axis must still be swapped and contribute normally.
2060 #[test]
2061 fn coriolis_still_swaps_when_both_requests_have_latitude() {
2062 let build = |coriolis: bool| {
2063 serde_json::json!({
2064 "schema_version": 1,
2065 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2066 "ballistic_coefficient": 0.243},
2067 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
2068 "shot": {"max_range_m": 900.0},
2069 "atmosphere": {"temperature_k": 288.0, "latitude_rad": 0.7},
2070 "wind": {}, "solver": {}, "effects": {"coriolis": coriolis},
2071 "sampling": {"interval_m": 50.0}
2072 })
2073 .to_string()
2074 };
2075 let a = crate::solve_v1::solve_v1(
2076 crate::solve_json::decode_solve_request_v1(&build(true)).unwrap(),
2077 )
2078 .unwrap()
2079 .resolved_request;
2080 let b = crate::solve_v1::solve_v1(
2081 crate::solve_json::decode_solve_request_v1(&build(false)).unwrap(),
2082 )
2083 .unwrap()
2084 .resolved_request;
2085 assert_eq!(
2086 a.atmosphere.latitude_rad, b.atmosphere.latitude_rad,
2087 "fixture assumption: identical latitude_rad on both sides"
2088 );
2089 assert_ne!(
2090 a.effects.coriolis, b.effects.coriolis,
2091 "fixture assumption: flags differ"
2092 );
2093
2094 let rep = explain_difference(&a, &b, &[600.0]).unwrap();
2095
2096 assert!(
2097 !rep.skipped_axes
2098 .iter()
2099 .any(|s| s.group == InputGroup::Effects && s.axis == InputAxis::CoriolisEnabled),
2100 "CoriolisEnabled must not be excluded when both sides supply the same latitude_rad; \
2101 got {:?}",
2102 rep.skipped_axes
2103 );
2104
2105 let effects = rep.rows[0]
2106 .contributions
2107 .iter()
2108 .find(|c| c.group == InputGroup::Effects)
2109 .unwrap();
2110 assert!(
2111 effects.delta.drop_m.abs() > 1e-6 || effects.delta.windage_m.abs() > 1e-6,
2112 "Effects' contribution must be substantial and non-zero here -- coriolis genuinely \
2113 differs and both sides can safely carry it -- got {:?}",
2114 effects.delta
2115 );
2116 }
2117
2118 /// This fixture's shape comes from the ordering hazard documented in this module's doc
2119 /// comment: `ShotGeometry` lists `TargetDistance`, `ShootingAngle` and `Cant` before
2120 /// `ShotAzimuth`. Deciding exclusions from `a`/`b` directly (`plan_exclusions`, never a
2121 /// request `swap_group` has already partly rewritten) makes that hazard structurally
2122 /// impossible now -- there is no accumulated, partly-laundered request for a check to see,
2123 /// regardless of axis order -- so this specific fixture can no longer directly exercise an
2124 /// order-dependent failure the way an earlier, per-swap_group-call revision of this module
2125 /// could. What it still usefully pins down is review I1's symmetric-exclusion fix: `b`'s
2126 /// wind is shooter-relative, so ONLY the Forward leg independently refuses `ShotAzimuth` (a
2127 /// compass-referenced `a`), yet Backward must be excluded too, with a reason that still
2128 /// names the compass refusal that drove it.
2129 #[test]
2130 fn shot_azimuth_is_refused_even_when_other_shot_geometry_axes_are_applied_first() {
2131 let compass_json = serde_json::json!({
2132 "schema_version": 1,
2133 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2134 "ballistic_coefficient": 0.243},
2135 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
2136 "shot": {"max_range_m": 900.0, "shooting_angle_rad": 0.05, "cant_angle_rad": 0.01,
2137 "shot_azimuth_rad": 0.3},
2138 "atmosphere": {"temperature_k": 288.0},
2139 "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "compass"},
2140 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
2141 })
2142 .to_string();
2143 let a = crate::solve_v1::solve_v1(
2144 crate::solve_json::decode_solve_request_v1(&compass_json).unwrap(),
2145 )
2146 .unwrap()
2147 .resolved_request;
2148 match &a.wind {
2149 crate::solve_json::ResolvedWindV1::Constant(c) => assert_eq!(
2150 c.wind_reference,
2151 Some(crate::solve_json::WindReferenceV1::Compass),
2152 "fixture assumption: a's wind must be compass-referenced"
2153 ),
2154 crate::solve_json::ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
2155 }
2156
2157 let shooter_json = serde_json::json!({
2158 "schema_version": 1,
2159 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2160 "ballistic_coefficient": 0.243},
2161 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
2162 "shot": {"max_range_m": 950.0, "shooting_angle_rad": 0.08, "cant_angle_rad": 0.02,
2163 "shot_azimuth_rad": 0.9},
2164 "atmosphere": {"temperature_k": 288.0},
2165 "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0},
2166 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
2167 })
2168 .to_string();
2169 let b = crate::solve_v1::solve_v1(
2170 crate::solve_json::decode_solve_request_v1(&shooter_json).unwrap(),
2171 )
2172 .unwrap()
2173 .resolved_request;
2174
2175 let rep = explain_difference(&a, &b, &[300.0])
2176 .expect("a refused axis must not abort the whole comparison");
2177
2178 let hit = rep
2179 .skipped_axes
2180 .iter()
2181 .find(|s| {
2182 s.group == InputGroup::ShotGeometry
2183 && s.axis == InputAxis::ShotAzimuth
2184 && s.direction == SwapDirectionV1::Forward
2185 })
2186 .unwrap_or_else(|| {
2187 panic!(
2188 "expected a Forward-direction ShotAzimuth skip under compass wind, even \
2189 though TargetDistance/ShootingAngle/Cant are applied (and each \
2190 re-resolved) first within the same ShotGeometry group; got skipped_axes = \
2191 {:?}",
2192 rep.skipped_axes
2193 )
2194 });
2195 assert!(
2196 hit.reason.to_lowercase().contains("compass"),
2197 "reason should name compass wind: {}",
2198 hit.reason
2199 );
2200 // Review I1: even though `b` is shooter-relative and would not refuse ShotAzimuth on
2201 // its own, Backward must ALSO be excluded -- otherwise forward keeps A's azimuth
2202 // (refused) while backward hands B the whole of A's azimuth (not refused), splitting
2203 // the two legs into different counterfactuals.
2204 let backward_hit = rep
2205 .skipped_axes
2206 .iter()
2207 .find(|s| {
2208 s.group == InputGroup::ShotGeometry
2209 && s.axis == InputAxis::ShotAzimuth
2210 && s.direction == SwapDirectionV1::Backward
2211 })
2212 .unwrap_or_else(|| {
2213 panic!(
2214 "expected a Backward-direction ShotAzimuth skip too (review I1: symmetric \
2215 exclusion), even though b's wind is shooter-relative; got skipped_axes = \
2216 {:?}",
2217 rep.skipped_axes
2218 )
2219 });
2220 assert!(
2221 backward_hit.reason.to_lowercase().contains("compass"),
2222 "the sympathetic Backward exclusion's reason should still explain the compass \
2223 refusal that drove it: {}",
2224 backward_hit.reason
2225 );
2226 }
2227
2228 /// Review round 3, N1 (derived-value exclusion rule, Instance 2): `with_axis` already
2229 /// refuses to swap `ShotAzimuth` under compass wind, protecting `ShotGeometry`'s own
2230 /// number, but nothing protected `WindDirection`, whose OWN resolved (shooter-relative)
2231 /// value is `(bearing - shot_azimuth_rad).rem_euclid(2*pi)` -- also a function of
2232 /// `shot_azimuth_rad`. Two compass-referenced requests with the SAME raw wind bearing but
2233 /// DIFFERENT `shot_azimuth_rad` resolve to DIFFERENT `direction_from_rad`, purely from the
2234 /// azimuth difference; `Wind`'s contribution must still be exactly zero, not a re-labelled
2235 /// azimuth effect.
2236 #[test]
2237 fn wind_direction_is_excluded_under_compass_wind_even_with_an_identical_raw_bearing() {
2238 let build = |shot_azimuth_rad: f64| {
2239 serde_json::json!({
2240 "schema_version": 1,
2241 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2242 "ballistic_coefficient": 0.243},
2243 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
2244 "shot": {"max_range_m": 900.0, "shot_azimuth_rad": shot_azimuth_rad},
2245 "atmosphere": {"temperature_k": 288.0},
2246 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2,
2247 "wind_reference": "compass"},
2248 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
2249 })
2250 .to_string()
2251 };
2252 let a = crate::solve_v1::solve_v1(
2253 crate::solve_json::decode_solve_request_v1(&build(0.0)).unwrap(),
2254 )
2255 .unwrap()
2256 .resolved_request;
2257 let b = crate::solve_v1::solve_v1(
2258 crate::solve_json::decode_solve_request_v1(&build(std::f64::consts::FRAC_PI_4))
2259 .unwrap(),
2260 )
2261 .unwrap()
2262 .resolved_request;
2263
2264 // Sanity: the SAME raw bearing (both pi/2) must resolve to DIFFERENT shooter-relative
2265 // directions, purely from the azimuth difference -- confirming this fixture actually
2266 // exercises the hazard, not a fixture that happens to agree for an unrelated reason.
2267 let (a_dir, b_dir) = match (&a.wind, &b.wind) {
2268 (
2269 crate::solve_json::ResolvedWindV1::Constant(ca),
2270 crate::solve_json::ResolvedWindV1::Constant(cb),
2271 ) => (ca.direction_from_rad, cb.direction_from_rad),
2272 _ => panic!("constant wind expected on both sides"),
2273 };
2274 assert!(
2275 (a_dir - b_dir).abs() > 0.1,
2276 "fixture must produce genuinely different resolved wind directions from the SAME \
2277 raw bearing (a: {a_dir}, b: {b_dir}), or this test proves nothing"
2278 );
2279
2280 let rep = explain_difference(&a, &b, &[300.0])
2281 .expect("a derived-value exclusion must not abort the whole comparison");
2282
2283 // Non-triviality (small consistency note, review round 4): the total must be genuinely
2284 // non-zero, or a fixture that collapsed to two identical trajectories overall would also
2285 // report Wind == 0.0 for the wrong reason (matching the pattern the Pressure tests
2286 // already use). This fixture's total IS expected to differ: a's and b's OWN effective
2287 // (shooter-relative) wind angle differ, since the SAME raw bearing is transformed by
2288 // DIFFERENT shot azimuths -- the sanity check above already pins that resolved-direction
2289 // difference down; this confirms it actually shows up in the un-swapped baseline too.
2290 assert!(
2291 rep.rows[0].total.windage_m.abs() > 0.01,
2292 "expected a's and b's own (un-swapped) trajectories to differ meaningfully in \
2293 windage, since their effective wind angles differ -- got total windage {}",
2294 rep.rows[0].total.windage_m
2295 );
2296
2297 let wind = rep.rows[0]
2298 .contributions
2299 .iter()
2300 .find(|c| c.group == InputGroup::Wind)
2301 .unwrap();
2302 assert_eq!(
2303 wind.delta.drop_m, 0.0,
2304 "Wind's drop contribution must be exactly zero (WindDirection excluded), got {}",
2305 wind.delta.drop_m
2306 );
2307 assert_eq!(
2308 wind.delta.windage_m, 0.0,
2309 "Wind's windage contribution must be exactly zero (WindDirection excluded), got {}",
2310 wind.delta.windage_m
2311 );
2312
2313 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
2314 let hit = rep
2315 .skipped_axes
2316 .iter()
2317 .find(|s| {
2318 s.group == InputGroup::Wind
2319 && s.axis == InputAxis::WindDirection
2320 && s.direction == direction
2321 })
2322 .unwrap_or_else(|| {
2323 panic!(
2324 "expected a {direction:?} WindDirection skip under compass wind; got \
2325 {:?}",
2326 rep.skipped_axes
2327 )
2328 });
2329 assert!(
2330 hit.reason.to_lowercase().contains("shot_azimuth")
2331 || hit.reason.to_lowercase().contains("azimuth"),
2332 "reason should name the shot-azimuth dependency: {}",
2333 hit.reason
2334 );
2335 }
2336 }
2337
2338 /// Review round 4, F1: the WindDirection exclusion must NOT fire when the two requests'
2339 /// shot azimuths MATCH -- only a differing azimuth actually entangles the compass-to-
2340 /// shooter-relative transform. Two compass-referenced requests with the SAME
2341 /// `shot_azimuth_rad` but DIFFERENT raw wind bearings must attribute that difference to
2342 /// `Wind` normally: excluding `WindDirection` unconditionally on the compass mode flag
2343 /// alone (the bug this test guards against) would UNDER-attribute a genuine wind-bearing
2344 /// difference into the interaction remainder on the most natural same-shot-direction
2345 /// compass comparison anyone would run.
2346 #[test]
2347 fn wind_direction_is_still_swapped_when_the_shot_azimuth_matches() {
2348 let build = |bearing_rad: f64| {
2349 serde_json::json!({
2350 "schema_version": 1,
2351 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2352 "ballistic_coefficient": 0.243},
2353 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
2354 "shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
2355 "atmosphere": {"temperature_k": 288.0},
2356 "wind": {"speed_mps": 3.0, "direction_from_rad": bearing_rad,
2357 "wind_reference": "compass"},
2358 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
2359 })
2360 .to_string()
2361 };
2362 let a = crate::solve_v1::solve_v1(
2363 crate::solve_json::decode_solve_request_v1(&build(std::f64::consts::FRAC_PI_2))
2364 .unwrap(),
2365 )
2366 .unwrap()
2367 .resolved_request;
2368 let b = crate::solve_v1::solve_v1(
2369 crate::solve_json::decode_solve_request_v1(&build(std::f64::consts::FRAC_PI_4))
2370 .unwrap(),
2371 )
2372 .unwrap()
2373 .resolved_request;
2374 assert_eq!(
2375 a.shot.shot_azimuth_rad, b.shot.shot_azimuth_rad,
2376 "fixture assumption: identical shot azimuth on both sides"
2377 );
2378
2379 let rep = explain_difference(&a, &b, &[300.0]).unwrap();
2380
2381 assert!(
2382 !rep.skipped_axes
2383 .iter()
2384 .any(|s| s.group == InputGroup::Wind && s.axis == InputAxis::WindDirection),
2385 "WindDirection must not be excluded when the shot azimuth matches on both sides; \
2386 got {:?}",
2387 rep.skipped_axes
2388 );
2389
2390 let wind = rep.rows[0]
2391 .contributions
2392 .iter()
2393 .find(|c| c.group == InputGroup::Wind)
2394 .unwrap();
2395 assert!(
2396 wind.delta.windage_m.abs() > 0.01,
2397 "Wind's windage contribution must be substantial and non-zero here -- the raw \
2398 bearing genuinely differs and the shared azimuth means WindDirection is safe to \
2399 swap -- got {}",
2400 wind.delta.windage_m
2401 );
2402 }
2403
2404 /// Requirement (1)'s third example, both detection paths at once: `a` is constant wind and
2405 /// `b` is segmented, so the Forward leg (reading FROM b) hits `read_axis` returning `None`,
2406 /// and the Backward leg (writing TO b) hits `with_axis`'s own `AxisAbsent`.
2407 #[test]
2408 fn wind_axes_are_skipped_and_recorded_under_segmented_wind_on_either_side() {
2409 let a = resolved(823.0, 288.0);
2410 let segmented_json = serde_json::json!({
2411 "schema_version": 1,
2412 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2413 "ballistic_coefficient": 0.243},
2414 "rifle": {"muzzle_velocity_mps": 850.0, "sight_height_m": 0.05},
2415 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
2416 "atmosphere": {"temperature_k": 295.0},
2417 "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 4.0,
2418 "direction_from_rad": 1.2}]},
2419 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
2420 })
2421 .to_string();
2422 let b = crate::solve_v1::solve_v1(
2423 crate::solve_json::decode_solve_request_v1(&segmented_json).unwrap(),
2424 )
2425 .unwrap()
2426 .resolved_request;
2427 assert!(matches!(
2428 b.wind,
2429 crate::solve_json::ResolvedWindV1::Segmented(_)
2430 ));
2431
2432 let rep = explain_difference(&a, &b, &[300.0])
2433 .expect("segmented wind must not abort the whole comparison");
2434
2435 for axis in [
2436 InputAxis::WindSpeed,
2437 InputAxis::WindDirection,
2438 InputAxis::WindVertical,
2439 ] {
2440 assert!(
2441 rep.skipped_axes.iter().any(|s| s.group == InputGroup::Wind
2442 && s.axis == axis
2443 && s.direction == SwapDirectionV1::Forward),
2444 "{axis:?}: expected a Forward skip (the source, b, is segmented); got {:?}",
2445 rep.skipped_axes
2446 );
2447 assert!(
2448 rep.skipped_axes.iter().any(|s| s.group == InputGroup::Wind
2449 && s.axis == axis
2450 && s.direction == SwapDirectionV1::Backward),
2451 "{axis:?}: expected a Backward skip (the destination, b, is segmented); got {:?}",
2452 rep.skipped_axes
2453 );
2454 }
2455 }
2456
2457 /// (F2, 0.33.0 final-review fix wave) `Effects` lists `MagnusEnabled` before
2458 /// `EnhancedSpinDriftEnabled`; naively swapping Magnus on (from B into A) while A still
2459 /// carries its own `enhanced_spin_drift = true` from before would produce an intermediate
2460 /// request with both flags true, which `solve_v1` rejects as `ConflictingFields`
2461 /// (`taxonomy.rs`'s Known Limitation (d)) -- even though the FINAL state after the whole
2462 /// group is applied (magnus on, enhanced spin drift off) would have been perfectly valid,
2463 /// and even though `a` and `b` each solve fine individually. Before this fix,
2464 /// `explain_difference` let that intermediate failure propagate and abort the ENTIRE
2465 /// report; `plan_exclusions`'s own principle is that an exclusion never aborts the
2466 /// comparison, so this must instead exclude both conflicting axes together (see
2467 /// `plan_exclusions`'s `MagnusEnabled` guard for why BOTH, not just one) and let the
2468 /// comparison succeed.
2469 #[test]
2470 fn magnus_and_enhanced_spin_drift_conflict_is_excluded_not_a_hard_abort() {
2471 let a = resolved_with_effects(823.0, false, true); // magnus off, enhanced_spin_drift on
2472 let b = resolved_with_effects(823.0, true, false); // magnus on, enhanced_spin_drift off
2473 let rep = explain_difference(&a, &b, &[300.0]).expect(
2474 "a magnus/enhanced_spin_drift conflict must be excluded, not abort the whole report",
2475 );
2476
2477 for axis in [
2478 InputAxis::MagnusEnabled,
2479 InputAxis::EnhancedSpinDriftEnabled,
2480 ] {
2481 for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
2482 assert!(
2483 rep.skipped_axes
2484 .iter()
2485 .any(|s| s.group == InputGroup::Effects
2486 && s.axis == axis
2487 && s.direction == direction),
2488 "{axis:?} {direction:?}: expected a skip naming the magnus/\
2489 enhanced_spin_drift conflict; got {:?}",
2490 rep.skipped_axes
2491 );
2492 }
2493 }
2494 // The reason names the real conflict, not a placeholder.
2495 let reason = &rep
2496 .skipped_axes
2497 .iter()
2498 .find(|s| s.group == InputGroup::Effects && s.axis == InputAxis::MagnusEnabled)
2499 .unwrap()
2500 .reason;
2501 assert!(reason.contains("enhanced_spin_drift"), "{reason}");
2502
2503 assert_eq!(rep.rows.len(), 1);
2504 }
2505
2506 // ---- MBA-1603: the drag description beyond the scalar ProjectileDrag axes ----
2507
2508 /// The `resolved_with_bc_and_wind_speed(0.243, 3.0)` baseline with `projectile` members
2509 /// overwritten or added from `projectile_extra`. Every OTHER input stays byte-identical, so
2510 /// any difference between two of these is a ProjectileDrag difference and nothing else.
2511 fn resolved_with_projectile(
2512 projectile_extra: serde_json::Value,
2513 ) -> crate::solve_json::ResolvedSolveRequestV1 {
2514 let mut json = serde_json::json!({
2515 "schema_version": 1,
2516 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
2517 "ballistic_coefficient": 0.243},
2518 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
2519 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
2520 "atmosphere": {"temperature_k": 288.0},
2521 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
2522 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
2523 });
2524 for (key, value) in projectile_extra.as_object().unwrap() {
2525 json["projectile"][key] = value.clone();
2526 }
2527 let req = crate::solve_json::decode_solve_request_v1(&json.to_string()).unwrap();
2528 crate::solve_v1::solve_v1(req).unwrap().resolved_request
2529 }
2530
2531 fn mba1603_band(min: f64, max: f64, bc: f64) -> serde_json::Value {
2532 serde_json::json!({"velocity_min_mps": min, "velocity_max_mps": max,
2533 "ballistic_coefficient": bc})
2534 }
2535
2536 /// A five-point G1-shaped curve: far enough from G7 that flying it moves the 600 m drop by
2537 /// metres, not millimetres.
2538 fn mba1603_g1ish_points() -> serde_json::Value {
2539 serde_json::json!([
2540 {"mach": 0.0, "cd": 0.2629}, {"mach": 0.9, "cd": 0.3085},
2541 {"mach": 1.0, "cd": 0.4805}, {"mach": 1.5, "cd": 0.6573},
2542 {"mach": 3.0, "cd": 0.5250}
2543 ])
2544 }
2545
2546 /// The whole of a pure projectile-drag difference must land in ProjectileDrag: every other
2547 /// group swaps byte-identical values (exactly zero), so the remainder must be zero too, and
2548 /// nothing may be reported as skipped. Before MBA-1603 the swap never carried
2549 /// `drag_table`/`bc_segments`/`corrections`, so all of it sat in the remainder instead.
2550 fn assert_all_of_it_is_projectile_drag(
2551 a: &crate::solve_json::ResolvedSolveRequestV1,
2552 b: &crate::solve_json::ResolvedSolveRequestV1,
2553 ) {
2554 let rep = explain_difference(a, b, &[300.0, 600.0]).unwrap();
2555 assert!(rep.skipped_axes.is_empty(), "{:?}", rep.skipped_axes);
2556 for row in &rep.rows {
2557 assert!(
2558 row.total.drop_m.abs() > 1e-3,
2559 "fixture must really differ at {} m: {:?}",
2560 row.range_m,
2561 row.total
2562 );
2563 let drag = row
2564 .contributions
2565 .iter()
2566 .find(|c| c.group == InputGroup::ProjectileDrag)
2567 .unwrap()
2568 .delta;
2569 assert!(
2570 (drag.drop_m - row.total.drop_m).abs() < 1e-9
2571 && (drag.windage_m - row.total.windage_m).abs() < 1e-9,
2572 "range {}: ProjectileDrag {:?} must equal the total {:?} (remainder {:?})",
2573 row.range_m,
2574 drag,
2575 row.total,
2576 row.interaction_remainder
2577 );
2578 assert!(
2579 row.interaction_remainder.drop_m.abs() < 1e-9
2580 && row.interaction_remainder.windage_m.abs() < 1e-9,
2581 "range {}: remainder {:?}",
2582 row.range_m,
2583 row.interaction_remainder
2584 );
2585 for c in row
2586 .contributions
2587 .iter()
2588 .filter(|c| c.group != InputGroup::ProjectileDrag)
2589 {
2590 assert!(
2591 c.delta.drop_m.abs() < 1e-9,
2592 "{:?} {}",
2593 c.group,
2594 c.delta.drop_m
2595 );
2596 }
2597 }
2598 }
2599
2600 #[test]
2601 fn a_bc_ladder_difference_is_attributed_to_projectile_drag() {
2602 let a = resolved_with_projectile(serde_json::json!({}));
2603 let b = resolved_with_projectile(serde_json::json!({
2604 "bc_segments": [mba1603_band(600.0, 999.0, 0.260), mba1603_band(0.0, 600.0, 0.220)]
2605 }));
2606 assert_all_of_it_is_projectile_drag(&a, &b);
2607 }
2608
2609 #[test]
2610 fn a_drag_table_difference_is_attributed_to_projectile_drag() {
2611 let a = resolved_with_projectile(serde_json::json!({}));
2612 let b = resolved_with_projectile(serde_json::json!({
2613 "drag_table": {"kind": "reference", "points": mba1603_g1ish_points()}
2614 }));
2615 assert_all_of_it_is_projectile_drag(&a, &b);
2616 }
2617
2618 /// The reference-curve hazard: swapping `drag_model` alone flew A's G7-referenced band BCs
2619 /// against G1 (and B's G1 bands against G7), a projectile neither request describes. The
2620 /// ladder must travel with the model it is measured against.
2621 #[test]
2622 fn a_drag_model_swap_carries_the_ladder_measured_against_it() {
2623 let a = resolved_with_projectile(serde_json::json!({
2624 "drag_model": "G7",
2625 "bc_segments": [mba1603_band(600.0, 999.0, 0.260), mba1603_band(0.0, 600.0, 0.220)]
2626 }));
2627 let b = resolved_with_projectile(serde_json::json!({
2628 "drag_model": "G1",
2629 "bc_segments": [mba1603_band(600.0, 999.0, 0.500), mba1603_band(0.0, 600.0, 0.450)]
2630 }));
2631 assert_all_of_it_is_projectile_drag(&a, &b);
2632 }
2633
2634 /// A drag table on one side and a ladder on the other must swap as one unit: carrying
2635 /// either member alone onto the other request would build a ladder-plus-table request that
2636 /// solve_v1 refuses (`conflicting_fields`), aborting the whole report.
2637 #[test]
2638 fn a_drag_table_versus_a_bc_ladder_swaps_as_one_unit() {
2639 let a = resolved_with_projectile(serde_json::json!({
2640 "drag_table": {"kind": "reference", "points": mba1603_g1ish_points()}
2641 }));
2642 let b = resolved_with_projectile(serde_json::json!({
2643 "bc_segments": [mba1603_band(600.0, 999.0, 0.260), mba1603_band(0.0, 600.0, 0.220)]
2644 }));
2645 assert_all_of_it_is_projectile_drag(&a, &b);
2646 }
2647
2648 /// Equal drag descriptions keep the pre-MBA-1603 path exactly: the guard reports no
2649 /// difference, so `swap_group` never clears or rewrites the description, a scalar BC
2650 /// difference under the SAME ladder still attributes to ProjectileDrag, and the report
2651 /// matches the two legs recomputed from that swap.
2652 ///
2653 /// Carrying an equal description anyway would give the same numbers at the cost of extra
2654 /// solves, so the report alone cannot tell the two paths apart; the guard is asserted
2655 /// directly.
2656 #[test]
2657 fn equal_drag_descriptions_leave_the_existing_swap_untouched() {
2658 // An interior gap (500-700 m/s) is where the scalar BC flies under a ladder.
2659 let ladder = serde_json::json!([
2660 mba1603_band(700.0, 999.0, 0.260),
2661 mba1603_band(0.0, 500.0, 0.220)
2662 ]);
2663 let a = resolved_with_projectile(serde_json::json!({"bc_segments": ladder.clone()}));
2664 let b = resolved_with_projectile(serde_json::json!({
2665 "ballistic_coefficient": 0.300, "bc_segments": ladder
2666 }));
2667 assert!(
2668 !drag_descriptions_differ(&a, &b) && !drag_descriptions_differ(&b, &a),
2669 "a scalar BC difference under one ladder is not a drag-description difference"
2670 );
2671 let rep = explain_difference(&a, &b, &[600.0]).unwrap();
2672 let (excluded, _) = plan_exclusions(&a, &b, InputGroup::ProjectileDrag).unwrap();
2673 let fwd = evaluate(
2674 &swap_group(&a, &b, InputGroup::ProjectileDrag, &excluded).unwrap(),
2675 &[600.0],
2676 )
2677 .unwrap();
2678 let bwd = evaluate(
2679 &swap_group(&b, &a, InputGroup::ProjectileDrag, &excluded).unwrap(),
2680 &[600.0],
2681 )
2682 .unwrap();
2683 let obs_a = evaluate(&(&a).into(), &[600.0]).unwrap();
2684 let obs_b = evaluate(&(&b).into(), &[600.0]).unwrap();
2685 let expected = DeltaV1::mean(
2686 DeltaV1::between(&obs_a[0], &fwd[0]),
2687 DeltaV1::between(&obs_b[0], &bwd[0]).neg(),
2688 );
2689 let drag = rep.rows[0]
2690 .contributions
2691 .iter()
2692 .find(|c| c.group == InputGroup::ProjectileDrag)
2693 .unwrap()
2694 .delta;
2695 assert_eq!(drag, expected);
2696 assert!(drag.drop_m.abs() > 1e-4, "the gap BC must fly: {drag:?}");
2697 }
2698}