Skip to main content

Module error_budget

Module error_budget 

Source
Expand description

MBA-1347: a per-input error budget and measurement-priority report.

A shooter usually has time to improve exactly ONE input before a shot: rezero the sight, get a better wind call, chronograph another string, and so on. This module propagates each DECLARED per-input uncertainty (a caller-supplied one-sigma value for one InputAxis) to impact covariance at the ranges of interest, via the same central-difference kernel the rest of the 0.33.0 decision-support train is built on (central_difference), then ranks the sources so the report can end with a concrete answer: which single input is worth improving here, and which ones are not.

Sources are preserved individually and NEVER collapsed into an “other” bucket – that is this report’s whole reason to exist, as distinct from the existing WEZ (monte-carlo --wez) attribution, which lumps everything not explicitly modelled into dispersion the caller cannot attribute to a specific input at all. See error_budget’s doc comment for the full contract.

On top of the covariance/ranking above, error_budget_with_target (Task 11) answers the decision a shooter actually faces: given a target size, what is the hit probability, and how much would it improve if one particular source were measured perfectly? See p_hit_bivariate’s doc comment for the hit-probability math and TargetGeometryV1 for the target shapes it accepts.

§Unavailable sources are recorded, never silently dropped

central_difference can legitimately refuse to differentiate a declared axis: KernelError::AxisUnsupportedForRequest (Altitude under a QNH-referenced atmosphere, ShotAzimuth under compass-referenced wind), KernelError::AxisAbsent (a wind axis under segmented wind), KernelError::CategoricalAxis (an effect toggle), or KernelError::StepOutOfDomain (both perturbed sides, using the axis’s OWN default step – error_budget always requests central_difference’s None-step formula, never a custom one, so the declared sigma has no bearing on whether this fires – left the axis’s physical domain). Every one of these is recorded as an UnavailableSourceV1 (axis, declared sigma, a machine-readable UnavailableReasonCodeV1, and a human-readable reason) in ErrorBudgetReportV1::unavailable_sources, and the rest of the report is still produced from whatever sources DID evaluate.

Silently skipping an unavailable axis would report “this input contributes no uncertainty,” the one wrong answer this ticket exists to prevent – a source that could not be measured must never look identical to a source that WAS measured and found to contribute exactly zero. SourceContributionV1 is never constructed for an axis that failed; it only ever describes an axis central_difference actually evaluated.

Any OTHER error central_difference reports (KernelError::Solve, KernelError::Observation, or the two defensive variants KernelError::TypeMismatch/KernelError::NonFinite) is a genuine solver or trajectory failure, not a normal “this input cannot be perturbed here” fact, and error_budget propagates it unchanged rather than folding it into unavailable_sources. The classification (see the private unavailable_reason below) is an exhaustive match with no wildcard arm, so a future KernelError variant fails to compile here until it is explicitly placed in one bucket or the other, rather than silently defaulting into whichever this match’s last arm happens to be. KernelError::DuplicateAxis is classified there too even though error_budget itself constructs and returns it before that match ever runs (see “Sources are validated up front” below) – the exhaustiveness is over the whole KernelError type, not only the subset central_difference can produce.

One caller mistake is deliberately NOT laundered through this mechanism: a range in ranges_m that cannot actually be observed – either beyond the declared base.shot.max_range_m, or (less obviously) still inside max_range_m but past where THIS trajectory actually terminates, e.g. a steep downward shot that strikes the ground at 95 m under a declared max_range_m: 900 – would otherwise fail BOTH perturbed sides of EVERY axis identically (an KernelError::Observation domain rejection on each side becomes KernelError::StepOutOfDomain), recording every declared source as unavailable with a misleading reason that blames the axis’s own step when the real problem is that the caller queried past the trajectory. See “Sources and ranges are validated up front” below – error_budget rejects both forms of that mistake directly instead.

§Sources and ranges are validated up front

Before any central_difference call, in two stages:

  1. Every range_m in ranges_m must be finite and in [0, base.shot.max_range_m], or error_budget returns KernelError::Observation immediately – a cheap check against the DECLARED bound, requiring no solve, that rejects the unambiguous cases (negative, non-finite, or past the caller’s own stated max_range_m).
  2. base is then solved once via evaluate, over the whole of ranges_m at once – the same nominal-reference-point pattern crate::tolerance::tolerance_envelope and crate::explain::explain_difference already use. A range that passed stage 1 but lies past where THIS trajectory actually terminates is rejected here with an honest KernelError::Observation naming the REAL computed trajectory extent, not a per-axis “unavailable” that blames the wrong cause. The observations evaluate returns are otherwise unused in this function – this call exists for its validation, not its output; see “Cost” below for what it adds.

