Skip to main content

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