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_mstays LOS-perpendicular (seeevaluate’s “Drop reference plane” doc comment inmod.rs): neither function in this file looks atshot.drops_referenceat all.- The specialized
KernelErrorvariantswith_axisuses to refuse a physically-invalid axis/request combination –AxisUnsupportedForRequest(Altitudeunder QNH pressure,ShotAzimuthunder compass wind) andAxisAbsent(the three wind axes under segmented wind) – propagate out ofcentral_differenceandbisect_axisunchanged 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§
- Difference
Scheme - 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’sINVERSE_MAX_ITERATIONS,src/main.rs).
Functions§
- bisect_
axis - Bisect
axisoverdomainat a singlerange_muntilpredicate(evaluated on the observation at that range) changes truth value, to withintolerance. See the module doc’s “Bisection contract” for whatNonemeans and whatpredicatemust satisfy. - central_
difference - Central difference:
(f(x+h) - f(x-h)) / 2h, covering every range inranges_mwith exactly two solves total (one per side) in the common case – see the module doc’s “Cost” section. The step follows the crate conventionh = (|x| * default_rel_step).max(min_abs_step)(axis_meta,taxonomy.rs) unless the caller supplies an explicitstep. 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 andDerivative::scheme.