Module derive

Module derive 

Source
Expand description

Derived numerics over the perturbation kernel: central-difference derivatives (feeding an uncertainty/error budget) and monotone bisection (feeding tolerance envelopes).

Both operations are built ONLY on the Task 5/6 primitives – read_axis, with_axis, and evaluate – and inherit their semantics unchanged rather than reimplementing or “correcting” anything:

  • drop_m stays LOS-perpendicular (see evaluate’s “Drop reference plane” doc comment in mod.rs): neither function in this file looks at shot.drops_reference at all.
  • The specialized KernelError variants with_axis uses to refuse a physically-invalid axis/request combination – AxisUnsupportedForRequest (Altitude under QNH pressure, ShotAzimuth under compass wind) and AxisAbsent (the three wind axes under segmented wind) – propagate out of central_difference and bisect_axis unchanged via ?. Neither function catches, retries, or maps them onto a derivative/bisection result: a caller must be able to tell “this axis cannot be perturbed on this request” apart from “the effect is zero” or “there is no crossing”.

§Step convention

central_difference follows the one step-size heuristic the taxonomy already defines (axis_meta(axis).kind’s default_rel_step/min_abs_step, src/perturbation/taxonomy.rs): h = (|x| * default_rel_step).max(min_abs_step), overridable by the caller’s explicit step. No second heuristic is introduced here.

§Cost

A central difference costs exactly two solves PER AXIS in the common case, not two solves per range: both evaluate calls below take the whole ranges_m slice at once, so N ranges still cost 2 solves total (evaluate itself runs one TrajectorySolver::solve per call, however many ranges are then read off the single result). When the one-sided fallback below fires, a THIRD solve (of the unperturbed request, at x itself) is needed – still independent of how many ranges are requested. For a requires_rezero axis (taxonomy.rs), each solve is itself preceded by up to 60 trial solves inside the elevation search (find_zero_angle, src/cli_api.rs) – unavoidable here, not something this task changes. bisect_axis pays that same per-solve cost once per bisection iteration (up to BISECTION_MAX_ITERATIONS, capped the same way as the existing inverse-solver search, HoldCurve::range_for_angular_drop_mil in src/main.rs), always at the single range_m the caller asked to bisect at.

§One-sided fallback

Several continuous axes have a hard physical domain narrower than all reals: WindSpeed is a non-negative magnitude, RelativeHumidity is confined to [0, 1], and TargetDistance (== shot.max_range_m) cannot shrink below a range the caller is asking about. A central difference at or near such a boundary needs a perturbed value on the wrong side of it – still air (speed_mps: 0.0, the default absent any wind block at all) is the most ordinary example, not an exotic one: WindSpeed’s min_abs_step (0.05 m/s) makes the minus side -0.05, which resolve_wind’s require_non_negative("$.wind.speed_mps") (src/solve_v1.rs) rejects outright.

When exactly one of the two perturbed solves fails to evaluate and the other succeeds, central_difference falls back to a one-sided difference using the side that worked plus the UNPERTURBED value at x itself: (f(x+h) - f(x)) / h (DifferenceScheme::ForwardOneSided) if the minus side failed, or (f(x) - f(x-h)) / h (DifferenceScheme::BackwardOneSided) if the plus side failed. This is not merely an accommodation for the solver rejecting an unphysical input: at a hard domain edge (wind speed pinned at exactly zero) a symmetric difference is not merely blocked, it would be answering the wrong question, since windage as a function of SIGNED wind speed is not smooth across that boundary (it is V-shaped, not linear) in the first place.

Which scheme actually ran is part of Derivative’s public contract (its scheme field): a one-sided difference has different (generally larger, O(h) rather than O(h^2)) truncation error than a central one, and a caller building an error budget or reporting a method must be able to tell them apart rather than silently trusting every Derivative as if it were central.

