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