Skip to main content

ballistics_engine/perturbation/
access.rs

1//! Reading and writing one taxonomy axis on a canonical request.
2//!
3//! `with_axis` produces a full `SolveRequestV1` via the Phase 0 reverse conversion
4//! (`request_roundtrip.rs`) and then overwrites exactly one field, so every other input is
5//! carried across unchanged.
6//!
7//! Two axes are enum-valued rather than scalar or boolean (`DragModel`: G1/G6/G7/G8;
8//! `TwistDirection`: left/right), so [`AxisValue`] carries a dedicated variant for each
9//! instead of round-tripping through a string -- a string round-trip would turn a typo into a
10//! runtime [`KernelError::TypeMismatch`] instead of a compile error.
11//!
12//! `with_axis` also refuses two axis/request combinations that would otherwise silently build
13//! a physically WRONG counterfactual, because [the reverse conversion](crate::solve_json)
14//! (see `request_roundtrip.rs`'s module doc) always emits absolute station pressure and
15//! shooter-relative wind regardless of how the original request entered them:
16//!
17//! - `Altitude` when the original request declared a QNH pressure: perturbing altitude would
18//!   change air density-by-altitude without moving the QNH-referenced station pressure the
19//!   way the caller means -- the opposite of the intended counterfactual.
20//! - `ShotAzimuth` when the original request declared compass-referenced wind: perturbing the
21//!   shot azimuth would rotate the wind WITH the rifle instead of keeping it earth-fixed --
22//!   physically inverted.
23//!
24//! Both are detected from the ORIGINAL resolved request's echoed reference mode (added in
25//! Task 1 precisely so this is detectable) and rejected with
26//! [`KernelError::AxisUnsupportedForRequest`] rather than producing a silently-wrong request.
27//! See `taxonomy.rs`'s "KNOWN LIMITATIONS" comment, items (a) and (b).
28//!
29//! A third check keeps `with_axis` consistent with `read_axis` rather than physics-aware: the
30//! three wind axes have no single scalar to write when the resolved wind is
31//! [`ResolvedWindV1::Segmented`] (taxonomy.rs Known Limitation (c)). `read_axis` already
32//! returns `None` for that combination; `with_axis` now returns [`KernelError::AxisAbsent`]
33//! for the same combination instead of silently writing a constant-wind field (`speed_mps`
34//! etc.) alongside the still-present `wind.segments`, which `solve_v1`'s `resolve_wind` would
35//! otherwise reject downstream as an opaque `$.wind` "segments cannot be combined with
36//! constant-wind fields" error.
37//!
38//! The zero-affecting-axis rezero clear (see `with_axis`'s body) is evaluated from
39//! `req.shot.zero_distance_m` **after** the axis has been written, not before: for the
40//! `ZeroDistance` axis itself, the value that matters is the NEW distance being written, not
41//! whatever `zero_distance_m` was on the original request (which may have been absent).
42//! Gating on the pre-write value would let a request originally specified purely by
43//! `muzzle_angle_rad` silently keep that stale angle after a `ZeroDistance` write, so
44//! `solve_v1` would skip the elevation search entirely (it takes an explicit angle over a
45//! zero distance whenever both are present) and the new zero distance would have no effect on
46//! elevation at all.
47
48use crate::perturbation::taxonomy::{axis_meta, InputAxis};
49use crate::solve_json::{
50    DragModelV1, PressureReferenceV1, ResolvedSolveRequestV1, ResolvedWindV1, SolveErrorCodeV1,
51    SolveRequestV1, TwistDirectionV1, WindReferenceV1,
52};
53use crate::trajectory_observation::TrajectoryObservationError;
54
55/// One taxonomy axis's value, read from or written to a request.
56///
57/// `Scalar`/`Flag` cover every continuous and boolean axis. `DragModel`/`TwistDirection`
58/// cover the two enum-valued axes losslessly -- see the module doc for why a `String` variant
59/// would be the wrong choice.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub enum AxisValue {
62    Scalar(f64),
63    Flag(bool),
64    DragModel(DragModelV1),
65    TwistDirection(TwistDirectionV1),
66}
67
68#[derive(Debug, Clone, PartialEq)]
69pub enum KernelError {
70    /// Reserved for a later (differentiation) task: an axis whose `axis_meta(axis).kind` is
71    /// `AxisKind::Categorical` was asked to be treated as continuous. Not constructed by
72    /// `read_axis`/`with_axis` themselves, which accept categorical axes just like any other.
73    CategoricalAxis(InputAxis),
74    /// The axis is structurally unavailable on THIS request's resolved shape -- currently only
75    /// the three wind axes when the resolved wind is `ResolvedWindV1::Segmented`, mirroring
76    /// `read_axis` returning `None` for the identical condition (see the module doc).
77    AxisAbsent(InputAxis),
78    /// The `AxisValue` variant supplied to `with_axis` does not match what `axis` expects
79    /// (e.g. a `Scalar` for `DragModel`, or a `Flag` for any continuous axis).
80    TypeMismatch(InputAxis),
81    /// The axis is well-formed and present, but this particular request's OTHER inputs make
82    /// perturbing it physically wrong rather than merely unrepresentable -- see the guards
83    /// documented on `with_axis`.
84    AxisUnsupportedForRequest {
85        axis: InputAxis,
86        reason: &'static str,
87    },
88    /// The rebuilt request failed to resolve or solve. Not constructed here -- `with_axis` only
89    /// builds the request, it does not solve it; [`crate::perturbation::evaluate`] (0.33.0
90    /// decision-support Task 6) is the constructor, uniformly for every stage that can fail
91    /// (`solve_v1::prepare_request`, `solve_v1::build_zeroed_solver`, and
92    /// `TrajectorySolver::solve`).
93    ///
94    /// `code` is carried alongside `message` (review fix I4(a), `derive.rs`) specifically so a
95    /// caller -- most notably `central_difference`'s one-sided fallback -- can tell a domain
96    /// rejection (`SolveErrorCodeV1::InvalidValue`, produced by `require_range`/
97    /// `require_non_negative`/`require_positive` in `solve_v1.rs`) apart from a genuine solver
98    /// failure (`SolveFailed`, `ResourceLimit`, `InternalError`) or a malformed-request bug
99    /// (`ConflictingFields`, `MissingField`, `UnknownField`, ...) that merely happened to be
100    /// triggered by one particular perturbed value. Collapsing this to a `String` (the previous
101    /// shape) made that distinction unrecoverable except by parsing the message.
102    Solve {
103        code: SolveErrorCodeV1,
104        message: String,
105    },
106    /// Post-solve observation extraction failed -- most notably a requested range outside the
107    /// computed trajectory. Not constructed here for the same reason as `Solve`;
108    /// [`crate::perturbation::evaluate`] constructs it directly from a
109    /// [`crate::trajectory_observation::TrajectoryObservationError`], preserved WHOLE (not
110    /// collapsed to its `Display` string) for the same reason as `Solve`'s `code` above: only
111    /// [`TrajectoryObservationError::OutOfRange`] is a domain rejection eligible for
112    /// `central_difference`'s one-sided fallback -- `NonMonotonicTrajectory`, `NonFiniteState`,
113    /// `SampleLimitExceeded`, `AllocationFailed`, and the rest are genuine bugs that must
114    /// propagate, not be silently reinterpreted as "this side left the domain."
115    Observation(TrajectoryObservationError),
116    /// A `Scalar` value supplied to `with_axis` was not finite (NaN or infinite).
117    NonFinite(InputAxis),
118    /// Both perturbed sides of a central difference failed to produce a usable observation --
119    /// neither `x + attempted` nor `x - attempted` evaluates -- so not even a one-sided
120    /// difference can be built (`central_difference`'s domain fallback,
121    /// `src/perturbation/derive.rs`). Distinct from a derivative of zero: this means
122    /// "undifferentiable with this step," never "no effect."
123    StepOutOfDomain { axis: InputAxis, attempted: f64 },
124    /// The same axis was declared more than once in a caller-supplied source list. Not
125    /// constructed by `read_axis`/`with_axis`/`central_difference` (none of which see more than
126    /// one axis at a time) -- `crate::error_budget::error_budget` (0.33.0 decision-support Task
127    /// 10, MBA-1347 review) constructs this from its own up-front validation, because two
128    /// entries for the same axis would double-count that axis's variance and corrupt its
129    /// leave-one-out counterfactual (removing "the" declaration for that axis is ambiguous when
130    /// there are two). `KernelError` had not shipped on any released version when this variant
131    /// was added, so there is no compatibility concern in extending it.
132    DuplicateAxis(InputAxis),
133    /// The domain supplied to `crate::tolerance::tolerance_envelope` for one axis is missing, or
134    /// does not have the shape a one-variable bisection needs: both bounds finite, the lower
135    /// bound strictly less than the upper, and the axis's own current value strictly between
136    /// them. A domain where the current value sits AT (not strictly inside) one edge would make
137    /// that search direction a zero-width probe -- `bisect_axis` would report `Ok(None)` for it
138    /// (the two identical endpoints trivially agree), and that `None` would be indistinguishable
139    /// from a genuine "stays inside throughout" result even though nothing beyond the current
140    /// value was ever actually searched. 0.33.0 decision-support Task 12 (MBA-1350): never
141    /// constructed by `with_axis`/`evaluate`/`bisect_axis` themselves -- `tolerance_envelope`
142    /// validates its own domain argument up front, before any solve, the same way
143    /// `crate::error_budget::error_budget` validates its `ranges_m`/`sources` arguments.
144    InvalidDomain { axis: InputAxis, reason: &'static str },
145}
146
147impl std::fmt::Display for KernelError {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        match self {
150            KernelError::CategoricalAxis(a) => {
151                write!(f, "axis {a:?} is categorical and cannot be differentiated")
152            }
153            KernelError::AxisAbsent(a) => write!(f, "axis {a:?} is not present in this request"),
154            KernelError::TypeMismatch(a) => write!(f, "value type does not match axis {a:?}"),
155            KernelError::AxisUnsupportedForRequest { axis, reason } => {
156                write!(f, "axis {axis:?} is not supported for this request: {reason}")
157            }
158            KernelError::Solve { code, message } => write!(f, "solve failed ({code:?}): {message}"),
159            KernelError::Observation(e) => write!(f, "observation failed: {e}"),
160            KernelError::NonFinite(a) => write!(f, "axis {a:?} produced a non-finite result"),
161            KernelError::StepOutOfDomain { axis, attempted } => write!(
162                f,
163                "axis {axis:?} could not be differentiated with step {attempted}: both the \
164                 forward and backward perturbed values failed to evaluate"
165            ),
166            KernelError::DuplicateAxis(a) => {
167                write!(f, "axis {a:?} was declared more than once")
168            }
169            KernelError::InvalidDomain { axis, reason } => {
170                write!(f, "invalid tolerance-envelope domain for axis {axis:?}: {reason}")
171            }
172        }
173    }
174}
175impl std::error::Error for KernelError {}
176
177impl KernelError {
178    /// True when this error means the specific perturbed VALUE fell outside the axis's physical
179    /// domain -- an `InvalidValue` solve rejection (from `require_range`/`require_non_negative`/
180    /// `require_positive` in `solve_v1.rs`), or an `OutOfRange` observation query (a requested
181    /// range that fell outside a trajectory shrunk by the perturbation) -- as opposed to a
182    /// genuine solver or trajectory failure that merely happened to be triggered by one
183    /// particular perturbed value.
184    ///
185    /// This is the ONLY thing [`crate::perturbation::central_difference`]'s one-sided fallback
186    /// (`derive.rs`) may gate on. Review fix I4(a): an earlier revision gated on `Err(_)`
187    /// (any failure at all on one side), which silently reinterpreted genuine bugs -- a
188    /// non-convergent zero search, a non-finite trajectory state, a sample-limit overrun -- as
189    /// if they were domain boundaries, answering with a fabricated derivative instead of
190    /// reporting the real failure.
191    pub fn is_domain_rejection(&self) -> bool {
192        matches!(
193            self,
194            KernelError::Solve { code: SolveErrorCodeV1::InvalidValue, .. }
195                | KernelError::Observation(TrajectoryObservationError::OutOfRange { .. })
196        )
197    }
198}
199
200/// The request's wind-reference echo, whichever wind shape it resolved to.
201///
202/// `pub(crate)` (0.33.0 decision-support Task 9, review round 4): `explain.rs`'s
203/// `is_compass_referenced` needs the identical lookup to detect the `WindDirection` derived-
204/// value hazard under compass wind (a different axis than the `ShotAzimuth` guard just below,
205/// which is what this function was written for), so it shares this one instead of duplicating
206/// the two-arm match a second time.
207pub(crate) fn wind_reference_of(w: &ResolvedWindV1) -> Option<WindReferenceV1> {
208    match w {
209        ResolvedWindV1::Constant(c) => c.wind_reference,
210        ResolvedWindV1::Segmented(s) => s.wind_reference,
211    }
212}
213
214/// Read the current value of one taxonomy axis off a resolved request.
215///
216/// Returns `None` in two DIFFERENT situations a caller should not conflate:
217/// - the axis is an optional request field that was never supplied (`Length`, `Latitude`,
218///   `ZeroPoiUp`/`ZeroPoiRight`, `SightOffsetLateral`) -- the axis exists, it just has no
219///   value on this particular request; or
220/// - the axis is structurally unavailable given how a sibling field resolved -- currently only
221///   `WindSpeed`/`WindDirection`/`WindVertical` under `ResolvedWindV1::Segmented`, where there
222///   is no single scalar wind value to read at all (taxonomy.rs Known Limitation (c)).
223///
224/// Either way, `None` means "there is nothing to perturb here": a caller sweeping
225/// `InputAxis::ALL` should skip the axis, never invent a value for it.
226pub fn read_axis(r: &ResolvedSolveRequestV1, axis: InputAxis) -> Option<AxisValue> {
227    use InputAxis::*;
228    let wind_speed = match &r.wind {
229        ResolvedWindV1::Constant(c) => Some(c.speed_mps),
230        ResolvedWindV1::Segmented(_) => None,
231    };
232    let wind_dir = match &r.wind {
233        ResolvedWindV1::Constant(c) => Some(c.direction_from_rad),
234        ResolvedWindV1::Segmented(_) => None,
235    };
236    let wind_vert = match &r.wind {
237        ResolvedWindV1::Constant(c) => Some(c.vertical_speed_mps),
238        ResolvedWindV1::Segmented(_) => None,
239    };
240    Some(match axis {
241        Mass => AxisValue::Scalar(r.projectile.mass_kg),
242        Diameter => AxisValue::Scalar(r.projectile.diameter_m),
243        Length => AxisValue::Scalar(r.projectile.length_m?),
244        BallisticCoefficient => AxisValue::Scalar(r.projectile.ballistic_coefficient),
245        TwistRate => AxisValue::Scalar(r.rifle.twist_rate_m_per_turn),
246        TwistDirection => AxisValue::TwistDirection(r.rifle.twist_direction),
247        DragModel => AxisValue::DragModel(r.projectile.drag_model),
248        MuzzleVelocityMps => AxisValue::Scalar(r.rifle.muzzle_velocity_mps),
249        SightHeight => AxisValue::Scalar(r.rifle.sight_height_m),
250        ZeroDistance => AxisValue::Scalar(r.shot.zero_distance_m?),
251        ZeroPoiUp => AxisValue::Scalar(r.shot.zero_poi_up_m?),
252        ZeroPoiRight => AxisValue::Scalar(r.shot.zero_poi_right_m?),
253        SightOffsetLateral => AxisValue::Scalar(r.rifle.sight_offset_lateral_m?),
254        MuzzleHeight => AxisValue::Scalar(r.rifle.muzzle_height_m),
255        MuzzleAngle => AxisValue::Scalar(r.shot.muzzle_angle_rad),
256        Altitude => AxisValue::Scalar(r.atmosphere.altitude_m),
257        Temperature => AxisValue::Scalar(r.atmosphere.temperature_k),
258        Pressure => AxisValue::Scalar(r.atmosphere.pressure_pa),
259        RelativeHumidity => AxisValue::Scalar(r.atmosphere.relative_humidity),
260        Latitude => AxisValue::Scalar(r.atmosphere.latitude_rad?),
261        WindSpeed => AxisValue::Scalar(wind_speed?),
262        WindDirection => AxisValue::Scalar(wind_dir?),
263        WindVertical => AxisValue::Scalar(wind_vert?),
264        TargetDistance => AxisValue::Scalar(r.shot.max_range_m),
265        ShootingAngle => AxisValue::Scalar(r.shot.shooting_angle_rad),
266        Cant => AxisValue::Scalar(r.shot.cant_angle_rad),
267        ShotAzimuth => AxisValue::Scalar(r.shot.shot_azimuth_rad),
268        AimAzimuth => AxisValue::Scalar(r.shot.aim_azimuth_rad),
269        TargetHeight => AxisValue::Scalar(r.shot.target_height_m),
270        MagnusEnabled => AxisValue::Flag(r.effects.magnus),
271        CoriolisEnabled => AxisValue::Flag(r.effects.coriolis),
272        EnhancedSpinDriftEnabled => AxisValue::Flag(r.effects.enhanced_spin_drift),
273    })
274}
275
276/// Rebuild `r` as a solvable [`SolveRequestV1`] with exactly one axis overwritten to `v`.
277///
278/// Every other input is carried across unchanged via the reverse conversion
279/// (`request_roundtrip.rs`). Writing a `requires_rezero` axis (see
280/// [`crate::perturbation::axis_meta`]) while a `zero_distance_m` is present on the REBUILT
281/// request clears the carried effective `muzzle_angle_rad`, so the next solve re-zeroes at
282/// the (possibly just-changed) distance instead of reusing a stale angle -- see the module
283/// doc for why this is checked after the axis is written, not before.
284///
285/// # Errors
286///
287/// - [`KernelError::AxisUnsupportedForRequest`] if `axis` is well-formed and present, but this
288///   request's OTHER inputs make perturbing it physically wrong (the two guards in the module
289///   doc: `Altitude` under QNH pressure, `ShotAzimuth` under compass wind).
290/// - [`KernelError::AxisAbsent`] if `axis` is structurally unavailable on this request (the
291///   three wind axes under segmented wind), mirroring `read_axis` returning `None` for the
292///   same condition.
293/// - [`KernelError::TypeMismatch`] if `v`'s kind does not match `axis` (e.g. a `Scalar` for
294///   `DragModel`).
295/// - [`KernelError::NonFinite`] if `v` is a non-finite `Scalar`.
296pub fn with_axis(
297    r: &ResolvedSolveRequestV1,
298    axis: InputAxis,
299    v: AxisValue,
300) -> Result<SolveRequestV1, KernelError> {
301    // Physics guards (see the module doc): detected from the ORIGINAL resolved request's
302    // echoed reference mode, before anything else, so a dangerous combination never reaches
303    // the reverse conversion below.
304    if axis == InputAxis::Altitude
305        && r.atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh)
306    {
307        return Err(KernelError::AxisUnsupportedForRequest {
308            axis,
309            reason: "the original request declared a QNH pressure_reference; the rebuilt \
310                     request always carries absolute station pressure (request_roundtrip.rs \
311                     cannot re-derive the original altimeter setting), so perturbing altitude \
312                     would change air density-by-altitude without moving the QNH-referenced \
313                     station pressure the way the caller means",
314        });
315    }
316    if axis == InputAxis::ShotAzimuth
317        && wind_reference_of(&r.wind) == Some(WindReferenceV1::Compass)
318    {
319        return Err(KernelError::AxisUnsupportedForRequest {
320            axis,
321            reason: "the original request declared compass-referenced wind; the rebuilt \
322                     request always carries shooter-relative wind (request_roundtrip.rs \
323                     cannot re-derive the original earth-fixed bearing), so perturbing the \
324                     shot azimuth would rotate the wind WITH the rifle instead of keeping it \
325                     earth-fixed",
326        });
327    }
328    // Third check (see the module doc): keeps with_axis consistent with read_axis, which
329    // already returns None for these three axes under segmented wind. Without this, writing
330    // e.g. WindSpeed here would set req.wind.speed_mps alongside the still-present
331    // req.wind.segments, which solve_v1's resolve_wind rejects downstream as an opaque
332    // "segments cannot be combined with constant-wind fields" error instead of this specific,
333    // named one.
334    if matches!(
335        axis,
336        InputAxis::WindSpeed | InputAxis::WindDirection | InputAxis::WindVertical
337    ) && matches!(r.wind, ResolvedWindV1::Segmented(_))
338    {
339        return Err(KernelError::AxisAbsent(axis));
340    }
341
342    let mut req: SolveRequestV1 = r.into();
343    let scalar = |v: AxisValue| -> Result<f64, KernelError> {
344        match v {
345            AxisValue::Scalar(x) if x.is_finite() => Ok(x),
346            AxisValue::Scalar(_) => Err(KernelError::NonFinite(axis)),
347            _ => Err(KernelError::TypeMismatch(axis)),
348        }
349    };
350    let flag = |v: AxisValue| -> Result<bool, KernelError> {
351        match v {
352            AxisValue::Flag(b) => Ok(b),
353            _ => Err(KernelError::TypeMismatch(axis)),
354        }
355    };
356    let drag_model = |v: AxisValue| -> Result<DragModelV1, KernelError> {
357        match v {
358            AxisValue::DragModel(m) => Ok(m),
359            _ => Err(KernelError::TypeMismatch(axis)),
360        }
361    };
362    let twist_direction = |v: AxisValue| -> Result<TwistDirectionV1, KernelError> {
363        match v {
364            AxisValue::TwistDirection(d) => Ok(d),
365            _ => Err(KernelError::TypeMismatch(axis)),
366        }
367    };
368    use InputAxis::*;
369    match axis {
370        Mass => req.projectile.mass_kg = scalar(v)?,
371        Diameter => req.projectile.diameter_m = scalar(v)?,
372        Length => req.projectile.length_m = Some(scalar(v)?),
373        BallisticCoefficient => req.projectile.ballistic_coefficient = scalar(v)?,
374        TwistRate => req.rifle.twist_rate_m_per_turn = Some(scalar(v)?),
375        TwistDirection => req.rifle.twist_direction = Some(twist_direction(v)?),
376        DragModel => req.projectile.drag_model = drag_model(v)?,
377        MuzzleVelocityMps => req.rifle.muzzle_velocity_mps = scalar(v)?,
378        SightHeight => req.rifle.sight_height_m = Some(scalar(v)?),
379        ZeroDistance => req.shot.zero_distance_m = Some(scalar(v)?),
380        ZeroPoiUp => req.shot.zero_poi_up_m = Some(scalar(v)?),
381        ZeroPoiRight => req.shot.zero_poi_right_m = Some(scalar(v)?),
382        SightOffsetLateral => req.rifle.sight_offset_lateral_m = Some(scalar(v)?),
383        MuzzleHeight => req.rifle.muzzle_height_m = Some(scalar(v)?),
384        MuzzleAngle => req.shot.muzzle_angle_rad = Some(scalar(v)?),
385        Altitude => req.atmosphere.altitude_m = Some(scalar(v)?),
386        Temperature => req.atmosphere.temperature_k = Some(scalar(v)?),
387        Pressure => req.atmosphere.pressure_pa = Some(scalar(v)?),
388        RelativeHumidity => req.atmosphere.relative_humidity = Some(scalar(v)?),
389        Latitude => req.atmosphere.latitude_rad = Some(scalar(v)?),
390        WindSpeed => req.wind.speed_mps = Some(scalar(v)?),
391        WindDirection => req.wind.direction_from_rad = Some(scalar(v)?),
392        WindVertical => req.wind.vertical_speed_mps = Some(scalar(v)?),
393        TargetDistance => req.shot.max_range_m = scalar(v)?,
394        ShootingAngle => req.shot.shooting_angle_rad = Some(scalar(v)?),
395        Cant => req.shot.cant_angle_rad = Some(scalar(v)?),
396        ShotAzimuth => req.shot.shot_azimuth_rad = Some(scalar(v)?),
397        AimAzimuth => req.shot.aim_azimuth_rad = Some(scalar(v)?),
398        TargetHeight => req.shot.target_height_m = Some(scalar(v)?),
399        MagnusEnabled => req.effects.magnus = Some(flag(v)?),
400        CoriolisEnabled => req.effects.coriolis = Some(flag(v)?),
401        EnhancedSpinDriftEnabled => req.effects.enhanced_spin_drift = Some(flag(v)?),
402    }
403    // Changing a zero-affecting axis invalidates the stored effective angle: drop it so the
404    // service re-zeroes from zero_distance_m rather than reusing a stale angle. Checked AFTER
405    // the match (not before, which was a bug -- see the module doc): for the ZeroDistance axis
406    // itself, this must react to the NEW distance just written, not whatever zero_distance_m
407    // was on the original request.
408    if axis_meta(axis).requires_rezero && req.shot.zero_distance_m.is_some() {
409        req.shot.muzzle_angle_rad = None;
410    }
411    Ok(req)
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::perturbation::InputAxis;
418
419    /// Non-default values for every axis this file's tests touch, INCLUDING the five
420    /// previously-omitted optional fields (`length_m`, `zero_poi_up_m`, `zero_poi_right_m`,
421    /// `sight_offset_lateral_m`, `latitude_rad`) so that all 32 taxonomy axes -- not just 27
422    /// of them -- read back `Some(..)` here. Without these five, `read_axis` returns `None`
423    /// for `Length`/`ZeroPoiUp`/`ZeroPoiRight`/`SightOffsetLateral`/`Latitude` on this fixture
424    /// and a naive per-axis loop (see `every_present_axis_round_trips_without_disturbing_any_other_field`
425    /// below) would silently skip exactly the pair -- `ZeroPoiUp`/`ZeroPoiRight` -- most likely
426    /// to be transposed by a future edit.
427    fn resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
428        let json = serde_json::json!({
429            "schema_version": 1,
430            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
431                           "ballistic_coefficient": 0.243, "length_m": 0.032},
432            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05,
433                      "muzzle_height_m": 0.02, "twist_rate_m_per_turn": 0.2794,
434                      "twist_direction": "left", "sight_offset_lateral_m": 0.03},
435            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "target_height_m": 0.3,
436                     "zero_poi_up_m": 0.01, "zero_poi_right_m": 0.02},
437            "atmosphere": {"latitude_rad": 0.6},
438            "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
439            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
440        }).to_string();
441        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
442        crate::solve_v1::solve_v1(req).unwrap().resolved_request
443    }
444
445    #[test]
446    fn read_then_write_is_identity() {
447        let r = resolved();
448        let v = read_axis(&r, InputAxis::MuzzleVelocityMps).expect("axis present");
449        let rebuilt = with_axis(&r, InputAxis::MuzzleVelocityMps, v).unwrap();
450        assert_eq!(rebuilt.rifle.muzzle_velocity_mps, r.rifle.muzzle_velocity_mps);
451    }
452
453    #[test]
454    fn writing_an_axis_changes_only_that_axis() {
455        let r = resolved();
456        let changed = with_axis(&r, InputAxis::MuzzleVelocityMps, AxisValue::Scalar(900.0)).unwrap();
457        let baseline: crate::solve_json::SolveRequestV1 = (&r).into();
458        assert_eq!(changed.rifle.muzzle_velocity_mps, 900.0);
459        assert_eq!(changed.atmosphere.pressure_pa, baseline.atmosphere.pressure_pa);
460        assert_eq!(changed.wind.speed_mps, baseline.wind.speed_mps);
461        assert_eq!(changed.shot.max_range_m, baseline.shot.max_range_m);
462        // The five axes added after the brief was written must also be carried unchanged.
463        assert_eq!(changed.projectile.drag_model, baseline.projectile.drag_model);
464        assert_eq!(changed.rifle.twist_rate_m_per_turn, baseline.rifle.twist_rate_m_per_turn);
465        assert_eq!(changed.rifle.twist_direction, baseline.rifle.twist_direction);
466        assert_eq!(changed.rifle.muzzle_height_m, baseline.rifle.muzzle_height_m);
467        assert_eq!(changed.shot.target_height_m, baseline.shot.target_height_m);
468    }
469
470    #[test]
471    fn writing_a_flag_into_a_scalar_axis_is_a_type_error() {
472        let r = resolved();
473        let e = with_axis(&r, InputAxis::MuzzleVelocityMps, AxisValue::Flag(true));
474        assert!(matches!(e, Err(KernelError::TypeMismatch(_))));
475    }
476
477    #[test]
478    fn effect_flags_round_trip() {
479        let r = resolved();
480        let changed = with_axis(&r, InputAxis::CoriolisEnabled, AxisValue::Flag(true)).unwrap();
481        assert_eq!(changed.effects.coriolis, Some(true));
482    }
483
484    /// DragModel is a four-way enum (G1/G6/G7/G8), not a bool or float: AxisValue needs a
485    /// dedicated variant to carry it losslessly (see the module doc).
486    #[test]
487    fn drag_model_axis_reads_and_writes_the_enum_value() {
488        let r = resolved();
489        let v = read_axis(&r, InputAxis::DragModel).expect("axis present");
490        assert_eq!(v, AxisValue::DragModel(DragModelV1::G7));
491
492        let changed =
493            with_axis(&r, InputAxis::DragModel, AxisValue::DragModel(DragModelV1::G1)).unwrap();
494        assert_eq!(changed.projectile.drag_model, DragModelV1::G1);
495        // Nothing else moved.
496        assert_eq!(changed.projectile.mass_kg, r.projectile.mass_kg);
497        assert_eq!(
498            changed.projectile.ballistic_coefficient,
499            r.projectile.ballistic_coefficient
500        );
501    }
502
503    /// TwistDirection is a two-way enum (left/right); same rationale as DragModel above.
504    #[test]
505    fn twist_direction_axis_reads_and_writes_the_enum_value() {
506        let r = resolved();
507        let v = read_axis(&r, InputAxis::TwistDirection).expect("axis present");
508        assert_eq!(v, AxisValue::TwistDirection(TwistDirectionV1::Left));
509
510        let changed = with_axis(
511            &r,
512            InputAxis::TwistDirection,
513            AxisValue::TwistDirection(TwistDirectionV1::Right),
514        )
515        .unwrap();
516        assert_eq!(changed.rifle.twist_direction, Some(TwistDirectionV1::Right));
517    }
518
519    /// Extending AxisValue with enum variants must not weaken the existing type-checking:
520    /// a scalar is still rejected for an enum-valued axis.
521    #[test]
522    fn writing_a_scalar_into_the_drag_model_axis_is_a_type_error() {
523        let r = resolved();
524        let e = with_axis(&r, InputAxis::DragModel, AxisValue::Scalar(1.0));
525        assert!(matches!(
526            e,
527            Err(KernelError::TypeMismatch(InputAxis::DragModel))
528        ));
529    }
530
531    /// ...and the reverse: an enum value is rejected for a scalar axis.
532    #[test]
533    fn writing_a_drag_model_into_a_scalar_axis_is_a_type_error() {
534        let r = resolved();
535        let e = with_axis(&r, InputAxis::Mass, AxisValue::DragModel(DragModelV1::G1));
536        assert!(matches!(e, Err(KernelError::TypeMismatch(InputAxis::Mass))));
537    }
538
539    /// ...and the two enum-valued axes are not interchangeable with EACH OTHER either: both
540    /// are "enum-shaped" AxisValue variants now, so it's worth confirming DragModel's closure
541    /// rejects a TwistDirection value (and not just a Scalar/Flag) as a TypeMismatch.
542    #[test]
543    fn writing_a_twist_direction_into_the_drag_model_axis_is_a_type_error() {
544        let r = resolved();
545        let e = with_axis(
546            &r,
547            InputAxis::DragModel,
548            AxisValue::TwistDirection(TwistDirectionV1::Left),
549        );
550        assert!(matches!(
551            e,
552            Err(KernelError::TypeMismatch(InputAxis::DragModel))
553        ));
554    }
555
556    /// Read/write identity for three of the five axes added after the brief was written,
557    /// exercising each field mapping directly rather than only checking non-interference.
558    #[test]
559    fn twist_rate_and_muzzle_height_and_target_height_round_trip() {
560        let r = resolved();
561        for axis in [InputAxis::TwistRate, InputAxis::MuzzleHeight, InputAxis::TargetHeight] {
562            let v = read_axis(&r, axis).unwrap_or_else(|| panic!("{axis:?} should be present"));
563            let rebuilt = with_axis(&r, axis, v).unwrap();
564            match (axis, v) {
565                (InputAxis::TwistRate, AxisValue::Scalar(x)) => {
566                    assert_eq!(rebuilt.rifle.twist_rate_m_per_turn, Some(x))
567                }
568                (InputAxis::MuzzleHeight, AxisValue::Scalar(x)) => {
569                    assert_eq!(rebuilt.rifle.muzzle_height_m, Some(x))
570                }
571                (InputAxis::TargetHeight, AxisValue::Scalar(x)) => {
572                    assert_eq!(rebuilt.shot.target_height_m, Some(x))
573                }
574                _ => panic!("expected a scalar for {axis:?}"),
575            }
576        }
577    }
578
579    /// read_axis returns None for the three wind axes under segmented wind: there is no
580    /// single scalar to perturb (taxonomy.rs Known Limitation (c)). This is intended -- a
581    /// caller must treat the None as "axis absent," never invent a uniform per-segment
582    /// perturbation. with_axis must AGREE with read_axis about this (I4): it must not
583    /// silently write a constant-wind field alongside the still-present wind.segments, which
584    /// solve_v1 would later reject downstream as an opaque, differently-worded error.
585    #[test]
586    fn wind_axes_are_absent_under_segmented_wind() {
587        let json = serde_json::json!({
588            "schema_version": 1,
589            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
590                           "ballistic_coefficient": 0.243},
591            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
592            "shot": {"max_range_m": 900.0},
593            "atmosphere": {},
594            "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
595                                    "direction_from_rad": 1.0}]},
596            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
597        })
598        .to_string();
599        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
600        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
601        assert!(matches!(r.wind, ResolvedWindV1::Segmented(_)));
602
603        assert_eq!(read_axis(&r, InputAxis::WindSpeed), None);
604        assert_eq!(read_axis(&r, InputAxis::WindDirection), None);
605        assert_eq!(read_axis(&r, InputAxis::WindVertical), None);
606
607        for axis in [
608            InputAxis::WindSpeed,
609            InputAxis::WindDirection,
610            InputAxis::WindVertical,
611        ] {
612            let e = with_axis(&r, axis, AxisValue::Scalar(1.0));
613            assert!(
614                matches!(e, Err(KernelError::AxisAbsent(a)) if a == axis),
615                "{axis:?}: expected AxisAbsent, got {e:?}"
616            );
617        }
618    }
619
620    /// Regression test (I1): the rezero-clear used to be gated on the PRE-write
621    /// `zero_distance_m`, evaluated before the match instead of after. For a request
622    /// originally specified purely by `muzzle_angle_rad` (no `zero_distance_m` at all),
623    /// writing `ZeroDistance` would then see the ORIGINAL (absent) zero distance, decide there
624    /// was nothing to gate on, and leave the carried angle in place. The rebuilt request would
625    /// carry both a brand new `zero_distance_m` AND the stale `muzzle_angle_rad`, and
626    /// `solve_v1` always prefers an explicit angle over a zero distance when both are present
627    /// (see the module doc), so the elevation search would never run: the new zero distance
628    /// would have zero effect on elevation, and a sensitivity sweep over `ZeroDistance` would
629    /// silently report "no effect" instead of the truth.
630    #[test]
631    fn writing_zero_distance_onto_an_angle_only_request_clears_the_carried_angle() {
632        let json = serde_json::json!({
633            "schema_version": 1,
634            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
635                           "ballistic_coefficient": 0.243},
636            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
637            "shot": {"max_range_m": 900.0, "muzzle_angle_rad": 0.01},
638            "atmosphere": {}, "wind": {},
639            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
640        })
641        .to_string();
642        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
643        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
644        // Sanity check on the fixture: this request has NO zero_distance_m at all, only a
645        // directly-supplied angle, so the bug's precondition actually holds.
646        assert_eq!(r.shot.zero_distance_m, None);
647        assert_eq!(r.shot.muzzle_angle_rad, 0.01);
648
649        let rebuilt = with_axis(&r, InputAxis::ZeroDistance, AxisValue::Scalar(100.0)).unwrap();
650        assert_eq!(rebuilt.shot.zero_distance_m, Some(100.0));
651        assert_eq!(
652            rebuilt.shot.muzzle_angle_rad, None,
653            "the carried angle must be cleared so solve_v1 actually re-zeroes at the new \
654             distance instead of skipping the elevation search because an explicit angle was \
655             still present"
656        );
657    }
658
659    /// I2, assertion 1 of 3 ("clears"): a `requires_rezero` axis clears the carried
660    /// `muzzle_angle_rad` when a `zero_distance_m` is present.
661    #[test]
662    fn rezero_axis_clears_the_carried_muzzle_angle_when_a_zero_distance_is_present() {
663        let r = resolved(); // zero_distance_m = Some(100.0) in this fixture.
664        assert!(r.shot.zero_distance_m.is_some());
665        assert!(
666            axis_meta(InputAxis::Mass).requires_rezero,
667            "fixture assumption: Mass must be a requires_rezero axis for this test to mean \
668             anything"
669        );
670
671        let changed = with_axis(&r, InputAxis::Mass, AxisValue::Scalar(0.02)).unwrap();
672        assert_eq!(changed.shot.muzzle_angle_rad, None);
673    }
674
675    /// I2, assertion 2 of 3 ("preserves"): a non-`requires_rezero` axis leaves the carried
676    /// `muzzle_angle_rad` untouched, even when a `zero_distance_m` is present. (Perturbing
677    /// `MuzzleVelocityMps` in `writing_an_axis_changes_only_that_axis` does NOT demonstrate
678    /// this -- that axis IS a rezero axis, so its angle is expected to change; that test never
679    /// asserts on `muzzle_angle_rad` at all.)
680    #[test]
681    fn non_rezero_axis_preserves_the_carried_muzzle_angle() {
682        let r = resolved(); // zero_distance_m = Some(100.0) in this fixture.
683        assert!(r.shot.zero_distance_m.is_some());
684        assert!(
685            !axis_meta(InputAxis::WindSpeed).requires_rezero,
686            "fixture assumption: WindSpeed must NOT be a requires_rezero axis for this test to \
687             mean anything"
688        );
689
690        let changed = with_axis(&r, InputAxis::WindSpeed, AxisValue::Scalar(5.0)).unwrap();
691        assert_eq!(changed.shot.muzzle_angle_rad, Some(r.shot.muzzle_angle_rad));
692    }
693
694    /// I2, assertion 3 of 3 ("the gate"): a `requires_rezero` axis does NOT clear the carried
695    /// angle when there is no `zero_distance_m` to begin with -- the clearing is gated on
696    /// `zero_distance_m` being present, not on `requires_rezero` alone.
697    #[test]
698    fn rezero_axis_does_not_clear_the_angle_when_no_zero_distance_is_present() {
699        let json = serde_json::json!({
700            "schema_version": 1,
701            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
702                           "ballistic_coefficient": 0.243},
703            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
704            "shot": {"max_range_m": 900.0, "muzzle_angle_rad": 0.01},
705            "atmosphere": {}, "wind": {},
706            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
707        })
708        .to_string();
709        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
710        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
711        assert_eq!(r.shot.zero_distance_m, None);
712        assert!(axis_meta(InputAxis::Mass).requires_rezero);
713
714        let changed = with_axis(&r, InputAxis::Mass, AxisValue::Scalar(0.02)).unwrap();
715        assert_eq!(changed.shot.muzzle_angle_rad, Some(r.shot.muzzle_angle_rad));
716    }
717
718    /// Guards the "changes only that axis" property: for each axis present on the fixture,
719    /// read its current value and write that SAME value back. Since nothing numerically
720    /// changes, the rebuilt request must be identical to `SolveRequestV1::from(&r)` in EVERY
721    /// field except `shot.muzzle_angle_rad`, which a `requires_rezero` axis deliberately
722    /// clears whenever `zero_distance_m` is present (see the module doc).
723    ///
724    /// CORRECTNESS CAVEAT (found by review, do not re-widen this claim): this loop is
725    /// structurally BLIND to a *self-consistent* transposition, where `read_axis` and
726    /// `with_axis` are both wrong about the same axis in the same way -- e.g. both mapping
727    /// `ZeroPoiUp` to `zero_poi_right_m`. In that case `read_axis(ZeroPoiUp)` returns
728    /// `zero_poi_right_m`'s current value, `with_axis` writes that SAME value back onto that
729    /// SAME (wrong) field, and the result is a byte-identical no-op: there is nothing for a
730    /// whole-struct diff to see. The same blindness covers a two-axis alias (e.g. `AimAzimuth`
731    /// reading AND writing `shot_azimuth_rad`). This loop therefore does NOT prove the mapping
732    /// is correct, only that whichever field each axis actually touches, it touches only that
733    /// one. `every_axis_writes_to_its_own_named_destination_field` below is what actually
734    /// pins each axis to its correct field, via a hardcoded per-axis destination that is
735    /// independent of (and so cannot silently agree with) a bug in `read_axis`.
736    #[test]
737    fn every_present_axis_round_trips_without_disturbing_any_other_field() {
738        let r = resolved();
739        let baseline: crate::solve_json::SolveRequestV1 = (&r).into();
740        // In THIS fixture zero_distance_m is present, so "will the angle be cleared" reduces
741        // to "is this a requires_rezero axis" -- computed once, outside the loop, from the
742        // same (pre-write) state with_axis itself will see for every axis except
743        // ZeroDistance, whose own write cannot change whether zero_distance_m.is_some() (it
744        // was already Some, and it is written back Some).
745        assert!(r.shot.zero_distance_m.is_some());
746
747        let mut exercised = 0usize;
748        for &axis in InputAxis::ALL {
749            let Some(v) = read_axis(&r, axis) else {
750                continue;
751            };
752            exercised += 1;
753            let rebuilt = with_axis(&r, axis, v).unwrap();
754
755            let mut expected = baseline.clone();
756            if axis_meta(axis).requires_rezero {
757                expected.shot.muzzle_angle_rad = None;
758            }
759            assert_eq!(
760                rebuilt, expected,
761                "writing {axis:?} back onto its own current value changed a field other than \
762                 itself (and, for a rezero axis, the carried angle)"
763            );
764        }
765        // Prerequisite the fixture must satisfy for the loop above to mean anything: every one
766        // of the 32 taxonomy axes must actually be present (Some) on this fixture, or the loop
767        // would silently skip whichever axes read back None -- exactly how a naive version of
768        // this fixture would have skipped the ZeroPoiUp/ZeroPoiRight pair before it was
769        // enriched with the five previously-omitted optional fields (see `resolved`'s doc).
770        assert_eq!(
771            exercised,
772            InputAxis::ALL.len(),
773            "fixture does not make every axis readable -- this test is silently under-covering"
774        );
775    }
776
777    /// The actual mapping check (I3, corrected): for every one of the 32 axes, write a
778    /// SENTINEL value distinct from whatever that axis's own field currently holds, then
779    /// assert -- via a hardcoded, explicitly-named `match axis { .. }`, the same pattern
780    /// `twist_rate_and_muzzle_height_and_target_height_round_trip` already uses for three
781    /// axes -- that the sentinel landed in that axis's OWN specific destination field.
782    ///
783    /// This is a second, independent statement of the axis -> field mapping, written directly
784    /// against the real `SolveRequestV1` field paths rather than derived from `read_axis`. That
785    /// independence is the whole point: `every_present_axis_round_trips_without_disturbing_any_other_field`
786    /// above reads a value FROM `read_axis` and writes it back, so if `read_axis` and
787    /// `with_axis` are both wrong about the same axis in the same way, that loop's read and
788    /// write agree with each other and the bug is invisible. This test never calls `read_axis`
789    /// at all: the expected destination for every axis is spelled out here by hand, so it
790    /// cannot silently agree with a mistake made in `access.rs`'s implementation.
791    ///
792    /// Distinctness matters: a sentinel MUST differ from the field's pre-write value, or a
793    /// transposition that leaves the correct field untouched (because it wrote the sentinel to
794    /// the WRONG field instead) would go unnoticed -- the untouched field would coincidentally
795    /// already equal the "expected" value. `MuzzleAngle`/`Temperature`/`Pressure` add a fixed
796    /// delta to whatever the fixture's resolved value happens to be (avoiding a dependency on
797    /// the exact auto-zeroed angle or ICAO default) instead of a hardcoded literal.
798    #[test]
799    fn every_axis_writes_to_its_own_named_destination_field() {
800        let r = resolved();
801        use InputAxis::*;
802
803        for &axis in InputAxis::ALL {
804            match axis {
805                Mass => {
806                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.0199)).unwrap();
807                    assert_eq!(rebuilt.projectile.mass_kg, 0.0199, "{axis:?}");
808                }
809                Diameter => {
810                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.0090)).unwrap();
811                    assert_eq!(rebuilt.projectile.diameter_m, 0.0090, "{axis:?}");
812                }
813                Length => {
814                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.040)).unwrap();
815                    assert_eq!(rebuilt.projectile.length_m, Some(0.040), "{axis:?}");
816                }
817                BallisticCoefficient => {
818                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.300)).unwrap();
819                    assert_eq!(rebuilt.projectile.ballistic_coefficient, 0.300, "{axis:?}");
820                }
821                TwistRate => {
822                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.3556)).unwrap();
823                    assert_eq!(rebuilt.rifle.twist_rate_m_per_turn, Some(0.3556), "{axis:?}");
824                }
825                TwistDirection => {
826                    let rebuilt = with_axis(
827                        &r,
828                        axis,
829                        AxisValue::TwistDirection(TwistDirectionV1::Right),
830                    )
831                    .unwrap();
832                    assert_eq!(
833                        rebuilt.rifle.twist_direction,
834                        Some(TwistDirectionV1::Right),
835                        "{axis:?}"
836                    );
837                }
838                DragModel => {
839                    let rebuilt =
840                        with_axis(&r, axis, AxisValue::DragModel(DragModelV1::G1)).unwrap();
841                    assert_eq!(rebuilt.projectile.drag_model, DragModelV1::G1, "{axis:?}");
842                }
843                MuzzleVelocityMps => {
844                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(900.0)).unwrap();
845                    assert_eq!(rebuilt.rifle.muzzle_velocity_mps, 900.0, "{axis:?}");
846                }
847                SightHeight => {
848                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.06)).unwrap();
849                    assert_eq!(rebuilt.rifle.sight_height_m, Some(0.06), "{axis:?}");
850                }
851                ZeroDistance => {
852                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(200.0)).unwrap();
853                    assert_eq!(rebuilt.shot.zero_distance_m, Some(200.0), "{axis:?}");
854                }
855                ZeroPoiUp => {
856                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.05)).unwrap();
857                    assert_eq!(rebuilt.shot.zero_poi_up_m, Some(0.05), "{axis:?}");
858                }
859                ZeroPoiRight => {
860                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.06)).unwrap();
861                    assert_eq!(rebuilt.shot.zero_poi_right_m, Some(0.06), "{axis:?}");
862                }
863                SightOffsetLateral => {
864                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.10)).unwrap();
865                    assert_eq!(rebuilt.rifle.sight_offset_lateral_m, Some(0.10), "{axis:?}");
866                }
867                MuzzleHeight => {
868                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.05)).unwrap();
869                    assert_eq!(rebuilt.rifle.muzzle_height_m, Some(0.05), "{axis:?}");
870                }
871                MuzzleAngle => {
872                    let sentinel = r.shot.muzzle_angle_rad + 0.01;
873                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
874                    assert_eq!(rebuilt.shot.muzzle_angle_rad, Some(sentinel), "{axis:?}");
875                }
876                Altitude => {
877                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1500.0)).unwrap();
878                    assert_eq!(rebuilt.atmosphere.altitude_m, Some(1500.0), "{axis:?}");
879                }
880                Temperature => {
881                    let sentinel = r.atmosphere.temperature_k + 5.0;
882                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
883                    assert_eq!(rebuilt.atmosphere.temperature_k, Some(sentinel), "{axis:?}");
884                }
885                Pressure => {
886                    let sentinel = r.atmosphere.pressure_pa + 500.0;
887                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
888                    assert_eq!(rebuilt.atmosphere.pressure_pa, Some(sentinel), "{axis:?}");
889                }
890                RelativeHumidity => {
891                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.7)).unwrap();
892                    assert_eq!(rebuilt.atmosphere.relative_humidity, Some(0.7), "{axis:?}");
893                }
894                Latitude => {
895                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.3)).unwrap();
896                    assert_eq!(rebuilt.atmosphere.latitude_rad, Some(0.3), "{axis:?}");
897                }
898                WindSpeed => {
899                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(7.0)).unwrap();
900                    assert_eq!(rebuilt.wind.speed_mps, Some(7.0), "{axis:?}");
901                }
902                WindDirection => {
903                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.4)).unwrap();
904                    assert_eq!(rebuilt.wind.direction_from_rad, Some(0.4), "{axis:?}");
905                }
906                WindVertical => {
907                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1.5)).unwrap();
908                    assert_eq!(rebuilt.wind.vertical_speed_mps, Some(1.5), "{axis:?}");
909                }
910                TargetDistance => {
911                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1000.0)).unwrap();
912                    assert_eq!(rebuilt.shot.max_range_m, 1000.0, "{axis:?}");
913                }
914                ShootingAngle => {
915                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.2)).unwrap();
916                    assert_eq!(rebuilt.shot.shooting_angle_rad, Some(0.2), "{axis:?}");
917                }
918                Cant => {
919                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.15)).unwrap();
920                    assert_eq!(rebuilt.shot.cant_angle_rad, Some(0.15), "{axis:?}");
921                }
922                ShotAzimuth => {
923                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.25)).unwrap();
924                    assert_eq!(rebuilt.shot.shot_azimuth_rad, Some(0.25), "{axis:?}");
925                }
926                AimAzimuth => {
927                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.35)).unwrap();
928                    assert_eq!(rebuilt.shot.aim_azimuth_rad, Some(0.35), "{axis:?}");
929                }
930                TargetHeight => {
931                    let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.5)).unwrap();
932                    assert_eq!(rebuilt.shot.target_height_m, Some(0.5), "{axis:?}");
933                }
934                MagnusEnabled => {
935                    let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
936                    assert_eq!(rebuilt.effects.magnus, Some(true), "{axis:?}");
937                }
938                CoriolisEnabled => {
939                    let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
940                    assert_eq!(rebuilt.effects.coriolis, Some(true), "{axis:?}");
941                }
942                EnhancedSpinDriftEnabled => {
943                    let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
944                    assert_eq!(rebuilt.effects.enhanced_spin_drift, Some(true), "{axis:?}");
945                }
946            }
947        }
948    }
949
950    /// Physics guard (a): perturbing altitude on a QNH-pressure request would silently build
951    /// a wrong counterfactual (the rebuilt request always carries absolute station pressure --
952    /// see request_roundtrip.rs -- so density-by-altitude would move but the QNH-referenced
953    /// station pressure would not, backwards from what a QNH-entering caller means). Detected
954    /// from the ORIGINAL resolved request's pressure_reference echo and rejected outright.
955    #[test]
956    fn altitude_axis_is_unsupported_when_the_original_pressure_was_qnh() {
957        let json = serde_json::json!({
958            "schema_version": 1,
959            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
960                           "ballistic_coefficient": 0.243},
961            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
962            "shot": {"max_range_m": 900.0},
963            "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
964                           "pressure_reference": "qnh"},
965            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
966        })
967        .to_string();
968        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
969        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
970        assert_eq!(r.atmosphere.pressure_reference, Some(PressureReferenceV1::Qnh));
971
972        let e = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(600.0));
973        match e {
974            Err(KernelError::AxisUnsupportedForRequest { axis: InputAxis::Altitude, reason }) => {
975                assert!(
976                    reason.to_lowercase().contains("qnh"),
977                    "reason should name QNH: {reason}"
978                );
979            }
980            other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
981        }
982    }
983
984    /// The QNH guard must be specific to QNH mode: an OMITTED pressure_reference (the default,
985    /// and every request that predates MBA-1397) must still allow perturbing altitude
986    /// normally. This only proves the guard doesn't fire on absence; see
987    /// `altitude_axis_is_perturbable_when_pressure_is_explicitly_absolute` below for the
988    /// stronger claim that it doesn't fire on an explicit non-QNH value either.
989    #[test]
990    fn altitude_axis_is_perturbable_when_pressure_is_absolute() {
991        let r = resolved();
992        assert_eq!(r.atmosphere.pressure_reference, None);
993        let changed = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(1200.0)).unwrap();
994        assert_eq!(changed.atmosphere.altitude_m, Some(1200.0));
995    }
996
997    /// Stronger version of the test above: an EXPLICIT `pressure_reference: "absolute"` (not
998    /// just an omitted field defaulting to it) must still allow perturbing altitude normally.
999    /// Guards against a hypothetical `!= Some(Qnh)` -> `== None` typo that happens to pass the
1000    /// omitted-field test above but would reject this explicit-but-equivalent case.
1001    #[test]
1002    fn altitude_axis_is_perturbable_when_pressure_is_explicitly_absolute() {
1003        let json = serde_json::json!({
1004            "schema_version": 1,
1005            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1006                           "ballistic_coefficient": 0.243},
1007            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1008            "shot": {"max_range_m": 900.0},
1009            "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1010                           "pressure_reference": "absolute"},
1011            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1012        })
1013        .to_string();
1014        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1015        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1016        assert_eq!(
1017            r.atmosphere.pressure_reference,
1018            Some(PressureReferenceV1::Absolute)
1019        );
1020
1021        let changed = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(600.0)).unwrap();
1022        assert_eq!(changed.atmosphere.altitude_m, Some(600.0));
1023    }
1024
1025    /// Physics guard (b): perturbing shot azimuth on a compass-referenced-wind request would
1026    /// silently build a wrong counterfactual (the rebuilt request always carries
1027    /// shooter-relative wind -- see request_roundtrip.rs -- so the wind would rotate WITH the
1028    /// rifle instead of staying earth-fixed). Detected from the ORIGINAL resolved request's
1029    /// wind_reference echo and rejected outright.
1030    #[test]
1031    fn shot_azimuth_axis_is_unsupported_when_the_original_wind_was_compass_referenced() {
1032        let json = serde_json::json!({
1033            "schema_version": 1,
1034            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1035                           "ballistic_coefficient": 0.243},
1036            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1037            "shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
1038            "atmosphere": {},
1039            "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "compass"},
1040            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1041        })
1042        .to_string();
1043        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1044        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1045
1046        let e = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5));
1047        match e {
1048            Err(KernelError::AxisUnsupportedForRequest {
1049                axis: InputAxis::ShotAzimuth,
1050                reason,
1051            }) => {
1052                assert!(
1053                    reason.to_lowercase().contains("compass"),
1054                    "reason should name compass wind: {reason}"
1055                );
1056            }
1057            other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
1058        }
1059    }
1060
1061    /// The compass guard must be specific to compass mode: an OMITTED wind_reference (the
1062    /// default) must still allow perturbing shot azimuth normally. This only proves the guard
1063    /// doesn't fire on absence; see
1064    /// `shot_azimuth_axis_is_perturbable_when_wind_is_explicitly_shooter_relative` below for
1065    /// the stronger claim that it doesn't fire on an explicit non-compass value either.
1066    #[test]
1067    fn shot_azimuth_axis_is_perturbable_when_wind_is_shooter_relative() {
1068        let r = resolved();
1069        assert_eq!(
1070            match &r.wind {
1071                ResolvedWindV1::Constant(c) => c.wind_reference,
1072                ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
1073            },
1074            None
1075        );
1076        let changed = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5)).unwrap();
1077        assert_eq!(changed.shot.shot_azimuth_rad, Some(0.5));
1078    }
1079
1080    /// Stronger version of the test above: an EXPLICIT `wind_reference: "shooter"` (not just
1081    /// an omitted field defaulting to it) must still allow perturbing shot azimuth normally.
1082    /// Guards against a hypothetical `!= Some(Compass)` -> `== None` typo that happens to pass
1083    /// the omitted-field test above but would reject this explicit-but-equivalent case.
1084    #[test]
1085    fn shot_azimuth_axis_is_perturbable_when_wind_is_explicitly_shooter_relative() {
1086        let json = serde_json::json!({
1087            "schema_version": 1,
1088            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1089                           "ballistic_coefficient": 0.243},
1090            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1091            "shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
1092            "atmosphere": {},
1093            "wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "shooter"},
1094            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1095        })
1096        .to_string();
1097        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1098        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1099        let ResolvedWindV1::Constant(wind) = &r.wind else {
1100            panic!("constant wind expected");
1101        };
1102        assert_eq!(wind.wind_reference, Some(WindReferenceV1::Shooter));
1103
1104        let changed = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5)).unwrap();
1105        assert_eq!(changed.shot.shot_azimuth_rad, Some(0.5));
1106    }
1107}