Skip to main content

ballistics_engine/perturbation/
taxonomy.rs

1//! The single input taxonomy for the decision-support train.
2//!
3//! Two levels: `InputAxis` leaves (what MBA-1347 propagates individually) grouped
4//! into `InputGroup` buckets (what MBA-1345 attributes by). Defined once so the two
5//! features cannot drift apart.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum InputGroup {
12    ProjectileDrag, MuzzleVelocity, ZeroSightGeometry,
13    Atmosphere, Wind, ShotGeometry, Effects,
14}
15
16impl InputGroup {
17    pub const ALL: &'static [InputGroup] = &[
18        InputGroup::ProjectileDrag, InputGroup::MuzzleVelocity, InputGroup::ZeroSightGeometry,
19        InputGroup::Atmosphere, InputGroup::Wind, InputGroup::ShotGeometry, InputGroup::Effects,
20    ];
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum InputAxis {
26    Mass, Diameter, Length, BallisticCoefficient, TwistRate, TwistDirection, DragModel,
27    MuzzleVelocityMps,
28    SightHeight, ZeroDistance, ZeroPoiUp, ZeroPoiRight, SightOffsetLateral, MuzzleHeight, MuzzleAngle,
29    Altitude, Temperature, Pressure, RelativeHumidity, Latitude,
30    WindSpeed, WindDirection, WindVertical,
31    TargetDistance, ShootingAngle, Cant, ShotAzimuth, AimAzimuth, TargetHeight,
32    MagnusEnabled, CoriolisEnabled, EnhancedSpinDriftEnabled,
33}
34
35impl InputAxis {
36    pub const ALL: &'static [InputAxis] = &[
37        InputAxis::Mass, InputAxis::Diameter, InputAxis::Length, InputAxis::BallisticCoefficient,
38        InputAxis::TwistRate, InputAxis::TwistDirection, InputAxis::DragModel,
39        InputAxis::MuzzleVelocityMps,
40        InputAxis::SightHeight, InputAxis::ZeroDistance, InputAxis::ZeroPoiUp, InputAxis::ZeroPoiRight,
41        InputAxis::SightOffsetLateral, InputAxis::MuzzleHeight, InputAxis::MuzzleAngle,
42        InputAxis::Altitude, InputAxis::Temperature, InputAxis::Pressure, InputAxis::RelativeHumidity,
43        InputAxis::Latitude,
44        InputAxis::WindSpeed, InputAxis::WindDirection, InputAxis::WindVertical,
45        InputAxis::TargetDistance, InputAxis::ShootingAngle, InputAxis::Cant, InputAxis::ShotAzimuth,
46        InputAxis::AimAzimuth, InputAxis::TargetHeight,
47        InputAxis::MagnusEnabled, InputAxis::CoriolisEnabled, InputAxis::EnhancedSpinDriftEnabled,
48    ];
49}
50
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum AxisKind {
53    /// A real-valued input. `default_rel_step`/`min_abs_step` follow the existing
54    /// convention h = (|x| * rel).max(min_abs) from src/truing.rs:1266.
55    Continuous { unit: &'static str, default_rel_step: f64, min_abs_step: f64 },
56    /// A boolean or enumerated input. Participates in counterfactuals; never differentiated.
57    Categorical,
58}
59
60#[derive(Debug, Clone, Copy)]
61pub struct AxisMeta {
62    pub group: InputGroup,
63    pub kind: AxisKind,
64    /// True when changing this axis invalidates the zero, so the perturbed solve must
65    /// re-zero before it is comparable. This flag is the cost control for the kernel.
66    pub requires_rezero: bool,
67}
68
69const fn cont(unit: &'static str, rel: f64, min_abs: f64) -> AxisKind {
70    AxisKind::Continuous { unit, default_rel_step: rel, min_abs_step: min_abs }
71}
72
73pub fn axis_meta(axis: InputAxis) -> AxisMeta {
74    use InputAxis::*;
75    use InputGroup::*;
76    let (group, kind, requires_rezero) = match axis {
77        Mass                 => (ProjectileDrag, cont("kg", 1e-3, 1e-7), true),
78        Diameter             => (ProjectileDrag, cont("m", 1e-3, 1e-7), true),
79        Length               => (ProjectileDrag, cont("m", 1e-3, 1e-6), false),
80        BallisticCoefficient => (ProjectileDrag, cont("", 1e-3, 1e-4), true),
81        TwistRate            => (ProjectileDrag, cont("m/turn", 1e-3, 1e-5), false),
82        TwistDirection       => (ProjectileDrag, AxisKind::Categorical, false),
83        DragModel            => (ProjectileDrag, AxisKind::Categorical, true),
84        MuzzleVelocityMps    => (MuzzleVelocity, cont("m/s", 1e-3, 0.15), true),
85        SightHeight          => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
86        ZeroDistance         => (ZeroSightGeometry, cont("m", 1e-3, 0.1), true),
87        ZeroPoiUp            => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
88        ZeroPoiRight         => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
89        SightOffsetLateral   => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
90        MuzzleHeight         => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
91        MuzzleAngle          => (ZeroSightGeometry, cont("rad", 1e-3, 1e-4), false),
92        Altitude             => (Atmosphere, cont("m", 1e-3, 1.0), false),
93        Temperature          => (Atmosphere, cont("K", 1e-3, 0.05), false),
94        Pressure             => (Atmosphere, cont("Pa", 1e-3, 10.0), false),
95        RelativeHumidity     => (Atmosphere, cont("", 1e-3, 1e-3), false),
96        Latitude             => (Atmosphere, cont("rad", 1e-3, 1e-5), false),
97        WindSpeed            => (Wind, cont("m/s", 1e-3, 0.05), false),
98        WindDirection        => (Wind, cont("rad", 1e-3, 1e-4), false),
99        WindVertical         => (Wind, cont("m/s", 1e-3, 0.05), false),
100        TargetDistance       => (ShotGeometry, cont("m", 1e-3, 0.5), false),
101        ShootingAngle        => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
102        Cant                 => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
103        ShotAzimuth          => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
104        AimAzimuth           => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
105        TargetHeight         => (ShotGeometry, cont("m", 1e-3, 1e-3), false),
106        MagnusEnabled            => (Effects, AxisKind::Categorical, false),
107        CoriolisEnabled          => (Effects, AxisKind::Categorical, false),
108        EnhancedSpinDriftEnabled => (Effects, AxisKind::Categorical, false),
109    };
110    AxisMeta { group, kind, requires_rezero }
111}
112
113pub fn axes_in_group(group: InputGroup) -> &'static [InputAxis] {
114    use InputAxis::*;
115    match group {
116        InputGroup::ProjectileDrag => &[Mass, Diameter, Length, BallisticCoefficient, TwistRate, TwistDirection, DragModel],
117        InputGroup::MuzzleVelocity => &[MuzzleVelocityMps],
118        // MuzzleAngle is listed FIRST, unlike every other group's more arbitrary order (0.33.0
119        // decision-support Task 9, MBA-1345 review C1). On a RESOLVED request, muzzle_angle_rad
120        // is not an independent input whenever zero_distance_m is also present -- it is the
121        // ALREADY-SEARCHED elevation, a function of muzzle velocity and atmosphere as much as of
122        // anything in this group. A caller that applies a group's axes in turn and re-resolves
123        // between writes (explain.rs's swap_group/plan_exclusions) needs a LATER
124        // requires_rezero axis's own re-zero to overwrite whatever MuzzleAngle wrote, with a
125        // freshly re-derived, destination-consistent angle -- not the source's baked-in one.
126        // Putting MuzzleAngle first guarantees SightHeight (always present, requires_rezero,
127        // listed second) does exactly that. For an angle-only request (zero_distance_m absent
128        // on the destination), nothing later clears it, so MuzzleAngle's own write correctly
129        // stands as the swapped input in that case -- see explain.rs's module doc for the full
130        // account of why this needed fixing and how it was verified.
131        InputGroup::ZeroSightGeometry =>
132            &[MuzzleAngle, SightHeight, ZeroDistance, ZeroPoiUp, ZeroPoiRight, SightOffsetLateral, MuzzleHeight],
133        InputGroup::Atmosphere => &[Altitude, Temperature, Pressure, RelativeHumidity, Latitude],
134        InputGroup::Wind => &[WindSpeed, WindDirection, WindVertical],
135        InputGroup::ShotGeometry =>
136            &[TargetDistance, ShootingAngle, Cant, ShotAzimuth, AimAzimuth, TargetHeight],
137        InputGroup::Effects =>
138            &[MagnusEnabled, CoriolisEnabled, EnhancedSpinDriftEnabled],
139    }
140}
141
142// KNOWN LIMITATIONS: Several axis/mode combinations require guards in a later task (not this one —
143// this task is data only):
144//
145// (a) Altitude when the original request used QNH pressure: the rebuilt request carries an
146//     absolute station pressure, so perturbing altitude changes density-by-altitude but NOT
147//     station pressure, which is the opposite of what a QNH-entering user means.
148//
149// (b) ShotAzimuth when the original request used compass-referenced wind: the rebuilt request
150//     carries shooter-relative wind, so the wind rotates WITH the rifle instead of staying
151//     earth-fixed — the counterfactual is physically inverted.
152//
153// (c) WindSpeed/WindDirection/WindVertical when the original request used segmented wind: there
154//     is no single scalar to read or perturb. These axes return None from read_axis under
155//     segmented wind and the kernel treats them as absent.
156//
157// (d) Magnus + EnhancedSpinDrift together: validate_effects (src/solve_json.rs:1544-1552)
158//     rejects magnus: true + enhanced_spin_drift: true as ConflictingFields. A future with_axis
159//     that flips one to true while the other is already true produces a request that fails
160//     validation. This cross-category constraint cannot be expressed purely in the taxonomy.
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    /// Every axis belongs to exactly one group, and every group lists it.
167    /// Set equality: InputAxis::ALL == union of axes_in_group over all groups.
168    #[test]
169    fn taxonomy_is_a_partition() {
170        let mut seen = Vec::new();
171        for g in InputGroup::ALL {
172            for a in axes_in_group(*g) {
173                assert_eq!(axis_meta(*a).group, *g, "{a:?} listed under the wrong group");
174                assert!(!seen.contains(a), "{a:?} appears in more than one group");
175                seen.push(*a);
176            }
177        }
178        // Set equality: every axis in ALL is listed exactly once, and every listed axis is in ALL.
179        assert_eq!(seen.len(), InputAxis::ALL.len(),
180                   "axes_in_group() covers {} axes; InputAxis::ALL has {}",
181                   seen.len(), InputAxis::ALL.len());
182        for axis in InputAxis::ALL {
183            assert!(seen.contains(axis), "{axis:?} is in InputAxis::ALL but not in any group");
184        }
185        // Also verify axis_meta is callable for every member of ALL.
186        for axis in InputAxis::ALL {
187            let _ = axis_meta(*axis);
188        }
189    }
190
191    /// Effects are boolean toggles and must never be differentiated (spec D7).
192    #[test]
193    fn effects_are_categorical() {
194        for a in axes_in_group(InputGroup::Effects) {
195            assert!(matches!(axis_meta(*a).kind, AxisKind::Categorical),
196                    "{a:?} must be Categorical");
197        }
198    }
199
200    /// Cost control: only zero-affecting axes force a re-zero.
201    #[test]
202    fn only_zero_affecting_axes_require_rezero() {
203        assert!(axis_meta(InputAxis::SightHeight).requires_rezero);
204        assert!(axis_meta(InputAxis::ZeroDistance).requires_rezero);
205        assert!(!axis_meta(InputAxis::WindSpeed).requires_rezero);
206        assert!(!axis_meta(InputAxis::TargetDistance).requires_rezero);
207    }
208}