Expand description
MBA-1345: explain why two fully resolved solutions differ.
For each input group the difference is attributed by a SYMMETRIC counterfactual: swap the group from B into A, and independently from A into B, and average. That makes the answer independent of replacement order, which a one-directional swap is not. Whatever the group contributions fail to explain is reported as an explicit interaction remainder and is never distributed across groups: for correlated inputs there is no unique causal attribution, and pretending otherwise is the failure this design exists to avoid.
§The derived-value exclusion rule
A resolved request echoes some axes as plain, independent inputs, but a few are actually COMPUTED from a different axis’s value. A group swap must exclude any axis whose resolved value is computed from another axis whenever that source axis is:
(a) in a DIFFERENT group, or (b) itself excluded (from the SAME group).
Same-group derivation where the source is ALSO swapped normally is self-consistent and
needs no special handling – both values move together, so the pairing the derivation
depends on is preserved. That is why, for instance, Temperature’s ICAO-standard default
from Altitude is ordinarily harmless: swapping the whole Atmosphere group moves both
(when Altitude is not itself excluded – see the QNH case below, which is exactly the
exception).
Three axes are known to violate this, found one at a time by successive reviews, each
documented below as its own subsection: MuzzleAngle (case (a): derived from
MuzzleVelocity and Atmosphere, both different groups), WindDirection under compass
wind (case (a): derived from ShotGeometry’s ShotAzimuth), and Pressure under a QNH
atmosphere (case (b): derived from Atmosphere’s own Altitude, which is excluded rather
than swapped). There is no taxonomy metadata recording “derived from” relationships – each
is handled as an explicit, documented special case in plan_exclusions rather than a
generic mechanism, since three known instances do not justify inventing a
derivation-tracking data model the taxonomy does not otherwise have. A future instance
should be diagnosed against this same rule and added the same way.
§Instance 1: MuzzleAngle is derived, not independent, once a zero distance is present
ZeroSightGeometry lists MuzzleAngle – but on a RESOLVED request, muzzle_angle_rad is
not an independent input whenever zero_distance_m is also present: it is the
ALREADY-SEARCHED elevation, a function of muzzle velocity and atmosphere as much as of any
sight/zero knob in this group (review C1; rule case (a) – MuzzleVelocity and Atmosphere
are different groups). Naively applying it as an ordinary axis would import muzzle-velocity’s
and atmosphere’s own differences into ZeroSightGeometry’s bucket – concretely, swapping a
group in which every visible sight/zero setting is byte-identical between the two requests
would still report a non-zero contribution, because the group also carried the OTHER
request’s fully-baked elevation. A shooter reading “5 cm of this is your zero/sight geometry”
would go check a scope that never moved.
taxonomy.rs’s axes_in_group(ZeroSightGeometry) lists MuzzleAngle FIRST specifically to
fix this: SightHeight (always present, requires_rezero) is applied second, and its own
re-resolve clears and re-derives the elevation for the DESTINATION’s own muzzle velocity and
atmosphere, overwriting whatever MuzzleAngle’s write introduced – see that match arm’s own
comment for the full mechanism, and every_groups_contribution_matches_an_independent_recomputation
below for the acceptance test (identical sight/zero inputs must report an EXACTLY zero
ZeroSightGeometry, not merely a small one). For an angle-only request (no zero_distance_m
on the destination), nothing later in the group clears the angle, so MuzzleAngle’s own
write correctly stands: there it genuinely IS the independent input, and it must still be
swapped in that case – see muzzle_angle_is_still_swapped_for_an_angle_only_request (review
round 3, N3): the fix above is only correct BECAUSE the later clear-gate is conditioned on
zero_distance_m being present, not unconditionally on axis order, and there was no test
pinning that down until this one.
§Instance 2: WindDirection is derived from ShotAzimuth under compass-referenced wind
Under wind_reference: "compass", the resolved (shooter-relative) direction_from_rad is
compass_bearing_to_shooter_relative_rad(bearing, shot_azimuth_rad) =
(bearing - shot_azimuth_rad).rem_euclid(2*pi) (solve_v1.rs’s resolve_wind, wind.rs) –
a function of ShotAzimuth, which lives in ShotGeometry, a DIFFERENT group (review round
3, N1; rule case (a)). with_axis already refuses to swap ShotAzimuth itself under
compass wind, protecting ShotGeometry’s own number, but nothing stopped WindDirection
from moving on ITS OWN: two compass-referenced requests with an IDENTICAL raw wind bearing
but different shot_azimuth_rad resolve to DIFFERENT direction_from_rad values purely
because of the azimuth difference, and swapping WindDirection would report that as a Wind
contribution – the exact MuzzleAngle shape, relabelled onto a different pair of groups.
plan_exclusions now excludes WindDirection (both legs, both directions) whenever EITHER
a or b is compass-referenced, regardless of whether the raw bearing or the azimuth
actually differ – the same unconditional-on-values pattern the Altitude/ShotAzimuth
guards already use. See wind_direction_is_excluded_under_compass_wind_even_with_an_identical_raw_bearing.
§Instance 3: Pressure is derived from Altitude under a QNH atmosphere
Under pressure_reference: "qnh", the resolved pressure_pa is
reduce_qnh_to_station_pressure(qnh_value, altitude_m) (solve_v1.rs’s resolve_atmosphere)
– a function of Altitude, which lives in the SAME group, Atmosphere (review round 3, N2;
rule case (b)). Same-group derivation is ordinarily fine (see the rule above), but Altitude
itself is excluded whenever pressure_reference is QNH – so the pairing breaks: Pressure
would still move while its source, Altitude, stays fixed on both legs, leaking a value that
is only physically valid at the OTHER request’s (unswapped) altitude into Atmosphere’s
contribution, in violation of the third assumptions entry’s promise that an excluded axis’s
effect lands in the remainder, not partially in its group. plan_exclusions now excludes
Pressure too, using the exact same QNH condition that already excludes Altitude. See
pressure_exclusion_does_not_leak_a_partial_effect_into_atmosphere (a QNH-vs-QNH fixture,
added alongside the existing QNH-vs-absolute altitude_exclusion_does_not_leak_a_partial_effect_into_atmosphere,
which cannot exercise this specific hazard because a plain absolute pressure has no altitude
dependency to leak in the first place).
Known limitation, not fixed: Temperature has the identical hazard when it is OMITTED
from the raw request under a QNH atmosphere – its ICAO-standard default
(calculate_icao_standard_atmosphere(altitude_m)) is ALSO a function of Altitude. Unlike
Pressure’s dependency, this one is not detectable from a resolved request alone: the
resolved temperature_k echo carries only the concrete numeric value, with no record of
whether it came from an explicit input or from this default, and the assumption notice that
WOULD say so is not part of ResolvedSolveRequestV1 at all. Excluding Temperature
unconditionally whenever Altitude is QNH-excluded would trade this rare, real leak for a
much more common one (silently discarding a genuinely independent temperature difference on
every ordinary QNH request that happens to also specify a temperature), which is a worse
trade. Left undetected and undocumented anywhere else but here.
§Symmetric exclusion: an axis unusable on either side must be excluded on BOTH legs
with_axis can refuse an axis for a request-specific structural reason:
KernelError::AxisUnsupportedForRequest for Altitude under a QNH-referenced atmosphere or
ShotAzimuth under compass-referenced wind, and KernelError::AxisAbsent for the three wind
axes when the request being written TO uses segmented wind (access.rs’s module doc).
read_axis separately returns None for the same three wind axes when the request being
read FROM is segmented, and also for a handful of ordinarily-optional scalar fields
(length_m, latitude_rad, the two zero-POI offsets, the lateral sight offset, and – true
of the code though not named in read_axis’s own doc comment – zero_distance_m on a
request zeroed purely by an explicit angle) that just were not supplied on a request.
An earlier revision of this module decided this PER LEG: forward excluded an axis only if
A itself refused it (or lacked a value to read from B), and backward only if B did.
That is wrong (review I1): if only ONE leg excludes an axis, the two legs stop measuring the
same counterfactual. A QNH-referenced A compared against a non-QNH B, for instance, has
forward keep A’s own altitude (refused) while backward hands B the whole of A’s
altitude (not refused) – so contribution(Atmosphere) ends up as the full
temperature/pressure/humidity effect PLUS HALF of a completely separate altitude difference,
while the report’s own assumptions claim that effect went to the interaction remainder.
plan_exclusions fixes this by deciding exclusions for a WHOLE group ONCE, from the two
ORIGINAL requests together, before either swap direction runs: an axis is excluded from BOTH
legs if EITHER a or b would refuse it as a destination, or if it is present on exactly
one of a/b (review I2 – the same splitting hazard, via read_axis instead of
with_axis: an ordinarily-optional field supplied on one saved profile and omitted on the
other, such as latitude_rad, would otherwise have forward keep A‘s own value while
backward overwrites B with it, again half-attributing a real difference with no record of
it at all). Both cases are recorded in SolutionDiffReportV1::skipped_axes – ALWAYS as a
pair, one entry per direction, so the report always explains both legs’ identical treatment
of that axis – with a reason reworded for this whole-group context rather than reused
verbatim from with_axis’s own per-axis-perturbation wording (review I1’s second point: a
reason like “perturbing altitude would move air density without moving pressure” is
misleading here, where Pressure is a SEPARATE axis in the same group and IS copied
normally). An axis absent on BOTH a and b is not reported at all: there is nothing to
attribute either way, the same as if neither request had ever mentioned it.
Either way, an exclusion never aborts the comparison – only that one axis (or, for the
magnus/enhanced_spin_drift pair below, both together) is left out of that one group, on both
legs. Two cross-axis conflicts are excluded this same way even though neither is a “derived
value” in the sense above (F2, 0.33.0 final-review fix wave): CoriolisEnabled differing
while either request lacks latitude_rad (solve_v1 requires it whenever coriolis is
enabled), and MagnusEnabled/EnhancedSpinDriftEnabled swapping between two requests with
opposite flags set (solve_v1 rejects both true on one request, taxonomy.rs’s Known
Limitation (d)) – see plan_exclusions’s own two guards for the full mechanism, and for why
the magnus/ESD case excludes BOTH axes together rather than just the one whose write would
fail first. A GENUINE failure – some OTHER solve_v1 re-resolve failure partway through
applying a group, not one of the cases plan_exclusions already recognizes – is NOT one of
these cases and propagates as an error, uncaught, exactly as central_difference and
bisect_axis already do for their own non-refusal failures.
§Why exclusions are decided from the original requests, not the accumulated one
plan_exclusions probes with_axis against a and b exactly as passed into
explain_difference – never a request swap_group has already partly rewritten. This
matters because a group with more than one axis applies its writes one at a time,
re-resolving between them (swap_group, via a real solve_v1 call) so each subsequent
with_axis call sees the previous write. That re-resolve goes through the reverse
conversion (request_roundtrip.rs), which ALWAYS clears pressure_reference and
wind_reference back to the omitted-field default (that module’s own doc: the resolved
values already have the transform baked in, so echoing the mode back would apply it a second
time). That is correct for solving, but it means the very fact with_axis’s guards key on –
“was this request originally QNH- or compass-referenced” – would be erased by the FIRST
re-resolve within a group, not preserved across it, if it were checked against that
progressively-modified request instead.
ShotGeometry lists TargetDistance, ShootingAngle and Cant before ShotAzimuth: three
unrelated axes, each triggering a re-resolve, would be applied before it. Checking the
compass guard against a progressively-modified request would have it silently pass by the
time ShotAzimuth is reached, building exactly the physically-inverted counterfactual the
guard exists to prevent – with no error and no skip recorded, because from with_axis’s
point of view at that moment the request no longer looks compass-referenced at all. Deciding
every axis’s exclusion up front, from a/b as originally given, makes this impossible by
construction: there is no accumulated, partly-laundered request for the check to see in the
first place. swap_group itself never re-derives a refusal at all – it trusts the
excluded list plan_exclusions already computed, safely, because a refusal can only become
MORE permissive as a group’s later axes are applied (round-tripping only ever clears a
reference-mode echo, it never introduces one): an axis plan_exclusions already cleared for
both a and b is guaranteed to still be writable on swap_group’s running request,
however many earlier axes in the same group have already re-resolved it. The test
shot_azimuth_is_refused_even_when_other_shot_geometry_axes_are_applied_first pins this down
by exercising exactly that four-axis ordering.
Structs§
- DeltaV1
- The difference between two
Observations at one range,b - a. SI throughout, same sign convention asObservationitself. - Group
Contribution V1 - Skipped
Axis V1 - One taxonomy axis that could not be carried across during a group’s counterfactual swap, and
why – see the module doc’s “Symmetric exclusion” section. Always recorded in pairs, one per
SwapDirectionV1, even when only one direction independently hit the refusal or absence: the OTHER direction is excluded too, to keep both legs measuring the same counterfactual. - Solution
Diff Report V1 - Solution
Diff RowV1
Enums§
- Swap
Direction V1 - Which leg of a group’s symmetric counterfactual swap a
SkippedAxisV1occurred on.
Constants§
- EXPLAIN_
SCHEMA_ VERSION_ V1 - Schema version for
SolutionDiffReportV1.
Functions§
- explain_
difference - Attribute the difference between two fully resolved solve results to the seven input groups (MBA-1345).