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}