Skip to main content

ballistics_engine/
wez.rs

1//! WEZ (Weapon Employment Zone) sweep core -- MBA-1317, extracted MBA-1343 Phase B.
2//!
3//! `monte-carlo --wez` reports hit probability vs range for a fixed target size, treating the
4//! shooter's wind-CALL error (how well they estimate the current wind) as a source of dispersion
5//! distinct from the ballistic --wind-std (gust-to-gust physical variability). See the "WEZ" doc
6//! section in CLI_USAGE.md for a worked example.
7//!
8//! Extracted from the CLI binary so non-CLI front ends (e.g. the WASM terminal) can reuse the
9//! exact compute path. All rendering (summary table / statistics CSV / full JSON) stays with the
10//! front ends; this module goes as far as building a [`WezResult`].
11
12use std::error::Error;
13
14use nalgebra::Vector3;
15use serde::Serialize;
16
17use crate::cli_api::UnitSystem;
18use crate::drag::DragTable;
19use crate::perturbation::{central_difference, InputAxis, KernelError};
20use crate::solve_json::{
21    DragModelV1, ResolvedAtmosphereV1, ResolvedConstantWindV1, ResolvedEffectsV1,
22    ResolvedProjectileV1, ResolvedRifleV1, ResolvedSamplingV1, ResolvedShotV1,
23    ResolvedSolveRequestV1, ResolvedSolverV1, ResolvedWindV1, SchemaVersionV1, SolverMethodV1,
24    TwistDirectionV1,
25};
26use crate::{
27    AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, MonteCarloParams,
28    MonteCarloResults, TrajectorySolver, WindConditions,
29};
30
31/// A parsed `--target-size` value, still in the CLI's chosen unit (inches imperial / cm
32/// metric) -- call [`TargetSize::to_metric`] before using it.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub enum TargetSize {
35    /// Full width (lateral) x height (vertical), e.g. an 18"x30" plate.
36    Rect { width: f64, height: f64 },
37    /// A circular radius, matching `--target-radius`'s existing hit semantics but expressed in
38    /// target-size units instead of range units.
39    Radius(f64),
40}
41
42/// Parse a `--target-size` argument: `WIDTHxHEIGHT` (e.g. `18x30`) for a rectangle, or a bare
43/// number (e.g. `12`) for a circular radius fallback. Case-insensitive on the `x` separator.
44pub fn parse_target_size(spec: &str) -> Result<TargetSize, String> {
45    let trimmed = spec.trim();
46    if trimmed.is_empty() {
47        return Err("expected a size like \"18x30\" or a single radius like \"12\"".to_string());
48    }
49
50    let x_positions: Vec<usize> = trimmed
51        .char_indices()
52        .filter(|(_, c)| *c == 'x' || *c == 'X')
53        .map(|(i, _)| i)
54        .collect();
55
56    match x_positions.len() {
57        0 => {
58            let radius: f64 = trimmed
59                .parse()
60                .map_err(|_| format!("\"{trimmed}\" is not a number or a WIDTHxHEIGHT pair"))?;
61            if !(radius.is_finite() && radius > 0.0) {
62                return Err(format!(
63                    "radius must be a positive, finite number, got {radius}"
64                ));
65            }
66            Ok(TargetSize::Radius(radius))
67        }
68        1 => {
69            let idx = x_positions[0];
70            let width_str = &trimmed[..idx];
71            let height_str = &trimmed[idx + 1..];
72            let width: f64 = width_str
73                .trim()
74                .parse()
75                .map_err(|_| format!("\"{}\" is not a valid width", width_str.trim()))?;
76            let height: f64 = height_str
77                .trim()
78                .parse()
79                .map_err(|_| format!("\"{}\" is not a valid height", height_str.trim()))?;
80            if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
81                return Err(format!(
82                    "width and height must be positive, finite numbers, got {width}x{height}"
83                ));
84            }
85            Ok(TargetSize::Rect { width, height })
86        }
87        _ => Err(format!(
88            "\"{trimmed}\" has more than one 'x' separator; expected WIDTHxHEIGHT or a single radius"
89        )),
90    }
91}
92
93/// A [`TargetSize`] converted to meters, ready for [`MonteCarloResults`]'s
94/// hit-probability methods.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub enum TargetSizeMetric {
97    Rect { width_m: f64, height_m: f64 },
98    Radius { radius_m: f64 },
99}
100
101/// WEZ target-size length (MBA-1317): inches under imperial, CENTIMETERS (not mm -- target
102/// sizes like an 18"x30" plate are naturally cm-scale under metric) under metric.
103fn target_size_to_metric(val: f64, units: UnitSystem) -> f64 {
104    match units {
105        UnitSystem::Metric => val * 0.01,     // cm to meters
106        UnitSystem::Imperial => val * 0.0254, // inches to meters
107    }
108}
109
110impl TargetSize {
111    pub fn to_metric(self, units: UnitSystem) -> TargetSizeMetric {
112        match self {
113            TargetSize::Rect { width, height } => TargetSizeMetric::Rect {
114                width_m: target_size_to_metric(width, units),
115                height_m: target_size_to_metric(height, units),
116            },
117            TargetSize::Radius(radius) => TargetSizeMetric::Radius {
118                radius_m: target_size_to_metric(radius, units),
119            },
120        }
121    }
122}
123
124/// Which WEZ variance-attribution bucket a miss-variance source belongs to.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum WezErrorBucket {
128    /// The shooter's wind-call error (`--wind-call-error`).
129    WindCall,
130    /// Muzzle-velocity standard deviation (`--velocity-std`).
131    MvSd,
132    /// Everything else: mechanical/ammo group dispersion (angle, azimuth, BC) plus the
133    /// *ballistic* (non-call) share of wind uncertainty (`--wind-std`, `--wind-direction-std`).
134    Other,
135}
136
137impl std::fmt::Display for WezErrorBucket {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        // MBA-1337 w2: one spelling everywhere. These must match the serde
140        // rename_all = "snake_case" values the -o full JSON contract shipped with
141        // (0.25.0), so summary/CSV/JSON all agree on the same strings.
142        let label = match self {
143            WezErrorBucket::WindCall => "wind_call",
144            WezErrorBucket::MvSd => "mv_sd",
145            WezErrorBucket::Other => "other",
146        };
147        write!(f, "{label}")
148    }
149}
150
151/// Per-range WEZ miss-variance attribution shares. `wind_call + mv_sd + other` sums to ~1.0
152/// whenever at least one modeled source has nonzero uncertainty; all fields are exactly 0.0 in
153/// the fully deterministic (zero-uncertainty) case, where there is no dominant source.
154#[derive(Debug, Clone, Copy, Default, Serialize)]
155struct WezVarianceShares {
156    wind_call: f64,
157    mv_sd: f64,
158    other: f64,
159}
160
161impl WezVarianceShares {
162    /// The largest nonzero share, or `None` if every share is zero (nothing to attribute).
163    fn dominant(&self) -> Option<WezErrorBucket> {
164        [
165            (WezErrorBucket::WindCall, self.wind_call),
166            (WezErrorBucket::MvSd, self.mv_sd),
167            (WezErrorBucket::Other, self.other),
168        ]
169        .into_iter()
170        .filter(|(_, share)| *share > 0.0)
171        .max_by(|a, b| a.1.total_cmp(&b.1))
172        .map(|(bucket, _)| bucket)
173    }
174}
175
176/// Solve a single deterministic trajectory and return its target-plane impact position.
177///
178/// The caller is responsible for setting `inputs.muzzle_velocity` to whatever value it wants
179/// solved (e.g. baseline + one sigma for the MV-SD sensitivity). The real Monte Carlo sampler
180/// instead applies a sampled velocity *delta* after `TrajectorySolver::new` resolves any
181/// powder-temperature curve (MBA-1176), because `TrajectorySolver` doesn't expose that resolved
182/// value to callers outside `cli_api`. That distinction is a no-op here: `monte-carlo` (and so
183/// `--wez`) never sets `powder_temp_curve` on its `BallisticInputs`, so there is no curve for
184/// `TrajectorySolver::new` to resolve and a plain pre-construction velocity assignment is
185/// equivalent to the sampler's post-construction delta.
186///
187/// Propagates `solve()`'s error instead of unwrapping it: neither the baseline solve (whose
188/// inputs are the CLI's raw, not-yet-validated `-v`/`-a`/etc. values -- clap's own range checks
189/// permit e.g. `-v 0`, which `solve()` rejects) nor a perturbed one-sigma solve (MV/BC/angle/wind
190/// nudged off a valid baseline) is guaranteed to stay within `solve()`'s validity gate (MBA-1317).
191fn wez_solve_target_plane(
192    inputs: BallisticInputs,
193    wind: WindConditions,
194    atmosphere: AtmosphericConditions,
195    solver_max_range: f64,
196    target_distance_m: f64,
197) -> Result<Vector3<f64>, BallisticsError> {
198    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
199    solver.set_max_range(solver_max_range);
200    let result = solver.solve()?;
201    // A successful `solve()` always produces a non-empty trajectory (every solve path errors
202    // out on an empty point list before returning `Ok`), so `position_at_range` -- which only
203    // returns `None` for an empty trajectory, clamping to the last point otherwise -- cannot
204    // fail here. See cli_api::TrajectoryResult::position_at_range.
205    Ok(result
206        .position_at_range(target_distance_m)
207        .expect("WEZ attribution solve: non-empty trajectory always has a last point"))
208}
209
210/// Map every built-in engine `DragModel` to its solve-json v1 counterpart.
211///
212/// The exhaustive match deliberately has no wildcard arm: a future engine model must gain an
213/// explicit solve-json representation before WEZ attribution can compile, rather than silently
214/// differentiating under the wrong drag physics.
215fn wez_kernel_drag_model(model: DragModel) -> DragModelV1 {
216    match model {
217        DragModel::G1 => DragModelV1::G1,
218        DragModel::G2 => DragModelV1::G2,
219        DragModel::G5 => DragModelV1::G5,
220        DragModel::G6 => DragModelV1::G6,
221        DragModel::G7 => DragModelV1::G7,
222        DragModel::G8 => DragModelV1::G8,
223        DragModel::GI => DragModelV1::GI,
224        DragModel::GS => DragModelV1::GS,
225        DragModel::RA4 => DragModelV1::RA4,
226    }
227}
228
229/// Build the [`ResolvedSolveRequestV1`] the shared perturbation kernel needs to attribute
230/// variance for one WEZ range step (0.33.0 decision-support D2), mirroring `base_inputs`/
231/// `base_wind`'s already-fully-resolved values field for field -- same units, same defaults --
232/// rather than re-deriving them through solve-json's own defaulting a second time. Every field
233/// `compute_wez` does not expose as a sweep parameter is left at exactly the value
234/// `BallisticInputs::default()`/`WindConditions::default()` already carry, which is also the
235/// value `solve_v1::prepare_request`'s own hardcoded mappings for engine-only fields (powder
236/// sensitivity, tipoff, wind shear, pitch damping, ...) always resolve to regardless of the
237/// request -- so the two paths agree everywhere the kernel has no way to differ, and this
238/// function only needs to carry the fields WEZ actually threads through.
239///
240/// `solver_max_range` is threaded through separately rather than read off `base_inputs` because
241/// it is not a `BallisticInputs` field at all: `compute_wez` derives it per range step
242/// (`range_m.max(1000.0) * 2.0`) and applies it via `TrajectorySolver::set_max_range`, so the
243/// resolved request's `shot.max_range_m` must match that same per-step value for the kernel's
244/// perturbed solves to see the identical trajectory truncation the row's own baseline solve
245/// used.
246///
247/// Returns `None` only for a loaded custom drag table (`base_inputs.custom_drag_table`), which
248/// replaces G-model+BC drag entirely and has no solve-json v1 field. Differentiating under a
249/// built-in fallback model would silently misattribute variance, so custom-deck attribution
250/// remains explicitly unavailable. Every built-in [`DragModel`] is represented exactly by
251/// [`wez_kernel_drag_model`].
252fn wez_resolved_request(
253    base_inputs: &BallisticInputs,
254    base_wind: &WindConditions,
255    solver_max_range: f64,
256) -> Option<ResolvedSolveRequestV1> {
257    if base_inputs.custom_drag_table.is_some() {
258        return None;
259    }
260    let drag_model = wez_kernel_drag_model(base_inputs.bc_type);
261
262    Some(ResolvedSolveRequestV1 {
263        schema_version: SchemaVersionV1,
264        projectile: ResolvedProjectileV1 {
265            mass_kg: base_inputs.bullet_mass,
266            diameter_m: base_inputs.bullet_diameter,
267            length_m: Some(base_inputs.bullet_length),
268            drag_model,
269            ballistic_coefficient: base_inputs.bc_value,
270        },
271        rifle: ResolvedRifleV1 {
272            muzzle_velocity_mps: base_inputs.muzzle_velocity,
273            sight_height_m: base_inputs.sight_height,
274            muzzle_height_m: base_inputs.muzzle_height,
275            // BallisticInputs stores twist rate in inches/turn; solve-json v1 is SI.
276            twist_rate_m_per_turn: base_inputs.twist_rate * 0.0254,
277            twist_direction: if base_inputs.is_twist_right {
278                TwistDirectionV1::Right
279            } else {
280                TwistDirectionV1::Left
281            },
282            sight_offset_lateral_m: Some(base_inputs.sight_offset_lateral_m),
283        },
284        shot: ResolvedShotV1 {
285            max_range_m: solver_max_range,
286            // compute_wez always supplies an explicit muzzle angle directly and never zeroes
287            // (see its own doc comment) -- no re-zero search may run on any perturbed solve.
288            zero_distance_m: None,
289            muzzle_angle_rad: base_inputs.muzzle_angle,
290            aim_azimuth_rad: base_inputs.azimuth_angle,
291            shot_azimuth_rad: base_inputs.shot_azimuth,
292            shooting_angle_rad: base_inputs.shooting_angle,
293            cant_angle_rad: base_inputs.cant_angle,
294            target_height_m: base_inputs.target_height,
295            ground_threshold_m: base_inputs.ground_threshold,
296            zero_poi_up_m: Some(base_inputs.zero_poi_vertical_m),
297            zero_poi_right_m: Some(base_inputs.zero_poi_horizontal_m),
298            drops_reference: None,
299        },
300        atmosphere: ResolvedAtmosphereV1 {
301            altitude_m: base_inputs.altitude,
302            temperature_k: base_inputs.temperature + 273.15,
303            pressure_pa: base_inputs.pressure * 100.0,
304            relative_humidity: base_inputs.humidity,
305            latitude_rad: base_inputs.latitude.map(f64::to_radians),
306            pressure_reference: None,
307        },
308        wind: ResolvedWindV1::Constant(ResolvedConstantWindV1 {
309            speed_mps: base_wind.speed,
310            direction_from_rad: base_wind.direction,
311            vertical_speed_mps: base_wind.vertical_speed,
312            wind_reference: None,
313        }),
314        solver: ResolvedSolverV1 {
315            // BallisticInputs::default() runs adaptive RK45 (use_rk4 && use_adaptive_rk45,
316            // both true, and compute_wez never overrides either), so the kernel's perturbed
317            // solves must use the same method to match the row's own baseline solve.
318            method: SolverMethodV1::Rk45,
319            time_step_s: 0.001, // ignored by RK45; any positive finite value is inert here.
320        },
321        effects: ResolvedEffectsV1 {
322            magnus: false,
323            coriolis: false,
324            enhanced_spin_drift: false,
325            // WEZ rows are rebuilt from BallisticInputs, which the WEZ path never configures
326            // for shear; `None` here keeps the rebuilt request asking for none, matching the
327            // baseline solve it is perturbing around.
328            wind_shear_model: None,
329        },
330        // Unused by `perturbation::evaluate` (it queries a specific range off the solved
331        // trajectory directly, never the regular sampling grid); any positive value is inert.
332        sampling: ResolvedSamplingV1 { interval_m: 10.0 },
333        reticle: None,
334        // WEZ rows are rebuilt from BallisticInputs, which carry no table path — the same
335        // pre-existing "no velocity-keyed schedule in the WEZ kernel" limitation as
336        // use_bc_segments itself (scalar bc_value only).
337        corrections: None,
338    })
339}
340
341/// Central-difference WEZ miss-variance attribution at a single range (0.33.0 decision-support
342/// D2), replacing the retired `wez_source_variance`'s one-sided, one-sigma solves with the
343/// shared perturbation kernel's central differences -- see the module doc's opening paragraph
344/// for why the two numerical methods needed to converge onto one.
345///
346/// For each source with standard deviation `sigma` and kernel axis `axis`, the squared
347/// one-sigma displacement is `(d.d_drop_d_x * sigma)^2 + (d.d_windage_d_x * sigma)^2`, where `d`
348/// is [`central_difference`]`(base_resolved, axis, &[target_distance_m], None)`'s single
349/// [`crate::perturbation::Derivative`] -- the same delta-method approximation
350/// `crate::error_budget` uses, applied here to WEZ's three collapsed buckets instead of
351/// `error_budget`'s per-source ranking. Treating the sources as independent gives
352/// `Var(total) ~= sum_i displacement_i^2`, exactly as the retired one-sided version did; only
353/// how each `displacement_i` is measured has changed. A source whose `sigma` is non-positive or
354/// NaN short-circuits to a `0.0` contribution WITHOUT calling the kernel at all (mirroring
355/// `wez_source_variance`'s own guard), so a genuinely-disabled source costs nothing rather than
356/// one wasted pair of solves.
357///
358/// Bucket assignment is unchanged from the retired one-sided version: `MvSd` <- muzzle velocity
359/// ([`InputAxis::MuzzleVelocityMps`]); `WindCall` <- the wind-call error channel
360/// ([`InputAxis::WindSpeed`], `wind_call_error_std_dev`); `Other` <- the accumulated sum of
361/// muzzle angle ([`InputAxis::MuzzleAngle`]), ballistic coefficient
362/// ([`InputAxis::BallisticCoefficient`]), aim azimuth ([`InputAxis::AimAzimuth`] -- WEZ's
363/// `azimuth_angle` is the small horizontal AIMING offset, not the compass-referenced
364/// `InputAxis::ShotAzimuth` Coriolis uses), wind direction ([`InputAxis::WindDirection`]), and
365/// the ballistic (non-call) share of wind speed ([`InputAxis::WindSpeed`] again,
366/// `wind_speed_std_dev`).
367///
368/// `WindSpeed` is the ONE axis two different sources share (`wind_speed_std_dev` into `Other`,
369/// `wind_call_error_std_dev` into `WindCall`), and unlike every other pair of sources here they
370/// are not independently measured: the SAME [`Derivative`](crate::perturbation::Derivative) is
371/// computed at most once (see the code below) and both displacements are scaled off it. A real,
372/// worth-stating consequence (review finding, not part of the original D2 design intent): the
373/// wind-call share and the ballistic-wind PORTION of `Other` are now in an EXACT
374/// `(wind_call_error_std_dev / wind_speed_std_dev)^2` ratio whenever both are positive --
375/// `displacement_call^2 / displacement_wind^2 = (d * sigma_call)^2 / (d * sigma_wind)^2 =
376/// (sigma_call / sigma_wind)^2`, since the shared `d` cancels out of the ratio entirely, leaving
377/// pure sigma-squared algebra with no ballistic response left in it. The retired one-sided
378/// version did NOT have this property: it perturbed `wind.speed` twice, independently, at two
379/// different absolute deltas (`+sigma_call` and `+sigma_wind`), so its two displacements were
380/// two genuinely different finite differences, only APPROXIMATELY proportional to
381/// `sigma^2` in the locally-linear regime -- see
382/// `wind_call_and_ballistic_wind_shares_are_exactly_proportional_to_sigma_squared` in this
383/// module's tests.
384///
385/// Returns `Ok(None)` when ANY of the seven sources hits a structural kernel refusal --
386/// [`KernelError::AxisUnsupportedForRequest`], [`KernelError::AxisAbsent`],
387/// [`KernelError::CategoricalAxis`], or [`KernelError::StepOutOfDomain`], classified via
388/// `crate::error_budget::unavailable_reason` (the same four-way split `error_budget` and
389/// `crate::tolerance::tolerance_envelope` already share) -- rather than silently normalizing
390/// over whichever sources DID evaluate, which would misattribute the missing source's variance
391/// to the other two buckets with no visible sign anything was skipped. `compute_wez` maps this
392/// `None` onto [`WezRow::attribution_unavailable`], its existing "nothing to attribute here"
393/// contract -- the same flag already used when the baseline does not reach this range. In
394/// practice none of the seven axes above carries an `AxisUnsupportedForRequest` guard (those
395/// are `Altitude` under QNH pressure and `ShotAzimuth` under compass wind; [`wez_resolved_request`]
396/// never sets either reference mode) and the resolved wind is always `Constant` (never
397/// `AxisAbsent`'s segmented-wind trigger), so this path is defensive rather than routinely hit --
398/// see the task report for the specific fixtures that DO reach it. Short-circuits on the FIRST
399/// such refusal rather than evaluating the remaining sources, since the row's attribution is
400/// already going to be reported unavailable either way.
401///
402/// # Errors
403/// Propagates any other [`KernelError`] (`Solve`, `Observation`, `TypeMismatch`, `NonFinite`) --
404/// a genuine solver or trajectory failure, not a normal "this input cannot be perturbed here"
405/// fact -- exactly as a failed perturbed solve propagated out of the retired one-sided
406/// `wez_source_variance` (MBA-1317).
407#[allow(
408    clippy::too_many_arguments,
409    reason = "flat sigma list mirrors the Monte Carlo sampler's own parameter set (MBA-1317)"
410)]
411fn wez_variance_shares(
412    base_resolved: &ResolvedSolveRequestV1,
413    target_distance_m: f64,
414    velocity_std_dev: f64,
415    angle_std_dev_rad: f64,
416    bc_std_dev: f64,
417    azimuth_std_dev_rad: f64,
418    wind_speed_std_dev: f64,
419    wind_call_error_std_dev: f64,
420    wind_direction_std_dev_rad: f64,
421) -> Result<Option<WezVarianceShares>, KernelError> {
422    // One source's squared one-sigma displacement from an already-computed derivative, or
423    // `0.0` when `sigma` is non-positive or NaN (that source is disabled) -- mirrors
424    // `wez_source_variance`'s own guard, without needing the kernel call that produced `d` to
425    // know anything about `sigma`.
426    let displacement_sq = |d: &crate::perturbation::Derivative, sigma: f64| -> f64 {
427        if sigma.is_nan() || sigma <= 0.0 {
428            0.0
429        } else {
430            (d.d_drop_d_x * sigma).powi(2) + (d.d_windage_d_x * sigma).powi(2)
431        }
432    };
433
434    // One axis's central-difference derivative, independent of any particular source's sigma.
435    // `Ok(None)` signals a structural refusal the caller must treat as attribution-unavailable
436    // rather than a genuine error (see the doc comment above).
437    let derivative_for =
438        |axis: InputAxis| -> Result<Option<crate::perturbation::Derivative>, KernelError> {
439            match central_difference(base_resolved, axis, &[target_distance_m], None) {
440                Ok(d) => Ok(Some(d[0])),
441                Err(e) if crate::error_budget::unavailable_reason(&e).is_some() => Ok(None),
442                Err(e) => Err(e),
443            }
444        };
445
446    // A single source with sigma `sigma` and kernel axis `axis`: `Ok(Some(0.0))` without
447    // calling the kernel at all when `sigma` is non-positive or NaN (that source is disabled),
448    // so a genuinely-disabled source costs nothing rather than one wasted pair of solves.
449    let contribution = |axis: InputAxis, sigma: f64| -> Result<Option<f64>, KernelError> {
450        if sigma.is_nan() || sigma <= 0.0 {
451            return Ok(Some(0.0));
452        }
453        Ok(derivative_for(axis)?.map(|d| displacement_sq(&d, sigma)))
454    };
455
456    // MV SD bucket: muzzle-velocity dispersion.
457    let Some(mv_sd_var) = contribution(InputAxis::MuzzleVelocityMps, velocity_std_dev)? else {
458        return Ok(None);
459    };
460
461    // Other/group bucket: elevation, aim azimuth, and BC dispersion (mechanical/ammo "group").
462    // The ballistic (non-call) share of wind uncertainty is folded in just below, alongside the
463    // WindCall bucket, since both need the SAME WindSpeed derivative (review finding M1).
464    let mut other_var = 0.0;
465    for (axis, sigma) in [
466        (InputAxis::MuzzleAngle, angle_std_dev_rad),
467        (InputAxis::BallisticCoefficient, bc_std_dev),
468        (InputAxis::AimAzimuth, azimuth_std_dev_rad),
469        (InputAxis::WindDirection, wind_direction_std_dev_rad),
470    ] {
471        let Some(v) = contribution(axis, sigma)? else {
472            return Ok(None);
473        };
474        other_var += v;
475    }
476
477    // Wind speed and wind-call bucket: `wind_speed_std_dev` (ballistic, folded into `other_var`
478    // above) and `wind_call_error_std_dev` (the WindCall bucket) perturb the SAME
479    // `InputAxis::WindSpeed` channel -- kept as separate attribution buckets (the shooter's own
480    // wind-call estimation error vs. physical gust-to-gust variability) but sharing one
481    // derivative computed AT MOST ONCE (review finding M1: this used to call the kernel twice
482    // with identical arguments), only when at least one of the two sigmas is actually active.
483    // See the doc comment above for the exact-proportionality consequence this has.
484    let wind_call_var = if (wind_speed_std_dev.is_nan() || wind_speed_std_dev <= 0.0)
485        && (wind_call_error_std_dev.is_nan() || wind_call_error_std_dev <= 0.0)
486    {
487        0.0
488    } else {
489        let Some(d) = derivative_for(InputAxis::WindSpeed)? else {
490            return Ok(None);
491        };
492        other_var += displacement_sq(&d, wind_speed_std_dev);
493        displacement_sq(&d, wind_call_error_std_dev)
494    };
495
496    let total = wind_call_var + mv_sd_var + other_var;
497    if total.is_nan() || total <= 0.0 {
498        return Ok(Some(WezVarianceShares::default()));
499    }
500    Ok(Some(WezVarianceShares {
501        wind_call: wind_call_var / total,
502        mv_sd: mv_sd_var / total,
503        other: other_var / total,
504    }))
505}
506
507/// WEZ hit probability: the fraction of `results`' samples whose ABSOLUTE target-plane position
508/// -- reconstructed as `baseline + (that sample's deviation from baseline)`, since
509/// [`MonteCarloResults::impact_positions`] stores only the deviation -- falls
510/// within `target_size`, centered on the fixed line of sight (`line_of_sight_height_m` vertically,
511/// `z = 0` laterally).
512///
513/// This is deliberately NOT [`MonteCarloResults::hit_probability`] /
514/// `rect_hit_probability`, which measure the miss distance from that SAME range's own baseline
515/// (i.e. assume the shooter re-dials elevation perfectly for every range). A WEZ sweep instead
516/// answers "how far can I hit this target size with ONE hold", so it must also count the
517/// systematic ballistic drop below the fixed line of sight as a source of misses, not just random
518/// dispersion -- see the module doc comment above `compute_wez`.
519///
520/// A sample that never reached the target plane keeps `MonteCarloResults`'s sentinel deviation
521/// (`TARGET_NOT_REACHED_SENTINEL_M`, roughly -1e9 m). Added to any finite baseline that stays a
522/// miss by a vast margin, so it is correctly excluded here without a separate check.
523fn wez_p_hit(
524    results: &MonteCarloResults,
525    baseline: &Vector3<f64>,
526    line_of_sight_height_m: f64,
527    target_size: TargetSizeMetric,
528) -> f64 {
529    if results.impact_positions.is_empty() {
530        return 0.0;
531    }
532    let hits = results
533        .impact_positions
534        .iter()
535        .filter(|deviation| {
536            let absolute_y = baseline.y + deviation.y;
537            let absolute_z = baseline.z + deviation.z;
538            let drop_from_los = absolute_y - line_of_sight_height_m;
539            match target_size {
540                TargetSizeMetric::Rect { width_m, height_m } => {
541                    drop_from_los.abs() <= height_m / 2.0 && absolute_z.abs() <= width_m / 2.0
542                }
543                TargetSizeMetric::Radius { radius_m } => {
544                    (drop_from_los * drop_from_los + absolute_z * absolute_z).sqrt() <= radius_m
545                }
546            }
547        })
548        .count();
549    hits as f64 / results.impact_positions.len() as f64
550}
551
552/// One range step of a WEZ sweep.
553#[derive(Debug, Clone, Serialize)]
554pub struct WezRow {
555    pub range_m: f64,
556    pub p_hit: f64,
557    pub dominant_error_source: Option<WezErrorBucket>,
558    pub wind_call_share: f64,
559    pub mv_sd_share: f64,
560    pub other_share: f64,
561    /// `*_share` and `dominant_error_source` above are not meaningful (left at their
562    /// zero/`None` default) for any of THREE reasons, and this flag does not distinguish them:
563    /// (1) the undispersed baseline trajectory did not reach this range; (2) central-difference
564    /// attribution (0.33.0 decision-support D2) hit a structural kernel refusal on one of its
565    /// seven sources (`crate::perturbation::KernelError::AxisUnsupportedForRequest`/
566    /// `AxisAbsent`/`CategoricalAxis`/`StepOutOfDomain`); or (3) this configuration cannot be
567    /// represented on the shared kernel's solve-json v1 wire contract at all -- currently only
568    /// a loaded custom drag table (see `wez_resolved_request`). Reason (3) is the one most likely
569    /// to surprise a caller: every row of a `--drag-table` sweep reads `n/a` here, which is NOT a
570    /// claim that the bullet fails to reach that range. All nine built-in reference drag models
571    /// retain attribution. `p_hit` is unaffected in every case -- it comes from the
572    /// fully-dispersed Monte Carlo run directly, never from the kernel.
573    pub attribution_unavailable: bool,
574}
575
576#[derive(Debug, Clone, Serialize)]
577pub struct WezTargetSizeJson {
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub width_m: Option<f64>,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub height_m: Option<f64>,
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub radius_m: Option<f64>,
584}
585
586#[derive(Debug, Clone, Serialize)]
587pub struct WezResult {
588    pub target_size: WezTargetSizeJson,
589    pub wind_speed_std_mps: f64,
590    pub wind_call_error_mps: f64,
591    /// `sqrt(wind_speed_std_mps^2 + wind_call_error_mps^2)`: the effective wind-speed standard
592    /// deviation actually fed to the underlying Monte Carlo sampler at each range step.
593    pub combined_wind_speed_std_mps: f64,
594    pub num_sims_per_step: usize,
595    pub rows: Vec<WezRow>,
596}
597
598/// Run a WEZ sweep and return its per-range rows plus the sweep-level parameters as a
599/// [`WezResult`], leaving all rendering (summary table / statistics CSV / full JSON) to the
600/// caller. All inputs are metric (the CLI converts from user units before calling).
601///
602/// # Parameters
603///
604/// NOTE the angle-like parameters (`angle`, `cant`, `wind_direction`, `angle_std`,
605/// `wind_direction_std`) are in DEGREES — this function converts to radians itself,
606/// unlike [`BallisticInputs`]/[`WindConditions`] elsewhere in the crate, which carry
607/// radians. This mirrors the CLI flag set the sweep was extracted from (MBA-1317).
608///
609/// * `velocity` — muzzle velocity, m/s.
610/// * `angle` — launch (elevation) angle held for every sweep step, DEGREES.
611/// * `bc` — ballistic coefficient (dimensionless; referenced to `drag_model`).
612/// * `mass` — bullet mass, kg.
613/// * `diameter` — bullet diameter, m.
614/// * `num_sims` — Monte Carlo samples per range step.
615/// * `velocity_std` — muzzle-velocity standard deviation, m/s.
616/// * `angle_std` — elevation-angle standard deviation, DEGREES (the derived
617///   azimuth dispersion is half of it, matching the base Monte Carlo command).
618/// * `bc_std` — BC standard deviation (dimensionless).
619/// * `wind_std` — ballistic (gust-to-gust) wind-speed standard deviation, m/s.
620/// * `wind_direction_std` — wind-direction standard deviation, DEGREES.
621/// * `wind_speed` — base wind speed, m/s.
622/// * `wind_direction` — base wind direction, DEGREES (wind-FROM: 0 = headwind,
623///   90 = from the right).
624/// * `wind_vertical` — base vertical wind, m/s, positive = updraft.
625/// * `wind_call_error` — the shooter's wind-CALL error, m/s; composed with
626///   `wind_std` in quadrature (see [`WezResult::combined_wind_speed_std_mps`]).
627/// * `target_size` — the target box/radius, already in meters
628///   ([`TargetSize::to_metric`]).
629/// * `wez_start` / `wez_end` / `wez_step` — sweep bounds and step, meters
630///   (`wez_end` inclusive).
631/// * `drag_model` — the G-model `bc` is referenced to ([`DragModel::G1`] /
632///   [`DragModel::G7`]); ignored for drag whenever `custom_drag_table` is set.
633/// * `custom_drag_table` — optional Mach-keyed Cd deck replacing the G-model +
634///   BC drag entirely.
635/// * `cd_scale` — whole-curve multiplier on `custom_drag_table`'s interpolated Cd (MBA-1356);
636///   `1.0` = neutral. Inert when `custom_drag_table` is `None`.
637/// * `cant` — rifle cant, DEGREES, positive = clockwise from the shooter.
638/// * `sight_offset_lateral_m` — lateral sight-to-bore mount offset, METERS, positive =
639///   sight right of bore (MBA-1396); displaces every sample's initial lateral position
640///   exactly like the trajectory command. `0.0` = neutral (byte-identical).
641///
642/// A fixed, distinct seed per range step (`0x57_45_5A_00 ^ step_index`) keeps a sweep
643/// reproducible run-to-run while still drawing independent samples at each range.
644#[allow(
645    clippy::too_many_arguments,
646    reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
647)]
648pub fn compute_wez(
649    velocity: f64,
650    angle: f64,
651    bc: f64,
652    mass: f64,
653    diameter: f64,
654    num_sims: usize,
655    velocity_std: f64,
656    angle_std: f64,
657    bc_std: f64,
658    wind_std: f64,
659    wind_direction_std: f64,
660    wind_speed: f64,
661    wind_direction: f64,
662    wind_vertical: f64,
663    wind_call_error: f64,
664    target_size: TargetSizeMetric,
665    wez_start: f64,
666    wez_end: f64,
667    wez_step: f64,
668    drag_model: DragModel,
669    custom_drag_table: Option<DragTable>,
670    cd_scale: f64,
671    cant: f64,
672    sight_offset_lateral_m: f64,
673) -> Result<WezResult, Box<dyn Error>> {
674    if !(wez_step > 0.0 && wez_step.is_finite()) {
675        return Err("--wez-step must be a positive, finite distance".into());
676    }
677    if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
678        return Err("--wez-end must be finite and >= --wez-start".into());
679    }
680
681    // Same bore-height/ground convention as the base `monte-carlo` command (MBA-967).
682    let bore_height_metric = 1.5_f64;
683    let base_inputs = BallisticInputs {
684        muzzle_velocity: velocity,
685        muzzle_angle: angle.to_radians(),
686        bc_value: bc,
687        bc_type: drag_model,
688        bullet_mass: mass,
689        bullet_diameter: diameter,
690        muzzle_height: bore_height_metric,
691        ground_threshold: 0.0,
692        custom_drag_table,
693        cd_scale,
694        cant_angle: cant.to_radians(),
695        sight_offset_lateral_m,
696        ..Default::default()
697    };
698    let base_wind = WindConditions {
699        speed: wind_speed,
700        direction: wind_direction.to_radians(),
701        vertical_speed: wind_vertical,
702    };
703
704    // The shooter's wind-call error is a dispersion source distinct from the ballistic
705    // (gust-to-gust) wind-speed uncertainty --wind-std already models, but both perturb the same
706    // physical channel (wind speed fed to the solve). As independent random errors they compose
707    // in quadrature -- not by simple addition -- into the effective standard deviation the
708    // underlying Monte Carlo sampler uses.
709    let combined_wind_speed_std = wind_std.hypot(wind_call_error);
710
711    // Matches run_monte_carlo's own convention: horizontal (azimuth) aim dispersion defaults to
712    // half of the vertical (elevation) dispersion.
713    let angle_std_rad = angle_std.to_radians();
714    let azimuth_std_dev = angle_std_rad * 0.5;
715    let wind_direction_std_rad = wind_direction_std.to_radians();
716
717    // The fixed reference the WEZ target box is centered on: the horizontal line of sight,
718    // extended straight (not the curved bullet path). This is what makes the zero-uncertainty
719    // case a genuine step function -- ballistic drop below this fixed line, not just random
720    // dispersion, can carry the bullet outside the box as range grows. It does NOT change per
721    // range step: a WEZ sweep answers "how far can I engage this target size with ONE hold",
722    // the classic point-blank-range question, not "assuming I re-dial for every range".
723    let atmosphere = AtmosphericConditions {
724        temperature: base_inputs.temperature,
725        pressure: base_inputs.pressure,
726        humidity: base_inputs.humidity_percent(),
727        altitude: base_inputs.altitude,
728    };
729    let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
730
731    let mut ranges_m = Vec::new();
732    let mut next = wez_start;
733    // Guard against an unbounded loop from a step so small that floating-point addition never
734    // advances `next` past `wez_end`.
735    for _ in 0..100_000 {
736        if next > wez_end + wez_step * 1e-9 {
737            break;
738        }
739        ranges_m.push(next);
740        next += wez_step;
741    }
742
743    let mut rows = Vec::with_capacity(ranges_m.len());
744    for (step_index, &range_m) in ranges_m.iter().enumerate() {
745        let solver_max_range = range_m.max(1000.0) * 2.0;
746        let baseline = wez_solve_target_plane(
747            base_inputs.clone(),
748            base_wind.clone(),
749            atmosphere.clone(),
750            solver_max_range,
751            range_m,
752        )?;
753        let baseline_reached = baseline.x >= range_m - 1e-6;
754
755        let mc_params = MonteCarloParams {
756            num_simulations: num_sims,
757            velocity_std_dev: velocity_std,
758            angle_std_dev: angle_std_rad,
759            bc_std_dev: bc_std,
760            wind_speed_std_dev: combined_wind_speed_std,
761            target_distance: Some(range_m),
762            base_wind_speed: wind_speed,
763            base_wind_direction: wind_direction.to_radians(),
764            azimuth_std_dev,
765        };
766
767        // A fixed, distinct seed per range step keeps a sweep reproducible run-to-run while
768        // still drawing independent samples at each range.
769        let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
770        let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
771            base_inputs.clone(),
772            base_wind.clone(),
773            mc_params,
774            wind_direction_std_rad,
775            seed,
776        ) {
777            Ok(results) => {
778                wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
779            }
780            // The baseline never reached this range plane at all -> every sample is a definite
781            // miss for it.
782            Err(_) => 0.0,
783        };
784
785        let (shares, attribution_unavailable) = if baseline_reached {
786            match wez_resolved_request(&base_inputs, &base_wind, solver_max_range) {
787                Some(resolved) => match wez_variance_shares(
788                    &resolved,
789                    range_m,
790                    velocity_std,
791                    angle_std_rad,
792                    bc_std,
793                    azimuth_std_dev,
794                    wind_std,
795                    wind_call_error,
796                    wind_direction_std_rad,
797                )? {
798                    Some(shares) => (shares, false),
799                    // A structural kernel refusal (see wez_variance_shares's doc) -- treated
800                    // the same as the baseline-not-reached case just below: nothing to
801                    // attribute, but p_hit is unaffected.
802                    None => (WezVarianceShares::default(), true),
803                },
804                // A custom drag table cannot be represented on the solve-json v1 wire contract
805                // at all -- see wez_resolved_request's doc. Every built-in model is represented.
806                None => (WezVarianceShares::default(), true),
807            }
808        } else {
809            (WezVarianceShares::default(), true)
810        };
811
812        rows.push(WezRow {
813            range_m,
814            p_hit,
815            dominant_error_source: shares.dominant(),
816            wind_call_share: shares.wind_call,
817            mv_sd_share: shares.mv_sd,
818            other_share: shares.other,
819            attribution_unavailable,
820        });
821    }
822
823    Ok(WezResult {
824        target_size: match target_size {
825            TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
826                width_m: Some(width_m),
827                height_m: Some(height_m),
828                radius_m: None,
829            },
830            TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
831                width_m: None,
832                height_m: None,
833                radius_m: Some(radius_m),
834            },
835        },
836        wind_speed_std_mps: wind_std,
837        wind_call_error_mps: wind_call_error,
838        combined_wind_speed_std_mps: combined_wind_speed_std,
839        num_sims_per_step: num_sims,
840        rows,
841    })
842}
843
844#[cfg(test)]
845mod wez_tests {
846    use super::*;
847
848    // A modest .308/168gr load, zeroed at 300 m with a shallow elevation that keeps the
849    // trajectory well above ground for the whole 50-600 m range these tests sweep -- chosen with
850    // `ballistics zero` (see CLI_USAGE.md's WEZ worked example for the imperial equivalent).
851    fn test_base_inputs() -> BallisticInputs {
852        BallisticInputs {
853            muzzle_velocity: 823.0, // ~2700 fps
854            muzzle_angle: 0.001274, // ~0.073 degrees: a 300 m zero for this load
855            bc_value: 0.475,
856            bullet_mass: 0.010_886, // 168 gr
857            bullet_diameter: 0.007_82, // .308 in
858            muzzle_height: 1.5,
859            ground_threshold: 0.0,
860            ..Default::default()
861        }
862    }
863
864    fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
865        AtmosphericConditions {
866            temperature: inputs.temperature,
867            pressure: inputs.pressure,
868            humidity: inputs.humidity_percent(),
869            altitude: inputs.altitude,
870        }
871    }
872
873    #[test]
874    fn every_engine_drag_model_maps_to_its_exact_wire_variant() {
875        for (engine, wire) in [
876            (DragModel::G1, DragModelV1::G1),
877            (DragModel::G2, DragModelV1::G2),
878            (DragModel::G5, DragModelV1::G5),
879            (DragModel::G6, DragModelV1::G6),
880            (DragModel::G7, DragModelV1::G7),
881            (DragModel::G8, DragModelV1::G8),
882            (DragModel::GI, DragModelV1::GI),
883            (DragModel::GS, DragModelV1::GS),
884            (DragModel::RA4, DragModelV1::RA4),
885        ] {
886            assert_eq!(wez_kernel_drag_model(engine), wire);
887        }
888    }
889
890    // ---- parse_target_size --------------------------------------------------------------
891
892    #[test]
893    fn parse_target_size_accepts_a_wxh_rectangle() {
894        assert_eq!(
895            parse_target_size("18x30").unwrap(),
896            TargetSize::Rect {
897                width: 18.0,
898                height: 30.0
899            }
900        );
901        // Case-insensitive separator and surrounding whitespace.
902        assert_eq!(
903            parse_target_size(" 18.5X30.25 ").unwrap(),
904            TargetSize::Rect {
905                width: 18.5,
906                height: 30.25
907            }
908        );
909    }
910
911    #[test]
912    fn parse_target_size_accepts_a_single_radius() {
913        assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
914        assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
915    }
916
917    #[test]
918    fn parse_target_size_rejects_garbage() {
919        for bad in [
920            "",
921            "   ",
922            "abc",
923            "18xthirty",
924            "eighteenx30",
925            "18x30x40",
926            "0",
927            "-5",
928            "18x-5",
929            "18x0",
930            "NaN",
931        ] {
932            assert!(
933                parse_target_size(bad).is_err(),
934                "expected an error for {bad:?}"
935            );
936        }
937    }
938
939    // ---- WEZ hit-probability step function -----------------------------------------------
940
941    #[test]
942    fn zero_uncertainty_is_a_step_function_in_range() {
943        let inputs = test_base_inputs();
944        let wind = WindConditions::default();
945        let atmosphere = test_atmosphere(&inputs);
946        // 18x30 box: 0.4572 m x 0.762 m.
947        let target = TargetSizeMetric::Rect {
948            width_m: 0.4572,
949            height_m: 0.762,
950        };
951        let los_height_m = inputs.muzzle_height + inputs.sight_height;
952
953        let mc_params = MonteCarloParams {
954            num_simulations: 20,
955            velocity_std_dev: 0.0,
956            angle_std_dev: 0.0,
957            bc_std_dev: 0.0,
958            wind_speed_std_dev: 0.0,
959            target_distance: None,
960            base_wind_speed: 0.0,
961            base_wind_direction: 0.0,
962            azimuth_std_dev: 0.0,
963        };
964
965        let mut p_hits = Vec::new();
966        for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
967            let solver_max_range = range_m.max(1000.0) * 2.0;
968            let baseline = wez_solve_target_plane(
969                inputs.clone(),
970                wind.clone(),
971                atmosphere.clone(),
972                solver_max_range,
973                range_m,
974            )
975            .expect("valid test baseline solve");
976            let mut params = mc_params.clone();
977            params.target_distance = Some(range_m);
978            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
979                inputs.clone(),
980                wind.clone(),
981                params,
982                0.0,
983                0xA11CE,
984            )
985            .expect("zero-uncertainty solve");
986            let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
987            // Every one of the (identical, undispersed) samples must agree: exactly a hit or
988            // exactly a miss, never a fractional probability.
989            assert!(
990                p_hit == 0.0 || p_hit == 1.0,
991                "range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
992            );
993            p_hits.push((range_m, p_hit));
994        }
995
996        assert!(
997            p_hits.iter().any(|&(_, p)| p == 1.0),
998            "expected at least one in-box range close to the muzzle: {p_hits:?}"
999        );
1000        assert!(
1001            p_hits.iter().any(|&(_, p)| p == 0.0),
1002            "expected at least one out-of-box range far downrange: {p_hits:?}"
1003        );
1004        // Once it steps down to a miss, a plain (unheld) trajectory that has already passed its
1005        // zero does not come back into a fixed-size box further downrange.
1006        let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
1007        if let Some(idx) = first_miss {
1008            assert!(
1009                p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
1010                "expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
1011            );
1012        }
1013    }
1014
1015    // ---- P(hit) monotonicity with real dispersion -----------------------------------------
1016
1017    #[test]
1018    fn p_hit_is_monotone_non_increasing_with_range() {
1019        let inputs = test_base_inputs();
1020        let wind = WindConditions::default();
1021        let atmosphere = test_atmosphere(&inputs);
1022        let target = TargetSizeMetric::Rect {
1023            width_m: 0.4572,
1024            height_m: 0.762,
1025        };
1026        let los_height_m = inputs.muzzle_height + inputs.sight_height;
1027        let wind_call_error = 1.5_f64; // m/s
1028        let wind_std = 0.5_f64; // m/s
1029        let combined_wind_std = wind_std.hypot(wind_call_error);
1030
1031        let mc_params = MonteCarloParams {
1032            num_simulations: 500, // a fixed seed keeps this run-to-run deterministic
1033            velocity_std_dev: 1.0,
1034            angle_std_dev: 0.001,
1035            bc_std_dev: 0.01,
1036            wind_speed_std_dev: combined_wind_std,
1037            target_distance: None,
1038            base_wind_speed: 0.0,
1039            base_wind_direction: 0.0,
1040            azimuth_std_dev: 0.0005,
1041        };
1042
1043        let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
1044        let mut p_hits = Vec::new();
1045        for (step_index, &range_m) in ranges_m.iter().enumerate() {
1046            let solver_max_range = range_m.max(1000.0) * 2.0;
1047            let baseline = wez_solve_target_plane(
1048                inputs.clone(),
1049                wind.clone(),
1050                atmosphere.clone(),
1051                solver_max_range,
1052                range_m,
1053            )
1054            .expect("valid test baseline solve");
1055            let mut params = mc_params.clone();
1056            params.target_distance = Some(range_m);
1057            let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
1058            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
1059                inputs.clone(),
1060                wind.clone(),
1061                params,
1062                0.0,
1063                seed,
1064            )
1065            .expect("dispersed solve");
1066            p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
1067        }
1068
1069        // A large sample count plus a fixed seed makes this close to the noiseless limit, but a
1070        // finite Monte Carlo estimate can still tick up by a hair at the boundary between two
1071        // adjacent steps -- allow a small generous tolerance rather than asserting exact
1072        // non-increase (MBA-1317 test spec).
1073        let tolerance = 0.03;
1074        for pair in p_hits.windows(2) {
1075            assert!(
1076                pair[1] <= pair[0] + tolerance,
1077                "P(hit) rose more than the allowed jitter: {p_hits:?}"
1078            );
1079        }
1080        // The overall trend across the full sweep must be a clear decline.
1081        assert!(
1082            p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
1083            "expected a clear overall decline across the sweep: {p_hits:?}"
1084        );
1085    }
1086
1087    // ---- Variance-attribution shares --------------------------------------------------------
1088
1089    #[test]
1090    fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
1091        let inputs = test_base_inputs();
1092        let wind = WindConditions::default();
1093        let range_m: f64 = 300.0;
1094        let solver_max_range = range_m.max(1000.0) * 2.0;
1095        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1096            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1097
1098        let shares = wez_variance_shares(
1099            &resolved,
1100            range_m,
1101            /* velocity_std_dev */ 1.0,
1102            /* angle_std_dev_rad */ 0.001,
1103            /* bc_std_dev */ 0.01,
1104            /* azimuth_std_dev_rad */ 0.0005,
1105            /* wind_speed_std_dev */ 0.4,
1106            /* wind_call_error_std_dev */ 1.2,
1107            /* wind_direction_std_dev_rad */ 0.02,
1108        )
1109        .expect("valid test attribution solve")
1110        .expect("attribution must be available for this fixture");
1111
1112        let sum = shares.wind_call + shares.mv_sd + shares.other;
1113        assert!(
1114            (sum - 1.0).abs() < 1e-9,
1115            "shares should sum to ~1.0, got {sum} ({shares:?})"
1116        );
1117        for share in [shares.wind_call, shares.mv_sd, shares.other] {
1118            assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
1119        }
1120        assert!(shares.dominant().is_some());
1121    }
1122
1123    #[test]
1124    fn variance_shares_are_all_zero_with_no_dispersion_sources() {
1125        let inputs = test_base_inputs();
1126        let wind = WindConditions::default();
1127        let range_m: f64 = 300.0;
1128        let solver_max_range = range_m.max(1000.0) * 2.0;
1129        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1130            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1131
1132        let shares = wez_variance_shares(
1133            &resolved, range_m, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1134        )
1135        .expect("valid test attribution solve")
1136        .expect("attribution must be available for this fixture");
1137
1138        assert_eq!(shares.wind_call, 0.0);
1139        assert_eq!(shares.mv_sd, 0.0);
1140        assert_eq!(shares.other, 0.0);
1141        assert!(shares.dominant().is_none());
1142    }
1143
1144    #[test]
1145    fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
1146        let inputs = test_base_inputs();
1147        let wind = WindConditions::default();
1148        let range_m: f64 = 300.0;
1149        let solver_max_range = range_m.max(1000.0) * 2.0;
1150        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1151            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1152
1153        let shares = wez_variance_shares(
1154            &resolved,
1155            range_m,
1156            0.0,
1157            0.0,
1158            0.0,
1159            0.0,
1160            0.0,
1161            /* wind_call_error_std_dev */ 3.0,
1162            0.0,
1163        )
1164        .expect("valid test attribution solve")
1165        .expect("attribution must be available for this fixture");
1166
1167        assert!((shares.wind_call - 1.0).abs() < 1e-9);
1168        assert_eq!(shares.mv_sd, 0.0);
1169        assert_eq!(shares.other, 0.0);
1170        assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
1171    }
1172
1173    /// Review finding M1: `WindSpeed`'s derivative is now computed AT MOST ONCE and reused for
1174    /// both `wind_speed_std_dev` (folded into `Other`) and `wind_call_error_std_dev`
1175    /// (`WindCall`). With every OTHER source's sigma at zero, `other_share` is ENTIRELY the
1176    /// ballistic wind-speed contribution, so the ratio between the two buckets isolates exactly
1177    /// `(wind_call_error_std_dev / wind_speed_std_dev)^2` -- the shared derivative cancels out
1178    /// of the ratio entirely, leaving pure sigma-squared algebra with no ballistic response left
1179    /// in it. This is a real, stated behavior change from the retired one-sided version, which
1180    /// perturbed `wind.speed` independently at two different absolute deltas (`+sigma_call` and
1181    /// `+sigma_wind`) and so was only approximately proportional to `sigma^2` in the
1182    /// locally-linear regime, not exactly.
1183    #[test]
1184    fn wind_call_and_ballistic_wind_shares_are_exactly_proportional_to_sigma_squared() {
1185        let inputs = test_base_inputs();
1186        let wind = WindConditions::default();
1187        let range_m: f64 = 300.0;
1188        let solver_max_range = range_m.max(1000.0) * 2.0;
1189        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1190            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1191
1192        let wind_speed_std_dev = 0.4_f64;
1193        let wind_call_error_std_dev = 1.2_f64;
1194        let shares = wez_variance_shares(
1195            &resolved,
1196            range_m,
1197            0.0,
1198            0.0,
1199            0.0,
1200            0.0,
1201            wind_speed_std_dev,
1202            wind_call_error_std_dev,
1203            0.0,
1204        )
1205        .expect("valid test attribution solve")
1206        .expect("attribution must be available for this fixture");
1207
1208        assert!(shares.other > 0.0, "fixture must produce a nonzero ballistic-wind share");
1209        let expected_ratio = (wind_call_error_std_dev / wind_speed_std_dev).powi(2);
1210        let actual_ratio = shares.wind_call / shares.other;
1211        assert!(
1212            (actual_ratio - expected_ratio).abs() < 1e-9,
1213            "expected wind_call/other == (sigma_call/sigma_wind)^2 == {expected_ratio}, got \
1214             {actual_ratio}"
1215        );
1216    }
1217
1218    /// `wez_resolved_request`'s output must describe the SAME physical trajectory
1219    /// `wez_solve_target_plane` computes directly from `BallisticInputs` -- otherwise
1220    /// central-difference attribution would measure sensitivity against a subtly different
1221    /// configuration than the one WEZ actually simulates for `p_hit`. Independent oracle:
1222    /// `TrajectoryObservation::drop_m` is LOS-perpendicular and positive BELOW the line of
1223    /// sight (`line_of_sight_height_m - position.y`, `trajectory_observation.rs`);
1224    /// `wez_solve_target_plane`'s raw `position.y` is world-frame height, so
1225    /// `line_of_sight_height_m - baseline.y` is the independently-derived equivalent.
1226    /// `windage_m` is `position.z` directly (no sign flip, no scale change), so it compares to
1227    /// `baseline.z` with no transform at all.
1228    ///
1229    /// M3 review fix: `cant_angle`, `sight_offset_lateral_m`, and `azimuth_angle` are
1230    /// deliberately nonzero here (the first two are directly user-settable on `monte-carlo
1231    /// --wez`, `--cant`/`--sight-offset`; `azimuth_angle` is not exposed as a baseline there but
1232    /// `wez_resolved_request` must still map it correctly for any `BallisticInputs`). With all
1233    /// three left at `test_base_inputs()`'s shared 0.0 default, a mis-mapping -- e.g.
1234    /// `cant_angle_rad` accidentally written to `shooting_angle_rad`, a DIFFERENT `ResolvedShotV1`
1235    /// field -- would leave every assertion in this file green anyway, since both fields agree
1236    /// at the neutral value; nonzero values are the only way this oracle can actually catch that
1237    /// class of bug.
1238    #[test]
1239    fn resolved_request_matches_wez_solve_target_plane_baseline() {
1240        let wind = WindConditions::default();
1241        let range_m: f64 = 300.0;
1242        let solver_max_range = range_m.max(1000.0) * 2.0;
1243
1244        for (name, model) in [
1245            ("G1", DragModel::G1),
1246            ("G2", DragModel::G2),
1247            ("G5", DragModel::G5),
1248            ("G6", DragModel::G6),
1249            ("G7", DragModel::G7),
1250            ("G8", DragModel::G8),
1251            ("GI", DragModel::GI),
1252            ("GS", DragModel::GS),
1253            ("RA4", DragModel::RA4),
1254        ] {
1255            let mut inputs = test_base_inputs();
1256            inputs.bc_type = model;
1257            inputs.cant_angle = 5.0_f64.to_radians();
1258            inputs.sight_offset_lateral_m = 0.02;
1259            inputs.azimuth_angle = 0.001;
1260            let atmosphere = test_atmosphere(&inputs);
1261
1262            let baseline = wez_solve_target_plane(
1263                inputs.clone(),
1264                wind.clone(),
1265                atmosphere,
1266                solver_max_range,
1267                range_m,
1268            )
1269            .expect("valid test baseline solve");
1270
1271            let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1272                .expect("every built-in drag model is kernel-representable");
1273            let req: crate::solve_json::SolveRequestV1 = (&resolved).into();
1274            let obs = crate::perturbation::evaluate(&req, &[range_m]).expect("kernel evaluate");
1275            assert_eq!(obs.len(), 1);
1276
1277            let line_of_sight_height_m = inputs.muzzle_height + inputs.sight_height;
1278            let expected_drop_m = line_of_sight_height_m - baseline.y;
1279            assert!(
1280                (obs[0].drop_m - expected_drop_m).abs() < 1e-6,
1281                "{name}: kernel drop_m {} disagrees with wez_solve_target_plane-derived {} -- \
1282                 wez_resolved_request likely mismaps a field",
1283                obs[0].drop_m,
1284                expected_drop_m
1285            );
1286            assert!(
1287                (obs[0].windage_m - baseline.z).abs() < 1e-6,
1288                "{name}: kernel windage_m {} disagrees with wez_solve_target_plane baseline.z \
1289                 {} -- wez_resolved_request likely mismaps a field",
1290                obs[0].windage_m,
1291                baseline.z
1292            );
1293        }
1294    }
1295
1296    fn assert_builtin_drag_model_attribution_available(model: DragModel) {
1297        let result = compute_wez(
1298            823.0,
1299            0.0,
1300            0.243,
1301            0.0113,
1302            0.00782,
1303            20,
1304            5.0,
1305            0.0001,
1306            0.005,
1307            1.0,
1308            0.05,
1309            3.0,
1310            90.0,
1311            0.0,
1312            1.5,
1313            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1314            300.0,
1315            300.0,
1316            100.0,
1317            model,
1318            None,
1319            1.0,
1320            0.0,
1321            0.0,
1322        )
1323        .expect("compute_wez");
1324        let row = result.rows.first().expect("one row");
1325        assert!(
1326            !row.attribution_unavailable,
1327            "{model} has a solve-json v1 counterpart; attribution must run"
1328        );
1329        assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
1330        for (name, share) in [
1331            ("wind_call", row.wind_call_share),
1332            ("mv_sd", row.mv_sd_share),
1333            ("other", row.other_share),
1334        ] {
1335            assert!(share.is_finite(), "{model} {name} share must be finite");
1336            assert!(
1337                (0.0..=1.0).contains(&share),
1338                "{model} {name} share must be within [0, 1], got {share}"
1339            );
1340        }
1341        let sum = row.wind_call_share + row.mv_sd_share + row.other_share;
1342        assert!(
1343            (sum - 1.0).abs() < 1e-9,
1344            "{model} attribution shares must sum to one, got {sum}"
1345        );
1346    }
1347
1348    /// MBA-1442: every built-in model now has an exact solve-json v1 representation, so the
1349    /// shared perturbation kernel can attribute WEZ variance without changing drag physics.
1350    #[test]
1351    fn g2_attribution_is_available() {
1352        assert_builtin_drag_model_attribution_available(DragModel::G2);
1353    }
1354
1355    #[test]
1356    fn g5_attribution_is_available() {
1357        assert_builtin_drag_model_attribution_available(DragModel::G5);
1358    }
1359
1360    #[test]
1361    fn gi_attribution_is_available() {
1362        assert_builtin_drag_model_attribution_available(DragModel::GI);
1363    }
1364
1365    #[test]
1366    fn gs_attribution_is_available() {
1367        assert_builtin_drag_model_attribution_available(DragModel::GS);
1368    }
1369
1370    #[test]
1371    fn ra4_attribution_is_available() {
1372        assert_builtin_drag_model_attribution_available(DragModel::RA4);
1373    }
1374
1375    /// A loaded custom drag table replaces G-model+BC drag entirely, and solve-json v1 has no
1376    /// field for a custom deck at all -- the kernel cannot represent this configuration, so
1377    /// attribution is unavailable rather than silently computed under the wrong (G-model+BC)
1378    /// physics. Reachable via `--drag-table`/`--cd-scale` on `monte-carlo --wez`.
1379    #[test]
1380    fn attribution_unavailable_with_a_custom_drag_table() {
1381        let table = crate::drag::DragTable::new(
1382            vec![0.0, 0.5, 1.0, 1.5, 2.0, 3.0],
1383            vec![0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
1384        );
1385        let result = compute_wez(
1386            823.0,
1387            0.0,
1388            0.243,
1389            0.0113,
1390            0.00782,
1391            20,
1392            5.0,
1393            0.0001,
1394            0.005,
1395            1.0,
1396            0.05,
1397            3.0,
1398            90.0,
1399            0.0,
1400            1.5,
1401            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1402            300.0,
1403            300.0,
1404            100.0,
1405            DragModel::G7,
1406            Some(table),
1407            1.0,
1408            0.0,
1409            0.0,
1410        )
1411        .expect("compute_wez");
1412        let row = result.rows.first().expect("one row");
1413        assert!(
1414            row.attribution_unavailable,
1415            "a custom drag table has no solve-json v1 representation; attribution cannot run"
1416        );
1417        assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
1418    }
1419
1420    /// Characterization: pins the attribution shares for one fixed configuration.
1421    /// D2 changes these values (one-sided -> central differences). When this test
1422    /// fails during the upgrade, record the before/after in the commit message and
1423    /// update the expected constants -- do not silently re-baseline.
1424    ///
1425    /// Deliberately written against the public compute_wez so the refactor of the
1426    /// private attributor cannot change what this test measures.
1427    ///
1428    /// Range deviation from the task-13 brief's literal worked example: the brief's own
1429    /// numbers (angle 0.0, a single row at 600 m) are not reachable -- a perfectly level
1430    /// (angle = 0) shot from a 1.5 m bore height with the default ground_threshold hits the
1431    /// ground at ~365 m (verified via `trajectory -v 823 -a 0 -b 0.243 -m 11.3 -d 7.82
1432    /// --units metric --bore-height 1500 --max-range 700 -o json` => `"max_range":
1433    /// 364.756..."`), so `attribution_unavailable` is `true` at 600 m for TODAY's code too,
1434    /// before any D2 change. 300 m keeps every other parameter from the brief unchanged and
1435    /// sits comfortably inside that ~365 m ceiling.
1436    #[test]
1437    fn attribution_shares_for_a_fixed_configuration() {
1438        let result = compute_wez(
1439            823.0,      // velocity
1440            0.0,        // angle
1441            0.243,      // bc
1442            0.0113,     // mass
1443            0.00782,    // diameter
1444            20,         // num_sims
1445            5.0,        // velocity_std
1446            0.0001,     // angle_std
1447            0.005,      // bc_std
1448            1.0,        // wind_std
1449            0.05,       // wind_direction_std
1450            3.0,        // wind_speed
1451            90.0,       // wind_direction
1452            0.0,        // wind_vertical
1453            1.5,        // wind_call_error
1454            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1455            300.0,      // wez_start (brief specifies 600.0; unreachable at angle=0, see above)
1456            300.0,      // wez_end
1457            100.0,      // wez_step
1458            DragModel::G7,
1459            None,       // custom_drag_table
1460            1.0,        // cd_scale
1461            0.0,        // cant
1462            0.0,        // sight_offset_lateral_m
1463        ).expect("compute_wez");
1464
1465        let row = result.rows.first().expect("one row");
1466        assert!(!row.attribution_unavailable, "attribution must be available here");
1467        eprintln!("CHARACTERIZATION wind_call={} mv_sd={} other={}",
1468                  row.wind_call_share, row.mv_sd_share, row.other_share);
1469        assert!((row.wind_call_share + row.mv_sd_share + row.other_share - 1.0).abs() < 1e-9);
1470
1471        // AFTER (central differences, D2). BEFORE (one-sided, pre-D2) was:
1472        //   wind_call=0.6820206535206624 mv_sd=0.012457193963072112 other=0.30552215251626547
1473        // Measured gap between the two methods on this fixture: wind_call ~4.91e-4,
1474        // mv_sd ~5.12e-4, other ~2.06e-5 (the tightest of the three). The 1e-6 tolerance below
1475        // is ~20x tighter than that smallest gap, so reverting the central-difference calls in
1476        // wez_variance_shares back to one-sided solves fails this test on EVERY bucket, not
1477        // just the two with a larger gap.
1478        assert!((row.wind_call_share - 0.681_529_624_943_378).abs() < 1e-6,
1479                 "update the characterization constant");
1480        assert!((row.mv_sd_share - 0.012_968_843_319_743_649).abs() < 1e-6,
1481                 "update the characterization constant");
1482        assert!((row.other_share - 0.305_501_531_736_878_2).abs() < 1e-6,
1483                 "update the characterization constant");
1484    }
1485}