use serde::{Deserialize, Serialize};
use crate::perturbation::access::KernelError;
use crate::perturbation::derive::{central_difference, DifferenceScheme, Derivative};
use crate::perturbation::evaluate;
use crate::perturbation::taxonomy::InputAxis;
use crate::solve_json::ResolvedSolveRequestV1;
use crate::special::normal_cdf;
use crate::trajectory_observation::TrajectoryObservationError;
use crate::truing_uncertainty::Symmetric2;
pub const ERROR_BUDGET_SCHEMA_VERSION_V1: u32 = 1;
const CHI2_95_2DOF: f64 = 5.991;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Ellipse95V1 {
pub semi_major_m: f64,
pub semi_minor_m: f64,
pub rotation_rad: f64,
pub area_m2: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetGeometryV1 {
Rect { width_m: f64, height_m: f64 },
Circle { radius_m: f64 },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SourceContributionV1 {
pub axis: InputAxis,
pub sigma: f64,
pub d_drop_d_x: f64,
pub d_windage_d_x: f64,
pub scheme: DifferenceScheme,
pub variance_share: f64,
pub ellipse_area_reduction_m2: f64,
pub p_hit_gain_if_perfect: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnavailableReasonCodeV1 {
AxisUnsupportedForRequest,
AxisAbsent,
CategoricalAxis,
StepOutOfDomain,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnavailableSourceV1 {
pub axis: InputAxis,
pub sigma: f64,
pub code: UnavailableReasonCodeV1,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorBudgetRowV1 {
pub range_m: f64,
pub sigma_drop_m: f64,
pub sigma_windage_m: f64,
pub covariance_m2: f64,
pub ellipse_95: Ellipse95V1,
pub p_hit: Option<f64>,
pub sources: Vec<SourceContributionV1>,
pub priority_statement: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorBudgetReportV1 {
pub schema_version: u32,
pub method: String,
pub assumptions: Vec<String>,
pub unavailable_sources: Vec<UnavailableSourceV1>,
pub rows: Vec<ErrorBudgetRowV1>,
}
struct AxisJacobian {
axis: InputAxis,
sigma: f64,
derivatives: Vec<Derivative>,
}
#[derive(Debug, Clone, Copy)]
struct Entry {
axis: InputAxis,
sigma: f64,
scheme: DifferenceScheme,
d_drop_d_x: f64,
d_windage_d_x: f64,
}
pub(crate) fn unavailable_reason(e: &KernelError) -> Option<(UnavailableReasonCodeV1, String)> {
match e {
KernelError::AxisUnsupportedForRequest { reason, .. } => {
Some((UnavailableReasonCodeV1::AxisUnsupportedForRequest, reason.to_string()))
}
KernelError::AxisAbsent(_) => Some((
UnavailableReasonCodeV1::AxisAbsent,
"this axis has no single scalar value on this request (for the three wind axes, \
this means the wind is declared as a segmented profile rather than a constant \
speed/direction)"
.to_string(),
)),
KernelError::CategoricalAxis(_) => Some((
UnavailableReasonCodeV1::CategoricalAxis,
"this axis is categorical (a toggle or an enumerated choice), not a continuous \
quantity, and cannot be assigned a one-sigma uncertainty or differentiated"
.to_string(),
)),
KernelError::StepOutOfDomain { attempted, .. } => Some((
UnavailableReasonCodeV1::StepOutOfDomain,
format!(
"central differencing needs to perturb this axis by {attempted:.6} in both \
directions from its nominal value, using the axis's own default step (this does \
not depend on the declared sigma), and both directions left its physical \
domain; its sensitivity could not be measured at this operating point"
),
)),
KernelError::Solve { .. }
| KernelError::Observation(_)
| KernelError::TypeMismatch(_)
| KernelError::NonFinite(_)
| KernelError::DuplicateAxis(_)
| KernelError::InvalidDomain { .. } => None,
}
}
fn accumulate(entries: &[Entry], exclude: Option<InputAxis>) -> Symmetric2 {
let mut total = Symmetric2::default();
for e in entries {
if Some(e.axis) == exclude {
continue;
}
let s2 = e.sigma * e.sigma;
total.add_assign(Symmetric2 {
a00: e.d_drop_d_x * e.d_drop_d_x * s2,
a01: e.d_drop_d_x * e.d_windage_d_x * s2,
a11: e.d_windage_d_x * e.d_windage_d_x * s2,
});
}
total
}
fn ellipse_95(cov: Symmetric2) -> Ellipse95V1 {
let (largest, smallest) = cov.largest_smallest_eigenvalues();
let semi_major_m = (CHI2_95_2DOF * largest).sqrt();
let semi_minor_m = (CHI2_95_2DOF * smallest).sqrt();
let rotation_rad = 0.5 * (2.0 * cov.a01).atan2(cov.a00 - cov.a11);
Ellipse95V1 {
semi_major_m,
semi_minor_m,
rotation_rad,
area_m2: std::f64::consts::PI * semi_major_m * semi_minor_m,
}
}
fn sort_by_variance_share_desc(sources: &mut [SourceContributionV1]) {
sources.sort_by(|x, y| {
y.variance_share
.total_cmp(&x.variance_share)
.then_with(|| format!("{:?}", x.axis).cmp(&format!("{:?}", y.axis)))
});
}
fn build_priority_statement(
sources: &[SourceContributionV1],
unavailable: &[UnavailableSourceV1],
range_m: f64,
) -> String {
let unavailable_note = || -> String {
if unavailable.is_empty() {
String::new()
} else {
format!(
" {} declared source{} could not be evaluated at all -- see \
unavailable_sources.",
unavailable.len(),
if unavailable.len() == 1 { "" } else { "s" },
)
}
};
match sources.first() {
Some(top) if top.variance_share > 0.0 => {
let caveat = if top.scheme != DifferenceScheme::Central {
" (from a one-sided approximation, not a central difference -- see assumptions)"
} else {
""
};
format!(
"{:?} dominates at {range_m:.0} m ({:.1}% of impact variance){caveat}. \
Measuring it better is the highest-value single improvement here.{}{}",
top.axis,
top.variance_share * 100.0,
gain_divergence_note(sources, top),
unavailable_note(),
)
}
Some(_) => format!(
"No declared source contributes uncertainty at {range_m:.0} m (every evaluated \
sigma is zero).{}",
unavailable_note(),
),
None if !unavailable.is_empty() => format!(
"None of the declared sources could be evaluated at {range_m:.0} m -- see \
unavailable_sources for why."
),
None => format!("No sources were declared for this report at {range_m:.0} m."),
}
}
fn gain_divergence_note(sources: &[SourceContributionV1], top: &SourceContributionV1) -> String {
let Some(gain_leader) = top_by_gain(sources) else {
return String::new(); };
let Some(top_gain) = top.p_hit_gain_if_perfect else {
debug_assert!(
false,
"a target is supplied (top_by_gain found a source with Some gain) so EVERY source, \
including the variance leader, must also carry a Some gain -- see \
error_budget_with_target's own doc comment"
);
return String::new();
};
let gain_leader_gain = gain_leader
.p_hit_gain_if_perfect
.expect("top_by_gain only ever returns a source with a Some gain");
if gain_leader.axis == top.axis || gain_leader_gain <= top_gain {
return String::new();
}
format!(
" However, perfecting {:?} yields the larger hit-probability gain (+{:.1}% vs +{:.1}%); \
if hitting this target is the goal, improve {:?} first.",
gain_leader.axis,
gain_leader_gain * 100.0,
top_gain * 100.0,
gain_leader.axis,
)
}
fn top_by_gain(sources: &[SourceContributionV1]) -> Option<&SourceContributionV1> {
sources
.iter()
.filter(|s| s.p_hit_gain_if_perfect.is_some())
.max_by(|x, y| {
x.p_hit_gain_if_perfect
.expect("filtered to Some above")
.total_cmp(&y.p_hit_gain_if_perfect.expect("filtered to Some above"))
.then_with(|| format!("{:?}", y.axis).cmp(&format!("{:?}", x.axis)))
})
}
const GL20_X: [f64; 10] = [
0.0765265211334973, 0.2277858511416451, 0.3737060887154195, 0.5108670019508271,
0.636_053_680_726_515, 0.7463319064601508, 0.8391169718222188, 0.912_234_428_251_326,
0.9639719272779138, 0.9931285991850949,
];
const GL20_W: [f64; 10] = [
0.1527533871307258, 0.1491729864726037, 0.142_096_109_318_382, 0.1316886384491766,
0.1181945319615184, 0.1019301198172404, 0.0832767415767048, 0.0626720483341091,
0.0406014298003869, 0.0176140071391521,
];
fn gauss_legendre_20(lo: f64, hi: f64, f: impl Fn(f64) -> f64) -> f64 {
if hi <= lo {
return 0.0;
}
let mid = 0.5 * (hi + lo);
let half = 0.5 * (hi - lo);
let mut acc = 0.0;
for k in 0..10 {
for sign in [-1.0f64, 1.0f64] {
let u = mid + sign * half * GL20_X[k];
acc += GL20_W[k] * half * f(u);
}
}
acc
}
fn windage_bounds_at(u: f64, target: TargetGeometryV1) -> (f64, f64) {
match target {
TargetGeometryV1::Rect { width_m, .. } => {
let half_w = width_m.max(0.0) / 2.0;
(-half_w, half_w)
}
TargetGeometryV1::Circle { radius_m } => {
let r = radius_m.max(0.0);
let x = (r * r - u * u).max(0.0).sqrt();
(-x, x)
}
}
}
pub fn p_hit_bivariate(var_drop: f64, var_wind: f64, cov: f64, target: TargetGeometryV1) -> f64 {
let target_has_positive_area = match target {
TargetGeometryV1::Rect { width_m, height_m } => width_m > 0.0 && height_m > 0.0,
TargetGeometryV1::Circle { radius_m } => radius_m > 0.0,
};
if !target_has_positive_area {
return 0.0;
}
let sd = var_drop.max(0.0).sqrt();
let sw = var_wind.max(0.0).sqrt();
if sd <= 0.0 && sw <= 0.0 {
return 1.0;
}
if sd <= 0.0 {
let (a, b) = windage_bounds_at(0.0, target);
return (normal_cdf(b / sw) - normal_cdf(a / sw)).clamp(0.0, 1.0);
}
let rho = if sw > 0.0 { (cov / (sd * sw)).clamp(-0.999_999, 0.999_999) } else { 0.0 };
let cond_sw = sw * (1.0 - rho * rho).max(0.0).sqrt();
let edge = match target {
TargetGeometryV1::Rect { height_m, .. } => height_m.max(0.0) / 2.0,
TargetGeometryV1::Circle { radius_m } => radius_m.max(0.0),
};
let hi = (6.0 * sd).min(edge);
if hi <= 0.0 {
return 0.0;
}
let lo = -hi;
let mut panel_bounds = vec![lo, hi];
if rho.abs() > 1e-9 {
let k = rho * (sw / sd); let c = match target {
TargetGeometryV1::Rect { width_m, .. } => (width_m.max(0.0) / 2.0) / k,
TargetGeometryV1::Circle { radius_m } => radius_m.max(0.0) / (1.0 + k * k).sqrt(),
};
for candidate in [c, -c] {
if candidate.is_finite() && candidate > lo && candidate < hi {
panel_bounds.push(candidate);
}
}
}
panel_bounds.sort_by(f64::total_cmp);
panel_bounds.dedup();
let sqrt_2pi = (2.0 * std::f64::consts::PI).sqrt();
let mut acc = 0.0;
for w in panel_bounds.windows(2) {
acc += gauss_legendre_20(w[0], w[1], |u| {
let density = (-0.5 * (u / sd) * (u / sd)).exp() / (sd * sqrt_2pi);
let (a, b) = windage_bounds_at(u, target);
let mean = rho * (sw / sd) * u;
let p = if cond_sw > 0.0 {
normal_cdf((b - mean) / cond_sw) - normal_cdf((a - mean) / cond_sw)
} else if mean >= a && mean <= b {
1.0
} else {
0.0
};
density * p
});
}
acc.clamp(0.0, 1.0)
}
pub fn error_budget(
base: &ResolvedSolveRequestV1,
sources: &[(InputAxis, f64)],
ranges_m: &[f64],
) -> Result<ErrorBudgetReportV1, KernelError> {
error_budget_with_target(base, sources, ranges_m, None)
}
pub fn error_budget_with_target(
base: &ResolvedSolveRequestV1,
sources: &[(InputAxis, f64)],
ranges_m: &[f64],
target: Option<TargetGeometryV1>,
) -> Result<ErrorBudgetReportV1, KernelError> {
for &range_m in ranges_m {
if !range_m.is_finite() {
return Err(KernelError::Observation(TrajectoryObservationError::NonFiniteQuery {
distance_m: range_m,
}));
}
if range_m < 0.0 || range_m > base.shot.max_range_m {
return Err(KernelError::Observation(TrajectoryObservationError::OutOfRange {
requested_m: range_m,
minimum_m: 0.0,
maximum_m: base.shot.max_range_m,
}));
}
}
evaluate(&base.into(), ranges_m)?;
for (i, &(axis, sigma)) in sources.iter().enumerate() {
if !(sigma.is_finite() && sigma >= 0.0) {
return Err(KernelError::NonFinite(axis));
}
if sources[..i].iter().any(|&(earlier_axis, _)| earlier_axis == axis) {
return Err(KernelError::DuplicateAxis(axis));
}
}
let mut jac: Vec<AxisJacobian> = Vec::with_capacity(sources.len());
let mut unavailable: Vec<UnavailableSourceV1> = Vec::new();
for &(axis, sigma) in sources {
match central_difference(base, axis, ranges_m, None) {
Ok(derivatives) => jac.push(AxisJacobian { axis, sigma, derivatives }),
Err(e) => match unavailable_reason(&e) {
Some((code, reason)) => {
unavailable.push(UnavailableSourceV1 { axis, sigma, code, reason })
}
None => return Err(e),
},
}
}
let mut rows = Vec::with_capacity(ranges_m.len());
for (i, &range_m) in ranges_m.iter().enumerate() {
let entries: Vec<Entry> = jac
.iter()
.map(|j| {
let d = &j.derivatives[i];
debug_assert_eq!(
d.range_m, range_m,
"central_difference's Nth derivative must be tagged with ranges_m's Nth range"
);
Entry {
axis: j.axis,
sigma: j.sigma,
scheme: d.scheme,
d_drop_d_x: d.d_drop_d_x,
d_windage_d_x: d.d_windage_d_x,
}
})
.collect();
let total_cov = accumulate(&entries, None);
let total_var = total_cov.a00 + total_cov.a11;
let full_ellipse = ellipse_95(total_cov);
let p_hit = target.map(|t| p_hit_bivariate(total_cov.a00, total_cov.a11, total_cov.a01, t));
let mut sources_out: Vec<SourceContributionV1> = entries
.iter()
.map(|e| {
let s2 = e.sigma * e.sigma;
let this_var = e.d_drop_d_x * e.d_drop_d_x * s2 + e.d_windage_d_x * e.d_windage_d_x * s2;
let reduced = accumulate(&entries, Some(e.axis));
let reduced_ellipse = ellipse_95(reduced);
let p_hit_gain_if_perfect = target.zip(p_hit).map(|(t, base_p_hit)| {
let raw = p_hit_bivariate(reduced.a00, reduced.a11, reduced.a01, t) - base_p_hit;
debug_assert!(
raw > -2e-3,
"perfecting {:?} at range {range_m} produced a meaningfully negative raw \
p_hit gain ({raw}) before clamping -- perfecting a source shrinks the \
impact covariance in the Loewner order, and shrinking a target-centred \
covariance that way cannot reduce the mass of a symmetric normal over a \
symmetric convex target (Anderson's theorem), so a value this far below \
zero means the quadrature or the excluded-source covariance is wrong, \
not ordinary numerical noise (measured worst-case quadrature error is \
under 1e-3 across a broad stress sweep, and orders of magnitude better \
for realistic target shapes -- see p_hit_bivariate's doc comment; -2e-3 \
keeps roughly 3x headroom over that worst case rather than the 10x+ a \
looser threshold would give, so a systematically wrong excluded-source \
covariance is less likely to hide under the clamp)",
e.axis
);
raw.max(0.0)
});
SourceContributionV1 {
axis: e.axis,
sigma: e.sigma,
d_drop_d_x: e.d_drop_d_x,
d_windage_d_x: e.d_windage_d_x,
scheme: e.scheme,
variance_share: if total_var > 0.0 { this_var / total_var } else { 0.0 },
ellipse_area_reduction_m2: (full_ellipse.area_m2 - reduced_ellipse.area_m2)
.max(0.0),
p_hit_gain_if_perfect,
}
})
.collect();
sort_by_variance_share_desc(&mut sources_out);
let priority_statement = build_priority_statement(&sources_out, &unavailable, range_m);
rows.push(ErrorBudgetRowV1 {
range_m,
sigma_drop_m: total_cov.a00.sqrt(),
sigma_windage_m: total_cov.a11.sqrt(),
covariance_m2: total_cov.a01,
ellipse_95: full_ellipse,
p_hit,
sources: sources_out,
priority_statement,
});
}
let mut method = "central_difference_first_order_propagation".to_string();
let mut assumptions = vec![
"Declared sources are treated as independent; correlations between them are not \
modelled."
.to_string(),
"Propagation is first-order (local linear) about the nominal solution, evaluated by \
central differences through the real solver using each axis's own small default \
step -- independent of the declared sigma, never a step scaled to it. A large \
declared sigma is therefore a linear extrapolation from a slope measured over a \
much smaller window, which is not exact for large or non-Gaussian input \
uncertainty."
.to_string(),
"The 95% ellipse uses the chi-square 2-dof critical value 5.991 and assumes an \
approximately Gaussian impact distribution."
.to_string(),
"A source's derivative may come from a one-sided (forward- or backward-only) \
difference rather than a central one when its nominal value sits at a physical \
domain boundary (for example, still air for wind speed); see that source's scheme \
field. A one-sided difference has larger truncation error than a central one."
.to_string(),
"A source listed in unavailable_sources could not be evaluated for this request and \
is excluded from every row's variance and ranking. That is not the same fact as a \
source contributing zero -- it means this report cannot currently measure that \
source's effect at all."
.to_string(),
];
if target.is_some() {
method.push_str("_gl20_panelled_pm6sigma");
assumptions.push(
"Hit probability is the bivariate-normal mass over the target, computed by 20-point \
Gauss-Legendre quadrature per smooth sub-interval, truncated at +/-6 sigma and \
split at the target's own edge and (when two sources are strongly correlated) at \
the drop values where the conditional windage window is crossed, so the fixed-order \
rule is never applied across a hidden discontinuity or an unresolved sharp \
transition. It reflects the declared input uncertainty only -- not model error -- \
and assumes the target is centred on the aim point (the nominal trajectory's own \
impact point), not offset from it."
.to_string(),
);
}
Ok(ErrorBudgetReportV1 {
schema_version: ERROR_BUDGET_SCHEMA_VERSION_V1,
method,
assumptions,
unavailable_sources: unavailable,
rows,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::perturbation::InputAxis;
fn resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {}, "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
}).to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
crate::solve_v1::solve_v1(req).unwrap().resolved_request
}
fn qnh_resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
"pressure_reference": "qnh"},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
crate::solve_v1::solve_v1(req).unwrap().resolved_request
}
#[test]
fn a_zero_sigma_source_contributes_exactly_zero() {
let r = resolved();
let rep = error_budget(&r, &[(InputAxis::MuzzleVelocityMps, 0.0),
(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
let mv = rep.rows[0].sources.iter()
.find(|s| s.axis == InputAxis::MuzzleVelocityMps).unwrap();
assert_eq!(mv.variance_share, 0.0);
assert_eq!(mv.sigma, 0.0);
assert!(mv.d_drop_d_x != 0.0, "the real derivative must still be reported");
assert_eq!(mv.ellipse_area_reduction_m2, 0.0);
}
#[test]
fn every_declared_source_appears_individually() {
let r = resolved();
let declared = [(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.0),
(InputAxis::BallisticCoefficient, 0.005)];
let rep = error_budget(&r, &declared, &[600.0]).unwrap();
assert_eq!(rep.rows[0].sources.len(), declared.len());
for (axis, _) in declared {
assert!(rep.rows[0].sources.iter().any(|s| s.axis == axis), "{axis:?} missing");
}
}
#[test]
fn ranking_is_invariant_to_declaration_order() {
let r = resolved();
let a = error_budget(&r, &[(InputAxis::MuzzleVelocityMps, 5.0),
(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
let b = error_budget(&r, &[(InputAxis::WindSpeed, 1.0),
(InputAxis::MuzzleVelocityMps, 5.0)], &[600.0]).unwrap();
let order_a: Vec<_> = a.rows[0].sources.iter().map(|s| s.axis).collect();
let order_b: Vec<_> = b.rows[0].sources.iter().map(|s| s.axis).collect();
assert_eq!(order_a, order_b);
}
#[test]
fn variance_shares_sum_to_one() {
let r = resolved();
let rep = error_budget(&r, &[(InputAxis::MuzzleVelocityMps, 5.0),
(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
let sum: f64 = rep.rows[0].sources.iter().map(|s| s.variance_share).sum();
assert!((sum - 1.0).abs() < 1e-9, "shares summed to {sum}");
}
#[test]
fn the_priority_statement_names_the_gain_leader_when_it_diverges_from_the_variance_leader() {
let r = resolved();
let declared =
[(InputAxis::MuzzleVelocityMps, 40.0), (InputAxis::WindSpeed, 0.5)];
let target = TargetGeometryV1::Rect { width_m: 0.15, height_m: 3.0 };
let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
let row = &rep.rows[0];
let mv = row.sources.iter().find(|s| s.axis == InputAxis::MuzzleVelocityMps).unwrap();
let ws = row.sources.iter().find(|s| s.axis == InputAxis::WindSpeed).unwrap();
assert!(
mv.variance_share > ws.variance_share,
"fixture assumption: MuzzleVelocityMps must be the variance leader; got MV={} \
WS={}",
mv.variance_share,
ws.variance_share
);
assert_eq!(row.sources[0].axis, InputAxis::MuzzleVelocityMps, "sorted by variance share");
let (mv_gain, ws_gain) =
(mv.p_hit_gain_if_perfect.unwrap(), ws.p_hit_gain_if_perfect.unwrap());
assert!(
ws_gain > mv_gain + 0.1,
"fixture assumption: WindSpeed's gain must diverge sharply from the variance \
leader's own -- got MV gain={mv_gain} WS gain={ws_gain}"
);
let statement = &row.priority_statement;
assert!(
statement.starts_with("MuzzleVelocityMps dominates"),
"the variance leader's own sentence must still be stated first: {statement}"
);
assert!(
statement.contains("perfecting WindSpeed yields the larger hit-probability gain"),
"expected the statement to name the gain leader when it diverges from the variance \
leader: {statement}"
);
assert!(
statement.contains("improve WindSpeed first"),
"expected a concrete recommendation naming the gain leader: {statement}"
);
}
#[test]
fn the_priority_statement_is_unchanged_when_the_leaders_coincide() {
let r = resolved();
let declared = [(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.5)];
let target = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.75 };
let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
let row = &rep.rows[0];
assert_eq!(
row.sources[0].axis,
InputAxis::WindSpeed,
"fixture assumption: WindSpeed is both the variance leader and (per \
p_hit_gain_if_perfect_discriminates_with_only_two_sources) the gain leader here"
);
assert_eq!(
row.priority_statement,
"WindSpeed dominates at 600 m (98.2% of impact variance). Measuring it better is \
the highest-value single improvement here."
);
}
#[test]
fn the_priority_statement_is_unchanged_with_no_target_supplied() {
let r = resolved();
let rep = error_budget(
&r,
&[(InputAxis::MuzzleVelocityMps, 40.0), (InputAxis::WindSpeed, 0.5)],
&[600.0],
)
.unwrap();
let statement = &rep.rows[0].priority_statement;
assert!(
statement.starts_with("MuzzleVelocityMps dominates at 600 m ("),
"{statement}"
);
assert!(
statement.ends_with(
"Measuring it better is the highest-value single improvement here."
),
"the statement must end exactly where it always did when no target is supplied (no \
extra sentence appended): {statement}"
);
assert!(
!statement.contains("However"),
"no target was supplied, so no divergence sentence should ever be appended: \
{statement}"
);
}
#[test]
fn the_report_declares_independence_and_linearity() {
let r = resolved();
let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
assert_eq!(rep.method, "central_difference_first_order_propagation");
assert!(rep.assumptions.iter().any(|s| s.contains("independent")));
assert!(rep.assumptions.iter().any(|s| s.to_lowercase().contains("linear")));
}
#[test]
fn the_report_declares_the_gaussian_ellipse_assumption_and_the_two_added_caveats() {
let r = resolved();
let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
assert!(
rep.assumptions.iter().any(|s| s.contains("5.991") && s.to_lowercase().contains("gaussian")),
"no assumption states the chi-square constant and the Gaussian-ellipse assumption: {:#?}",
rep.assumptions
);
assert!(
rep.assumptions.iter().any(|s| s.to_lowercase().contains("one-sided")),
"no assumption warns that a source's derivative may be one-sided: {:#?}",
rep.assumptions
);
assert!(
rep.assumptions.iter().any(|s| s.contains("unavailable_sources")
&& s.to_lowercase().contains("not the same fact as")),
"no assumption distinguishes an unavailable source from a zero-contribution one: {:#?}",
rep.assumptions
);
}
#[test]
fn a_non_central_scheme_is_surfaced_in_the_source_and_the_priority_statement() {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {},
"wind": {"speed_mps": 0.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
let ws = &rep.rows[0].sources[0];
assert_eq!(ws.axis, InputAxis::WindSpeed);
assert_eq!(ws.scheme, DifferenceScheme::ForwardOneSided);
assert!(
rep.rows[0].priority_statement.contains("one-sided"),
"priority_statement should flag a one-sided dominant source: {}",
rep.rows[0].priority_statement
);
assert!(rep.assumptions.iter().any(|s| s.to_lowercase().contains("one-sided")));
}
#[test]
fn an_unavailable_source_is_recorded_not_silently_dropped() {
let r = qnh_resolved();
let rep = error_budget(
&r,
&[(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::Altitude, 50.0)],
&[300.0],
)
.unwrap();
assert!(rep.rows[0].sources.iter().all(|s| s.axis != InputAxis::Altitude));
let skipped = rep
.unavailable_sources
.iter()
.find(|u| u.axis == InputAxis::Altitude)
.expect("Altitude must be recorded as unavailable, not dropped");
assert_eq!(skipped.sigma, 50.0);
assert_eq!(skipped.code, UnavailableReasonCodeV1::AxisUnsupportedForRequest);
assert!(skipped.reason.to_lowercase().contains("qnh"), "{}", skipped.reason);
assert_eq!(rep.unavailable_sources.len(), 1);
assert_eq!(rep.rows[0].sources.len(), 1);
let mv = &rep.rows[0].sources[0];
assert_eq!(mv.axis, InputAxis::MuzzleVelocityMps);
assert!(mv.variance_share > 0.0);
assert!(
rep.rows[0].priority_statement.contains("could not be evaluated"),
"priority_statement should mention the unavailable source too: {}",
rep.rows[0].priority_statement
);
}
#[test]
fn every_source_unavailable_still_produces_a_well_formed_report() {
let r = qnh_resolved();
let rep = error_budget(&r, &[(InputAxis::Altitude, 50.0)], &[300.0]).unwrap();
assert!(rep.rows[0].sources.is_empty());
assert_eq!(rep.unavailable_sources.len(), 1);
assert_eq!(rep.unavailable_sources[0].axis, InputAxis::Altitude);
assert_eq!(
rep.unavailable_sources[0].code,
UnavailableReasonCodeV1::AxisUnsupportedForRequest
);
assert_eq!(rep.rows[0].ellipse_95.area_m2, 0.0);
assert!(rep.rows[0].priority_statement.contains("None of the declared sources"));
}
#[test]
fn unavailable_reason_classifies_every_kernel_error_variant() {
use crate::solve_json::SolveErrorCodeV1;
use crate::trajectory_observation::TrajectoryObservationError;
let structural = [
(
KernelError::AxisUnsupportedForRequest { axis: InputAxis::Altitude, reason: "x" },
UnavailableReasonCodeV1::AxisUnsupportedForRequest,
),
(KernelError::AxisAbsent(InputAxis::WindSpeed), UnavailableReasonCodeV1::AxisAbsent),
(
KernelError::CategoricalAxis(InputAxis::CoriolisEnabled),
UnavailableReasonCodeV1::CategoricalAxis,
),
(
KernelError::StepOutOfDomain { axis: InputAxis::RelativeHumidity, attempted: 2.0 },
UnavailableReasonCodeV1::StepOutOfDomain,
),
];
for (e, expected_code) in &structural {
match unavailable_reason(e) {
Some((code, _)) => assert_eq!(
code, *expected_code,
"{e:?} classified with the wrong UnavailableReasonCodeV1"
),
None => panic!("{e:?} must be classified as unavailable (recorded), not propagated"),
}
}
let genuine = [
KernelError::Solve { code: SolveErrorCodeV1::SolveFailed, message: "x".into() },
KernelError::Observation(TrajectoryObservationError::NonMonotonicTrajectory {
index: 3,
previous_distance_m: 10.0,
distance_m: 9.0,
}),
KernelError::TypeMismatch(InputAxis::Mass),
KernelError::NonFinite(InputAxis::Mass),
KernelError::DuplicateAxis(InputAxis::Mass),
KernelError::InvalidDomain { axis: InputAxis::WindSpeed, reason: "x" },
];
for e in &genuine {
assert!(
unavailable_reason(e).is_none(),
"{e:?} must be classified as a genuine failure (propagated), not recorded"
);
}
}
#[test]
fn tied_variance_shares_break_deterministically_regardless_of_input_order() {
fn stub(axis: InputAxis, variance_share: f64) -> SourceContributionV1 {
SourceContributionV1 {
axis,
sigma: 1.0,
d_drop_d_x: 0.0,
d_windage_d_x: 0.0,
scheme: DifferenceScheme::Central,
variance_share,
ellipse_area_reduction_m2: 0.0,
p_hit_gain_if_perfect: None,
}
}
let mut a = vec![stub(InputAxis::WindSpeed, 0.5), stub(InputAxis::MuzzleVelocityMps, 0.5)];
let mut b = vec![stub(InputAxis::MuzzleVelocityMps, 0.5), stub(InputAxis::WindSpeed, 0.5)];
sort_by_variance_share_desc(&mut a);
sort_by_variance_share_desc(&mut b);
let order_a: Vec<_> = a.iter().map(|s| s.axis).collect();
let order_b: Vec<_> = b.iter().map(|s| s.axis).collect();
assert_eq!(order_a, order_b, "a tie must break the same way regardless of input order");
assert_eq!(order_a, vec![InputAxis::MuzzleVelocityMps, InputAxis::WindSpeed]);
}
#[test]
fn a_three_way_tie_is_fully_deterministic_across_every_rotation() {
fn stub(axis: InputAxis) -> SourceContributionV1 {
SourceContributionV1 {
axis,
sigma: 1.0,
d_drop_d_x: 0.0,
d_windage_d_x: 0.0,
scheme: DifferenceScheme::Central,
variance_share: 1.0 / 3.0,
ellipse_area_reduction_m2: 0.0,
p_hit_gain_if_perfect: None,
}
}
let axes = [InputAxis::WindSpeed, InputAxis::MuzzleVelocityMps, InputAxis::Mass];
let mut orders = Vec::new();
for rotation in 0..axes.len() {
let mut rotated: Vec<SourceContributionV1> =
(0..axes.len()).map(|k| stub(axes[(k + rotation) % axes.len()])).collect();
sort_by_variance_share_desc(&mut rotated);
orders.push(rotated.iter().map(|s| s.axis).collect::<Vec<_>>());
}
for w in orders.windows(2) {
assert_eq!(w[0], w[1], "every rotation of a full tie must sort identically");
}
}
#[test]
fn two_sources_contributions_are_not_transposed() {
let r = resolved();
let mv_sigma = 5.0_f64;
let ws_sigma = 1.0_f64;
let rep = error_budget(
&r,
&[(InputAxis::MuzzleVelocityMps, mv_sigma), (InputAxis::WindSpeed, ws_sigma)],
&[600.0],
)
.unwrap();
let mv_deriv = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None)
.unwrap()[0];
let ws_deriv = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap()[0];
let mv_var = (mv_deriv.d_drop_d_x * mv_sigma).powi(2)
+ (mv_deriv.d_windage_d_x * mv_sigma).powi(2);
let ws_var = (ws_deriv.d_drop_d_x * ws_sigma).powi(2)
+ (ws_deriv.d_windage_d_x * ws_sigma).powi(2);
let independent_total = mv_var + ws_var;
let mv_row = rep.rows[0].sources.iter().find(|s| s.axis == InputAxis::MuzzleVelocityMps)
.unwrap();
let ws_row = rep.rows[0].sources.iter().find(|s| s.axis == InputAxis::WindSpeed).unwrap();
assert_eq!(mv_row.d_drop_d_x, mv_deriv.d_drop_d_x);
assert_eq!(mv_row.d_windage_d_x, mv_deriv.d_windage_d_x);
assert_eq!(mv_row.sigma, mv_sigma);
assert_eq!(ws_row.d_drop_d_x, ws_deriv.d_drop_d_x);
assert_eq!(ws_row.d_windage_d_x, ws_deriv.d_windage_d_x);
assert_eq!(ws_row.sigma, ws_sigma);
assert!((mv_row.variance_share - mv_var / independent_total).abs() < 1e-9);
assert!((ws_row.variance_share - ws_var / independent_total).abs() < 1e-9);
assert!(
(mv_row.variance_share - ws_row.variance_share).abs() > 0.05,
"fixture must give the two sources distinguishably different shares: mv={}, ws={}",
mv_row.variance_share,
ws_row.variance_share
);
}
#[test]
fn variance_shares_sum_to_one_against_an_independently_recomputed_total() {
let r = resolved();
let declared = [
(InputAxis::MuzzleVelocityMps, 5.0),
(InputAxis::WindSpeed, 1.0),
(InputAxis::BallisticCoefficient, 0.005),
];
let rep = error_budget(&r, &declared, &[600.0]).unwrap();
let mut independent_total = 0.0;
let mut independent_var = std::collections::HashMap::new();
for &(axis, sigma) in &declared {
let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
let v = (d.d_drop_d_x * sigma).powi(2) + (d.d_windage_d_x * sigma).powi(2);
independent_total += v;
independent_var.insert(axis, v);
}
let mut share_sum = 0.0;
for s in &rep.rows[0].sources {
let expected_share = independent_var[&s.axis] / independent_total;
assert!(
(s.variance_share - expected_share).abs() < 1e-9,
"{:?}: report said {}, independently expected {}",
s.axis,
s.variance_share,
expected_share
);
share_sum += s.variance_share;
}
assert!((share_sum - 1.0).abs() < 1e-9, "shares summed to {share_sum}");
}
#[test]
fn sigma_covariance_and_rotation_are_verified_independently_not_against_themselves() {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "cant_angle_rad": 0.5},
"atmosphere": {},
"wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let declared = [
(InputAxis::MuzzleVelocityMps, 5.0),
(InputAxis::WindSpeed, 1.0),
(InputAxis::Cant, 0.01),
];
let rep = error_budget(&r, &declared, &[600.0]).unwrap();
let mut var_drop = 0.0_f64;
let mut var_wind = 0.0_f64;
let mut cov = 0.0_f64;
for &(axis, sigma) in &declared {
let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
let s2 = sigma * sigma;
var_drop += d.d_drop_d_x * d.d_drop_d_x * s2;
var_wind += d.d_windage_d_x * d.d_windage_d_x * s2;
cov += d.d_drop_d_x * d.d_windage_d_x * s2;
}
let row = &rep.rows[0];
assert!(
(row.sigma_drop_m - var_drop.sqrt()).abs() < 1e-9,
"sigma_drop_m: got {}, expected {}",
row.sigma_drop_m,
var_drop.sqrt()
);
assert!(
(row.sigma_windage_m - var_wind.sqrt()).abs() < 1e-9,
"sigma_windage_m: got {}, expected {}",
row.sigma_windage_m,
var_wind.sqrt()
);
assert!(
(row.sigma_drop_m - row.sigma_windage_m).abs()
> 0.1 * row.sigma_drop_m.max(row.sigma_windage_m),
"fixture must give sigma_drop_m and sigma_windage_m distinguishably different \
values, or a swap between them would be invisible here: drop={}, wind={}",
row.sigma_drop_m,
row.sigma_windage_m
);
assert!(
(row.covariance_m2 - cov).abs() < 1e-9 * cov.abs().max(1.0),
"covariance_m2: got {}, expected {}",
row.covariance_m2,
cov
);
assert!(cov.abs() > 1e-5, "fixture must give a clearly nonzero covariance: {cov}");
let (s, c) = row.ellipse_95.rotation_rad.sin_cos();
let var_along_major = c * c * var_drop + 2.0 * c * s * cov + s * s * var_wind;
let lambda_max = row.ellipse_95.semi_major_m.powi(2) / CHI2_95_2DOF;
assert!(
(var_along_major - lambda_max).abs() < 1e-9 * lambda_max.max(1.0),
"rotation_rad ({}) does not point along the major axis: variance projected along it \
is {}, but the major-axis eigenvalue is {}",
row.ellipse_95.rotation_rad,
var_along_major,
lambda_max
);
assert_ne!(
row.ellipse_95.rotation_rad, 0.0,
"fixture must give a genuinely nonzero rotation"
);
}
#[test]
fn ellipse_area_reduction_is_nonzero_and_discriminating_with_three_or_more_sources() {
fn independent_ellipse_area(var_drop: f64, var_wind: f64, cov: f64) -> f64 {
let trace = var_drop + var_wind;
let det = (var_drop * var_wind - cov * cov).max(0.0);
let disc = ((trace * trace / 4.0) - det).max(0.0).sqrt();
let l1 = (trace / 2.0 + disc).max(0.0);
let l2 = (trace / 2.0 - disc).max(0.0);
std::f64::consts::PI * (CHI2_95_2DOF * l1).sqrt() * (CHI2_95_2DOF * l2).sqrt()
}
let r = resolved();
let declared = [
(InputAxis::MuzzleVelocityMps, 5.0),
(InputAxis::WindSpeed, 0.3),
(InputAxis::BallisticCoefficient, 0.001),
];
let rep = error_budget(&r, &declared, &[600.0]).unwrap();
assert_eq!(rep.rows[0].sources.len(), 3);
let mut derivs = std::collections::HashMap::new();
for &(axis, sigma) in &declared {
let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
derivs.insert(axis, (sigma, d.d_drop_d_x, d.d_windage_d_x));
}
let variance_excluding = |exclude: Option<InputAxis>| -> (f64, f64, f64) {
let mut vd = 0.0;
let mut vw = 0.0;
let mut cv = 0.0;
for (&axis, &(sigma, dd, dw)) in &derivs {
if Some(axis) == exclude {
continue;
}
let s2 = sigma * sigma;
vd += dd * dd * s2;
vw += dw * dw * s2;
cv += dd * dw * s2;
}
(vd, vw, cv)
};
let (fvd, fvw, fcv) = variance_excluding(None);
let full_area = independent_ellipse_area(fvd, fvw, fcv);
let mut reductions = Vec::new();
for s in &rep.rows[0].sources {
let (vd, vw, cv) = variance_excluding(Some(s.axis));
let reduced_area = independent_ellipse_area(vd, vw, cv);
let expected_reduction = (full_area - reduced_area).max(0.0);
assert!(
(s.ellipse_area_reduction_m2 - expected_reduction).abs()
< 1e-9 * expected_reduction.max(1.0),
"{:?}: got {}, independently expected {}",
s.axis,
s.ellipse_area_reduction_m2,
expected_reduction
);
reductions.push((s.axis, s.ellipse_area_reduction_m2));
}
assert!(
reductions.iter().all(|&(_, red)| red > 0.0),
"every reduction should be positive with 3+ sources: {reductions:?}"
);
let first = reductions[0].1;
assert!(
reductions.iter().any(|&(_, red)| (red - first).abs() > 1e-6 * first.max(1.0)),
"reductions must discriminate between sources with 3+ declared, not all be equal: \
{reductions:?}"
);
}
#[test]
fn error_budget_computes_a_correctly_indexed_row_per_requested_range() {
let r = resolved();
let ranges = [300.0_f64, 600.0_f64, 850.0_f64];
let rep = error_budget(&r, &[(InputAxis::MuzzleVelocityMps, 5.0)], &ranges).unwrap();
assert_eq!(rep.rows.len(), ranges.len());
for (i, &range_m) in ranges.iter().enumerate() {
assert_eq!(rep.rows[i].range_m, range_m);
let expected = central_difference(&r, InputAxis::MuzzleVelocityMps, &[range_m], None)
.unwrap()[0];
let got = &rep.rows[i].sources[0];
assert_eq!(got.d_drop_d_x, expected.d_drop_d_x, "range {range_m}");
assert_eq!(got.d_windage_d_x, expected.d_windage_d_x, "range {range_m}");
}
assert!(
rep.rows[2].sources[0].d_drop_d_x.abs() > rep.rows[0].sources[0].d_drop_d_x.abs() * 2.0
);
}
#[test]
fn a_non_finite_or_negative_sigma_is_rejected() {
let r = resolved();
let nan = error_budget(&r, &[(InputAxis::WindSpeed, f64::NAN)], &[600.0]);
assert!(matches!(nan, Err(KernelError::NonFinite(InputAxis::WindSpeed))));
let neg = error_budget(&r, &[(InputAxis::WindSpeed, -1.0)], &[600.0]);
assert!(matches!(neg, Err(KernelError::NonFinite(InputAxis::WindSpeed))));
let inf = error_budget(&r, &[(InputAxis::WindSpeed, f64::INFINITY)], &[600.0]);
assert!(matches!(inf, Err(KernelError::NonFinite(InputAxis::WindSpeed))));
}
#[test]
fn an_astronomically_large_sigma_does_not_panic_the_sort() {
let r = resolved();
let huge = 1e200_f64;
assert!(
huge.is_finite() && (huge * huge).is_infinite(),
"fixture assumption: sigma^2 must overflow to infinity"
);
let rep = error_budget(
&r,
&[(InputAxis::MuzzleVelocityMps, huge), (InputAxis::WindSpeed, 1.0)],
&[600.0],
)
.unwrap();
assert_eq!(rep.rows[0].sources.len(), 2);
}
#[test]
fn a_duplicate_axis_declaration_is_rejected() {
let r = resolved();
let e = error_budget(
&r,
&[(InputAxis::WindSpeed, 1.0), (InputAxis::WindSpeed, 2.0)],
&[600.0],
);
assert!(matches!(e, Err(KernelError::DuplicateAxis(InputAxis::WindSpeed))));
}
#[test]
fn a_duplicate_axis_is_found_even_when_not_adjacent() {
let r = resolved();
let e = error_budget(
&r,
&[
(InputAxis::MuzzleVelocityMps, 5.0),
(InputAxis::BallisticCoefficient, 0.005),
(InputAxis::MuzzleVelocityMps, 6.0),
],
&[600.0],
);
assert!(matches!(e, Err(KernelError::DuplicateAxis(InputAxis::MuzzleVelocityMps))));
}
#[test]
fn a_range_beyond_max_range_m_is_rejected_directly_not_laundered_per_axis() {
let r = resolved(); let e = error_budget(
&r,
&[(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.0)],
&[600.0, 5000.0],
);
match e {
Err(KernelError::Observation(TrajectoryObservationError::OutOfRange {
requested_m,
maximum_m,
..
})) => {
assert_eq!(requested_m, 5000.0);
assert_eq!(maximum_m, 900.0);
}
other => panic!(
"expected Err(KernelError::Observation(OutOfRange {{ .. }})) naming the \
out-of-range query, got {other:?}"
),
}
}
#[test]
fn a_range_within_max_range_m_but_beyond_the_actual_trajectory_is_rejected_with_the_real_extent()
{
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "muzzle_angle_rad": -1.4},
"atmosphere": {}, "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let e = error_budget(
&r,
&[(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.0)],
&[900.0], );
match e {
Err(KernelError::Observation(
inner @ TrajectoryObservationError::OutOfRange { requested_m, maximum_m, .. },
)) => {
assert_eq!(requested_m, 900.0);
assert!(
maximum_m < 900.0 && maximum_m > 0.0,
"expected the actual short trajectory's extent, got maximum_m = {maximum_m}"
);
let msg = inner.to_string();
assert!(
msg.contains("outside the computed trajectory"),
"message did not name the computed trajectory: {msg}"
);
assert!(
!msg.to_lowercase().contains("step"),
"message must not blame a per-axis differencing step: {msg}"
);
}
other => panic!(
"expected Err(KernelError::Observation(OutOfRange {{ .. }})) naming the actual \
computed trajectory extent -- got {other:?} (an Ok(..) here would mean every \
declared source was laundered into a fabricated per-axis StepOutOfDomain \
explanation instead)"
),
}
}
#[test]
fn a_negative_range_is_rejected_directly() {
let r = resolved();
let e = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[-1.0]);
assert!(matches!(
e,
Err(KernelError::Observation(TrajectoryObservationError::OutOfRange {
requested_m,
..
})) if requested_m == -1.0
));
}
#[test]
fn step_out_of_domain_reason_blames_the_default_step_not_the_sigma() {
let e = KernelError::StepOutOfDomain { axis: InputAxis::RelativeHumidity, attempted: 2.0 };
let (code, reason) = unavailable_reason(&e).unwrap();
assert_eq!(code, UnavailableReasonCodeV1::StepOutOfDomain);
assert!(reason.to_lowercase().contains("default step"), "{reason}");
assert!(
!reason.to_lowercase().contains("sigma larger"),
"reason should not claim the declared sigma's SIZE caused this: {reason}"
);
assert!(
reason.to_lowercase().contains("does not depend on the declared sigma"),
"{reason}"
);
}
#[test]
fn declaring_no_sources_is_well_defined() {
let r = resolved();
let rep = error_budget(&r, &[], &[600.0]).unwrap();
assert!(rep.rows[0].sources.is_empty());
assert!(rep.unavailable_sources.is_empty());
assert_eq!(rep.rows[0].ellipse_95.area_m2, 0.0);
assert!(rep.rows[0].priority_statement.contains("No sources were declared"));
}
#[test]
fn single_source_ellipse_matches_the_closed_form_rank_one_case() {
let r = resolved();
let sigma = 1.0_f64;
let rep = error_budget(&r, &[(InputAxis::WindSpeed, sigma)], &[600.0]).unwrap();
let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap()[0];
let expected_largest = sigma * sigma * (d.d_drop_d_x.powi(2) + d.d_windage_d_x.powi(2));
let expected_major = (CHI2_95_2DOF * expected_largest).sqrt();
let ellipse = rep.rows[0].ellipse_95;
assert!(
ellipse.semi_minor_m <= 1e-9 * expected_major,
"semi_minor_m should be negligible relative to the major axis for a rank-1 \
covariance, got minor={}, major={}",
ellipse.semi_minor_m,
expected_major
);
assert!(
ellipse.area_m2 <= 1e-9 * std::f64::consts::PI * expected_major * expected_major,
"area_m2 should be negligible relative to a major-axis-radius circle for a rank-1 \
covariance, got area={}, major={}",
ellipse.area_m2,
expected_major
);
assert!(
(ellipse.semi_major_m - expected_major).abs() < 1e-9,
"got {}, expected {}",
ellipse.semi_major_m,
expected_major
);
}
#[test]
fn the_report_round_trips_through_json_including_unavailable_sources_and_scheme() {
let r = qnh_resolved();
let rep = error_budget(
&r,
&[(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::Altitude, 50.0)],
&[300.0],
)
.unwrap();
let json = serde_json::to_string(&rep).expect("report must serialize");
assert!(json.contains("\"unavailable_sources\""));
assert!(
json.contains("\"scheme\":\"central\"") || json.contains("\"scheme\": \"central\""),
"DifferenceScheme must serialize snake_case, got: {json}"
);
let round_tripped: ErrorBudgetReportV1 =
serde_json::from_str(&json).expect("report must deserialize back");
assert_eq!(round_tripped, rep);
}
#[test]
fn uncorrelated_rectangle_matches_the_separable_closed_form() {
use crate::special::normal_cdf;
let (sd, sw) = (0.10_f64, 0.20_f64);
let (w, h) = (0.30_f64, 0.40_f64);
let want = (normal_cdf(h / 2.0 / sd) - normal_cdf(-h / 2.0 / sd))
* (normal_cdf(w / 2.0 / sw) - normal_cdf(-w / 2.0 / sw));
let got = p_hit_bivariate(
sd * sd,
sw * sw,
0.0,
TargetGeometryV1::Rect { width_m: w, height_m: h },
);
assert!((got - want).abs() < 1e-6, "got {got} want {want}");
}
#[test]
fn p_hit_is_bounded_and_grows_with_target_size() {
let small = p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Circle { radius_m: 0.1 });
let big = p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Circle { radius_m: 0.5 });
assert!((0.0..=1.0).contains(&small) && (0.0..=1.0).contains(&big));
assert!(big > small);
}
#[test]
fn circle_matches_the_rayleigh_closed_form_when_uncorrelated_and_isotropic() {
for (sigma, r) in [(0.1_f64, 0.1_f64), (0.1, 0.15), (1.0, 1.2)] {
let got = p_hit_bivariate(sigma * sigma, sigma * sigma, 0.0, TargetGeometryV1::Circle { radius_m: r });
let want = 1.0 - (-(r * r) / (2.0 * sigma * sigma)).exp();
assert!(
(got - want).abs() < 1e-4,
"sigma={sigma} r={r}: got={got} want={want} diff={}",
(got - want).abs()
);
}
}
#[test]
fn circle_probability_is_rotation_invariant() {
for (vd, vw, cov, r) in [(0.02_f64, 0.05_f64, 0.015_f64, 0.15_f64), (0.3, 0.1, -0.12, 0.4)] {
let (l1, l2) = Symmetric2 { a00: vd, a01: cov, a11: vw }.largest_smallest_eigenvalues();
let direct = p_hit_bivariate(vd, vw, cov, TargetGeometryV1::Circle { radius_m: r });
let rotated = p_hit_bivariate(l1, l2, 0.0, TargetGeometryV1::Circle { radius_m: r });
assert!(
(direct - rotated).abs() < 1e-4,
"vd={vd} vw={vw} cov={cov} r={r}: direct={direct} rotated={rotated} diff={}",
(direct - rotated).abs()
);
}
}
#[test]
fn perfecting_a_source_never_lowers_p_hit() {
let r = resolved();
let rep = error_budget_with_target(
&r,
&[(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.5)],
&[600.0],
Some(TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.75 }),
)
.unwrap();
for s in &rep.rows[0].sources {
let gain = s.p_hit_gain_if_perfect.expect("target supplied");
assert!(gain >= -1e-9, "{:?} reported a negative gain {gain}", s.axis);
}
}
#[test]
fn p_hit_gain_if_perfect_discriminates_with_only_two_sources() {
let r = resolved();
let declared = [(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.5)];
let target = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.75 };
let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
let row = &rep.rows[0];
assert_eq!(row.sources.len(), 2);
let base_p_hit = row.p_hit.expect("target supplied");
let mut derivs = std::collections::HashMap::new();
for &(axis, sigma) in &declared {
let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
derivs.insert(axis, (sigma, d.d_drop_d_x, d.d_windage_d_x));
}
let variance_excluding = |exclude: InputAxis| -> (f64, f64, f64) {
let mut vd = 0.0;
let mut vw = 0.0;
let mut cv = 0.0;
for (&axis, &(sigma, dd, dw)) in &derivs {
if axis == exclude {
continue;
}
let s2 = sigma * sigma;
vd += dd * dd * s2;
vw += dw * dw * s2;
cv += dd * dw * s2;
}
(vd, vw, cv)
};
let mut gains = std::collections::HashMap::new();
for s in &row.sources {
let (vd, vw, cv) = variance_excluding(s.axis);
let expected = (p_hit_bivariate(vd, vw, cv, target) - base_p_hit).max(0.0);
let got = s.p_hit_gain_if_perfect.expect("target supplied");
assert!(
(got - expected).abs() < 1e-9,
"{:?}: got {got}, independently expected {expected}",
s.axis
);
gains.insert(s.axis, got);
}
let gain_ws = gains[&InputAxis::WindSpeed];
let gain_mv = gains[&InputAxis::MuzzleVelocityMps];
assert!(
(gain_ws - gain_mv).abs() > 0.05,
"expected the two gains to discriminate sharply at n=2, got WindSpeed={gain_ws} \
MuzzleVelocityMps={gain_mv}"
);
}
#[test]
fn a_strongly_correlated_case_differs_materially_from_the_wrong_separable_approximation() {
let (sd, sw) = (0.10_f64, 0.20_f64);
let (w, h) = (0.30_f64, 0.40_f64);
let rho = 0.9_f64;
let cov = rho * sd * sw;
let got = p_hit_bivariate(
sd * sd,
sw * sw,
cov,
TargetGeometryV1::Rect { width_m: w, height_m: h },
);
let wrong_separable = (normal_cdf(h / 2.0 / sd) - normal_cdf(-h / 2.0 / sd))
* (normal_cdf(w / 2.0 / sw) - normal_cdf(-w / 2.0 / sw));
assert!((0.0..=1.0).contains(&got), "got={got} out of bounds");
assert!(
(got - wrong_separable).abs() > 0.01,
"a correlated case (rho={rho}) should differ materially from the separable \
approximation, proving the conditional decomposition changes the answer: got={got} \
wrong_separable={wrong_separable} diff={}",
(got - wrong_separable).abs()
);
}
#[test]
fn correlation_crossing_split_matches_the_near_degenerate_line_limit() {
let (dd, dw, sigma) = (3.7_f64, -1.2_f64, 5.0_f64);
let var_drop = (dd * sigma).powi(2);
let var_wind = (dw * sigma).powi(2);
let cov = dd * dw * sigma * sigma;
let sd = var_drop.sqrt();
let sw = var_wind.sqrt();
let target = TargetGeometryV1::Rect { width_m: sw, height_m: 20.0 * sd };
let got = p_hit_bivariate(var_drop, var_wind, cov, target);
let rho = (cov / (sd * sw)).clamp(-0.999_999, 0.999_999);
let k = rho * (sw / sd);
let half_h = 10.0 * sd;
let half_w = sw / 2.0;
let oracle = 2.0 * normal_cdf(half_h.min(half_w / k.abs()) / sd) - 1.0;
assert!(
(got - oracle).abs() < 2e-3,
"got={got} oracle={oracle} diff={}",
(got - oracle).abs()
);
}
#[test]
fn p_hit_grows_with_target_size_for_a_rectangle_too() {
let small =
p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Rect { width_m: 0.1, height_m: 0.1 });
let big =
p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 });
assert!((0.0..=1.0).contains(&small) && (0.0..=1.0).contains(&big));
assert!(big > small, "small={small} big={big}");
let wider =
p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.1 });
let taller =
p_hit_bivariate(0.01, 0.01, 0.0, TargetGeometryV1::Rect { width_m: 0.1, height_m: 0.5 });
assert!(wider > small, "wider={wider} small={small}");
assert!(taller > small, "taller={taller} small={small}");
}
#[test]
fn p_hit_is_monotone_in_target_size_across_a_sweep_including_near_rank_one_correlation() {
let (sd, sw) = (0.1_f64, 0.2_f64);
let (w0, h0) = (0.3_f64, 0.4_f64);
let r0 = 0.25_f64;
let scales: Vec<f64> = (0..20)
.map(|i| 0.05_f64 * 60.0_f64.powf(i as f64 / 19.0))
.collect();
for &rho in &[0.0_f64, 0.9, 0.999_999] {
let cov = rho * sd * sw;
let rect_vals: Vec<f64> = scales
.iter()
.map(|&s| {
p_hit_bivariate(
sd * sd,
sw * sw,
cov,
TargetGeometryV1::Rect { width_m: s * w0, height_m: s * h0 },
)
})
.collect();
for w in rect_vals.windows(2) {
assert!(
w[1] >= w[0] - 1e-9,
"rho={rho}: rectangle p_hit decreased as target grew: {} -> {}",
w[0],
w[1]
);
}
assert!(
*rect_vals.last().unwrap() > *rect_vals.first().unwrap() + 0.1,
"rho={rho}: rectangle sweep should show substantial net growth, got {:?}",
rect_vals
);
let circle_vals: Vec<f64> = scales
.iter()
.map(|&s| {
p_hit_bivariate(sd * sd, sd * sd, cov, TargetGeometryV1::Circle { radius_m: s * r0 })
})
.collect();
for w in circle_vals.windows(2) {
assert!(
w[1] >= w[0] - 1e-9,
"rho={rho}: circle p_hit decreased as target grew: {} -> {}",
w[0],
w[1]
);
}
assert!(
*circle_vals.last().unwrap() > *circle_vals.first().unwrap() + 0.1,
"rho={rho}: circle sweep should show substantial net growth, got {:?}",
circle_vals
);
}
}
#[test]
fn drop_deterministic_windage_random_matches_closed_form_not_hardcoded_zero() {
let sw = 0.2_f64;
let target = TargetGeometryV1::Rect { width_m: 0.3, height_m: 10.0 };
let got = p_hit_bivariate(0.0, sw * sw, 0.0, target);
let want = normal_cdf(0.15 / sw) - normal_cdf(-0.15 / sw);
assert!((got - want).abs() < 1e-9, "got={got} want={want}");
assert!(got > 0.0 && got < 1.0, "fixture must give a non-degenerate probability: {got}");
let wider = p_hit_bivariate(
0.0,
sw * sw,
0.0,
TargetGeometryV1::Rect { width_m: 1.0, height_m: 10.0 },
);
assert!(wider > got, "a hardcoded-zero bug would make wider == got == 0.0: {wider} {got}");
let r = 0.15_f64;
let got_circle = p_hit_bivariate(0.0, sw * sw, 0.0, TargetGeometryV1::Circle { radius_m: r });
let want_circle = normal_cdf(r / sw) - normal_cdf(-r / sw);
assert!((got_circle - want_circle).abs() < 1e-9, "got={got_circle} want={want_circle}");
}
#[test]
fn windage_deterministic_drop_random_matches_closed_form() {
let sd = 0.15_f64;
let target = TargetGeometryV1::Rect { width_m: 10.0, height_m: 0.4 };
let got = p_hit_bivariate(sd * sd, 0.0, 0.0, target);
let want = normal_cdf(0.2 / sd) - normal_cdf(-0.2 / sd);
assert!((got - want).abs() < 1e-9, "got={got} want={want}");
let r = 0.2_f64;
let got_circle = p_hit_bivariate(sd * sd, 0.0, 0.0, TargetGeometryV1::Circle { radius_m: r });
let want_circle = normal_cdf(r / sd) - normal_cdf(-r / sd);
assert!((got_circle - want_circle).abs() < 1e-9, "got={got_circle} want={want_circle}");
assert!(!got.is_nan() && !got_circle.is_nan());
}
#[test]
fn zero_total_variance_means_a_deterministic_impact_at_the_nominal_point() {
for target in [
TargetGeometryV1::Rect { width_m: 0.001, height_m: 0.001 },
TargetGeometryV1::Rect { width_m: 5.0, height_m: 5.0 },
TargetGeometryV1::Circle { radius_m: 0.001 },
TargetGeometryV1::Circle { radius_m: 5.0 },
] {
let got = p_hit_bivariate(0.0, 0.0, 0.0, target);
assert_eq!(
got, 1.0,
"{target:?}: a deterministic impact at the nominal point (always the target's \
own centre here) must be inside with probability exactly 1.0, got {got}"
);
}
let got = p_hit_bivariate(0.0, 0.0, 999.0, TargetGeometryV1::Circle { radius_m: 1.0 });
assert_eq!(got, 1.0);
assert!(!got.is_nan());
}
#[test]
fn a_degenerate_target_can_never_be_hit_regardless_of_covariance() {
let degenerate_targets = [
TargetGeometryV1::Rect { width_m: 0.0, height_m: 0.0 },
TargetGeometryV1::Rect { width_m: 0.0, height_m: 5.0 },
TargetGeometryV1::Rect { width_m: 5.0, height_m: 0.0 },
TargetGeometryV1::Rect { width_m: -1.0, height_m: 5.0 },
TargetGeometryV1::Circle { radius_m: 0.0 },
TargetGeometryV1::Circle { radius_m: -1.0 },
];
for target in degenerate_targets {
let deterministic = p_hit_bivariate(0.0, 0.0, 0.0, target);
assert_eq!(
deterministic, 0.0,
"{target:?}: a degenerate (non-positive-area) target must be unhittable even \
when the impact is otherwise deterministic at its centre, got {deterministic}"
);
let with_real_uncertainty = p_hit_bivariate(0.01, 0.05, 0.0, target);
assert_eq!(
with_real_uncertainty, 0.0,
"{target:?}: a degenerate target must be unhittable with real declared \
uncertainty too, got {with_real_uncertainty}"
);
}
}
#[test]
fn a_single_declared_source_gives_a_well_formed_rank_one_p_hit_not_nan_or_negative() {
let r = resolved();
let target = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.75 };
let rep =
error_budget_with_target(&r, &[(InputAxis::WindSpeed, 1.5)], &[600.0], Some(target))
.unwrap();
let row = &rep.rows[0];
let p_hit = row.p_hit.expect("target supplied");
assert!(!p_hit.is_nan(), "single-source rank-1 covariance must not produce NaN");
assert!((0.0..=1.0).contains(&p_hit), "p_hit out of bounds: {p_hit}");
let gain = row.sources[0].p_hit_gain_if_perfect.expect("target supplied");
assert!(!gain.is_nan());
assert!(gain >= 0.0, "gain={gain}");
}
#[test]
fn an_unavailable_source_has_no_gain_field_while_the_evaluated_sibling_does_when_a_target_is_supplied()
{
let r = qnh_resolved();
let target = TargetGeometryV1::Circle { radius_m: 0.5 };
let rep = error_budget_with_target(
&r,
&[(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::Altitude, 50.0)],
&[300.0],
Some(target),
)
.unwrap();
assert_eq!(rep.unavailable_sources.len(), 1);
assert_eq!(rep.unavailable_sources[0].axis, InputAxis::Altitude);
assert_eq!(rep.rows[0].sources.len(), 1);
let mv = &rep.rows[0].sources[0];
assert_eq!(mv.axis, InputAxis::MuzzleVelocityMps);
let gain = mv.p_hit_gain_if_perfect.expect("target supplied, and MV evaluated");
let base_p_hit = rep.rows[0].p_hit.expect("target supplied");
let expected = (1.0 - base_p_hit).max(0.0);
assert!((gain - expected).abs() < 1e-9, "gain={gain} expected={expected}");
assert!(
gain > 0.0,
"a real muzzle-velocity uncertainty against a finite target should show a strictly \
positive gain, not merely >= 0: {gain}"
);
}
#[test]
fn p_hit_fields_are_none_when_no_target_is_supplied() {
let r = resolved();
let rep = error_budget(&r, &[(InputAxis::WindSpeed, 1.0)], &[600.0]).unwrap();
assert_eq!(rep.rows[0].p_hit, None);
assert_eq!(rep.rows[0].sources[0].p_hit_gain_if_perfect, None);
}
#[test]
fn p_hit_is_computed_from_the_rows_own_covariance_not_a_constant() {
let r = resolved();
let declared = [(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.0)];
let probe = error_budget(&r, &declared, &[600.0]).unwrap();
let row0 = &probe.rows[0];
let target = TargetGeometryV1::Rect {
width_m: 2.0 * row0.sigma_windage_m,
height_m: 2.0 * row0.sigma_drop_m,
};
let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
let row = &rep.rows[0];
let got = row.p_hit.expect("target supplied");
assert!((0.01..0.99).contains(&got), "fixture should give a non-degenerate p_hit: {got}");
let oracle = p_hit_bivariate(
row.sigma_drop_m * row.sigma_drop_m,
row.sigma_windage_m * row.sigma_windage_m,
row.covariance_m2,
target,
);
assert!(
(got - oracle).abs() < 1e-9,
"p_hit ({got}) does not match an independent p_hit_bivariate call on this row's own \
covariance ({oracle})"
);
}
#[test]
fn p_hit_gain_if_perfect_matches_an_independent_oracle_and_discriminates_with_three_sources() {
let r = resolved();
let declared = [
(InputAxis::MuzzleVelocityMps, 5.0),
(InputAxis::WindSpeed, 0.3),
(InputAxis::BallisticCoefficient, 0.001),
];
let probe = error_budget(&r, &declared, &[600.0]).unwrap();
let row0 = &probe.rows[0];
let target = TargetGeometryV1::Rect {
width_m: 2.0 * row0.sigma_windage_m,
height_m: 2.0 * row0.sigma_drop_m,
};
let rep = error_budget_with_target(&r, &declared, &[600.0], Some(target)).unwrap();
let row = &rep.rows[0];
assert_eq!(row.sources.len(), 3);
let base_p_hit = row.p_hit.expect("target supplied");
let mut derivs = std::collections::HashMap::new();
for &(axis, sigma) in &declared {
let d = central_difference(&r, axis, &[600.0], None).unwrap()[0];
derivs.insert(axis, (sigma, d.d_drop_d_x, d.d_windage_d_x));
}
let variance_excluding = |exclude: Option<InputAxis>| -> (f64, f64, f64) {
let mut vd = 0.0;
let mut vw = 0.0;
let mut cv = 0.0;
for (&axis, &(sigma, dd, dw)) in &derivs {
if Some(axis) == exclude {
continue;
}
let s2 = sigma * sigma;
vd += dd * dd * s2;
vw += dw * dw * s2;
cv += dd * dw * s2;
}
(vd, vw, cv)
};
let (fvd, fvw, fcv) = variance_excluding(None);
let oracle_base_p_hit = p_hit_bivariate(fvd, fvw, fcv, target);
assert!((base_p_hit - oracle_base_p_hit).abs() < 1e-9);
let mut gains = Vec::new();
for s in &row.sources {
let (vd, vw, cv) = variance_excluding(Some(s.axis));
let expected = (p_hit_bivariate(vd, vw, cv, target) - oracle_base_p_hit).max(0.0);
let got = s.p_hit_gain_if_perfect.expect("target supplied");
assert!(
(got - expected).abs() < 1e-9,
"{:?}: got {got}, independently expected {expected}",
s.axis
);
gains.push((s.axis, got));
}
assert!(
gains.iter().any(|&(_, g)| g > 0.0),
"at least one source should show a positive gain: {gains:?}"
);
let first = gains[0].1;
assert!(
gains.iter().any(|&(_, g)| (g - first).abs() > 1e-6 * first.max(1.0)),
"gains must discriminate between sources, not all be equal: {gains:?}"
);
}
#[test]
fn the_report_names_the_quadrature_and_the_aim_point_assumption_only_when_a_target_is_supplied()
{
let r = resolved();
let sources = [(InputAxis::WindSpeed, 1.0)];
let without = error_budget_with_target(&r, &sources, &[600.0], None).unwrap();
assert_eq!(without.method, "central_difference_first_order_propagation");
assert!(!without.assumptions.iter().any(|s| s.to_lowercase().contains("gauss-legendre")));
let with_target = error_budget_with_target(
&r,
&sources,
&[600.0],
Some(TargetGeometryV1::Circle { radius_m: 0.3 }),
)
.unwrap();
assert_eq!(
with_target.method,
"central_difference_first_order_propagation_gl20_panelled_pm6sigma"
);
assert!(
with_target
.assumptions
.iter()
.any(|s| s.to_lowercase().contains("gauss-legendre") && s.contains("aim point")),
"{:#?}",
with_target.assumptions
);
}
#[test]
fn target_geometry_serializes_snake_case_externally_tagged() {
let rect = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.75 };
let json = serde_json::to_string(&rect).unwrap();
assert!(json.contains("\"rect\""), "{json}");
assert!(json.contains("\"width_m\":0.5") || json.contains("\"width_m\": 0.5"), "{json}");
assert!(
json.contains("\"height_m\":0.75") || json.contains("\"height_m\": 0.75"),
"{json}"
);
let back: TargetGeometryV1 = serde_json::from_str(&json).expect("must deserialize");
assert_eq!(back, rect);
let circle = TargetGeometryV1::Circle { radius_m: 0.3 };
let json2 = serde_json::to_string(&circle).unwrap();
assert!(json2.contains("\"circle\""), "{json2}");
assert!(
json2.contains("\"radius_m\":0.3") || json2.contains("\"radius_m\": 0.3"),
"{json2}"
);
let back2: TargetGeometryV1 = serde_json::from_str(&json2).expect("must deserialize");
assert_eq!(back2, circle);
}
#[test]
fn p_hit_and_gain_round_trip_through_json_when_present() {
let r = resolved();
let target = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.75 };
let rep = error_budget_with_target(
&r,
&[(InputAxis::MuzzleVelocityMps, 5.0), (InputAxis::WindSpeed, 1.5)],
&[600.0],
Some(target),
)
.unwrap();
let json = serde_json::to_string(&rep).expect("report must serialize");
assert!(json.contains("\"p_hit\":"), "{json}");
assert!(json.contains("\"p_hit_gain_if_perfect\":"), "{json}");
let round_tripped: ErrorBudgetReportV1 =
serde_json::from_str(&json).expect("report must deserialize back");
let want_p_hit = rep.rows[0].p_hit.expect("target supplied");
let got_p_hit = round_tripped.rows[0].p_hit.expect("must round-trip as Some");
assert!((got_p_hit - want_p_hit).abs() < 1e-9, "got={got_p_hit} want={want_p_hit}");
assert_eq!(round_tripped.rows[0].sources.len(), rep.rows[0].sources.len());
for (got_s, want_s) in
round_tripped.rows[0].sources.iter().zip(rep.rows[0].sources.iter())
{
assert_eq!(got_s.axis, want_s.axis);
let want_gain = want_s.p_hit_gain_if_perfect.expect("target supplied");
let got_gain = got_s.p_hit_gain_if_perfect.expect("must round-trip as Some");
assert!(
(got_gain - want_gain).abs() < 1e-9,
"{:?}: got={got_gain} want={want_gain}",
got_s.axis
);
}
}
}