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