Skip to main content

Module explain

Module explain 

Source
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 as Observation itself.
GroupContributionV1
SkippedAxisV1
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.
SolutionDiffReportV1
SolutionDiffRowV1

Enums§

SwapDirectionV1
Which leg of a group’s symmetric counterfactual swap a SkippedAxisV1 occurred 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).