Only a DOMAIN REJECTION on one side triggers this fallback – precisely, KernelError::is_domain_rejection must be true: a Solve failure whose SolveErrorCodeV1 is InvalidValue (what require_range/require_non_negative/require_positive in solve_v1.rs produce), or an Observation failure that is specifically TrajectoryObservationError::OutOfRange. This is deliberately narrower than “any evaluate failure on one side” (an earlier revision of this function gated on exactly that – a bare Err(_) – which was a regression: it silently reinterpreted a genuine solver or trajectory bug on one side as if it were a domain boundary, answering with a plausible-looking fabricated one-sided derivative instead of reporting the real failure. evaluate’s own documented failure modes include a zero search that does not converge (SolveFailed, not InvalidValue) and a non-finite effective muzzle angle, and every requires_rezero axis runs that search on every perturbed solve, so this was not a hypothetical case). Any error that is NOT a domain rejection – on EITHER side – propagates unchanged, exactly as it did before this function grew a fallback at all.

Failures from with_axis itself (AxisUnsupportedForRequest, AxisAbsent, TypeMismatch) are never domain rejections and so never trigger the fallback either, but for a different reason: they do not depend on the perturbed value at all – they would occur identically for x+h and x-h, since they are checked from axis and base’s OTHER fields before the value is even considered – so they always propagate immediately.

If BOTH perturbed sides fail with a domain rejection, there is no data left to build even a one-sided difference from: central_difference returns KernelError::StepOutOfDomain. If exactly one side’s failure is NOT a domain rejection, that error propagates (preferring the plus side’s error when both sides failed and neither qualifies, to match the evaluation order this function used before it grew a fallback at all).

§Bisection contract

bisect_axis assumes predicate changes truth value at most once across domain (hence “monotone” in this module’s summary) and returns the crossing to within tolerance.

Ok(None) means ONLY that predicate did not change truth value across domain – nothing more. It does NOT tell the caller which of two opposite facts that is: predicate could be true at both ends (e.g. “stays inside a tolerance band throughout this domain”) or FALSE at both ends (e.g. “stays outside it throughout”), and Ok(None) looks identical either way. A caller that needs to know which one happened must check predicate at an endpoint itself (or already know the end state some other way) – bisect_axis deliberately does not resolve that ambiguity. This matters most for a naturally two-sided predicate like |drop - nominal| <= tolerance, which is true in the middle and false at BOTH ends: widening domain to find the edge of such a band and getting Ok(None) back means only “no edge in this domain,” never “true throughout” – treating it as the latter would be exactly the fabricated-bound failure this function exists to avoid, just relocated into the caller’s interpretation of a correct result. Contrast HoldCurve::range_for_angular_drop_mil (src/main.rs), which instead reports each out-of-domain case as its own distinct outcome; bisect_axis does not do that here, so its caller must.

Structs§

Derivative
First derivative of impact with respect to one axis, at one range.

Enums§

DifferenceScheme
Which finite-difference formula actually produced a Derivative (review fix I4).

Constants§

BISECTION_MAX_ITERATIONS
Iteration cap for bisect_axis’s search – matches the existing inverse-solver cap (HoldCurve::range_for_angular_drop_mil’s INVERSE_MAX_ITERATIONS, src/main.rs).

Functions§

bisect_axis
Bisect axis over domain at a single range_m until predicate (evaluated on the observation at that range) changes truth value, to within tolerance. See the module doc’s “Bisection contract” for what None means and what predicate must satisfy.
central_difference
Central difference: (f(x+h) - f(x-h)) / 2h, covering every range in ranges_m with exactly two solves total (one per side) in the common case – see the module doc’s “Cost” section. The step follows the crate convention h = (|x| * default_rel_step).max(min_abs_step) (axis_meta, taxonomy.rs) unless the caller supplies an explicit step. When one perturbed side leaves the axis’s physical domain, falls back to a one-sided difference – see the module doc’s “One-sided fallback” section and Derivative::scheme.