use serde::{Deserialize, Serialize};
use crate::error_budget::{unavailable_reason, TargetGeometryV1, UnavailableReasonCodeV1};
use crate::perturbation::access::{read_axis, with_axis, AxisValue, KernelError};
use crate::perturbation::derive::bisect_axis;
use crate::perturbation::taxonomy::{axis_meta, AxisKind, InputAxis};
use crate::perturbation::{evaluate, Observation};
use crate::solve_json::ResolvedSolveRequestV1;
use crate::trajectory_observation::TrajectoryObservationError;
pub const TOLERANCE_SCHEMA_VERSION_V1: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LimitingBoundaryV1 {
Top,
Bottom,
Left,
Right,
Radial,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToleranceAxisV1 {
pub axis: InputAxis,
pub nominal: f64,
pub nominal_inside_target: bool,
pub near_bound: Option<f64>,
pub far_bound: Option<f64>,
pub near_limiting_boundary: Option<LimitingBoundaryV1>,
pub far_limiting_boundary: Option<LimitingBoundaryV1>,
pub unbounded_in_domain: bool,
pub near_has_no_effect: bool,
pub far_has_no_effect: bool,
pub margin_linear_m: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnavailableAxisV1 {
pub axis: InputAxis,
pub code: UnavailableReasonCodeV1,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToleranceReportV1 {
pub schema_version: u32,
pub method: String,
pub assumptions: Vec<String>,
pub range_m: f64,
pub unavailable_axes: Vec<UnavailableAxisV1>,
pub axes: Vec<ToleranceAxisV1>,
}
fn bisection_tolerance(lo: f64, hi: f64) -> f64 {
((hi - lo).abs() * 1e-6).max(1e-9)
}
fn target_margin_linear_m(target: TargetGeometryV1) -> f64 {
match target {
TargetGeometryV1::Rect { width_m, height_m } => {
(height_m.max(0.0) / 2.0).min(width_m.max(0.0) / 2.0)
}
TargetGeometryV1::Circle { radius_m } => radius_m.max(0.0),
}
}
fn inside(o: &Observation, nominal: &Observation, target: TargetGeometryV1) -> bool {
let dy = o.drop_m - nominal.drop_m;
let dz = o.windage_m - nominal.windage_m;
match target {
TargetGeometryV1::Rect { width_m, height_m } => {
width_m > 0.0
&& height_m > 0.0
&& dy.abs() <= height_m / 2.0
&& dz.abs() <= width_m / 2.0
}
TargetGeometryV1::Circle { radius_m } => {
radius_m > 0.0 && (dy * dy + dz * dz).sqrt() <= radius_m
}
}
}
fn observation_matches_nominal(o: &Observation, nominal: &Observation) -> bool {
(o.drop_m - nominal.drop_m).abs() < 1e-9 && (o.windage_m - nominal.windage_m).abs() < 1e-9
}
fn limiting_boundary(
o: &Observation,
nominal: &Observation,
target: TargetGeometryV1,
) -> LimitingBoundaryV1 {
let dy = o.drop_m - nominal.drop_m;
let dz = o.windage_m - nominal.windage_m;
match target {
TargetGeometryV1::Circle { .. } => LimitingBoundaryV1::Radial,
TargetGeometryV1::Rect { width_m, height_m } => {
if dy.abs() * width_m.max(0.0) >= dz.abs() * height_m.max(0.0) {
if dy > 0.0 {
LimitingBoundaryV1::Bottom
} else {
LimitingBoundaryV1::Top
}
} else if dz > 0.0 {
LimitingBoundaryV1::Right
} else {
LimitingBoundaryV1::Left
}
}
}
}
pub fn tolerance_envelope(
base: &ResolvedSolveRequestV1,
axes: &[InputAxis],
range_m: f64,
target: TargetGeometryV1,
domains: &[(InputAxis, (f64, f64))],
) -> Result<ToleranceReportV1, KernelError> {
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,
}));
}
let nominal = evaluate(&base.into(), &[range_m])?[0];
let margin_linear_m = target_margin_linear_m(target);
let mut out = Vec::with_capacity(axes.len());
let mut unavailable = Vec::new();
for &axis in axes {
if matches!(axis_meta(axis).kind, AxisKind::Categorical) {
let (code, reason) = unavailable_reason(&KernelError::CategoricalAxis(axis))
.expect("CategoricalAxis is always classified as unavailable");
unavailable.push(UnavailableAxisV1 { axis, code, reason });
continue;
}
let nominal_value = match read_axis(base, axis) {
Some(AxisValue::Scalar(x)) => x,
Some(_) => return Err(KernelError::TypeMismatch(axis)),
None => {
let (code, reason) = unavailable_reason(&KernelError::AxisAbsent(axis))
.expect("AxisAbsent is always classified as unavailable");
unavailable.push(UnavailableAxisV1 { axis, code, reason });
continue;
}
};
let (lo, hi) = match domains.iter().find(|(a, _)| *a == axis).map(|(_, d)| *d) {
Some(d) => d,
None => {
return Err(KernelError::InvalidDomain {
axis,
reason: "no domain was configured for this axis",
})
}
};
if !(lo.is_finite() && hi.is_finite()) {
return Err(KernelError::InvalidDomain {
axis,
reason: "domain bounds must both be finite",
});
}
if lo >= hi {
return Err(KernelError::InvalidDomain {
axis,
reason: "the domain's lower bound must be strictly less than its upper bound",
});
}
if !(nominal_value > lo && nominal_value < hi) {
return Err(KernelError::InvalidDomain {
axis,
reason: "the axis's own current value must sit strictly inside the configured \
domain, or one search direction would be a zero-width probe",
});
}
let pre = with_axis(base, axis, AxisValue::Scalar(nominal_value))
.and_then(|req| evaluate(&req, &[range_m]));
let at_nominal = match pre {
Ok(obs) => obs[0],
Err(e) => match unavailable_reason(&e) {
Some((code, reason)) => {
unavailable.push(UnavailableAxisV1 { axis, code, reason });
continue;
}
None => return Err(e),
},
};
let nominal_inside_target = inside(&at_nominal, &nominal, target);
if !nominal_inside_target {
out.push(ToleranceAxisV1 {
axis,
nominal: nominal_value,
nominal_inside_target: false,
near_bound: None,
far_bound: None,
near_limiting_boundary: None,
far_limiting_boundary: None,
unbounded_in_domain: false,
near_has_no_effect: false,
far_has_no_effect: false,
margin_linear_m,
});
continue;
}
let tol = bisection_tolerance(lo, hi);
let pred = |o: &Observation| inside(o, &nominal, target);
let near = bisect_axis(base, axis, range_m, (nominal_value, lo), &pred, tol)?;
let far = bisect_axis(base, axis, range_m, (nominal_value, hi), &pred, tol)?;
let (near_limiting_boundary, near_has_no_effect) = match near {
Some(v) => {
let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?[0];
(Some(limiting_boundary(&o, &nominal, target)), false)
}
None => {
let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(lo))?, &[range_m])?[0];
(None, observation_matches_nominal(&o, &nominal))
}
};
let (far_limiting_boundary, far_has_no_effect) = match far {
Some(v) => {
let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?[0];
(Some(limiting_boundary(&o, &nominal, target)), false)
}
None => {
let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(hi))?, &[range_m])?[0];
(None, observation_matches_nominal(&o, &nominal))
}
};
out.push(ToleranceAxisV1 {
axis,
nominal: nominal_value,
nominal_inside_target: true,
near_bound: near,
far_bound: far,
near_limiting_boundary,
far_limiting_boundary,
unbounded_in_domain: near.is_none() && far.is_none(),
near_has_no_effect,
far_has_no_effect,
margin_linear_m,
});
}
Ok(ToleranceReportV1 {
schema_version: TOLERANCE_SCHEMA_VERSION_V1,
method: "one_variable_deterministic_bisection".to_string(),
assumptions: vec![
"Each bound holds ONE input at its limit while every other input stays at its \
nominal value. Bounds from different axes may NOT be assumed to hold \
simultaneously: two inputs each at their own individual limit will generally miss \
even though neither alone would."
.to_string(),
"No probability distribution is assumed or implied by any bound here. These are \
deterministic limits of a one-variable search, not confidence intervals or a \
probability of hit."
.to_string(),
"A bound is reported only when found strictly within the axis's own configured \
search domain. It is never extrapolated beyond that domain: 'no bound within the \
domain' (unbounded_in_domain) is reported as exactly that fact, never as a guessed \
number."
.to_string(),
"Before any bound is searched for, the nominal solution's own impact is confirmed to \
read as inside the target (nominal_inside_target); an axis for which that check \
fails is reported as such instead of a fabricated or misleading bound."
.to_string(),
],
range_m,
unavailable_axes: unavailable,
axes: out,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error_budget::TargetGeometryV1;
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": 600.0},
"atmosphere": {}, "wind": {"speed_mps": 3.0,
"direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
}).to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
crate::solve_v1::solve_v1(req).unwrap().resolved_request
}
fn vacuum_resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
"ballistic_coefficient": 100.0},
"rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
"shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
"atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
"sampling": {"interval_m": 5.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_larger_target_never_shrinks_a_bound() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let small = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
let big = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Rect { width_m: 0.6, height_m: 0.9 }, &domains).unwrap();
let s = &small.axes[0];
let b = &big.axes[0];
if let (Some(sf), Some(bf)) = (s.far_bound, b.far_bound) {
assert!(bf >= sf - 1e-6, "larger target shrank the bound: {sf} -> {bf}");
}
}
#[test]
fn an_axis_that_never_exits_is_flagged_not_bounded() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (2.9_f64, 3.1_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Rect { width_m: 50.0, height_m: 50.0 }, &domains).unwrap();
assert!(rep.axes[0].unbounded_in_domain);
assert!(rep.axes[0].near_bound.is_none() && rep.axes[0].far_bound.is_none());
assert!(
!rep.axes[0].near_has_no_effect && !rep.axes[0].far_has_no_effect,
"WindSpeed does move the impact within this domain -- it just never leaves this \
huge target -- so has_no_effect must be false in both directions"
);
}
#[test]
fn target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim() {
let r = resolved(); let target = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 };
let td_domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
let td = tolerance_envelope(&r, &[InputAxis::TargetDistance], 400.0, target, &td_domains)
.unwrap();
let a = &td.axes[0];
assert!(a.nominal_inside_target);
assert!(a.near_bound.is_none() && a.far_bound.is_none());
assert!(a.unbounded_in_domain);
assert!(
a.near_has_no_effect && a.far_has_no_effect,
"TargetDistance must show NO measurable effect in either direction, not merely an \
unbounded one -- it cannot change the impact observed at a fixed range_m at all"
);
let zd_domains = [(InputAxis::ZeroDistance, (400.0_f64, 800.0_f64))];
let zd = tolerance_envelope(&r, &[InputAxis::ZeroDistance], 400.0, target, &zd_domains)
.unwrap();
let b = &zd.axes[0];
assert!(b.nominal_inside_target);
assert!(
b.near_bound.is_some() && b.far_bound.is_some(),
"ZeroDistance must produce real bounds: re-zeroing for a different assumed distance \
DOES move the impact observed at the true, fixed range_m"
);
assert!(!b.unbounded_in_domain);
}
#[test]
fn the_report_refuses_to_imply_probability() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.25 }, &domains).unwrap();
assert_eq!(rep.method, "one_variable_deterministic_bisection");
assert!(rep.assumptions.iter().any(|s| s.contains("probability")));
assert!(rep.assumptions.iter().any(|s| s.contains("simultaneously")));
}
#[test]
fn assumptions_cover_all_four_correctness_requirements() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
assert_eq!(rep.assumptions.len(), 4, "exactly four assumption sentences are expected");
assert!(
rep.assumptions[0].contains("simultaneously"),
"one-variable-at-a-time must be stated"
);
assert!(rep.assumptions[1].contains("probability"), "no probability must be stated");
assert!(
rep.assumptions[2].to_lowercase().contains("domain"),
"never-extrapolate-beyond-domain must be stated"
);
assert!(
rep.assumptions[3].contains("nominal_inside_target"),
"the nominal-inside precondition must be stated, discriminated from assumptions[0]'s \
own unrelated use of the word \"nominal\""
);
}
#[test]
fn a_degenerate_target_is_flagged_nominal_outside_not_confused_with_unbounded() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Rect { width_m: 0.0, height_m: 0.3 }, &domains).unwrap();
let a = &rep.axes[0];
assert!(
!a.nominal_inside_target,
"a zero-area target can never contain even the nominal impact"
);
assert!(
a.near_bound.is_none() && a.far_bound.is_none(),
"bounds must not be fabricated when the nominal itself is not inside"
);
assert!(
!a.unbounded_in_domain,
"this is NOT the same fact as 'stays inside throughout' -- conflating the two is \
the exact failure this ticket exists to prevent"
);
}
#[test]
fn bisection_bounds_match_the_vacuum_analytic_crossing() {
let r = vacuum_resolved();
let v0 = r.rifle.muzzle_velocity_mps;
let x = 400.0_f64;
const G: f64 = 9.80665;
let drop = |v: f64| 0.5 * G * (x / v) * (x / v);
let drop0 = drop(v0);
let half_height = 0.1_f64;
let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
let rep = tolerance_envelope(
&r,
&[InputAxis::MuzzleVelocityMps],
x,
TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 2.0 * half_height },
&domains,
)
.unwrap();
let a = &rep.axes[0];
assert!(a.nominal_inside_target);
assert_eq!(rep.range_m, x);
let expected_far = x / (2.0 * (drop0 - half_height) / G).sqrt();
let expected_near = x / (2.0 * (drop0 + half_height) / G).sqrt();
let far = a.far_bound.expect("a crossing exists well within (700, 1100)");
let near = a.near_bound.expect("a crossing exists well within (700, 1100)");
let rel_far = ((far - expected_far) / expected_far).abs();
let rel_near = ((near - expected_near) / expected_near).abs();
assert!(rel_far < 0.02, "far: expected ~{expected_far}, got {far} (rel {rel_far})");
assert!(rel_near < 0.02, "near: expected ~{expected_near}, got {near} (rel {rel_near})");
assert!((far - 900.0).abs() > 30.0, "far bound must not be the domain midpoint");
assert!((near - 900.0).abs() > 30.0, "near bound must not be the domain midpoint");
assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
}
#[test]
fn bisection_tolerance_scales_with_domain_width_not_a_flat_constant() {
assert_eq!(bisection_tolerance(0.0, 20.0), 20.0 * 1e-6);
assert_eq!(bisection_tolerance(700.0, 1100.0), 400.0 * 1e-6);
assert_eq!(bisection_tolerance(800.0, 800.0 + 1e-5), 1e-9);
}
#[test]
fn a_narrow_domain_gets_real_bisection_not_an_immediate_midpoint_return() {
let r = vacuum_resolved();
let x = 400.0_f64;
let half_height = 5e-8_f64;
let domains = [(InputAxis::MuzzleVelocityMps, (799.99995_f64, 800.00005_f64))];
let rep = tolerance_envelope(
&r,
&[InputAxis::MuzzleVelocityMps],
x,
TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 2.0 * half_height },
&domains,
)
.unwrap();
let a = &rep.axes[0];
let far_midpoint = 0.5 * (800.0_f64 + 800.00005_f64);
let far = a.far_bound.expect("a crossing must exist this close to nominal");
assert!(
(far - far_midpoint).abs() > 1e-7,
"far_bound ({far}) must differ meaningfully from the domain's own exact midpoint \
({far_midpoint}) -- an unchanged difference of ~0 would mean bisect_axis returned \
the midpoint verbatim without ever refining it, exactly what a flat 1e-4 tolerance \
does on a domain this narrow"
);
}
#[test]
fn a_found_bound_sits_on_the_target_boundary_verified_independently_of_with_axis() {
let r = resolved();
let target = TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 };
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
let a = &rep.axes[0];
assert!(a.nominal_inside_target);
let far = a.far_bound.expect("this target/domain combination must produce a far bound");
fn independent_deviation(wind_speed: f64) -> (f64, f64) {
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": 600.0},
"atmosphere": {},
"wind": {"speed_mps": wind_speed,
"direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let solved = crate::solve_v1::solve_v1(req).unwrap();
let sample = solved
.samples
.iter()
.find(|s| s.distance_m == 600.0)
.expect("600 m must be an exact grid point for this fixture");
(sample.drop_m, sample.windage_m)
}
let half_width = 0.2 / 2.0;
let half_height = 0.3 / 2.0;
let (nominal_drop, nominal_windage) = independent_deviation(3.0);
let ratio_at = |wind_speed: f64| -> f64 {
let (d, w) = independent_deviation(wind_speed);
let ry = (d - nominal_drop).abs() / half_height;
let rz = (w - nominal_windage).abs() / half_width;
ry.max(rz)
};
let ratio_far = ratio_at(far);
assert!(
(ratio_far - 1.0).abs() < 1e-3,
"found bound does not sit on the target boundary independently: ratio {ratio_far}"
);
let just_inside = 3.0 + (far - 3.0) * 0.99;
let just_outside = 3.0 + (far - 3.0) * 1.01;
assert!(
ratio_at(just_inside) < 1.0,
"a point just inside the found bound must independently read as inside the target"
);
assert!(
ratio_at(just_outside) > 1.0,
"a point just past the found bound must independently read as outside the target"
);
}
#[test]
fn a_larger_target_never_shrinks_a_bound_across_a_size_sweep() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let scales: Vec<f64> = (0..12).map(|i| 0.5 * 1.6_f64.powi(i)).collect();
let mut prev_far: Option<f64> = None;
let mut prev_near: Option<f64> = None;
let mut far_became_unbounded = false;
let mut near_became_unbounded = false;
for &scale in &scales {
let target =
TargetGeometryV1::Rect { width_m: 0.2 * scale, height_m: 0.3 * scale };
let rep =
tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
let a = &rep.axes[0];
assert!(
a.nominal_inside_target,
"scale {scale}: nominal must read as inside for any positive-size target"
);
if far_became_unbounded {
assert!(
a.far_bound.is_none(),
"scale {scale}: far bound reappeared after becoming unbounded at a smaller \
scale"
);
} else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
assert!(f >= pf - 1e-6, "far bound shrank at scale {scale}: {pf} -> {f}");
}
if a.far_bound.is_none() {
far_became_unbounded = true;
}
prev_far = a.far_bound;
if near_became_unbounded {
assert!(
a.near_bound.is_none(),
"scale {scale}: near bound reappeared after becoming unbounded at a smaller \
scale"
);
} else if let (Some(pn), Some(n)) = (prev_near, a.near_bound) {
assert!(
n <= pn + 1e-6,
"near bound shrank (moved toward nominal) at scale {scale}: {pn} -> {n}"
);
}
if a.near_bound.is_none() {
near_became_unbounded = true;
}
prev_near = a.near_bound;
}
assert!(
far_became_unbounded,
"sweep never reached an unbounded far regime -- the monotonicity check on that \
transition never actually ran"
);
assert!(
near_became_unbounded,
"sweep never reached an unbounded near regime -- the monotonicity check on that \
transition never actually ran"
);
assert!(scales[0] < 1.0, "sweep must start comfortably inside the bounded regime");
}
#[test]
fn a_larger_circle_never_shrinks_a_bound_across_a_size_sweep() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let scales = [0.5_f64, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0];
let mut prev_far: Option<f64> = None;
let mut far_became_unbounded = false;
for &scale in &scales {
let target = TargetGeometryV1::Circle { radius_m: 0.15 * scale };
let rep =
tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
let a = &rep.axes[0];
if far_became_unbounded {
assert!(a.far_bound.is_none(), "scale {scale}: far bound reappeared");
} else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
assert!(f >= pf - 1e-6, "far bound shrank at scale {scale}: {pf} -> {f}");
}
if a.far_bound.is_none() {
far_became_unbounded = true;
}
prev_far = a.far_bound;
}
assert!(far_became_unbounded, "circle sweep never reached an unbounded far regime");
}
#[test]
fn multiple_axes_are_each_independently_computed_and_correctly_tagged() {
let r = resolved();
let domains = [
(InputAxis::WindSpeed, (0.0_f64, 20.0_f64)),
(InputAxis::MuzzleVelocityMps, (400.0_f64, 1200.0_f64)),
];
let rep = tolerance_envelope(
&r,
&[InputAxis::WindSpeed, InputAxis::MuzzleVelocityMps],
600.0,
TargetGeometryV1::Rect { width_m: 0.4, height_m: 0.6 },
&domains,
)
.unwrap();
assert_eq!(rep.axes.len(), 2);
assert_eq!(rep.axes[0].axis, InputAxis::WindSpeed);
assert_eq!(rep.axes[1].axis, InputAxis::MuzzleVelocityMps);
let expected_ws = match read_axis(&r, InputAxis::WindSpeed).unwrap() {
AxisValue::Scalar(x) => x,
other => panic!("WindSpeed must read back as a scalar, got {other:?}"),
};
let expected_mv = match read_axis(&r, InputAxis::MuzzleVelocityMps).unwrap() {
AxisValue::Scalar(x) => x,
other => panic!("MuzzleVelocityMps must read back as a scalar, got {other:?}"),
};
assert_eq!(rep.axes[0].nominal, expected_ws);
assert_eq!(rep.axes[1].nominal, expected_mv);
assert_ne!(
rep.axes[0].nominal, rep.axes[1].nominal,
"fixture sanity: the two axes must have DIFFERENT nominal values or a \
transposition between them would be invisible"
);
assert!(rep.axes[0].nominal_inside_target);
assert!(rep.axes[1].nominal_inside_target);
assert!((rep.axes[0].margin_linear_m - 0.2).abs() < 1e-9);
assert!((rep.axes[1].margin_linear_m - 0.2).abs() < 1e-9);
assert_eq!(rep.range_m, 600.0);
}
#[test]
fn windage_dominant_axis_reports_left_or_right_not_top_or_bottom() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
let a = &rep.axes[0];
assert!(a.far_bound.is_some() && a.near_bound.is_some());
assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Left));
assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Right));
}
#[test]
fn drop_dominant_axis_reports_top_or_bottom_not_left_or_right() {
let r = vacuum_resolved();
let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::MuzzleVelocityMps], 400.0,
TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 0.2 }, &domains).unwrap();
let a = &rep.axes[0];
assert!(a.far_bound.is_some() && a.near_bound.is_some());
assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
}
#[test]
fn a_circle_target_always_reports_radial() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.15 }, &domains).unwrap();
let a = &rep.axes[0];
assert!(a.far_bound.is_some() && a.near_bound.is_some());
assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Radial));
assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Radial));
assert!(
(a.margin_linear_m - 0.15).abs() < 1e-9,
"Circle's margin_linear_m must be exactly its radius_m, got {}",
a.margin_linear_m
);
}
#[test]
fn limiting_boundary_uses_the_correct_axis_for_each_deviation_not_swapped() {
let nominal = Observation {
range_m: 600.0,
drop_m: 0.0,
windage_m: 0.0,
time_s: 1.0,
velocity_mps: 500.0,
};
let o = Observation {
range_m: 600.0,
drop_m: 0.1,
windage_m: 0.5,
time_s: 1.0,
velocity_mps: 500.0,
};
let target = TargetGeometryV1::Rect { width_m: 2.0, height_m: 0.2 };
assert_eq!(
limiting_boundary(&o, &nominal, target),
LimitingBoundaryV1::Bottom,
"dy/height_m ratio (1.0) exceeds dz/width_m ratio (0.5): must be a drop-family edge"
);
let o_top = Observation { drop_m: -0.1, ..o };
assert_eq!(limiting_boundary(&o_top, &nominal, target), LimitingBoundaryV1::Top);
}
#[test]
fn a_categorical_axis_is_recorded_unavailable_not_silently_dropped_or_hard_failed() {
let r = resolved();
let rep = tolerance_envelope(&r, &[InputAxis::CoriolisEnabled], 600.0,
TargetGeometryV1::Circle { radius_m: 0.25 }, &[]).unwrap();
assert!(rep.axes.is_empty(), "a categorical axis must never appear in `axes`");
assert_eq!(rep.unavailable_axes.len(), 1);
assert_eq!(rep.unavailable_axes[0].axis, InputAxis::CoriolisEnabled);
assert_eq!(rep.unavailable_axes[0].code, UnavailableReasonCodeV1::CategoricalAxis);
assert!(!rep.unavailable_axes[0].reason.is_empty());
}
#[test]
fn a_wind_axis_under_segmented_wind_is_recorded_unavailable() {
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": {},
"wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
"direction_from_rad": 1.0}]},
"solver": {}, "effects": {}, "sampling": {"interval_m": 50.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 = tolerance_envelope(&r, &[InputAxis::WindSpeed], 300.0,
TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 }, &[]).unwrap();
assert!(rep.axes.is_empty());
assert_eq!(rep.unavailable_axes.len(), 1);
assert_eq!(rep.unavailable_axes[0].axis, InputAxis::WindSpeed);
assert_eq!(rep.unavailable_axes[0].code, UnavailableReasonCodeV1::AxisAbsent);
}
#[test]
fn altitude_under_qnh_is_recorded_unavailable_not_hard_failed() {
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();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let domains = [(InputAxis::Altitude, (0.0_f64, 1000.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::Altitude], 600.0,
TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap();
assert!(rep.axes.is_empty());
assert_eq!(rep.unavailable_axes.len(), 1);
assert_eq!(rep.unavailable_axes[0].axis, InputAxis::Altitude);
assert_eq!(
rep.unavailable_axes[0].code,
UnavailableReasonCodeV1::AxisUnsupportedForRequest
);
assert!(rep.unavailable_axes[0].reason.to_lowercase().contains("qnh"));
}
#[test]
fn a_genuine_observation_error_propagates_not_recorded_as_unavailable() {
let r = resolved(); let domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
let err = tolerance_envelope(&r, &[InputAxis::TargetDistance], 600.0,
TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 }, &domains).unwrap_err();
match err {
KernelError::Observation(TrajectoryObservationError::OutOfRange { requested_m, .. }) => {
assert_eq!(requested_m, 600.0);
}
other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
}
}
#[test]
fn a_missing_domain_is_reported_as_invalid_not_defaulted() {
let r = resolved();
let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.3 }, &[]).unwrap_err();
match err {
KernelError::InvalidDomain { axis, .. } => assert_eq!(axis, InputAxis::WindSpeed),
other => panic!("expected InvalidDomain, got {other:?}"),
}
}
#[test]
fn nominal_outside_the_configured_domain_is_rejected() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (5.0_f64, 20.0_f64))];
let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
}
#[test]
fn an_inverted_domain_is_rejected() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (20.0_f64, 0.0_f64))];
let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
}
#[test]
fn nominal_at_the_lower_domain_edge_is_rejected() {
let r = resolved(); let domains = [(InputAxis::WindSpeed, (3.0_f64, 20.0_f64))];
let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
}
#[test]
fn nominal_at_the_upper_domain_edge_is_rejected() {
let r = resolved(); let domains = [(InputAxis::WindSpeed, (0.0_f64, 3.0_f64))];
let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
}
#[test]
fn an_out_of_range_query_is_rejected_directly() {
let r = resolved(); let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 5000.0,
TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
match err {
KernelError::Observation(TrajectoryObservationError::OutOfRange { requested_m, .. }) => {
assert_eq!(requested_m, 5000.0);
}
other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
}
}
#[test]
fn the_report_round_trips_through_json() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
let json = serde_json::to_string(&rep).unwrap();
assert!(json.contains("\"axis\":\"wind_speed\""));
let back: ToleranceReportV1 = serde_json::from_str(&json).unwrap();
assert_eq!(back.schema_version, rep.schema_version);
assert_eq!(back.method, rep.method);
assert_eq!(back.assumptions, rep.assumptions);
assert_eq!(back.range_m, rep.range_m);
assert_eq!(back.unavailable_axes, rep.unavailable_axes);
assert_eq!(back.axes.len(), rep.axes.len());
let (ba, ra) = (&back.axes[0], &rep.axes[0]);
assert_eq!(ba.axis, ra.axis);
assert_eq!(ba.nominal_inside_target, ra.nominal_inside_target);
assert_eq!(ba.unbounded_in_domain, ra.unbounded_in_domain);
assert_eq!(ba.near_limiting_boundary, ra.near_limiting_boundary);
assert_eq!(ba.far_limiting_boundary, ra.far_limiting_boundary);
assert!((ba.nominal - ra.nominal).abs() < 1e-9);
assert!((ba.margin_linear_m - ra.margin_linear_m).abs() < 1e-9);
assert!((ba.near_bound.unwrap() - ra.near_bound.unwrap()).abs() < 1e-9);
assert!((ba.far_bound.unwrap() - ra.far_bound.unwrap()).abs() < 1e-9);
}
#[test]
fn schema_version_matches_the_declared_constant() {
let r = resolved();
let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
TargetGeometryV1::Circle { radius_m: 0.25 }, &domains).unwrap();
assert_eq!(rep.schema_version, TOLERANCE_SCHEMA_VERSION_V1);
}
}