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}