Every declared sigma must also be finite and non-negative, and the same InputAxis must not appear twice in sources (two entries would double-count that axis’s variance and make its own leave-one-out counterfactual ambiguous), or error_budget returns KernelError::NonFinite / KernelError::DuplicateAxis respectively – checked immediately after the two range stages above, still before any central_difference call.

§Ranking is deterministic

Sources are ranked by SourceContributionV1::variance_share, descending. Ties (equal shares) break on a fixed, declaration-order-independent key (the axis’s own Debug name), so error_budget(base, &[(A, sa), (B, sb)], ranges) and error_budget(base, &[(B, sb), (A, sa)], ranges) produce IDENTICAL orderings even when two sources happen to contribute exactly the same share. A real-physics fixture essentially never produces an exact tie in floating point, so a declaration-order test alone (the brief’s own ranking_is_invariant_to_declaration_order) would still pass with NO tie-break at all, as long as Rust’s sort remains stable and the two shares genuinely differ – see tied_variance_shares_break_deterministically_regardless_of_input_order in this module’s tests, which constructs a genuine tie directly against the sort function itself, and would fail without the tie-break.

§Cost

Two parts: one fixed pre-check, then one central_difference call per DECLARED source.

The pre-check (added by the F1 fix, 0.33.0 final-review wave): one call to evaluate on the nominal, unperturbed base request, covering every range in ranges_m at once – see “Sources and ranges are validated up front” above. This costs exactly ONE real trajectory solve, paid once per call to error_budget/error_budget_with_target regardless of how many sources are declared or how many ranges are requested, and regardless of whether base carries a zero_distance_m: base’s own muzzle_angle_rad is already the resolved angle (request_roundtrip’s From<&ResolvedSolveRequestV1> for SolveRequestV1 always carries it alongside zero_distance_m), so build_zeroed_solver’s (Some, Some) arm applies only a cheap windage bias rather than re-running the elevation search – unlike the per-source solves below, which perturb an axis away from where base was resolved and so, for a requires_rezero axis, must re-search from scratch.

Per declared source, that is 2 real trajectory solves in the common (central-difference) case, or 3 if one side fell outside the axis’s physical domain and the kernel fell back to a one-sided difference (see DifferenceScheme). If the axis requires_rezero (crate::perturbation::axis_meta(axis).requires_rezero) and the request carries a zero_distance_m, each of those solves is itself preceded by a fresh elevation search of up to 60 trial solves (find_zero_angle, src/cli_api.rs) – unavoidable, and not something this module changes; see crate::perturbation::derive’s own module doc for where that number comes from. ranges_m is passed through to the kernel unchanged and the resulting Vec<Derivative> is indexed by range when building each row, so this part of the cost, like the pre-check, is independent of how many ranges are requested and scales only with the number of DECLARED sources.

Measured, not guessed (a previous task’s cost doc on this branch understated its own number by 5x before being corrected by direct measurement, so this number was obtained the same way): a temporary instrumented low-level solve counter was added to TrajectorySolver::solve, run once against this module’s own three-source test fixture (every_declared_source_appears_individually: MuzzleVelocityMps and BallisticCoefficient, both requires_rezero, plus WindSpeed, which is not), then removed (the working tree was diffed against the pre-instrumentation state afterward to confirm a byte-for-byte revert of src/cli_api.rs). Measured results, with the F1 pre-check included:

  • All three sources together at a single range: 80 low-level solves (79 before the F1 fix added the pre-check – exactly the expected +1).
  • The IDENTICAL three sources requested over FOUR ranges instead of one: 80 again – confirming the per-range independence above still holds with the pre-check included (the pre-check itself covers all four ranges from that same one solve).
  • Decomposed by declaring each source alone (also at one range): WindSpeed (not requires_rezero) now costs 3; MuzzleVelocityMps costs 38; BallisticCoefficient costs 41 – each exactly one more than its pre-F1 figure, since every SEPARATE call now pays its own copy of the fixed pre-check. Summing these three single-source figures (38 + 41 + 3 = 82) therefore OVERCOUNTS the combined three-source total (80) by 2: the pre-check is a fixed cost per CALL, not per source, so declaring the three sources as three separate calls pays it three times, while declaring them together in one call pays it once. Subtracting the pre-check from each isolated figure recovers the pre-F1 per-source numbers exactly (38 - 1 = 37, 41 - 1 = 40, 3 - 1 = 2), which still sum additively (37 + 40 + 2 = 79) and, with the combined call’s own single pre-check added back once (79 + 1 = 80), match its measured total exactly – confirming each declared source’s cost remains independent of, and additive with, every other declared source’s, exactly as before F1; only the fixed one-time pre-check is new, and it does not multiply with source count. The two requires_rezero axes each still cost roughly 17-19 trial solves per perturbed side beyond their one real solve ((37 - 2) / 2 = 17.5 average, (40 - 2) / 2 = 19 average) – well under the 60-iteration cap, not close to it, for this fixture’s zero geometry.

