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,
13    MuzzleVelocity,
14    ZeroSightGeometry,
15    Atmosphere,
16    Wind,
17    ShotGeometry,
18    Effects,
19}
20
21impl InputGroup {
22    pub const ALL: &'static [InputGroup] = &[
23        InputGroup::ProjectileDrag,
24        InputGroup::MuzzleVelocity,
25        InputGroup::ZeroSightGeometry,
26        InputGroup::Atmosphere,
27        InputGroup::Wind,
28        InputGroup::ShotGeometry,
29        InputGroup::Effects,
30    ];
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum InputAxis {
36    Mass,
37    Diameter,
38    Length,
39    BallisticCoefficient,
40    TwistRate,
41    TwistDirection,
42    DragModel,
43    MuzzleVelocityMps,
44    SightHeight,
45    ZeroDistance,
46    ZeroPoiUp,
47    ZeroPoiRight,
48    SightOffsetLateral,
49    MuzzleHeight,
50    MuzzleAngle,
51    Altitude,
52    Temperature,
53    Pressure,
54    RelativeHumidity,
55    Latitude,
56    WindSpeed,
57    WindDirection,
58    WindVertical,
59    TargetDistance,
60    ShootingAngle,
61    Cant,
62    ShotAzimuth,
63    AimAzimuth,
64    TargetHeight,
65    MagnusEnabled,
66    CoriolisEnabled,
67    EnhancedSpinDriftEnabled,
68}
69
70impl InputAxis {
71    pub const ALL: &'static [InputAxis] = &[
72        InputAxis::Mass,
73        InputAxis::Diameter,
74        InputAxis::Length,
75        InputAxis::BallisticCoefficient,
76        InputAxis::TwistRate,
77        InputAxis::TwistDirection,
78        InputAxis::DragModel,
79        InputAxis::MuzzleVelocityMps,
80        InputAxis::SightHeight,
81        InputAxis::ZeroDistance,
82        InputAxis::ZeroPoiUp,
83        InputAxis::ZeroPoiRight,
84        InputAxis::SightOffsetLateral,
85        InputAxis::MuzzleHeight,
86        InputAxis::MuzzleAngle,
87        InputAxis::Altitude,
88        InputAxis::Temperature,
89        InputAxis::Pressure,
90        InputAxis::RelativeHumidity,
91        InputAxis::Latitude,
92        InputAxis::WindSpeed,
93        InputAxis::WindDirection,
94        InputAxis::WindVertical,
95        InputAxis::TargetDistance,
96        InputAxis::ShootingAngle,
97        InputAxis::Cant,
98        InputAxis::ShotAzimuth,
99        InputAxis::AimAzimuth,
100        InputAxis::TargetHeight,
101        InputAxis::MagnusEnabled,
102        InputAxis::CoriolisEnabled,
103        InputAxis::EnhancedSpinDriftEnabled,
104    ];
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum AxisKind {
109    /// A real-valued input. `default_rel_step`/`min_abs_step` follow the existing
110    /// convention h = (|x| * rel).max(min_abs) from src/truing.rs:1266.
111    Continuous {
112        unit: &'static str,
113        default_rel_step: f64,
114        min_abs_step: f64,
115    },
116    /// A boolean or enumerated input. Participates in counterfactuals; never differentiated.
117    Categorical,
118}
119
120#[derive(Debug, Clone, Copy)]
121pub struct AxisMeta {
122    pub group: InputGroup,
123    pub kind: AxisKind,
124    /// True when changing this axis invalidates the zero, so the perturbed solve must
125    /// re-zero before it is comparable. This flag is the cost control for the kernel.
126    pub requires_rezero: bool,
127}
128
129const fn cont(unit: &'static str, rel: f64, min_abs: f64) -> AxisKind {
130    AxisKind::Continuous {
131        unit,
132        default_rel_step: rel,
133        min_abs_step: min_abs,
134    }
135}
136
137pub fn axis_meta(axis: InputAxis) -> AxisMeta {
138    use InputAxis::*;
139    use InputGroup::*;
140    let (group, kind, requires_rezero) = match axis {
141        Mass => (ProjectileDrag, cont("kg", 1e-3, 1e-7), true),
142        Diameter => (ProjectileDrag, cont("m", 1e-3, 1e-7), true),
143        Length => (ProjectileDrag, cont("m", 1e-3, 1e-6), false),
144        BallisticCoefficient => (ProjectileDrag, cont("", 1e-3, 1e-4), true),
145        TwistRate => (ProjectileDrag, cont("m/turn", 1e-3, 1e-5), false),
146        TwistDirection => (ProjectileDrag, AxisKind::Categorical, false),
147        DragModel => (ProjectileDrag, AxisKind::Categorical, true),
148        MuzzleVelocityMps => (MuzzleVelocity, cont("m/s", 1e-3, 0.15), true),
149        SightHeight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
150        ZeroDistance => (ZeroSightGeometry, cont("m", 1e-3, 0.1), true),
151        ZeroPoiUp => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
152        ZeroPoiRight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
153        SightOffsetLateral => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
154        MuzzleHeight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
155        MuzzleAngle => (ZeroSightGeometry, cont("rad", 1e-3, 1e-4), false),
156        Altitude => (Atmosphere, cont("m", 1e-3, 1.0), false),
157        Temperature => (Atmosphere, cont("K", 1e-3, 0.05), false),
158        Pressure => (Atmosphere, cont("Pa", 1e-3, 10.0), false),
159        RelativeHumidity => (Atmosphere, cont("", 1e-3, 1e-3), false),
160        Latitude => (Atmosphere, cont("rad", 1e-3, 1e-5), false),
161        WindSpeed => (Wind, cont("m/s", 1e-3, 0.05), false),
162        WindDirection => (Wind, cont("rad", 1e-3, 1e-4), false),
163        WindVertical => (Wind, cont("m/s", 1e-3, 0.05), false),
164        TargetDistance => (ShotGeometry, cont("m", 1e-3, 0.5), false),
165        ShootingAngle => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
166        Cant => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
167        ShotAzimuth => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
168        AimAzimuth => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
169        TargetHeight => (ShotGeometry, cont("m", 1e-3, 1e-3), false),
170        MagnusEnabled => (Effects, AxisKind::Categorical, false),
171        CoriolisEnabled => (Effects, AxisKind::Categorical, false),
172        EnhancedSpinDriftEnabled => (Effects, AxisKind::Categorical, false),
173    };
174    AxisMeta {
175        group,
176        kind,
177        requires_rezero,
178    }
179}
180
181pub fn axes_in_group(group: InputGroup) -> &'static [InputAxis] {
182    use InputAxis::*;
183    match group {
184        // explain.rs's ProjectileDrag swap also carries the drag description that is NOT an
185        // axis here: projectile.drag_table, projectile.bc_segments and
186        // corrections.bc5d_table_path travel with this group as one unit (MBA-1603; see that
187        // module's doc). They are deliberately not axes, so error-budget and tolerance never
188        // perturb them, except that BallisticCoefficient moves a one-band ladder's band with
189        // the scalar, which it must equal (`with_axis` in access.rs).
190        InputGroup::ProjectileDrag => &[
191            Mass,
192            Diameter,
193            Length,
194            BallisticCoefficient,
195            TwistRate,
196            TwistDirection,
197            DragModel,
198        ],
199        InputGroup::MuzzleVelocity => &[MuzzleVelocityMps],
200        // MuzzleAngle is listed FIRST, unlike every other group's more arbitrary order (0.33.0
201        // decision-support Task 9, MBA-1345 review C1). On a RESOLVED request, muzzle_angle_rad
202        // is not an independent input whenever zero_distance_m is also present -- it is the
203        // ALREADY-SEARCHED elevation, a function of muzzle velocity and atmosphere as much as of
204        // anything in this group. A caller that applies a group's axes in turn and re-resolves
205        // between writes (explain.rs's swap_group/plan_exclusions) needs a LATER
206        // requires_rezero axis's own re-zero to overwrite whatever MuzzleAngle wrote, with a
207        // freshly re-derived, destination-consistent angle -- not the source's baked-in one.
208        // Putting MuzzleAngle first guarantees SightHeight (always present, requires_rezero,
209        // listed second) does exactly that. For an angle-only request (zero_distance_m absent
210        // on the destination), nothing later clears it, so MuzzleAngle's own write correctly
211        // stands as the swapped input in that case -- see explain.rs's module doc for the full
212        // account of why this needed fixing and how it was verified.
213        InputGroup::ZeroSightGeometry => &[
214            MuzzleAngle,
215            SightHeight,
216            ZeroDistance,
217            ZeroPoiUp,
218            ZeroPoiRight,
219            SightOffsetLateral,
220            MuzzleHeight,
221        ],
222        InputGroup::Atmosphere => &[Altitude, Temperature, Pressure, RelativeHumidity, Latitude],
223        InputGroup::Wind => &[WindSpeed, WindDirection, WindVertical],
224        InputGroup::ShotGeometry => &[
225            TargetDistance,
226            ShootingAngle,
227            Cant,
228            ShotAzimuth,
229            AimAzimuth,
230            TargetHeight,
231        ],
232        InputGroup::Effects => &[MagnusEnabled, CoriolisEnabled, EnhancedSpinDriftEnabled],
233    }
234}
235
236// KNOWN LIMITATIONS: Several axis/mode combinations require guards in a later task (not this one —
237// this task is data only):
238//
239// (a) Altitude when the original request used QNH pressure: the rebuilt request carries an
240//     absolute station pressure, so perturbing altitude changes density-by-altitude but NOT
241//     station pressure, which is the opposite of what a QNH-entering user means.
242//
243// (b) ShotAzimuth when the original request used compass-referenced wind: the rebuilt request
244//     carries shooter-relative wind, so the wind rotates WITH the rifle instead of staying
245//     earth-fixed — the counterfactual is physically inverted.
246//
247// (c) WindSpeed/WindDirection/WindVertical when the original request used segmented wind: there
248//     is no single scalar to read or perturb. These axes return None from read_axis under
249//     segmented wind and the kernel treats them as absent.
250//
251// (d) Magnus + EnhancedSpinDrift together: validate_effects (src/solve_json.rs:1544-1552)
252//     rejects magnus: true + enhanced_spin_drift: true as ConflictingFields. A future with_axis
253//     that flips one to true while the other is already true produces a request that fails
254//     validation. This cross-category constraint cannot be expressed purely in the taxonomy.
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    /// Every axis belongs to exactly one group, and every group lists it.
261    /// Set equality: InputAxis::ALL == union of axes_in_group over all groups.
262    #[test]
263    fn taxonomy_is_a_partition() {
264        let mut seen = Vec::new();
265        for g in InputGroup::ALL {
266            for a in axes_in_group(*g) {
267                assert_eq!(
268                    axis_meta(*a).group,
269                    *g,
270                    "{a:?} listed under the wrong group"
271                );
272                assert!(!seen.contains(a), "{a:?} appears in more than one group");
273                seen.push(*a);
274            }
275        }
276        // Set equality: every axis in ALL is listed exactly once, and every listed axis is in ALL.
277        assert_eq!(
278            seen.len(),
279            InputAxis::ALL.len(),
280            "axes_in_group() covers {} axes; InputAxis::ALL has {}",
281            seen.len(),
282            InputAxis::ALL.len()
283        );
284        for axis in InputAxis::ALL {
285            assert!(
286                seen.contains(axis),
287                "{axis:?} is in InputAxis::ALL but not in any group"
288            );
289        }
290        // Also verify axis_meta is callable for every member of ALL.
291        for axis in InputAxis::ALL {
292            let _ = axis_meta(*axis);
293        }
294    }
295
296    /// Effects are boolean toggles and must never be differentiated (spec D7).
297    #[test]
298    fn effects_are_categorical() {
299        for a in axes_in_group(InputGroup::Effects) {
300            assert!(
301                matches!(axis_meta(*a).kind, AxisKind::Categorical),
302                "{a:?} must be Categorical"
303            );
304        }
305    }
306
307    /// Cost control: only zero-affecting axes force a re-zero.
308    #[test]
309    fn only_zero_affecting_axes_require_rezero() {
310        assert!(axis_meta(InputAxis::SightHeight).requires_rezero);
311        assert!(axis_meta(InputAxis::ZeroDistance).requires_rezero);
312        assert!(!axis_meta(InputAxis::WindSpeed).requires_rezero);
313        assert!(!axis_meta(InputAxis::TargetDistance).requires_rezero);
314    }
315}