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 { default_rel_step, min_abs_step, .. } => (default_rel_step, min_abs_step),
203        AxisKind::Categorical => return Err(KernelError::CategoricalAxis(axis)),
204    };
205    let x = match read_axis(base, axis) {
206        Some(AxisValue::Scalar(x)) => x,
207        // Defensive only -- see the "not reachable" note in the doc comment above: every
208        // Categorical axis (the only source of Flag/DragModel/TwistDirection) already returned
209        // above.
210        Some(_) => return Err(KernelError::TypeMismatch(axis)),
211        None => return Err(KernelError::AxisAbsent(axis)),
212    };
213    let h = step.unwrap_or_else(|| (x.abs() * rel).max(min_abs));
214    if !(h.is_finite() && h > 0.0) {
215        return Err(KernelError::NonFinite(axis));
216    }
217
218    // with_axis's own failure modes here (AxisUnsupportedForRequest, AxisAbsent, TypeMismatch)
219    // depend only on `axis` and `base`'s OTHER fields, never on the specific value written --
220    // x+h and x-h would fail identically -- so they propagate immediately via `?`, from
221    // whichever side is built first, exactly as before this function grew a one-sided fallback.
222    let plus_req = with_axis(base, axis, AxisValue::Scalar(x + h))?;
223    let minus_req = with_axis(base, axis, AxisValue::Scalar(x - h))?;
224
225    // Unlike with_axis's structural errors above, a failure HERE is about the specific
226    // perturbed VALUE (e.g. a negative wind speed, or a query range that fell outside a shrunk
227    // max_range_m) -- see the module doc's "One-sided fallback". But NOT every failure here
228    // qualifies for the fallback: only a genuine domain rejection does (review fix I4(a) -- see
229    // `KernelError::is_domain_rejection`'s doc for exactly why `Err(_)` alone is wrong here).
230    let plus_result = evaluate(&plus_req, ranges_m);
231    let minus_result = evaluate(&minus_req, ranges_m);
232
233    // Computed on borrowed references, BEFORE the match below moves `plus_result`/`minus_result`,
234    // so the match arms need no guard-vs-move subtlety.
235    let plus_is_domain_rejection = matches!(&plus_result, Err(e) if e.is_domain_rejection());
236    let minus_is_domain_rejection = matches!(&minus_result, Err(e) if e.is_domain_rejection());
237
238    // `hi`/`lo` always mean "the sample at the higher x" / "the sample at the lower x", so the
239    // derivative below is always (hi - lo) / denom regardless of which scheme produced them:
240    //   Central:  hi = f(x+h), lo = f(x-h), denom = 2h
241    //   Forward:  hi = f(x+h), lo = f(x),   denom = h   (minus side was a domain rejection)
242    //   Backward: hi = f(x),   lo = f(x-h), denom = h   (plus side was a domain rejection)
243    let (hi, lo, denom, scheme) = match (plus_result, minus_result) {
244        (Ok(p), Ok(m)) => (p, m, 2.0 * h, DifferenceScheme::Central),
245        (Ok(p), Err(_)) if minus_is_domain_rejection => {
246            let base_req: crate::solve_json::SolveRequestV1 = base.into();
247            let f_x = evaluate(&base_req, ranges_m)?;
248            (p, f_x, h, DifferenceScheme::ForwardOneSided)
249        }
250        (Err(_), Ok(m)) if plus_is_domain_rejection => {
251            let base_req: crate::solve_json::SolveRequestV1 = base.into();
252            let f_x = evaluate(&base_req, ranges_m)?;
253            (f_x, m, h, DifferenceScheme::BackwardOneSided)
254        }
255        (Err(_), Err(_)) if plus_is_domain_rejection && minus_is_domain_rejection => {
256            return Err(KernelError::StepOutOfDomain { axis, attempted: h });
257        }
258        // At least one side's failure is NOT a domain rejection -- a genuine solver or
259        // trajectory bug, not a step that merely crossed a physical boundary. Propagate it
260        // unchanged rather than silently reinterpreting it as "no data on this side" (the exact
261        // regression review fix I4(a) describes): prefer the plus side's error, matching the
262        // evaluation order this function used before it grew a fallback at all (`plus` was
263        // always checked first, via `?`, before either side could fail differently).
264        (Ok(_), Err(e)) => return Err(e),
265        (Err(e), _) => return Err(e),
266    };
267
268    debug_assert_eq!(hi.len(), lo.len());
269    let mut out = Vec::with_capacity(ranges_m.len());
270    for (a, b) in hi.iter().zip(lo.iter()) {
271        let d_drop = (a.drop_m - b.drop_m) / denom;
272        let d_wind = (a.windage_m - b.windage_m) / denom;
273        if !d_drop.is_finite() || !d_wind.is_finite() {
274            return Err(KernelError::NonFinite(axis));
275        }
276        out.push(Derivative {
277            axis,
278            range_m: a.range_m,
279            d_drop_d_x: d_drop,
280            d_windage_d_x: d_wind,
281            step_used: h,
282            scheme,
283        });
284    }
285    Ok(out)
286}
287
288/// Iteration cap for [`bisect_axis`]'s search -- matches the existing inverse-solver cap
289/// (`HoldCurve::range_for_angular_drop_mil`'s `INVERSE_MAX_ITERATIONS`, `src/main.rs`).
290pub const BISECTION_MAX_ITERATIONS: u32 = 80;
291
292/// Bisect `axis` over `domain` at a single `range_m` until `predicate` (evaluated on the
293/// observation at that range) changes truth value, to within `tolerance`. See the module doc's
294/// "Bisection contract" for what `None` means and what `predicate` must satisfy.
295///
296/// # Errors
297///
298/// - [`KernelError::CategoricalAxis`] if `axis` is categorical.
299/// - Any error [`with_axis`] or [`evaluate`] produce while probing `domain` -- including
300///   [`KernelError::AxisUnsupportedForRequest`] and [`KernelError::AxisAbsent`] -- propagated
301///   unchanged from the first probe that hits them (`domain.0`, checked before any bisection
302///   step runs), so a request/axis combination `with_axis` refuses is reported as that specific
303///   refusal, never silently folded into "no crossing" (`Ok(None)`).
304pub fn bisect_axis(
305    base: &ResolvedSolveRequestV1,
306    axis: InputAxis,
307    range_m: f64,
308    domain: (f64, f64),
309    predicate: &dyn Fn(&Observation) -> bool,
310    tolerance: f64,
311) -> Result<Option<f64>, KernelError> {
312    if matches!(axis_meta(axis).kind, AxisKind::Categorical) {
313        return Err(KernelError::CategoricalAxis(axis));
314    }
315    let at = |v: f64| -> Result<bool, KernelError> {
316        let obs = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?;
317        Ok(predicate(&obs[0]))
318    };
319    let (mut lo, mut hi) = domain;
320    let lo_state = at(lo)?;
321    if lo_state == at(hi)? {
322        return Ok(None);
323    }
324    for _ in 0..BISECTION_MAX_ITERATIONS {
325        if (hi - lo).abs() <= tolerance {
326            break;
327        }
328        let mid = 0.5 * (lo + hi);
329        if at(mid)? == lo_state {
330            lo = mid;
331        } else {
332            hi = mid;
333        }
334    }
335    Ok(Some(0.5 * (lo + hi)))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::perturbation::InputAxis;
342
343    fn resolved(mv: f64) -> crate::solve_json::ResolvedSolveRequestV1 {
344        let json = serde_json::json!({
345            "schema_version": 1,
346            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
347                           "ballistic_coefficient": 0.243},
348            "rifle": {"muzzle_velocity_mps": mv, "sight_height_m": 0.05},
349            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
350            "atmosphere": {},
351            "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
352            "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
353        }).to_string();
354        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
355        crate::solve_v1::solve_v1(req).unwrap().resolved_request
356    }
357
358    /// More muzzle velocity means less drop at a fixed range: the derivative is negative.
359    #[test]
360    fn drop_derivative_wrt_muzzle_velocity_is_negative() {
361        let r = resolved(823.0);
362        let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None).unwrap();
363        assert_eq!(d.len(), 1);
364        assert!(d[0].d_drop_d_x < 0.0, "expected negative, got {}", d[0].d_drop_d_x);
365        assert!(d[0].step_used > 0.0);
366        // Review fix (post-I4): the common both-sides-valid path must still report Central, not
367        // default or drift to a one-sided scheme when nothing forced one.
368        assert_eq!(d[0].scheme, DifferenceScheme::Central);
369    }
370
371    /// Categorical axes must be refused, never silently differentiated (spec D7).
372    #[test]
373    fn categorical_axes_cannot_be_differentiated() {
374        let r = resolved(823.0);
375        let e = central_difference(&r, InputAxis::CoriolisEnabled, &[600.0], None);
376        assert!(matches!(e, Err(KernelError::CategoricalAxis(_))));
377    }
378
379    /// Bisection finds the muzzle velocity at which drop crosses a chosen threshold.
380    #[test]
381    fn bisect_finds_the_crossing() {
382        let r = resolved(823.0);
383        let base = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None).unwrap();
384        let _ = base;
385        let target_drop = 2.0_f64;
386        let found = bisect_axis(&r, InputAxis::MuzzleVelocityMps, 600.0, (600.0, 1100.0),
387                                &|o: &Observation| o.drop_m < target_drop, 0.05).unwrap();
388        let mv = found.expect("a crossing exists in this domain");
389        assert!(mv > 600.0 && mv < 1100.0);
390    }
391
392    /// Against the vacuum oracle the derivative has a closed form:
393    /// drop = 0.5*g*(x/v)^2  =>  d(drop)/dv = -g*x^2/v^3.
394    #[test]
395    fn central_difference_matches_the_vacuum_analytic_derivative() {
396        let json = serde_json::json!({
397            "schema_version": 1,
398            // bc_value huge => retardation negligible, same trick as the analytic_vacuum fuzzer
399            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
400                           "ballistic_coefficient": 100.0},
401            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
402            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
403            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
404            "sampling": {"interval_m": 5.0}
405        }).to_string();
406        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
407        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
408        let x = 400.0_f64;
409        // Read back off the RESOLVED request rather than hardcoding 800.0 a second time here:
410        // otherwise a future edit to the fixture's muzzle_velocity_mps would silently compare
411        // the real derivative against a closed form for the WRONG v (review minor fix).
412        let v = r.rifle.muzzle_velocity_mps;
413        let expected = -9.80665 * x * x / (v * v * v);
414        let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[x], None).unwrap();
415        let rel = ((d[0].d_drop_d_x - expected) / expected).abs();
416        assert!(rel < 0.02, "expected ~{expected}, got {} (rel {rel})", d[0].d_drop_d_x);
417        assert_eq!(d[0].scheme, DifferenceScheme::Central);
418    }
419
420    /// None of the tests above ever pass more than one range to `central_difference`, even
421    /// though the whole point of taking a slice (see the module doc's "Cost" section) is to
422    /// cover every range from a SINGLE pair of solves. Extend the vacuum oracle across two
423    /// ranges at once: both must match their own closed form, tagged with the CALLER's range
424    /// (not the loop index or the other side's), and the longer range's derivative must be the
425    /// larger one in magnitude (sensitivity to muzzle velocity grows with range^2) -- this would
426    /// catch a bug that zipped `plus`/`minus` against the wrong range, or that reused `h` from
427    /// one range's scale for another's.
428    #[test]
429    fn central_difference_matches_the_vacuum_oracle_at_every_requested_range() {
430        let json = serde_json::json!({
431            "schema_version": 1,
432            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
433                           "ballistic_coefficient": 100.0},
434            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
435            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
436            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
437            "sampling": {"interval_m": 5.0}
438        }).to_string();
439        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
440        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
441        // Read back off the RESOLVED request rather than hardcoding 800.0 a second time here
442        // (review minor fix -- same rationale as the single-range oracle test above).
443        let v = r.rifle.muzzle_velocity_mps;
444        let ranges = [200.0_f64, 400.0_f64];
445        let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &ranges, None).unwrap();
446        assert_eq!(d.len(), 2);
447        for (i, &x) in ranges.iter().enumerate() {
448            assert_eq!(
449                d[i].range_m, x,
450                "derivative {i} must be tagged with the range it was requested at"
451            );
452            let expected = -9.80665 * x * x / (v * v * v);
453            let rel = ((d[i].d_drop_d_x - expected) / expected).abs();
454            assert!(
455                rel < 0.02,
456                "range {x}: expected ~{expected}, got {} (rel {rel})",
457                d[i].d_drop_d_x
458            );
459        }
460        assert!(
461            d[1].d_drop_d_x.abs() > d[0].d_drop_d_x.abs() * 3.0,
462            "sensitivity to muzzle velocity should grow with range^2: at {} m got {}, at {} m got {}",
463            ranges[0], d[0].d_drop_d_x, ranges[1], d[1].d_drop_d_x
464        );
465        assert!(d.iter().all(|d| d.scheme == DifferenceScheme::Central));
466    }
467
468    /// The tests above only ever check `d_drop_d_x`; `d_windage_d_x` is never exercised, so a
469    /// copy-paste bug that computed it from `drop_m` instead of `windage_m` (or one that always
470    /// left it at 0) would pass every other test in this file. Wind speed pins it down: the
471    /// `resolved` fixture's wind is a 90-degree (full) crosswind (see `mod.rs`'s
472    /// `base_request_json` doc for the same convention), so more wind speed must push windage
473    /// further in the SAME direction as the baseline windage, and that push must dominate
474    /// whatever tiny secondary effect wind speed has on drop.
475    #[test]
476    fn windage_derivative_wrt_wind_speed_dominates_and_matches_the_baseline_sign() {
477        let r = resolved(823.0);
478        let baseline_req: crate::solve_json::SolveRequestV1 = (&r).into();
479        let baseline = evaluate(&baseline_req, &[600.0]).expect("baseline evaluate");
480        assert!(
481            baseline[0].windage_m.abs() > 0.01,
482            "fixture must have non-negligible baseline windage, got {}",
483            baseline[0].windage_m
484        );
485
486        let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap();
487        assert_eq!(d.len(), 1);
488        assert_eq!(
489            d[0].d_windage_d_x.signum(),
490            baseline[0].windage_m.signum(),
491            "more crosswind should push windage further the SAME way: derivative {}, baseline {}",
492            d[0].d_windage_d_x,
493            baseline[0].windage_m
494        );
495        assert!(
496            d[0].d_windage_d_x.abs() > d[0].d_drop_d_x.abs() * 5.0,
497            "a pure crosswind should move windage far more than it moves drop: \
498             d_windage_d_x={}, d_drop_d_x={}",
499            d[0].d_windage_d_x,
500            d[0].d_drop_d_x
501        );
502    }
503
504    /// `step_used` is part of the public contract of a `Derivative` -- a caller building an
505    /// error budget reads it directly to scale an input uncertainty. This test only pins the
506    /// REPORTED value against an independently recomputed formula, decoupled from any
507    /// particular axis's physical response; it does NOT by itself prove the same `h` was the
508    /// one actually used inside the `2h` division (a bug that reports one `h` but divides by
509    /// another would still pass this test unchanged) -- that is what the vacuum oracle test
510    /// above protects, by checking the resulting NUMBER rather than this metadata field
511    /// (review fix: this comment previously overclaimed what this test covers).
512    #[test]
513    fn step_used_follows_the_crate_convention() {
514        let r = resolved(823.0);
515        let x = match read_axis(&r, InputAxis::WindSpeed).unwrap() {
516            AxisValue::Scalar(x) => x,
517            other => panic!("WindSpeed must read back as a scalar, got {other:?}"),
518        };
519        let (rel, min_abs) = match axis_meta(InputAxis::WindSpeed).kind {
520            AxisKind::Continuous { default_rel_step, min_abs_step, .. } => {
521                (default_rel_step, min_abs_step)
522            }
523            AxisKind::Categorical => panic!("WindSpeed must be continuous"),
524        };
525        let expected_h = (x.abs() * rel).max(min_abs);
526        let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap();
527        assert_eq!(d[0].step_used, expected_h);
528    }
529
530    /// If `predicate` never flips across `domain`, `bisect_axis` must say so with `None` -- not
531    /// fabricate a bound by returning some point inside the domain anyway (the exact failure
532    /// mode this function's doc comment calls out). A target so large that drop can never reach
533    /// it over a 600 m shot holds `true` at both ends of the domain, so there is nothing to
534    /// bisect.
535    #[test]
536    fn bisect_axis_returns_none_when_the_predicate_never_flips() {
537        let r = resolved(823.0);
538        let found = bisect_axis(
539            &r,
540            InputAxis::MuzzleVelocityMps,
541            600.0,
542            (600.0, 1100.0),
543            &|o: &Observation| o.drop_m < 1000.0,
544            0.05,
545        )
546        .unwrap();
547        assert!(
548            found.is_none(),
549            "predicate holds everywhere on this domain; bisect_axis must report None, not a \
550             fabricated crossing, got {found:?}"
551        );
552    }
553
554    /// I1 review fix: `Ok(None)` means only "no flip across this domain" -- NOT "predicate
555    /// holds throughout". This is the mirror of the test above: a target so far below any
556    /// reachable drop that the predicate is FALSE at both ends must ALSO come back as `None`,
557    /// indistinguishable at the type level from the "true at both ends" case above. That
558    /// asymmetry (identical `Ok(None)` for two opposite facts) is exactly what the module doc's
559    /// "Bisection contract" section now warns callers about -- this test only pins down that
560    /// `bisect_axis` itself does not silently pick one interpretation (e.g. by fabricating a
561    /// crossing when the predicate happens to be false everywhere).
562    #[test]
563    fn bisect_axis_returns_none_when_the_predicate_is_false_at_both_ends() {
564        let r = resolved(823.0);
565        let found = bisect_axis(
566            &r,
567            InputAxis::MuzzleVelocityMps,
568            600.0,
569            (600.0, 1100.0),
570            &|o: &Observation| o.drop_m < -1000.0, // never true: drop cannot be this negative
571            0.05,
572        )
573        .unwrap();
574        assert!(
575            found.is_none(),
576            "predicate is false everywhere on this domain; bisect_axis must report None, got \
577             {found:?}"
578        );
579    }
580
581    /// I3 review fix: deleting `bisect_axis`'s categorical guard fails nothing today -- with it
582    /// gone, a categorical axis would fall through to `with_axis`, which reports `TypeMismatch`
583    /// (since `AxisValue::Scalar` never matches a categorical axis's expected representation),
584    /// not the `CategoricalAxis` this function's own `# Errors` section promises. Pin it down
585    /// directly and independently of `central_difference`'s own (separately tested) categorical
586    /// guard.
587    #[test]
588    fn bisect_axis_refuses_categorical_axes() {
589        let r = resolved(823.0);
590        let e = bisect_axis(
591            &r,
592            InputAxis::CoriolisEnabled,
593            600.0,
594            (0.0, 1.0),
595            &|o: &Observation| o.drop_m < 1.0,
596            0.1,
597        );
598        assert!(matches!(e, Err(KernelError::CategoricalAxis(InputAxis::CoriolisEnabled))));
599    }
600
601    /// Independent closed-form check for `bisect_axis` itself (not just `central_difference`):
602    /// reuse the near-vacuum trick (huge BC => drag negligible) so `drop = 0.5*g*(x/v)^2` is
603    /// invertible in closed form: `v = x*sqrt(g/(2*drop))`. The domain `(500, 2000)` is
604    /// deliberately asymmetric around that root (~886 m/s, well off the domain's own midpoint
605    /// of 1250), so a bug that returned the domain midpoint instead of converging would be off
606    /// by roughly 41% -- nowhere close to the 2% tolerance this file uses elsewhere for the
607    /// same physical approximation. No `zero_distance_m` is present (only an explicit
608    /// `muzzle_angle_rad`), so -- like the derivative oracle above -- no re-zero search runs and
609    /// `MuzzleVelocityMps` bisects cheaply here despite being a `requires_rezero` axis in
610    /// general.
611    #[test]
612    fn bisect_axis_converges_to_the_vacuum_analytic_root_not_the_domain_midpoint() {
613        let json = serde_json::json!({
614            "schema_version": 1,
615            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
616                           "ballistic_coefficient": 100.0},
617            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
618            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
619            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
620            "sampling": {"interval_m": 5.0}
621        })
622        .to_string();
623        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
624        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
625
626        let x = 400.0_f64;
627        let target_drop = 1.0_f64;
628        let domain = (500.0_f64, 2000.0_f64);
629        let expected_v = x * (9.80665_f64 / (2.0 * target_drop)).sqrt();
630        // Sanity check on the fixture itself: the analytic root must not be suspiciously close
631        // to the domain's midpoint, or this test would not actually distinguish "converged" from
632        // "returned the midpoint".
633        let midpoint = 0.5 * (domain.0 + domain.1);
634        assert!(
635            (expected_v - midpoint).abs() / expected_v > 0.2,
636            "fixture must keep the analytic root well away from the domain midpoint: \
637             root {expected_v}, midpoint {midpoint}"
638        );
639
640        let found = bisect_axis(
641            &r,
642            InputAxis::MuzzleVelocityMps,
643            x,
644            domain,
645            &|o: &Observation| o.drop_m < target_drop,
646            0.05,
647        )
648        .unwrap()
649        .expect("a crossing exists in this domain");
650
651        let rel = ((found - expected_v) / expected_v).abs();
652        assert!(
653            rel < 0.02,
654            "expected the bisection to converge near the analytic root ~{expected_v}, got \
655             {found} (rel {rel})"
656        );
657    }
658
659    /// `with_axis` refuses `Altitude` on a QNH-referenced request (`access.rs`) because the
660    /// rebuilt request cannot re-derive the original altimeter setting. `central_difference`
661    /// must PROPAGATE that refusal, not swallow it into a derivative of zero -- a caller needs
662    /// to tell "this axis cannot be perturbed here" apart from "the effect is zero".
663    #[test]
664    fn central_difference_propagates_axis_unsupported_for_request() {
665        let json = serde_json::json!({
666            "schema_version": 1,
667            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
668                           "ballistic_coefficient": 0.243},
669            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
670            "shot": {"max_range_m": 900.0},
671            "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
672                           "pressure_reference": "qnh"},
673            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
674        })
675        .to_string();
676        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
677        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
678
679        let e = central_difference(&r, InputAxis::Altitude, &[300.0], None);
680        match e {
681            Err(KernelError::AxisUnsupportedForRequest { axis: InputAxis::Altitude, reason }) => {
682                assert!(reason.to_lowercase().contains("qnh"), "reason should name QNH: {reason}");
683            }
684            other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
685        }
686    }
687
688    /// Same guarantee via `bisect_axis`: the refusal must surface on the very first domain
689    /// probe, not be absorbed into `Ok(None)`.
690    #[test]
691    fn bisect_axis_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 = bisect_axis(
707            &r,
708            InputAxis::Altitude,
709            300.0,
710            (400.0, 600.0),
711            &|o: &Observation| o.drop_m < 1.0,
712            0.5,
713        );
714        match e {
715            Err(KernelError::AxisUnsupportedForRequest { axis: InputAxis::Altitude, reason }) => {
716                assert!(reason.to_lowercase().contains("qnh"), "reason should name QNH: {reason}");
717            }
718            other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
719        }
720    }
721
722    /// `read_axis` returns `None` for the three wind axes under segmented wind (no single
723    /// scalar to perturb, taxonomy.rs Known Limitation (c)). `central_difference` must turn
724    /// that into `KernelError::AxisAbsent` via its OWN `None` branch -- this is a different code
725    /// path than `with_axis`'s segmented-wind guard, so it needs its own test.
726    #[test]
727    fn central_difference_reports_axis_absent_for_wind_axes_under_segmented_wind() {
728        let json = serde_json::json!({
729            "schema_version": 1,
730            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
731                           "ballistic_coefficient": 0.243},
732            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
733            "shot": {"max_range_m": 900.0},
734            "atmosphere": {},
735            "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
736                                    "direction_from_rad": 1.0}]},
737            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
738        })
739        .to_string();
740        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
741        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
742
743        let e = central_difference(&r, InputAxis::WindSpeed, &[300.0], None);
744        assert!(matches!(e, Err(KernelError::AxisAbsent(InputAxis::WindSpeed))));
745    }
746
747    /// Same guarantee via `bisect_axis`, which never calls `read_axis` at all -- its
748    /// `AxisAbsent` must come through `with_axis`'s own segmented-wind guard instead, exercised
749    /// on the very first probe.
750    #[test]
751    fn bisect_axis_reports_axis_absent_for_wind_axes_under_segmented_wind() {
752        let json = serde_json::json!({
753            "schema_version": 1,
754            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
755                           "ballistic_coefficient": 0.243},
756            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
757            "shot": {"max_range_m": 900.0},
758            "atmosphere": {},
759            "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
760                                    "direction_from_rad": 1.0}]},
761            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
762        })
763        .to_string();
764        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
765        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
766
767        let e = bisect_axis(
768            &r,
769            InputAxis::WindSpeed,
770            300.0,
771            (0.0, 10.0),
772            &|o: &Observation| o.windage_m < 0.0,
773            0.5,
774        );
775        assert!(matches!(e, Err(KernelError::AxisAbsent(InputAxis::WindSpeed))));
776    }
777
778    /// I2 review fix: no test anywhere in this file ever passes `Some(...)` for the explicit
779    /// `step` parameter, so a mutation that silently discards the caller's step and falls back
780    /// to the default formula would pass every other test in this file unchanged. MBA-1347 is
781    /// the obvious consumer of an explicit step ("perturb by 1 sigma"). The chosen step (2.0) is
782    /// deliberately different from what the default formula would compute here (0.8, per
783    /// `central_difference_matches_the_vacuum_analytic_derivative`), so a bug that ignores
784    /// `step` cannot coincidentally pass by landing on the same number anyway.
785    #[test]
786    fn explicit_step_overrides_the_default_and_still_matches_the_vacuum_oracle() {
787        let json = serde_json::json!({
788            "schema_version": 1,
789            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
790                           "ballistic_coefficient": 100.0},
791            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
792            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
793            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
794            "sampling": {"interval_m": 5.0}
795        })
796        .to_string();
797        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
798        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
799        let v = r.rifle.muzzle_velocity_mps;
800        let x = 400.0_f64;
801        let custom_step = 2.0_f64;
802        let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[x], Some(custom_step))
803            .unwrap();
804        assert_eq!(
805            d[0].step_used, custom_step,
806            "an explicit step must override the default formula, not just be ignored"
807        );
808        let expected = -9.80665 * x * x / (v * v * v);
809        let rel = ((d[0].d_drop_d_x - expected) / expected).abs();
810        assert!(rel < 0.02, "expected ~{expected}, got {} (rel {rel})", d[0].d_drop_d_x);
811        assert_eq!(d[0].scheme, DifferenceScheme::Central);
812    }
813
814    /// I2 review fix, continued: a non-finite or non-positive explicit step must be rejected,
815    /// not silently used -- dividing by zero/NaN, or by a negative number (which would silently
816    /// flip the derivative's sign without changing anything else about the result's shape).
817    #[test]
818    fn non_finite_or_non_positive_explicit_step_is_rejected() {
819        let r = resolved(823.0);
820        let nan = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(f64::NAN));
821        assert!(matches!(nan, Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))));
822        let zero = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(0.0));
823        assert!(matches!(zero, Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))));
824        let negative = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(-1.0));
825        assert!(matches!(negative, Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))));
826    }
827
828    /// I4 review fix: `WindSpeed` at 0.0 (still air, the default absent any `wind` block at
829    /// all, and the SAME magnitude used by BOTH of this file's vacuum-oracle fixtures, so this
830    /// is not an exotic edge case) cannot be centrally differentiated. The minus side needs
831    /// `-min_abs_step` (-0.05 m/s), which `resolve_wind`'s `require_non_negative
832    /// ("$.wind.speed_mps")` (`src/solve_v1.rs`) rejects, since wind speed is a non-negative
833    /// magnitude. Falling back to a ONE-SIDED forward difference is not just an accommodation
834    /// for the solver rejecting an unphysical input -- it is the numerically correct thing to
835    /// do here, since windage as a function of SIGNED wind speed is V-shaped (not smooth) at
836    /// zero, so a central difference straddling it would differentiate through a kink.
837    ///
838    /// Speed and direction must be supplied TOGETHER (`resolve_wind` rejects one without the
839    /// other), so still air is expressed here as an EXPLICIT `speed_mps: 0.0` paired with a
840    /// 90-degree crosswind direction, rather than omitting the `wind` block entirely: the
841    /// omitted-block default direction is a pure head/tailwind (no crosswind component at all),
842    /// which would make `d_windage_d_x` genuinely, correctly zero regardless of whether the
843    /// fallback logic is right -- that would test nothing.
844    #[test]
845    fn wind_speed_falls_back_to_one_sided_in_still_air() {
846        let json = serde_json::json!({
847            "schema_version": 1,
848            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
849                           "ballistic_coefficient": 0.243},
850            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
851            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
852            "atmosphere": {},
853            "wind": {"speed_mps": 0.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
854            "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
855        })
856        .to_string();
857        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
858        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
859        assert_eq!(
860            match &r.wind {
861                crate::solve_json::ResolvedWindV1::Constant(c) => c.speed_mps,
862                crate::solve_json::ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
863            },
864            0.0,
865            "fixture assumption: still air"
866        );
867
868        let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None);
869        let d = d.unwrap_or_else(|e| panic!("still air must fall back, not error, got {e:?}"));
870        assert_eq!(d.len(), 1);
871        assert_eq!(
872            d[0].scheme,
873            DifferenceScheme::ForwardOneSided,
874            "the minus side (-0.05 m/s) leaves WindSpeed's domain, so this must be a forward \
875             one-sided fallback, not Central and not Backward"
876        );
877        assert!(d[0].d_windage_d_x.is_finite() && d[0].d_windage_d_x != 0.0);
878    }
879
880    /// I4 review fix, continued: `RelativeHumidity` hits the same class of domain violation at
881    /// its OTHER boundary. `resolve_atmosphere` validates it to `require_range(..., 0.0, 1.0)`
882    /// (`src/solve_v1.rs`); at the dry-air boundary (0.0, itself a common, unremarkable request
883    /// -- not every caller supplies humidity, and dry air is a normal thing to model
884    /// explicitly), the minus side (`-min_abs_step` = -0.001) falls outside that range.
885    #[test]
886    fn relative_humidity_falls_back_to_one_sided_at_the_dry_air_boundary() {
887        let json = serde_json::json!({
888            "schema_version": 1,
889            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
890                           "ballistic_coefficient": 0.243},
891            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
892            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
893            "atmosphere": {"relative_humidity": 0.0},
894            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
895        })
896        .to_string();
897        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
898        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
899        assert_eq!(
900            r.atmosphere.relative_humidity, 0.0,
901            "fixture assumption: an explicit 0.0 must resolve to exactly 0.0, not the 0.5 \
902             literal default"
903        );
904
905        let d = central_difference(&r, InputAxis::RelativeHumidity, &[600.0], None);
906        let d = d.unwrap_or_else(|e| panic!("the dry-air boundary must fall back, not error, got {e:?}"));
907        assert_eq!(d.len(), 1);
908        assert_eq!(
909            d[0].scheme,
910            DifferenceScheme::ForwardOneSided,
911            "the minus side (-0.001) leaves RelativeHumidity's [0, 1] domain"
912        );
913        assert!(d[0].d_drop_d_x.is_finite());
914    }
915
916    /// I4 review fix, continued: `TargetDistance` (== `shot.max_range_m`) hits the same class
917    /// of domain violation through a DIFFERENT `KernelError` variant than WindSpeed/
918    /// RelativeHumidity above -- `Observation` (a query range now outside the computed
919    /// trajectory), raised AFTER a successful solve, rather than `Solve` (a validation failure
920    /// before one). Differentiating drop's sensitivity to the target distance AT that same
921    /// target distance is the natural use of this axis (e.g. "how much does drop at 900 m
922    /// change if the target were a little closer or farther"); querying a range close to `x`
923    /// means the minus side (a slightly SMALLER max_range_m) can no longer see it.
924    ///
925    /// x = 900, h = max(900*1e-3, 0.5) = 0.9, so minus = 899.1 and plus = 900.9. A query at
926    /// 899.5 m is comfortably inside plus's and the unperturbed x's trajectories but strictly
927    /// beyond minus's -- deliberately not exactly 900.0, to avoid depending on whether an exact
928    /// upper-endpoint query is treated as inclusive.
929    #[test]
930    fn target_distance_falls_back_to_one_sided_when_queried_near_its_own_max_range() {
931        let json = serde_json::json!({
932            "schema_version": 1,
933            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
934                           "ballistic_coefficient": 0.243},
935            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
936            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
937            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
938            "sampling": {"interval_m": 25.0}
939        })
940        .to_string();
941        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
942        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
943        assert_eq!(r.shot.max_range_m, 900.0);
944
945        let d = central_difference(&r, InputAxis::TargetDistance, &[899.5], None);
946        let d = d.unwrap_or_else(|e| {
947            panic!("a range just inside the unperturbed max_range_m must fall back, not error, got {e:?}")
948        });
949        assert_eq!(d.len(), 1);
950        assert_eq!(
951            d[0].scheme,
952            DifferenceScheme::ForwardOneSided,
953            "the minus side (max_range_m = 899.1) no longer covers the 899.5 m query, so this \
954             must be a forward one-sided fallback"
955        );
956        assert!(d[0].d_drop_d_x.is_finite());
957    }
958
959    /// I4 review fix, continued: when BOTH perturbed sides fail, there is no data left to build
960    /// even a one-sided difference from. An oversized EXPLICIT step pushes `RelativeHumidity`
961    /// (at the 0.0 boundary) out of its `[0, 1]` domain in BOTH directions at once (x+h = 2.0,
962    /// x-h = -2.0), so this cannot fall back either way. `central_difference` must report the
963    /// distinct `StepOutOfDomain` -- not propagate whichever of the two solve errors happened to
964    /// be checked first (arbitrary, and neither alone names the real problem), and not silently
965    /// claim a zero-effect derivative.
966    #[test]
967    fn both_sides_out_of_domain_reports_step_out_of_domain() {
968        let json = serde_json::json!({
969            "schema_version": 1,
970            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
971                           "ballistic_coefficient": 0.243},
972            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
973            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
974            "atmosphere": {"relative_humidity": 0.0},
975            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
976        })
977        .to_string();
978        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
979        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
980        assert_eq!(r.atmosphere.relative_humidity, 0.0, "fixture assumption");
981
982        let e = central_difference(&r, InputAxis::RelativeHumidity, &[600.0], Some(2.0));
983        match e {
984            Err(KernelError::StepOutOfDomain { axis: InputAxis::RelativeHumidity, attempted }) => {
985                assert_eq!(attempted, 2.0);
986            }
987            other => panic!("expected StepOutOfDomain, got {other:?}"),
988        }
989    }
990
991    /// I4(a) review fix -- THE test that was missing, and that a bare `Err(_)` guard on either
992    /// perturbed side would pass incorrectly (an earlier revision had exactly that guard; see
993    /// the revert-verification note in the task report for confirmation that this test actually
994    /// fails under it). The fallback must trigger ONLY for a domain rejection
995    /// (`KernelError::is_domain_rejection`), never for a genuine solver failure that happens to
996    /// land on just one side.
997    ///
998    /// 10 m/s cannot reach a 100 m zero at ANY elevation angle: even the vacuum-ideal maximum
999    /// range at a 45-degree launch is `v^2/g` =~ 10.2 m, an order of magnitude short, and real
1000    /// drag only shortens that further. `build_zeroed_solver`'s elevation search
1001    /// (`calculate_and_set_zero_angle`, `src/solve_v1.rs`) reports that non-convergence via
1002    /// `.map_err(solve_failed)`, which is `SolveErrorCodeV1::SolveFailed` -- NEVER
1003    /// `InvalidValue`, since 10.0 m/s is a perfectly valid, positive velocity that
1004    /// `require_positive` never rejects. The plus side (1636 m/s) is merely a fast, ordinary,
1005    /// flat-shooting bullet that zeroes at 100 m without incident, so this is a genuine
1006    /// (`Ok`, `Err`) split where the `Err` is NOT a domain rejection.
1007    ///
1008    /// `central_difference` must propagate that `SolveFailed` unchanged -- not answer with a
1009    /// forward one-sided derivative computed from the plus side alone, which would be a
1010    /// confident, plausible-looking, WRONG number silently standing in for a bug report.
1011    #[test]
1012    fn a_genuine_non_convergent_zero_search_on_one_side_propagates_not_falls_back() {
1013        let r = resolved(823.0); // max_range_m: 900.0, zero_distance_m: 100.0
1014        let e = central_difference(&r, InputAxis::MuzzleVelocityMps, &[50.0], Some(813.0));
1015        match e {
1016            Err(KernelError::Solve { code, .. }) => {
1017                assert_eq!(
1018                    code,
1019                    crate::solve_json::SolveErrorCodeV1::SolveFailed,
1020                    "expected the zero search's own non-convergence code, not a re-labeled \
1021                     domain rejection"
1022                );
1023            }
1024            other => panic!(
1025                "expected the minus side's genuine SolveFailed to propagate unchanged, not be \
1026                 swallowed into a one-sided fallback; got {other:?}"
1027            ),
1028        }
1029    }
1030}