Expand description
MBA-1350: how wrong may one input be before the shot leaves the target?
This is the deterministic inverse of a WEZ sweep. For a nominal (already dialed-in, centred)
solution and an explicit target, each requested axis is bisected outward – toward its
configured domain’s lower bound (near_bound) and, independently, toward its upper bound
(far_bound) – until the impact crosses the target boundary. Bounds are strictly ONE
VARIABLE AT A TIME: two inputs each at their own individual limit will generally miss even
though neither alone would, and no probability is attached to any bound reported here. Both
of those are stated in ToleranceReportV1::assumptions itself, not only in this comment –
see the_report_refuses_to_imply_probability in this module’s tests.
Built entirely on the existing kernel: bisect_axis for the search,
evaluate/read_axis/with_axis for reading and rebuilding requests, and
TargetGeometryV1 (Task 11, crate::error_budget) for the target shape – reused verbatim,
not re-defined, including its “always centred on the nominal impact point” semantics.
§The central hazard: Ok(None) means two different things
bisect_axis’s own contract (crate::perturbation::derive’s module doc, “Bisection
contract”) is explicit that Ok(None) means ONLY “the predicate did not change truth value
across this domain” – nothing more. In general, for a two-sided “stays inside a region”
predicate, that single fact is consistent with TWO opposite situations that look identical at
the type level: the region is never exited, or the search never started inside it at all.
In this module specifically, the second situation has exactly one door. target
(TargetGeometryV1) has no offset field at all – every variant is, by that type’s own
doc, ALWAYS defined centred on the nominal impact point. That makes the nominal itself, read
against its own centred target, inside by construction for any target with positive area
(dy == dz == 0.0 trivially satisfies dy.abs() <= height_m / 2.0 etc. whenever height_m/
width_m/radius_m is positive). The ONLY way the nominal can fail that check is a
DEGENERATE target – non-positive width, height, or radius, which contains no point at all,
not even its own centre. A target genuinely offset from the nominal, or a nominal that is
“not really centred” for some other reason, is not an expressible input to this API at all –
TargetGeometryV1 has no field that could carry it – so that reading of “outside throughout”
cannot be the live case here, and this doc previously implied otherwise.
This module still checks it explicitly rather than assuming it, via a pre-flight step
that evaluates the shared anchor point itself – the IDENTICAL with_axis/evaluate
reconstruction bisect_axis uses for domain.0 in both the near and far searches (see
tolerance_envelope’s “Pre-flight” step below) – BEFORE trusting any Ok(None) it
returns. This closes the only door that DOES exist (a degenerate target, or a reconstruction
bug in with_axis/evaluate themselves) rather than guarding against a routinely-reachable
“off-centre nominal” scenario the type system cannot currently express; it is still worth
keeping for that narrower reason, and is still what makes the following distinction sound
rather than assumed. If the anchor check reads as “outside” –
ToleranceAxisV1::nominal_inside_target is false – the search is never even run: see
correctness requirement 1 below. Only once the anchor is CONFIRMED “inside” does an Ok(None)
from a search direction unambiguously mean “stays inside throughout that direction,” recorded
as ToleranceAxisV1::unbounded_in_domain. The two outcomes share the same _bound: None
shape but are otherwise completely distinct fields – see
a_degenerate_target_is_flagged_nominal_outside_not_confused_with_unbounded and
an_axis_that_never_exits_is_flagged_not_bounded in this module’s tests, which produce
IDENTICAL near_bound/far_bound (None/None) from two DIFFERENT root causes and assert
that nominal_inside_target/unbounded_in_domain tell them apart.
One limitation this module inherits rather than solves: bisect_axis itself assumes the
predicate changes truth value AT MOST ONCE across a search direction. If an axis’s effect on
impact were non-monotonic over the ENTIRE configured domain (physically unusual, but not
impossible for a very wide domain), a single bisection could in principle miss an excursion
that both re-enters “inside” before reaching the domain edge – this is a pre-existing,
documented property of bisect_axis itself (see its module doc), not something specific to
this module’s use of it, and not something a bounded amount of extra work here can fully
close without a much more expensive full-domain scan. Choosing a domain scoped to where the
axis is plausibly monotonic is the caller’s responsibility, exactly as it already is for every
other consumer of bisect_axis.
§The four correctness requirements
- The nominal must actually read as inside before a bound means anything. See “The central hazard” above – enforced by the pre-flight check, not assumed.
- Bounds are one axis at a time and may not be assumed simultaneously. Stated in
ToleranceReportV1::assumptions. - No probability is implied. Also stated in
assumptions. - A bound is never extrapolated beyond the caller’s configured domain.
bisect_axisitself never looks outsidedomain; this module additionally never INVENTS a domain the caller did not supply – see “Domains are validated up front, per axis” below andKernelError::InvalidDomain.
§Domains are validated up front, per axis
Unlike an earlier draft of this feature, there is no implicit fallback domain (e.g.
nominal * 0.5 ..= nominal * 1.5): that specific formula is not just unspecified but actively
wrong whenever an axis’s nominal value is exactly 0.0 (routine for WindDirection, Cant,
ShootingAngle, and several others), where it collapses to a zero-width (0.0, 0.0) domain
that can never be searched. Every axis in axes must have a corresponding entry in domains
with both bounds finite, the lower bound strictly less than the upper, and the axis’s own
current value strictly between them (not merely <=/>=: a nominal value sitting exactly AT
one edge would make that direction’s search a zero-width probe, i.e. bisect_axis’s two
endpoints would be the same point and it would trivially – and misleadingly – report
Ok(None)). A missing or invalid domain returns KernelError::InvalidDomain immediately,
before any solve.
§Unavailable axes
bisect_axis/with_axis can legitimately refuse to search a declared axis:
KernelError::CategoricalAxis (an effect toggle – no numeric domain to bisect),
KernelError::AxisAbsent (a wind axis under segmented wind – no single scalar value to
hold at a nominal or perturb), or KernelError::AxisUnsupportedForRequest (Altitude under
a QNH-referenced atmosphere, ShotAzimuth under compass-referenced wind). Every one of these
is recorded in ToleranceReportV1::unavailable_axes (axis, machine-readable
UnavailableReasonCodeV1, human-readable reason) and the rest of the report is still
produced from whatever axes DID evaluate – silently dropping an unavailable axis would look
identical to “this axis was searched and found to have no bound,” which is a different fact.
This reuses crate::error_budget’s existing four-way classification verbatim (widened to
pub(crate) for this module) rather than defining a second, independently maintained copy of
the same split – see that function’s own doc comment for the one caveat this creates (two of
its four reason strings read as written for differentiation/uncertainty, and
StepOutOfDomain is not actually reachable through bisect_axis today, only through
central_difference; both are accepted trade-offs of sharing one classifier).
Any OTHER error (Solve, Observation, the defensive TypeMismatch/NonFinite,
DuplicateAxis, or this module’s own KernelError::InvalidDomain) is a genuine failure –
most notably a domain whose lower bound, for the TargetDistance axis, dips below the
caller’s own range_m (an internally inconsistent request: “how close could the target
plausibly be” bisected past “the range I am currently asking about”) – and propagates
immediately, aborting the whole report rather than mislabelling it as a per-axis refusal. See
a_genuine_observation_error_propagates_not_recorded_as_unavailable.
§TargetDistance cannot answer “how wrong may my range estimate be”
The ticket’s own motivating example is a range-estimate question – “my rangefinder read
600 m; how far off could that reading be before I miss?” The axis that superficially matches,
InputAxis::TargetDistance (shot.max_range_m), does NOT answer it. TargetDistance is
requires_rezero: false (crate::perturbation::axis_meta): perturbing it changes only how
far the trajectory is COMPUTED, never the muzzle angle or any sight correction, so it has NO
effect at all on the impact observed at a fixed range_m as long as the perturbed
max_range_m stays at or above range_m. Bisecting it reports either a hard
KernelError::Observation (once the search dips below range_m, see above) or
unbounded_in_domain: true with ToleranceAxisV1::near_has_no_effect/
ToleranceAxisV1::far_has_no_effect both true – literally correct about this specific
axis, but not an answer to the range-estimate question, and a caller who reads
unbounded_in_domain: true alone (without also checking the _has_no_effect flags) could
easily mistake “this axis has no effect” for “your range estimate can be off by any amount
and still hit,” which is not what it means here.
The axis that actually answers a range-estimate question is InputAxis::ZeroDistance
(shot.zero_distance_m, requires_rezero: true): perturbing it re-runs the elevation search
for a DIFFERENT assumed zero distance, producing a different effective muzzle angle, and only
THEN observes the impact at the caller’s own fixed, unchanged range_m – i.e. “if I had
dialed for a different distance than the true one, how far off would I land at the true
range,” which is what a rangefinder error actually does to a shot. See
target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim in this
module’s tests, which pins both the TargetDistance “no effect” finding and the contrast
against an axis that genuinely does move the impact but merely stays inside a large target.
§Cost
Per axis: one pre-flight solve, then up to crate::perturbation::derive::BISECTION_MAX_ITERATIONS
solves for EACH of the two search directions (far fewer in practice – an Ok(None) result
costs only the two domain-endpoint evaluations, and a found crossing converges to this
module’s own domain-relative tolerance in on the order of twenty iterations for a typical
domain width, not the full cap), plus one more solve for each direction that DID find a bound
(to classify which edge of the target it crosses). A requires_rezero axis
(crate::perturbation::axis_meta) multiplies each of those solves by the elevation search’s
own cost, exactly as it does for crate::perturbation::derive::central_difference and
crate::error_budget::error_budget – unavoidable here, not something this module changes.
Structs§
- Tolerance
Axis V1 - One axis’s tolerance envelope: how far it may move from its own current value, in EACH
direction independently, before the impact crosses
target’s boundary. - Tolerance
Report V1 - One-variable tolerance envelope report (MBA-1350): how far each requested input may drift from its own current value, one at a time, before the impact leaves an explicit target.
- Unavailable
Axis V1 - One requested axis
tolerance_envelopecould not search at all, and why – distinct from an axis that WAS searched and found to have no bound (seeToleranceAxisV1). Never silently dropped: see this module’s “Unavailable axes” doc section.
Enums§
- Limiting
Boundary V1 - Which edge of a
TargetGeometryV1::Recta found bound crosses, from the shooter’s own point of view:Top/Bottomalong the drop axis,Left/Rightalong the windage axis.drop_mis positive BELOW the line of sight andwindage_mis positive to the shooter’s RIGHT (seecrate::perturbation::Observation), so more drop than nominal crosses theBottomedge and more rightward windage than nominal crosses theRightedge.TargetGeometryV1::Circlehas no distinct edges and always reportsRadial.
Constants§
- TOLERANCE_
SCHEMA_ VERSION_ V1 - Schema version for
ToleranceReportV1.
Functions§
- tolerance_
envelope - How far may each of
axesdrift from its own current value, one at a time, before the impact leavestarget– see the module doc for the full contract, and especially “The central hazard” for whatunbounded_in_domain/nominal_inside_targetmean together.