Skip to main content

ballistics_engine/perturbation/
derive.rs

1//! Derived numerics over the perturbation kernel: central-difference derivatives (feeding an
2//! uncertainty/error budget) and monotone bisection (feeding tolerance envelopes).
3//!
4//! Both operations are built ONLY on the Task 5/6 primitives -- [`read_axis`], [`with_axis`],
5//! and [`evaluate`] -- and inherit their semantics unchanged rather than reimplementing or
6//! "correcting" anything:
7//!
8//! - `drop_m` stays LOS-perpendicular (see `evaluate`'s "Drop reference plane" doc comment in
9//!   `mod.rs`): neither function in this file looks at `shot.drops_reference` at all.
10//! - The specialized [`KernelError`] variants `with_axis` uses to refuse a physically-invalid
11//!   axis/request combination -- `AxisUnsupportedForRequest` (`Altitude` under QNH pressure,
12//!   `ShotAzimuth` under compass wind) and `AxisAbsent` (the three wind axes under segmented
13//!   wind) -- propagate out of [`central_difference`] and [`bisect_axis`] unchanged via `?`.
14//!   Neither function catches, retries, or maps them onto a derivative/bisection result: a
15//!   caller must be able to tell "this axis cannot be perturbed on this request" apart from
16//!   "the effect is zero" or "there is no crossing".
17//!
18//! # Step convention
19//!
20//! [`central_difference`] follows the one step-size heuristic the taxonomy already defines
21//! (`axis_meta(axis).kind`'s `default_rel_step`/`min_abs_step`, `src/perturbation/taxonomy.rs`):
22//! `h = (|x| * default_rel_step).max(min_abs_step)`, overridable by the caller's explicit
23//! `step`. No second heuristic is introduced here.
24//!
25//! # Cost
26//!
27//! A central difference costs exactly two solves PER AXIS in the common case, not two solves
28//! per range: both `evaluate` calls below take the whole `ranges_m` slice at once, so N ranges
29//! still cost 2 solves total (`evaluate` itself runs one `TrajectorySolver::solve` per call,
30//! however many ranges are then read off the single result). When the one-sided fallback below
31//! fires, a THIRD solve (of the unperturbed request, at `x` itself) is needed -- still
32//! independent of how many ranges are requested. For a `requires_rezero` axis (`taxonomy.rs`),
33//! each solve is itself preceded by up to 60 trial solves inside the elevation search
34//! (`find_zero_angle`, `src/cli_api.rs`) -- unavoidable here, not something this task changes.
35//! [`bisect_axis`] pays that same per-solve cost once per bisection iteration (up to
36//! [`BISECTION_MAX_ITERATIONS`], capped the same way as the existing inverse-solver search,
37//! `HoldCurve::range_for_angular_drop_mil` in `src/main.rs`), always at the single `range_m`
38//! the caller asked to bisect at.
39//!
40//! # One-sided fallback
41//!
42//! Several continuous axes have a hard physical domain narrower than all reals: `WindSpeed` is
43//! a non-negative magnitude, `RelativeHumidity` is confined to `[0, 1]`, and `TargetDistance`
44//! (== `shot.max_range_m`) cannot shrink below a range the caller is asking about. A central
45//! difference at or near such a boundary needs a perturbed value on the wrong side of it --
46//! still air (`speed_mps: 0.0`, the default absent any `wind` block at all) is the most
47//! ordinary example, not an exotic one: `WindSpeed`'s `min_abs_step` (0.05 m/s) makes the minus
48//! side `-0.05`, which `resolve_wind`'s `require_non_negative("$.wind.speed_mps")`
49//! (`src/solve_v1.rs`) rejects outright.
50//!
51//! When exactly one of the two perturbed solves fails to evaluate and the other succeeds,
52//! [`central_difference`] falls back to a one-sided difference using the side that worked plus
53//! the UNPERTURBED value at `x` itself: `(f(x+h) - f(x)) / h`
54//! ([`DifferenceScheme::ForwardOneSided`]) if the minus side failed, or
55//! `(f(x) - f(x-h)) / h` ([`DifferenceScheme::BackwardOneSided`]) if the plus side failed. This
56//! is not merely an accommodation for the solver rejecting an unphysical input: at a hard
57//! domain edge (wind speed pinned at exactly zero) a symmetric difference is not merely
58//! blocked, it would be answering the wrong question, since windage as a function of SIGNED
59//! wind speed is not smooth across that boundary (it is V-shaped, not linear) in the first
60//! place.
61//!
62//! Which scheme actually ran is part of [`Derivative`]'s public contract (its `scheme` field):
63//! a one-sided difference has different (generally larger, `O(h)` rather than `O(h^2)`)
64//! truncation error than a central one, and a caller building an error budget or reporting a
65//! method must be able to tell them apart rather than silently trusting every [`Derivative`] as
66//! if it were central.
67//!
68//! Only a DOMAIN REJECTION on one side triggers this fallback -- precisely,
69//! [`KernelError::is_domain_rejection`] must be true: a `Solve` failure whose
70//! [`SolveErrorCodeV1`](crate::solve_json::SolveErrorCodeV1) is `InvalidValue` (what
71//! `require_range`/`require_non_negative`/`require_positive` in `solve_v1.rs` produce), or an
72//! `Observation` failure that is specifically
73//! [`TrajectoryObservationError::OutOfRange`](crate::trajectory_observation::TrajectoryObservationError::OutOfRange).
74//! This is deliberately narrower than "any `evaluate` failure on one side" (an earlier revision
75//! of this function gated on exactly that -- a bare `Err(_)` -- which was a regression: it
76//! silently reinterpreted a genuine solver or trajectory bug on one side as if it were a domain
77//! boundary, answering with a plausible-looking fabricated one-sided derivative instead of
78//! reporting the real failure. `evaluate`'s own documented failure modes include a zero search
79//! that does not converge (`SolveFailed`, not `InvalidValue`) and a non-finite effective muzzle
80//! angle, and every `requires_rezero` axis runs that search on every perturbed solve, so this
81//! was not a hypothetical case). Any error that is NOT a domain rejection -- on EITHER side --
82//! propagates unchanged, exactly as it did before this function grew a fallback at all.
83//!
84//! Failures from [`with_axis`] itself (`AxisUnsupportedForRequest`, `AxisAbsent`,
85//! `TypeMismatch`) are never domain rejections and so never trigger the fallback either, but for
86//! a different reason: they do not depend on the perturbed value at all -- they would occur
87//! identically for `x+h` and `x-h`, since they are checked from `axis` and `base`'s OTHER fields
88//! before the value is even considered -- so they always propagate immediately.
89//!
90//! If BOTH perturbed sides fail with a domain rejection, there is no data left to build even a
91//! one-sided difference from: [`central_difference`] returns [`KernelError::StepOutOfDomain`].
92//! If exactly one side's failure is NOT a domain rejection, that error propagates (preferring
93//! the plus side's error when both sides failed and neither qualifies, to match the evaluation
94//! order this function used before it grew a fallback at all).
95//!
96//! # Bisection contract
97//!
98//! [`bisect_axis`] assumes `predicate` changes truth value at most once across `domain` (hence
99//! "monotone" in this module's summary) and returns the crossing to within `tolerance`.
100//!
101//! `Ok(None)` means ONLY that `predicate` did not change truth value across `domain` -- nothing
102//! more. It does NOT tell the caller which of two opposite facts that is: `predicate` could be
103//! true at both ends (e.g. "stays inside a tolerance band throughout this domain") or FALSE at
104//! both ends (e.g. "stays outside it throughout"), and `Ok(None)` looks identical either way. A
105//! caller that needs to know which one happened must check `predicate` at an endpoint itself
106//! (or already know the end state some other way) -- `bisect_axis` deliberately does not
107//! resolve that ambiguity. This matters most for a naturally two-sided predicate like
108//! `|drop - nominal| <= tolerance`, which is true in the middle and false at BOTH ends:
109//! widening `domain` to find the edge of such a band and getting `Ok(None)` back means only "no
110//! edge in this domain," never "true throughout" -- treating it as the latter would be exactly
111//! the fabricated-bound failure this function exists to avoid, just relocated into the caller's
112//! interpretation of a correct result. Contrast `HoldCurve::range_for_angular_drop_mil`
113//! (`src/main.rs`), which instead reports each out-of-domain case as its own distinct outcome;
114//! `bisect_axis` does not do that here, so its caller must.
115
116use serde::{Deserialize, Serialize};
117
118use crate::perturbation::access::{read_axis, with_axis, AxisValue, KernelError};
119use crate::perturbation::taxonomy::{axis_meta, AxisKind, InputAxis};
120use crate::perturbation::{evaluate, Observation};
121use crate::solve_json::ResolvedSolveRequestV1;
122
123/// Which finite-difference formula actually produced a [`Derivative`] (review fix I4).
124///
125/// A one-sided scheme has different, generally larger (`O(h)` vs. `O(h^2)`) truncation error
126/// than a central one -- see the module doc's "One-sided fallback" section for when and why
127/// each one is chosen. This is part of the public contract precisely so a caller building an
128/// error budget (or reporting a method, MBA-1347) does not have to silently assume every
129/// [`Derivative`] is central.
130///
131/// `Serialize`/`Deserialize` (0.33.0 decision-support Task 10, MBA-1347): `error_budget`'s
132/// `SourceContributionV1` carries this scheme directly in its wire payload, per that task's
133/// requirement that a one-sided derivative's larger truncation error be visible in the report
134/// itself, not just in prose documentation.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum DifferenceScheme {
138    /// `(f(x+h) - f(x-h)) / 2h` -- both perturbed sides evaluated successfully.
139    Central,
140    /// `(f(x+h) - f(x)) / h` -- the backward side (`x-h`) failed to evaluate (most often
141    /// because it left the axis's physical domain), so the forward side and the unperturbed
142    /// value at `x` were used instead.
143    ForwardOneSided,
144    /// `(f(x) - f(x-h)) / h` -- the forward side (`x+h`) failed to evaluate (most often because
145    /// it left the axis's physical domain), so the backward side and the unperturbed value at
146    /// `x` were used instead.
147    BackwardOneSided,
148}
149
150/// First derivative of impact with respect to one axis, at one range.
151#[derive(Debug, Clone, Copy, PartialEq)]
152pub struct Derivative {
153    pub axis: InputAxis,
154    pub range_m: f64,
155    pub d_drop_d_x: f64,
156    pub d_windage_d_x: f64,
157    pub step_used: f64,
158    pub scheme: DifferenceScheme,
159}
160
161/// Central difference: `(f(x+h) - f(x-h)) / 2h`, covering every range in `ranges_m` with
162/// exactly two solves total (one per side) in the common case -- see the module doc's "Cost"
163/// section. The step follows the crate convention
164/// `h = (|x| * default_rel_step).max(min_abs_step)` (`axis_meta`, `taxonomy.rs`) unless the
165/// caller supplies an explicit `step`. When one perturbed side leaves the axis's physical
166/// domain, falls back to a one-sided difference -- see the module doc's "One-sided fallback"
167/// section and [`Derivative::scheme`](Derivative#structfield.scheme).
168///
169/// # Errors
170///
171/// - [`KernelError::CategoricalAxis`] if `axis_meta(axis).kind` is `AxisKind::Categorical`
172///   (spec D7: categorical axes are never differentiated).
173/// - [`KernelError::AxisAbsent`] if `axis` has no value on `base` -- `read_axis` returns `None`
174///   here directly, without ever reaching `with_axis` -- most notably the three wind axes under
175///   segmented wind.
176/// - [`KernelError::TypeMismatch`] if `read_axis` ever returns a non-`Scalar` value for a
177///   continuous axis. Not reachable with the current taxonomy (every `Continuous` axis reads
178///   back as `Scalar` or `None`; `Flag`/`DragModel`/`TwistDirection` only ever come from
179///   `Categorical` axes, already rejected above), kept as a defensive catch-all so a future
180///   axis added under the wrong `AxisKind` fails loudly here instead of miscomputing silently.
181/// - [`KernelError::NonFinite`] if the computed or caller-supplied step is not a finite
182///   positive number, or if either resulting derivative is not finite.
183/// - [`KernelError::AxisUnsupportedForRequest`] or [`KernelError::AxisAbsent`], propagated
184///   unchanged from [`with_axis`] -- these depend only on `axis`/`base`, never on the perturbed
185///   value, so they surface immediately with no fallback attempted (see the module doc).
186/// - [`KernelError::StepOutOfDomain`] if BOTH perturbed sides fail with a domain rejection
187///   ([`KernelError::is_domain_rejection`]) -- there is no data left to build even a one-sided
188///   difference from.
189/// - [`KernelError::Solve`] or [`KernelError::Observation`], propagated unchanged from
190///   [`evaluate`], for any failure that is NOT a domain rejection on either perturbed side (see
191///   the module doc's "One-sided fallback" -- this is the case the fallback must NOT swallow),
192///   or if the UNPERTURBED value at `x` itself also fails to evaluate during a one-sided
193///   fallback -- a degenerate case distinct from `StepOutOfDomain` in that even the base value
194///   is unusable, not just the step.
195pub fn central_difference(
196    base: &ResolvedSolveRequestV1,
197    axis: InputAxis,
198    ranges_m: &[f64],
199    step: Option<f64>,
200) -> Result<Vec<Derivative>, KernelError> {
201    let (rel, min_abs) = match axis_meta(axis).kind {
202        AxisKind::Continuous {
203            default_rel_step,
204            min_abs_step,
205            ..
206        } => (default_rel_step, min_abs_step),
207        AxisKind::Categorical => return Err(KernelError::CategoricalAxis(axis)),
208    };
209    let x = match read_axis(base, axis) {
210        Some(AxisValue::Scalar(x)) => x,
211        // Defensive only -- see the "not reachable" note in the doc comment above: every
212        // Categorical axis (the only source of Flag/DragModel/TwistDirection) already returned
213        // above.
214        Some(_) => return Err(KernelError::TypeMismatch(axis)),
215        None => return Err(KernelError::AxisAbsent(axis)),
216    };
217    let h = step.unwrap_or_else(|| (x.abs() * rel).max(min_abs));
218    if !(h.is_finite() && h > 0.0) {
219        return Err(KernelError::NonFinite(axis));
220    }
221
222    // with_axis's own failure modes here (AxisUnsupportedForRequest, AxisAbsent, TypeMismatch)
223    // depend only on `axis` and `base`'s OTHER fields, never on the specific value written --
224    // x+h and x-h would fail identically -- so they propagate immediately via `?`, from
225    // whichever side is built first, exactly as before this function grew a one-sided fallback.
226    let plus_req = with_axis(base, axis, AxisValue::Scalar(x + h))?;
227    let minus_req = with_axis(base, axis, AxisValue::Scalar(x - h))?;
228
229    // Unlike with_axis's structural errors above, a failure HERE is about the specific
230    // perturbed VALUE (e.g. a negative wind speed, or a query range that fell outside a shrunk
231    // max_range_m) -- see the module doc's "One-sided fallback". But NOT every failure here
232    // qualifies for the fallback: only a genuine domain rejection does (review fix I4(a) -- see
233    // `KernelError::is_domain_rejection`'s doc for exactly why `Err(_)` alone is wrong here).
234    let plus_result = evaluate(&plus_req, ranges_m);
235    let minus_result = evaluate(&minus_req, ranges_m);
236
237    // Computed on borrowed references, BEFORE the match below moves `plus_result`/`minus_result`,
238    // so the match arms need no guard-vs-move subtlety.
239    let plus_is_domain_rejection = matches!(&plus_result, Err(e) if e.is_domain_rejection());
240    let minus_is_domain_rejection = matches!(&minus_result, Err(e) if e.is_domain_rejection());
241
242    // `hi`/`lo` always mean "the sample at the higher x" / "the sample at the lower x", so the
243    // derivative below is always (hi - lo) / denom regardless of which scheme produced them:
244    //   Central:  hi = f(x+h), lo = f(x-h), denom = 2h
245    //   Forward:  hi = f(x+h), lo = f(x),   denom = h   (minus side was a domain rejection)
246    //   Backward: hi = f(x),   lo = f(x-h), denom = h   (plus side was a domain rejection)
247    let (hi, lo, denom, scheme) = match (plus_result, minus_result) {
248        (Ok(p), Ok(m)) => (p, m, 2.0 * h, DifferenceScheme::Central),
249        (Ok(p), Err(_)) if minus_is_domain_rejection => {
250            let base_req: crate::solve_json::SolveRequestV1 = base.into();
251            let f_x = evaluate(&base_req, ranges_m)?;
252            (p, f_x, h, DifferenceScheme::ForwardOneSided)
253        }
254        (Err(_), Ok(m)) if plus_is_domain_rejection => {
255            let base_req: crate::solve_json::SolveRequestV1 = base.into();
256            let f_x = evaluate(&base_req, ranges_m)?;
257            (f_x, m, h, DifferenceScheme::BackwardOneSided)
258        }
259        (Err(_), Err(_)) if plus_is_domain_rejection && minus_is_domain_rejection => {
260            return Err(KernelError::StepOutOfDomain { axis, attempted: h });
261        }
262        // At least one side's failure is NOT a domain rejection -- a genuine solver or
263        // trajectory bug, not a step that merely crossed a physical boundary. Propagate it
264        // unchanged rather than silently reinterpreting it as "no data on this side" (the exact
265        // regression review fix I4(a) describes): prefer the plus side's error, matching the
266        // evaluation order this function used before it grew a fallback at all (`plus` was
267        // always checked first, via `?`, before either side could fail differently).
268        (Ok(_), Err(e)) => return Err(e),
269        (Err(e), _) => return Err(e),
270    };
271
272    debug_assert_eq!(hi.len(), lo.len());
273    let mut out = Vec::with_capacity(ranges_m.len());
274    for (a, b) in hi.iter().zip(lo.iter()) {
275        let d_drop = (a.drop_m - b.drop_m) / denom;
276        let d_wind = (a.windage_m - b.windage_m) / denom;
277        if !d_drop.is_finite() || !d_wind.is_finite() {
278            return Err(KernelError::NonFinite(axis));
279        }
280        out.push(Derivative {
281            axis,
282            range_m: a.range_m,
283            d_drop_d_x: d_drop,
284            d_windage_d_x: d_wind,
285            step_used: h,
286            scheme,
287        });
288    }
289    Ok(out)
290}
291
292/// Iteration cap for [`bisect_axis`]'s search -- matches the existing inverse-solver cap
293/// (`HoldCurve::range_for_angular_drop_mil`'s `INVERSE_MAX_ITERATIONS`, `src/main.rs`).
294pub const BISECTION_MAX_ITERATIONS: u32 = 80;
295
296/// Bisect `axis` over `domain` at a single `range_m` until `predicate` (evaluated on the
297/// observation at that range) changes truth value, to within `tolerance`. See the module doc's
298/// "Bisection contract" for what `None` means and what `predicate` must satisfy.
299///
300/// # Errors
301///
302/// - [`KernelError::CategoricalAxis`] if `axis` is categorical.
303/// - Any error [`with_axis`] or [`evaluate`] produce while probing `domain` -- including
304///   [`KernelError::AxisUnsupportedForRequest`] and [`KernelError::AxisAbsent`] -- propagated
305///   unchanged from the first probe that hits them (`domain.0`, checked before any bisection
306///   step runs), so a request/axis combination `with_axis` refuses is reported as that specific
307///   refusal, never silently folded into "no crossing" (`Ok(None)`).
308pub fn bisect_axis(
309    base: &ResolvedSolveRequestV1,
310    axis: InputAxis,
311    range_m: f64,
312    domain: (f64, f64),
313    predicate: &dyn Fn(&Observation) -> bool,
314    tolerance: f64,
315) -> Result<Option<f64>, KernelError> {
316    if matches!(axis_meta(axis).kind, AxisKind::Categorical) {
317        return Err(KernelError::CategoricalAxis(axis));
318    }
319    let at = |v: f64| -> Result<bool, KernelError> {
320        let obs = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?;
321        Ok(predicate(&obs[0]))
322    };
323    let (mut lo, mut hi) = domain;
324    let lo_state = at(lo)?;
325    if lo_state == at(hi)? {
326        return Ok(None);
327    }
328    for _ in 0..BISECTION_MAX_ITERATIONS {
329        if (hi - lo).abs() <= tolerance {
330            break;
331        }
332        let mid = 0.5 * (lo + hi);
333        if at(mid)? == lo_state {
334            lo = mid;
335        } else {
336            hi = mid;
337        }
338    }
339    Ok(Some(0.5 * (lo + hi)))
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::perturbation::InputAxis;
346
347    fn resolved(mv: f64) -> crate::solve_json::ResolvedSolveRequestV1 {
348        let json = serde_json::json!({
349            "schema_version": 1,
350            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
351                           "ballistic_coefficient": 0.243},
352            "rifle": {"muzzle_velocity_mps": mv, "sight_height_m": 0.05},
353            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
354            "atmosphere": {},
355            "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
356            "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
357        })
358        .to_string();
359        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
360        crate::solve_v1::solve_v1(req).unwrap().resolved_request
361    }
362
363    /// More muzzle velocity means less drop at a fixed range: the derivative is negative.
364    #[test]
365    fn drop_derivative_wrt_muzzle_velocity_is_negative() {
366        let r = resolved(823.0);
367        let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None).unwrap();
368        assert_eq!(d.len(), 1);
369        assert!(
370            d[0].d_drop_d_x < 0.0,
371            "expected negative, got {}",
372            d[0].d_drop_d_x
373        );
374        assert!(d[0].step_used > 0.0);
375        // Review fix (post-I4): the common both-sides-valid path must still report Central, not
376        // default or drift to a one-sided scheme when nothing forced one.
377        assert_eq!(d[0].scheme, DifferenceScheme::Central);
378    }
379
380    /// Categorical axes must be refused, never silently differentiated (spec D7).
381    #[test]
382    fn categorical_axes_cannot_be_differentiated() {
383        let r = resolved(823.0);
384        let e = central_difference(&r, InputAxis::CoriolisEnabled, &[600.0], None);
385        assert!(matches!(e, Err(KernelError::CategoricalAxis(_))));
386    }
387
388    /// Bisection finds the muzzle velocity at which drop crosses a chosen threshold.
389    #[test]
390    fn bisect_finds_the_crossing() {
391        let r = resolved(823.0);
392        let base = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None).unwrap();
393        let _ = base;
394        let target_drop = 2.0_f64;
395        let found = bisect_axis(
396            &r,
397            InputAxis::MuzzleVelocityMps,
398            600.0,
399            (600.0, 1100.0),
400            &|o: &Observation| o.drop_m < target_drop,
401            0.05,
402        )
403        .unwrap();
404        let mv = found.expect("a crossing exists in this domain");
405        assert!(mv > 600.0 && mv < 1100.0);
406    }
407
408    /// Against the vacuum oracle the derivative has a closed form:
409    /// drop = 0.5*g*(x/v)^2  =>  d(drop)/dv = -g*x^2/v^3.
410    #[test]
411    fn central_difference_matches_the_vacuum_analytic_derivative() {
412        let json = serde_json::json!({
413            "schema_version": 1,
414            // bc_value huge => retardation negligible, same trick as the analytic_vacuum fuzzer
415            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
416                           "ballistic_coefficient": 100.0},
417            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
418            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
419            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
420            "sampling": {"interval_m": 5.0}
421        })
422        .to_string();
423        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
424        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
425        let x = 400.0_f64;
426        // Read back off the RESOLVED request rather than hardcoding 800.0 a second time here:
427        // otherwise a future edit to the fixture's muzzle_velocity_mps would silently compare
428        // the real derivative against a closed form for the WRONG v (review minor fix).
429        let v = r.rifle.muzzle_velocity_mps;
430        let expected = -9.80665 * x * x / (v * v * v);
431        let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[x], None).unwrap();
432        let rel = ((d[0].d_drop_d_x - expected) / expected).abs();
433        assert!(
434            rel < 0.02,
435            "expected ~{expected}, got {} (rel {rel})",
436            d[0].d_drop_d_x
437        );
438        assert_eq!(d[0].scheme, DifferenceScheme::Central);
439    }
440
441    /// None of the tests above ever pass more than one range to `central_difference`, even
442    /// though the whole point of taking a slice (see the module doc's "Cost" section) is to
443    /// cover every range from a SINGLE pair of solves. Extend the vacuum oracle across two
444    /// ranges at once: both must match their own closed form, tagged with the CALLER's range
445    /// (not the loop index or the other side's), and the longer range's derivative must be the
446    /// larger one in magnitude (sensitivity to muzzle velocity grows with range^2) -- this would
447    /// catch a bug that zipped `plus`/`minus` against the wrong range, or that reused `h` from
448    /// one range's scale for another's.
449    #[test]
450    fn central_difference_matches_the_vacuum_oracle_at_every_requested_range() {
451        let json = serde_json::json!({
452            "schema_version": 1,
453            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
454                           "ballistic_coefficient": 100.0},
455            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
456            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
457            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
458            "sampling": {"interval_m": 5.0}
459        })
460        .to_string();
461        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
462        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
463        // Read back off the RESOLVED request rather than hardcoding 800.0 a second time here
464        // (review minor fix -- same rationale as the single-range oracle test above).
465        let v = r.rifle.muzzle_velocity_mps;
466        let ranges = [200.0_f64, 400.0_f64];
467        let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &ranges, None).unwrap();
468        assert_eq!(d.len(), 2);
469        for (i, &x) in ranges.iter().enumerate() {
470            assert_eq!(
471                d[i].range_m, x,
472                "derivative {i} must be tagged with the range it was requested at"
473            );
474            let expected = -9.80665 * x * x / (v * v * v);
475            let rel = ((d[i].d_drop_d_x - expected) / expected).abs();
476            assert!(
477                rel < 0.02,
478                "range {x}: expected ~{expected}, got {} (rel {rel})",
479                d[i].d_drop_d_x
480            );
481        }
482        assert!(
483            d[1].d_drop_d_x.abs() > d[0].d_drop_d_x.abs() * 3.0,
484            "sensitivity to muzzle velocity should grow with range^2: at {} m got {}, at {} m got {}",
485            ranges[0], d[0].d_drop_d_x, ranges[1], d[1].d_drop_d_x
486        );
487        assert!(d.iter().all(|d| d.scheme == DifferenceScheme::Central));
488    }
489
490    /// The tests above only ever check `d_drop_d_x`; `d_windage_d_x` is never exercised, so a
491    /// copy-paste bug that computed it from `drop_m` instead of `windage_m` (or one that always
492    /// left it at 0) would pass every other test in this file. Wind speed pins it down: the
493    /// `resolved` fixture's wind is a 90-degree (full) crosswind (see `mod.rs`'s
494    /// `base_request_json` doc for the same convention), so more wind speed must push windage
495    /// further in the SAME direction as the baseline windage, and that push must dominate
496    /// whatever tiny secondary effect wind speed has on drop.
497    #[test]
498    fn windage_derivative_wrt_wind_speed_dominates_and_matches_the_baseline_sign() {
499        let r = resolved(823.0);
500        let baseline_req: crate::solve_json::SolveRequestV1 = (&r).into();
501        let baseline = evaluate(&baseline_req, &[600.0]).expect("baseline evaluate");
502        assert!(
503            baseline[0].windage_m.abs() > 0.01,
504            "fixture must have non-negligible baseline windage, got {}",
505            baseline[0].windage_m
506        );
507
508        let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap();
509        assert_eq!(d.len(), 1);
510        assert_eq!(
511            d[0].d_windage_d_x.signum(),
512            baseline[0].windage_m.signum(),
513            "more crosswind should push windage further the SAME way: derivative {}, baseline {}",
514            d[0].d_windage_d_x,
515            baseline[0].windage_m
516        );
517        assert!(
518            d[0].d_windage_d_x.abs() > d[0].d_drop_d_x.abs() * 5.0,
519            "a pure crosswind should move windage far more than it moves drop: \
520             d_windage_d_x={}, d_drop_d_x={}",
521            d[0].d_windage_d_x,
522            d[0].d_drop_d_x
523        );
524    }
525
526    /// `step_used` is part of the public contract of a `Derivative` -- a caller building an
527    /// error budget reads it directly to scale an input uncertainty. This test only pins the
528    /// REPORTED value against an independently recomputed formula, decoupled from any
529    /// particular axis's physical response; it does NOT by itself prove the same `h` was the
530    /// one actually used inside the `2h` division (a bug that reports one `h` but divides by
531    /// another would still pass this test unchanged) -- that is what the vacuum oracle test
532    /// above protects, by checking the resulting NUMBER rather than this metadata field
533    /// (review fix: this comment previously overclaimed what this test covers).
534    #[test]
535    fn step_used_follows_the_crate_convention() {
536        let r = resolved(823.0);
537        let x = match read_axis(&r, InputAxis::WindSpeed).unwrap() {
538            AxisValue::Scalar(x) => x,
539            other => panic!("WindSpeed must read back as a scalar, got {other:?}"),
540        };
541        let (rel, min_abs) = match axis_meta(InputAxis::WindSpeed).kind {
542            AxisKind::Continuous {
543                default_rel_step,
544                min_abs_step,
545                ..
546            } => (default_rel_step, min_abs_step),
547            AxisKind::Categorical => panic!("WindSpeed must be continuous"),
548        };
549        let expected_h = (x.abs() * rel).max(min_abs);
550        let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap();
551        assert_eq!(d[0].step_used, expected_h);
552    }
553
554    /// If `predicate` never flips across `domain`, `bisect_axis` must say so with `None` -- not
555    /// fabricate a bound by returning some point inside the domain anyway (the exact failure
556    /// mode this function's doc comment calls out). A target so large that drop can never reach
557    /// it over a 600 m shot holds `true` at both ends of the domain, so there is nothing to
558    /// bisect.
559    #[test]
560    fn bisect_axis_returns_none_when_the_predicate_never_flips() {
561        let r = resolved(823.0);
562        let found = bisect_axis(
563            &r,
564            InputAxis::MuzzleVelocityMps,
565            600.0,
566            (600.0, 1100.0),
567            &|o: &Observation| o.drop_m < 1000.0,
568            0.05,
569        )
570        .unwrap();
571        assert!(
572            found.is_none(),
573            "predicate holds everywhere on this domain; bisect_axis must report None, not a \
574             fabricated crossing, got {found:?}"
575        );
576    }
577
578    /// I1 review fix: `Ok(None)` means only "no flip across this domain" -- NOT "predicate
579    /// holds throughout". This is the mirror of the test above: a target so far below any
580    /// reachable drop that the predicate is FALSE at both ends must ALSO come back as `None`,
581    /// indistinguishable at the type level from the "true at both ends" case above. That
582    /// asymmetry (identical `Ok(None)` for two opposite facts) is exactly what the module doc's
583    /// "Bisection contract" section now warns callers about -- this test only pins down that
584    /// `bisect_axis` itself does not silently pick one interpretation (e.g. by fabricating a
585    /// crossing when the predicate happens to be false everywhere).
586    #[test]
587    fn bisect_axis_returns_none_when_the_predicate_is_false_at_both_ends() {
588        let r = resolved(823.0);
589        let found = bisect_axis(
590            &r,
591            InputAxis::MuzzleVelocityMps,
592            600.0,
593            (600.0, 1100.0),
594            &|o: &Observation| o.drop_m < -1000.0, // never true: drop cannot be this negative
595            0.05,
596        )
597        .unwrap();
598        assert!(
599            found.is_none(),
600            "predicate is false everywhere on this domain; bisect_axis must report None, got \
601             {found:?}"
602        );
603    }
604
605    /// I3 review fix: deleting `bisect_axis`'s categorical guard fails nothing today -- with it
606    /// gone, a categorical axis would fall through to `with_axis`, which reports `TypeMismatch`
607    /// (since `AxisValue::Scalar` never matches a categorical axis's expected representation),
608    /// not the `CategoricalAxis` this function's own `# Errors` section promises. Pin it down
609    /// directly and independently of `central_difference`'s own (separately tested) categorical
610    /// guard.
611    #[test]
612    fn bisect_axis_refuses_categorical_axes() {
613        let r = resolved(823.0);
614        let e = bisect_axis(
615            &r,
616            InputAxis::CoriolisEnabled,
617            600.0,
618            (0.0, 1.0),
619            &|o: &Observation| o.drop_m < 1.0,
620            0.1,
621        );
622        assert!(matches!(
623            e,
624            Err(KernelError::CategoricalAxis(InputAxis::CoriolisEnabled))
625        ));
626    }
627
628    /// Independent closed-form check for `bisect_axis` itself (not just `central_difference`):
629    /// reuse the near-vacuum trick (huge BC => drag negligible) so `drop = 0.5*g*(x/v)^2` is
630    /// invertible in closed form: `v = x*sqrt(g/(2*drop))`. The domain `(500, 2000)` is
631    /// deliberately asymmetric around that root (~886 m/s, well off the domain's own midpoint
632    /// of 1250), so a bug that returned the domain midpoint instead of converging would be off
633    /// by roughly 41% -- nowhere close to the 2% tolerance this file uses elsewhere for the
634    /// same physical approximation. No `zero_distance_m` is present (only an explicit
635    /// `muzzle_angle_rad`), so -- like the derivative oracle above -- no re-zero search runs and
636    /// `MuzzleVelocityMps` bisects cheaply here despite being a `requires_rezero` axis in
637    /// general.
638    #[test]
639    fn bisect_axis_converges_to_the_vacuum_analytic_root_not_the_domain_midpoint() {
640        let json = serde_json::json!({
641            "schema_version": 1,
642            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
643                           "ballistic_coefficient": 100.0},
644            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
645            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
646            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
647            "sampling": {"interval_m": 5.0}
648        })
649        .to_string();
650        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
651        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
652
653        let x = 400.0_f64;
654        let target_drop = 1.0_f64;
655        let domain = (500.0_f64, 2000.0_f64);
656        let expected_v = x * (9.80665_f64 / (2.0 * target_drop)).sqrt();
657        // Sanity check on the fixture itself: the analytic root must not be suspiciously close
658        // to the domain's midpoint, or this test would not actually distinguish "converged" from
659        // "returned the midpoint".
660        let midpoint = 0.5 * (domain.0 + domain.1);
661        assert!(
662            (expected_v - midpoint).abs() / expected_v > 0.2,
663            "fixture must keep the analytic root well away from the domain midpoint: \
664             root {expected_v}, midpoint {midpoint}"
665        );
666
667        let found = bisect_axis(
668            &r,
669            InputAxis::MuzzleVelocityMps,
670            x,
671            domain,
672            &|o: &Observation| o.drop_m < target_drop,
673            0.05,
674        )
675        .unwrap()
676        .expect("a crossing exists in this domain");
677
678        let rel = ((found - expected_v) / expected_v).abs();
679        assert!(
680            rel < 0.02,
681            "expected the bisection to converge near the analytic root ~{expected_v}, got \
682             {found} (rel {rel})"
683        );
684    }
685
686    /// `with_axis` refuses `Altitude` on a QNH-referenced request (`access.rs`) because the
687    /// rebuilt request cannot re-derive the original altimeter setting. `central_difference`
688    /// must PROPAGATE that refusal, not swallow it into a derivative of zero -- a caller needs
689    /// to tell "this axis cannot be perturbed here" apart from "the effect is zero".
690    #[test]
691    fn central_difference_propagates_axis_unsupported_for_request() {
692        let json = serde_json::json!({
693            "schema_version": 1,
694            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
695                           "ballistic_coefficient": 0.243},
696            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
697            "shot": {"max_range_m": 900.0},
698            "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
699                           "pressure_reference": "qnh"},
700            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
701        })
702        .to_string();
703        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
704        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
705
706        let e = central_difference(&r, InputAxis::Altitude, &[300.0], None);
707        match e {
708            Err(KernelError::AxisUnsupportedForRequest {
709                axis: InputAxis::Altitude,
710                reason,
711            }) => {
712                assert!(
713                    reason.to_lowercase().contains("qnh"),
714                    "reason should name QNH: {reason}"
715                );
716            }
717            other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
718        }
719    }
720
721    /// Same guarantee via `bisect_axis`: the refusal must surface on the very first domain
722    /// probe, not be absorbed into `Ok(None)`.
723    #[test]
724    fn bisect_axis_propagates_axis_unsupported_for_request() {
725        let json = serde_json::json!({
726            "schema_version": 1,
727            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
728                           "ballistic_coefficient": 0.243},
729            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
730            "shot": {"max_range_m": 900.0},
731            "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
732                           "pressure_reference": "qnh"},
733            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
734        })
735        .to_string();
736        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
737        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
738
739        let e = bisect_axis(
740            &r,
741            InputAxis::Altitude,
742            300.0,
743            (400.0, 600.0),
744            &|o: &Observation| o.drop_m < 1.0,
745            0.5,
746        );
747        match e {
748            Err(KernelError::AxisUnsupportedForRequest {
749                axis: InputAxis::Altitude,
750                reason,
751            }) => {
752                assert!(
753                    reason.to_lowercase().contains("qnh"),
754                    "reason should name QNH: {reason}"
755                );
756            }
757            other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
758        }
759    }
760
761    /// `read_axis` returns `None` for the three wind axes under segmented wind (no single
762    /// scalar to perturb, taxonomy.rs Known Limitation (c)). `central_difference` must turn
763    /// that into `KernelError::AxisAbsent` via its OWN `None` branch -- this is a different code
764    /// path than `with_axis`'s segmented-wind guard, so it needs its own test.
765    #[test]
766    fn central_difference_reports_axis_absent_for_wind_axes_under_segmented_wind() {
767        let json = serde_json::json!({
768            "schema_version": 1,
769            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
770                           "ballistic_coefficient": 0.243},
771            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
772            "shot": {"max_range_m": 900.0},
773            "atmosphere": {},
774            "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
775                                    "direction_from_rad": 1.0}]},
776            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
777        })
778        .to_string();
779        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
780        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
781
782        let e = central_difference(&r, InputAxis::WindSpeed, &[300.0], None);
783        assert!(matches!(
784            e,
785            Err(KernelError::AxisAbsent(InputAxis::WindSpeed))
786        ));
787    }
788
789    /// Same guarantee via `bisect_axis`, which never calls `read_axis` at all -- its
790    /// `AxisAbsent` must come through `with_axis`'s own segmented-wind guard instead, exercised
791    /// on the very first probe.
792    #[test]
793    fn bisect_axis_reports_axis_absent_for_wind_axes_under_segmented_wind() {
794        let json = serde_json::json!({
795            "schema_version": 1,
796            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
797                           "ballistic_coefficient": 0.243},
798            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
799            "shot": {"max_range_m": 900.0},
800            "atmosphere": {},
801            "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
802                                    "direction_from_rad": 1.0}]},
803            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
804        })
805        .to_string();
806        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
807        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
808
809        let e = bisect_axis(
810            &r,
811            InputAxis::WindSpeed,
812            300.0,
813            (0.0, 10.0),
814            &|o: &Observation| o.windage_m < 0.0,
815            0.5,
816        );
817        assert!(matches!(
818            e,
819            Err(KernelError::AxisAbsent(InputAxis::WindSpeed))
820        ));
821    }
822
823    /// I2 review fix: no test anywhere in this file ever passes `Some(...)` for the explicit
824    /// `step` parameter, so a mutation that silently discards the caller's step and falls back
825    /// to the default formula would pass every other test in this file unchanged. MBA-1347 is
826    /// the obvious consumer of an explicit step ("perturb by 1 sigma"). The chosen step (2.0) is
827    /// deliberately different from what the default formula would compute here (0.8, per
828    /// `central_difference_matches_the_vacuum_analytic_derivative`), so a bug that ignores
829    /// `step` cannot coincidentally pass by landing on the same number anyway.
830    #[test]
831    fn explicit_step_overrides_the_default_and_still_matches_the_vacuum_oracle() {
832        let json = serde_json::json!({
833            "schema_version": 1,
834            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
835                           "ballistic_coefficient": 100.0},
836            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
837            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
838            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
839            "sampling": {"interval_m": 5.0}
840        })
841        .to_string();
842        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
843        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
844        let v = r.rifle.muzzle_velocity_mps;
845        let x = 400.0_f64;
846        let custom_step = 2.0_f64;
847        let d =
848            central_difference(&r, InputAxis::MuzzleVelocityMps, &[x], Some(custom_step)).unwrap();
849        assert_eq!(
850            d[0].step_used, custom_step,
851            "an explicit step must override the default formula, not just be ignored"
852        );
853        let expected = -9.80665 * x * x / (v * v * v);
854        let rel = ((d[0].d_drop_d_x - expected) / expected).abs();
855        assert!(
856            rel < 0.02,
857            "expected ~{expected}, got {} (rel {rel})",
858            d[0].d_drop_d_x
859        );
860        assert_eq!(d[0].scheme, DifferenceScheme::Central);
861    }
862
863    /// I2 review fix, continued: a non-finite or non-positive explicit step must be rejected,
864    /// not silently used -- dividing by zero/NaN, or by a negative number (which would silently
865    /// flip the derivative's sign without changing anything else about the result's shape).
866    #[test]
867    fn non_finite_or_non_positive_explicit_step_is_rejected() {
868        let r = resolved(823.0);
869        let nan = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(f64::NAN));
870        assert!(matches!(
871            nan,
872            Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))
873        ));
874        let zero = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(0.0));
875        assert!(matches!(
876            zero,
877            Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))
878        ));
879        let negative = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(-1.0));
880        assert!(matches!(
881            negative,
882            Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))
883        ));
884    }
885
886    /// I4 review fix: `WindSpeed` at 0.0 (still air, the default absent any `wind` block at
887    /// all, and the SAME magnitude used by BOTH of this file's vacuum-oracle fixtures, so this
888    /// is not an exotic edge case) cannot be centrally differentiated. The minus side needs
889    /// `-min_abs_step` (-0.05 m/s), which `resolve_wind`'s `require_non_negative
890    /// ("$.wind.speed_mps")` (`src/solve_v1.rs`) rejects, since wind speed is a non-negative
891    /// magnitude. Falling back to a ONE-SIDED forward difference is not just an accommodation
892    /// for the solver rejecting an unphysical input -- it is the numerically correct thing to
893    /// do here, since windage as a function of SIGNED wind speed is V-shaped (not smooth) at
894    /// zero, so a central difference straddling it would differentiate through a kink.
895    ///
896    /// Speed and direction must be supplied TOGETHER (`resolve_wind` rejects one without the
897    /// other), so still air is expressed here as an EXPLICIT `speed_mps: 0.0` paired with a
898    /// 90-degree crosswind direction, rather than omitting the `wind` block entirely: the
899    /// omitted-block default direction is a pure head/tailwind (no crosswind component at all),
900    /// which would make `d_windage_d_x` genuinely, correctly zero regardless of whether the
901    /// fallback logic is right -- that would test nothing.
902    #[test]
903    fn wind_speed_falls_back_to_one_sided_in_still_air() {
904        let json = serde_json::json!({
905            "schema_version": 1,
906            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
907                           "ballistic_coefficient": 0.243},
908            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
909            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
910            "atmosphere": {},
911            "wind": {"speed_mps": 0.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
912            "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
913        })
914        .to_string();
915        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
916        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
917        assert_eq!(
918            match &r.wind {
919                crate::solve_json::ResolvedWindV1::Constant(c) => c.speed_mps,
920                crate::solve_json::ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
921            },
922            0.0,
923            "fixture assumption: still air"
924        );
925
926        let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None);
927        let d = d.unwrap_or_else(|e| panic!("still air must fall back, not error, got {e:?}"));
928        assert_eq!(d.len(), 1);
929        assert_eq!(
930            d[0].scheme,
931            DifferenceScheme::ForwardOneSided,
932            "the minus side (-0.05 m/s) leaves WindSpeed's domain, so this must be a forward \
933             one-sided fallback, not Central and not Backward"
934        );
935        assert!(d[0].d_windage_d_x.is_finite() && d[0].d_windage_d_x != 0.0);
936    }
937
938    /// I4 review fix, continued: `RelativeHumidity` hits the same class of domain violation at
939    /// its OTHER boundary. `resolve_atmosphere` validates it to `require_range(..., 0.0, 1.0)`
940    /// (`src/solve_v1.rs`); at the dry-air boundary (0.0, itself a common, unremarkable request
941    /// -- not every caller supplies humidity, and dry air is a normal thing to model
942    /// explicitly), the minus side (`-min_abs_step` = -0.001) falls outside that range.
943    #[test]
944    fn relative_humidity_falls_back_to_one_sided_at_the_dry_air_boundary() {
945        let json = serde_json::json!({
946            "schema_version": 1,
947            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
948                           "ballistic_coefficient": 0.243},
949            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
950            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
951            "atmosphere": {"relative_humidity": 0.0},
952            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
953        })
954        .to_string();
955        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
956        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
957        assert_eq!(
958            r.atmosphere.relative_humidity, 0.0,
959            "fixture assumption: an explicit 0.0 must resolve to exactly 0.0, not the 0.5 \
960             literal default"
961        );
962
963        let d = central_difference(&r, InputAxis::RelativeHumidity, &[600.0], None);
964        let d = d.unwrap_or_else(|e| {
965            panic!("the dry-air boundary must fall back, not error, got {e:?}")
966        });
967        assert_eq!(d.len(), 1);
968        assert_eq!(
969            d[0].scheme,
970            DifferenceScheme::ForwardOneSided,
971            "the minus side (-0.001) leaves RelativeHumidity's [0, 1] domain"
972        );
973        assert!(d[0].d_drop_d_x.is_finite());
974    }
975
976    /// I4 review fix, continued: `TargetDistance` (== `shot.max_range_m`) hits the same class
977    /// of domain violation through a DIFFERENT `KernelError` variant than WindSpeed/
978    /// RelativeHumidity above -- `Observation` (a query range now outside the computed
979    /// trajectory), raised AFTER a successful solve, rather than `Solve` (a validation failure
980    /// before one). Differentiating drop's sensitivity to the target distance AT that same
981    /// target distance is the natural use of this axis (e.g. "how much does drop at 900 m
982    /// change if the target were a little closer or farther"); querying a range close to `x`
983    /// means the minus side (a slightly SMALLER max_range_m) can no longer see it.
984    ///
985    /// x = 900, h = max(900*1e-3, 0.5) = 0.9, so minus = 899.1 and plus = 900.9. A query at
986    /// 899.5 m is comfortably inside plus's and the unperturbed x's trajectories but strictly
987    /// beyond minus's -- deliberately not exactly 900.0, to avoid depending on whether an exact
988    /// upper-endpoint query is treated as inclusive.
989    #[test]
990    fn target_distance_falls_back_to_one_sided_when_queried_near_its_own_max_range() {
991        let json = serde_json::json!({
992            "schema_version": 1,
993            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
994                           "ballistic_coefficient": 0.243},
995            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
996            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
997            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
998            "sampling": {"interval_m": 25.0}
999        })
1000        .to_string();
1001        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1002        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1003        assert_eq!(r.shot.max_range_m, 900.0);
1004
1005        let d = central_difference(&r, InputAxis::TargetDistance, &[899.5], None);
1006        let d = d.unwrap_or_else(|e| {
1007            panic!("a range just inside the unperturbed max_range_m must fall back, not error, got {e:?}")
1008        });
1009        assert_eq!(d.len(), 1);
1010        assert_eq!(
1011            d[0].scheme,
1012            DifferenceScheme::ForwardOneSided,
1013            "the minus side (max_range_m = 899.1) no longer covers the 899.5 m query, so this \
1014             must be a forward one-sided fallback"
1015        );
1016        assert!(d[0].d_drop_d_x.is_finite());
1017    }
1018
1019    /// I4 review fix, continued: when BOTH perturbed sides fail, there is no data left to build
1020    /// even a one-sided difference from. An oversized EXPLICIT step pushes `RelativeHumidity`
1021    /// (at the 0.0 boundary) out of its `[0, 1]` domain in BOTH directions at once (x+h = 2.0,
1022    /// x-h = -2.0), so this cannot fall back either way. `central_difference` must report the
1023    /// distinct `StepOutOfDomain` -- not propagate whichever of the two solve errors happened to
1024    /// be checked first (arbitrary, and neither alone names the real problem), and not silently
1025    /// claim a zero-effect derivative.
1026    #[test]
1027    fn both_sides_out_of_domain_reports_step_out_of_domain() {
1028        let json = serde_json::json!({
1029            "schema_version": 1,
1030            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1031                           "ballistic_coefficient": 0.243},
1032            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1033            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
1034            "atmosphere": {"relative_humidity": 0.0},
1035            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
1036        })
1037        .to_string();
1038        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1039        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1040        assert_eq!(r.atmosphere.relative_humidity, 0.0, "fixture assumption");
1041
1042        let e = central_difference(&r, InputAxis::RelativeHumidity, &[600.0], Some(2.0));
1043        match e {
1044            Err(KernelError::StepOutOfDomain {
1045                axis: InputAxis::RelativeHumidity,
1046                attempted,
1047            }) => {
1048                assert_eq!(attempted, 2.0);
1049            }
1050            other => panic!("expected StepOutOfDomain, got {other:?}"),
1051        }
1052    }
1053
1054    /// I4(a) review fix -- THE test that was missing, and that a bare `Err(_)` guard on either
1055    /// perturbed side would pass incorrectly (an earlier revision had exactly that guard; see
1056    /// the revert-verification note in the task report for confirmation that this test actually
1057    /// fails under it). The fallback must trigger ONLY for a domain rejection
1058    /// (`KernelError::is_domain_rejection`), never for a genuine solver failure that happens to
1059    /// land on just one side.
1060    ///
1061    /// 10 m/s cannot reach a 100 m zero at ANY elevation angle: even the vacuum-ideal maximum
1062    /// range at a 45-degree launch is `v^2/g` =~ 10.2 m, an order of magnitude short, and real
1063    /// drag only shortens that further. `build_zeroed_solver`'s elevation search
1064    /// (`calculate_and_set_zero_angle`, `src/solve_v1.rs`) reports that non-convergence via
1065    /// `.map_err(solve_failed)`, which is `SolveErrorCodeV1::SolveFailed` -- NEVER
1066    /// `InvalidValue`, since 10.0 m/s is a perfectly valid, positive velocity that
1067    /// `require_positive` never rejects. The plus side (1636 m/s) is merely a fast, ordinary,
1068    /// flat-shooting bullet that zeroes at 100 m without incident, so this is a genuine
1069    /// (`Ok`, `Err`) split where the `Err` is NOT a domain rejection.
1070    ///
1071    /// `central_difference` must propagate that `SolveFailed` unchanged -- not answer with a
1072    /// forward one-sided derivative computed from the plus side alone, which would be a
1073    /// confident, plausible-looking, WRONG number silently standing in for a bug report.
1074    #[test]
1075    fn a_genuine_non_convergent_zero_search_on_one_side_propagates_not_falls_back() {
1076        let r = resolved(823.0); // max_range_m: 900.0, zero_distance_m: 100.0
1077        let e = central_difference(&r, InputAxis::MuzzleVelocityMps, &[50.0], Some(813.0));
1078        match e {
1079            Err(KernelError::Solve { code, .. }) => {
1080                assert_eq!(
1081                    code,
1082                    crate::solve_json::SolveErrorCodeV1::SolveFailed,
1083                    "expected the zero search's own non-convergence code, not a re-labeled \
1084                     domain rejection"
1085                );
1086            }
1087            other => panic!(
1088                "expected the minus side's genuine SolveFailed to propagate unchanged, not be \
1089                 swallowed into a one-sided fallback; got {other:?}"
1090            ),
1091        }
1092    }
1093}