Skip to main content

Module tolerance

Module tolerance 

Source
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

  1. 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.
  2. Bounds are one axis at a time and may not be assumed simultaneously. Stated in ToleranceReportV1::assumptions.
  3. No probability is implied. Also stated in assumptions.
  4. A bound is never extrapolated beyond the caller’s configured domain. bisect_axis itself never looks outside domain; this module additionally never INVENTS a domain the caller did not supply – see “Domains are validated up front, per axis” below and KernelError::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§

ToleranceAxisV1
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.
ToleranceReportV1
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.
UnavailableAxisV1
One requested axis tolerance_envelope could not search at all, and why – distinct from an axis that WAS searched and found to have no bound (see ToleranceAxisV1). Never silently dropped: see this module’s “Unavailable axes” doc section.

Enums§

LimitingBoundaryV1
Which edge of a TargetGeometryV1::Rect a found bound crosses, from the shooter’s own point of view: Top/Bottom along the drop axis, Left/Right along the windage axis. drop_m is positive BELOW the line of sight and windage_m is positive to the shooter’s RIGHT (see crate::perturbation::Observation), so more drop than nominal crosses the Bottom edge and more rightward windage than nominal crosses the Right edge. TargetGeometryV1::Circle has no distinct edges and always reports Radial.

Constants§

TOLERANCE_SCHEMA_VERSION_V1
Schema version for ToleranceReportV1.

Functions§

tolerance_envelope
How far may each of axes drift from its own current value, one at a time, before the impact leaves target – see the module doc for the full contract, and especially “The central hazard” for what unbounded_in_domain/nominal_inside_target mean together.