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:
- Every
range_minranges_mmust be finite and in[0, base.shot.max_range_m], orerror_budgetreturnsKernelError::Observationimmediately – a cheap check against the DECLARED bound, requiring no solve, that rejects the unambiguous cases (negative, non-finite, or past the caller’s own statedmax_range_m). baseis then solved once viaevaluate, over the whole ofranges_mat once – the same nominal-reference-point patterncrate::tolerance::tolerance_envelopeandcrate::explain::explain_differencealready use. A range that passed stage 1 but lies past where THIS trajectory actually terminates is rejected here with an honestKernelError::Observationnaming the REAL computed trajectory extent, not a per-axis “unavailable” that blames the wrong cause. The observationsevaluatereturns 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(notrequires_rezero) now costs 3;MuzzleVelocityMpscosts 38;BallisticCoefficientcosts 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 tworequires_rezeroaxes each still cost roughly 17-19 trial solves per perturbed side beyond their one real solve ((37 - 2) / 2 = 17.5average,(40 - 2) / 2 = 19average) – 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§
- Ellipse95
V1 - 95% confidence ellipse for a 2-dof (drop, windage) impact covariance.
- Error
Budget Report V1 - Per-input uncertainty propagation and measurement-priority report (MBA-1347).
- Error
Budget RowV1 - The impact covariance and ranked sources at one requested range.
- Source
Contribution V1 - One declared source’s contribution to impact variance at one range.
- Unavailable
Source V1 - A declared source
error_budgetcould not evaluate for this request, and why.
Enums§
- Target
Geometry V1 - 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_mare clamped to non-negative internally byp_hit_bivariate; a negative value is treated as zero rather than producing an inverted or NaN result. - Unavailable
Reason Code V1 - Which structural refusal made a source unavailable – the machine-readable counterpart to
UnavailableSourceV1::reason’s prose. Named identically to theKernelErrorvariant it comes from. Added at the same time as the rest of thisV1type (not held back for a later schema revision) specifically because adding a field to an already-shippedV1wire type would be a breaking change; this crate had not shippederror_budgeton 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
sourcesto impact covariance at every range inranges_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 overtargetwhen it isSome: each row’sErrorBudgetRowV1::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_budgetis a thin wrapper passingNone. Validation, ranking, unavailable-source handling, and cost are otherwise IDENTICAL toerror_budgetand documented on it and this module’s top-level doc comment; this doc comment covers only whattargetadds. - 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 variancevar_drop, windage variancevar_wind, and drop/windage covariancecov.targetis always centred on that same origin; seeTargetGeometryV1’s doc for why there is no separate aim-point offset.