§Why the differencing step ignores the declared sigma

error_budget always calls central_difference with step: None – the axis’s own small default, never Some(sigma). The delta method’s Jacobian is the local SLOPE of impact at the nominal point; the declared sigma only enters afterward, scaling that slope via J * Sigma * J^T. The consequence: a large declared sigma is linearly extrapolated from a slope measured over a much SMALLER window (WindSpeed’s own default step is 0.05 m/s, regardless of whether the caller declared a 1 m/s or a 10 m/s wind-call sigma) – this is exactly the local-linearity limitation the assumptions payload already discloses, not a separate concern. Using Some(sigma) instead was considered and rejected: it would push WindSpeed/RelativeHumidity-style sigmas straight out of their physical domain on an ordinary still-air or dry-air request (see the “One-sided fallback” section in crate::perturbation::derive’s own module doc), making sources vanish into one-sided fallbacks or StepOutOfDomain far more often – the opposite of what a report whose whole purpose is surfacing every declared source should do.

Structs§

Ellipse95V1
95% confidence ellipse for a 2-dof (drop, windage) impact covariance.
ErrorBudgetReportV1
Per-input uncertainty propagation and measurement-priority report (MBA-1347).
ErrorBudgetRowV1
The impact covariance and ranked sources at one requested range.
SourceContributionV1
One declared source’s contribution to impact variance at one range.
UnavailableSourceV1
A declared source error_budget could not evaluate for this request, and why.

Enums§

TargetGeometryV1
A target shape for p_hit_bivariate / error_budget_with_target, always centred on the nominal (zero-mean) impact point – there is no separate “offset from point of aim” field, so the reported hit probability implicitly assumes a well-zeroed rifle aimed at the target’s own centre. width_m/height_m/radius_m are clamped to non-negative internally by p_hit_bivariate; a negative value is treated as zero rather than producing an inverted or NaN result.
UnavailableReasonCodeV1
Which structural refusal made a source unavailable – the machine-readable counterpart to UnavailableSourceV1::reason’s prose. Named identically to the KernelError variant it comes from. Added at the same time as the rest of this V1 type (not held back for a later schema revision) specifically because adding a field to an already-shipped V1 wire type would be a breaking change; this crate had not shipped error_budget on any released version when this field was added, so there is no such constraint yet.

Constants§

ERROR_BUDGET_SCHEMA_VERSION_V1
Schema version for ErrorBudgetReportV1.

Functions§

error_budget
Propagate each declared per-input uncertainty in sources to impact covariance at every range in ranges_m, via central differences through the real solver (central_difference), and rank the sources by their share of impact variance.
error_budget_with_target
As error_budget, but additionally reports hit probability over target when it is Some: each row’s ErrorBudgetRowV1::p_hit, and each of its sources’ SourceContributionV1::p_hit_gain_if_perfect – the hit-probability gain if that source alone were measured perfectly, the value-of-information number this ticket exists to answer. error_budget is a thin wrapper passing None. Validation, ranking, unavailable-source handling, and cost are otherwise IDENTICAL to error_budget and documented on it and this module’s top-level doc comment; this doc comment covers only what target adds.
p_hit_bivariate
P(impact falls inside target) for a bivariate normal impact distribution centred at the origin – the nominal (zero-mean) trajectory solution – with drop variance var_drop, windage variance var_wind, and drop/windage covariance cov. target is always centred on that same origin; see TargetGeometryV1’s doc for why there is no separate aim-point offset.