ballistics_engine/tolerance.rs
1//! MBA-1350: how wrong may one input be before the shot leaves the target?
2//!
3//! This is the deterministic inverse of a WEZ sweep. For a nominal (already dialed-in, centred)
4//! solution and an explicit target, each requested axis is bisected outward -- toward its
5//! configured domain's lower bound (`near_bound`) and, independently, toward its upper bound
6//! (`far_bound`) -- until the impact crosses the target boundary. Bounds are strictly ONE
7//! VARIABLE AT A TIME: two inputs each at their own individual limit will generally miss even
8//! though neither alone would, and no probability is attached to any bound reported here. Both
9//! of those are stated in [`ToleranceReportV1::assumptions`] itself, not only in this comment --
10//! see `the_report_refuses_to_imply_probability` in this module's tests.
11//!
12//! Built entirely on the existing kernel: [`bisect_axis`] for the search,
13//! [`evaluate`]/[`read_axis`]/[`with_axis`] for reading and rebuilding requests, and
14//! [`TargetGeometryV1`] (Task 11, `crate::error_budget`) for the target shape -- reused verbatim,
15//! not re-defined, including its "always centred on the nominal impact point" semantics.
16//!
17//! # The central hazard: `Ok(None)` means two different things
18//!
19//! [`bisect_axis`]'s own contract (`crate::perturbation::derive`'s module doc, "Bisection
20//! contract") is explicit that `Ok(None)` means ONLY "the predicate did not change truth value
21//! across this domain" -- nothing more. In general, for a two-sided "stays inside a region"
22//! predicate, that single fact is consistent with TWO opposite situations that look identical at
23//! the type level: the region is never exited, or the search never started inside it at all.
24//!
25//! **In this module specifically, the second situation has exactly one door.** `target`
26//! ([`TargetGeometryV1`]) has no offset field at all -- every variant is, by that type's own
27//! doc, ALWAYS defined centred on the nominal impact point. That makes the nominal itself, read
28//! against its own centred target, inside by construction for any target with positive area
29//! (`dy == dz == 0.0` trivially satisfies `dy.abs() <= height_m / 2.0` etc. whenever `height_m`/
30//! `width_m`/`radius_m` is positive). The ONLY way the nominal can fail that check is a
31//! DEGENERATE target -- non-positive width, height, or radius, which contains no point at all,
32//! not even its own centre. A target genuinely offset from the nominal, or a nominal that is
33//! "not really centred" for some other reason, is not an expressible input to this API at all --
34//! `TargetGeometryV1` has no field that could carry it -- so that reading of "outside throughout"
35//! cannot be the live case here, and this doc previously implied otherwise.
36//!
37//! **This module still checks it explicitly rather than assuming it**, via a pre-flight step
38//! that evaluates the shared anchor point itself -- the IDENTICAL `with_axis`/`evaluate`
39//! reconstruction [`bisect_axis`] uses for `domain.0` in both the near and far searches (see
40//! [`tolerance_envelope`]'s "Pre-flight" step below) -- BEFORE trusting any `Ok(None)` it
41//! returns. This closes the only door that DOES exist (a degenerate target, or a reconstruction
42//! bug in `with_axis`/`evaluate` themselves) rather than guarding against a routinely-reachable
43//! "off-centre nominal" scenario the type system cannot currently express; it is still worth
44//! keeping for that narrower reason, and is still what makes the following distinction sound
45//! rather than assumed. If the anchor check reads as "outside" --
46//! [`ToleranceAxisV1::nominal_inside_target`] is `false` -- the search is never even run: see
47//! correctness requirement 1 below. Only once the anchor is CONFIRMED "inside" does an `Ok(None)`
48//! from a search direction unambiguously mean "stays inside throughout that direction," recorded
49//! as [`ToleranceAxisV1::unbounded_in_domain`]. The two outcomes share the same `_bound: None`
50//! shape but are otherwise completely distinct fields -- see
51//! `a_degenerate_target_is_flagged_nominal_outside_not_confused_with_unbounded` and
52//! `an_axis_that_never_exits_is_flagged_not_bounded` in this module's tests, which produce
53//! IDENTICAL `near_bound`/`far_bound` (`None`/`None`) from two DIFFERENT root causes and assert
54//! that `nominal_inside_target`/`unbounded_in_domain` tell them apart.
55//!
56//! One limitation this module inherits rather than solves: [`bisect_axis`] itself assumes the
57//! predicate changes truth value AT MOST ONCE across a search direction. If an axis's effect on
58//! impact were non-monotonic over the ENTIRE configured domain (physically unusual, but not
59//! impossible for a very wide domain), a single bisection could in principle miss an excursion
60//! that both re-enters "inside" before reaching the domain edge -- this is a pre-existing,
61//! documented property of [`bisect_axis`] itself (see its module doc), not something specific to
62//! this module's use of it, and not something a bounded amount of extra work here can fully
63//! close without a much more expensive full-domain scan. Choosing a domain scoped to where the
64//! axis is plausibly monotonic is the caller's responsibility, exactly as it already is for every
65//! other consumer of [`bisect_axis`].
66//!
67//! # The four correctness requirements
68//!
69//! 1. **The nominal must actually read as inside before a bound means anything.** See "The
70//! central hazard" above -- enforced by the pre-flight check, not assumed.
71//! 2. **Bounds are one axis at a time and may not be assumed simultaneously.** Stated in
72//! [`ToleranceReportV1::assumptions`].
73//! 3. **No probability is implied.** Also stated in `assumptions`.
74//! 4. **A bound is never extrapolated beyond the caller's configured domain.** [`bisect_axis`]
75//! itself never looks outside `domain`; this module additionally never INVENTS a domain the
76//! caller did not supply -- see "Domains are validated up front, per axis" below and
77//! [`KernelError::InvalidDomain`].
78//!
79//! # Domains are validated up front, per axis
80//!
81//! Unlike an earlier draft of this feature, there is no implicit fallback domain (e.g.
82//! `nominal * 0.5 ..= nominal * 1.5`): that specific formula is not just unspecified but actively
83//! wrong whenever an axis's nominal value is exactly `0.0` (routine for `WindDirection`, `Cant`,
84//! `ShootingAngle`, and several others), where it collapses to a zero-width `(0.0, 0.0)` domain
85//! that can never be searched. Every axis in `axes` must have a corresponding entry in `domains`
86//! with both bounds finite, the lower bound strictly less than the upper, and the axis's own
87//! current value strictly between them (not merely `<=`/`>=`: a nominal value sitting exactly AT
88//! one edge would make that direction's search a zero-width probe, i.e. `bisect_axis`'s two
89//! endpoints would be the same point and it would trivially -- and misleadingly -- report
90//! `Ok(None)`). A missing or invalid domain returns [`KernelError::InvalidDomain`] immediately,
91//! before any solve.
92//!
93//! # Unavailable axes
94//!
95//! [`bisect_axis`]/[`with_axis`] can legitimately refuse to search a declared axis:
96//! [`KernelError::CategoricalAxis`] (an effect toggle -- no numeric domain to bisect),
97//! [`KernelError::AxisAbsent`] (a wind axis under segmented wind -- no single scalar value to
98//! hold at a nominal or perturb), or [`KernelError::AxisUnsupportedForRequest`] (`Altitude` under
99//! a QNH-referenced atmosphere, `ShotAzimuth` under compass-referenced wind). Every one of these
100//! is recorded in [`ToleranceReportV1::unavailable_axes`] (axis, machine-readable
101//! [`UnavailableReasonCodeV1`], human-readable reason) and the rest of the report is still
102//! produced from whatever axes DID evaluate -- silently dropping an unavailable axis would look
103//! identical to "this axis was searched and found to have no bound," which is a different fact.
104//!
105//! This reuses `crate::error_budget`'s existing four-way classification verbatim (widened to
106//! `pub(crate)` for this module) rather than defining a second, independently maintained copy of
107//! the same split -- see that function's own doc comment for the one caveat this creates (two of
108//! its four reason strings read as written for differentiation/uncertainty, and
109//! `StepOutOfDomain` is not actually reachable through `bisect_axis` today, only through
110//! `central_difference`; both are accepted trade-offs of sharing one classifier).
111//!
112//! Any OTHER error (`Solve`, `Observation`, the defensive `TypeMismatch`/`NonFinite`,
113//! `DuplicateAxis`, or this module's own [`KernelError::InvalidDomain`]) is a genuine failure --
114//! most notably a domain whose lower bound, for the `TargetDistance` axis, dips below the
115//! caller's own `range_m` (an internally inconsistent request: "how close could the target
116//! plausibly be" bisected past "the range I am currently asking about") -- and propagates
117//! immediately, aborting the whole report rather than mislabelling it as a per-axis refusal. See
118//! `a_genuine_observation_error_propagates_not_recorded_as_unavailable`.
119//!
120//! # `TargetDistance` cannot answer "how wrong may my range estimate be"
121//!
122//! The ticket's own motivating example is a range-estimate question -- "my rangefinder read
123//! 600 m; how far off could that reading be before I miss?" The axis that superficially matches,
124//! `InputAxis::TargetDistance` (`shot.max_range_m`), does NOT answer it. `TargetDistance` is
125//! `requires_rezero: false` (`crate::perturbation::axis_meta`): perturbing it changes only how
126//! far the trajectory is COMPUTED, never the muzzle angle or any sight correction, so it has NO
127//! effect at all on the impact observed at a fixed `range_m` as long as the perturbed
128//! `max_range_m` stays at or above `range_m`. Bisecting it reports either a hard
129//! [`KernelError::Observation`] (once the search dips below `range_m`, see above) or
130//! `unbounded_in_domain: true` with [`ToleranceAxisV1::near_has_no_effect`]/
131//! [`ToleranceAxisV1::far_has_no_effect`] both `true` -- literally correct about this specific
132//! axis, but not an answer to the range-estimate question, and a caller who reads
133//! `unbounded_in_domain: true` alone (without also checking the `_has_no_effect` flags) could
134//! easily mistake "this axis has no effect" for "your range estimate can be off by any amount
135//! and still hit," which is not what it means here.
136//!
137//! The axis that actually answers a range-estimate question is `InputAxis::ZeroDistance`
138//! (`shot.zero_distance_m`, `requires_rezero: true`): perturbing it re-runs the elevation search
139//! for a DIFFERENT assumed zero distance, producing a different effective muzzle angle, and only
140//! THEN observes the impact at the caller's own fixed, unchanged `range_m` -- i.e. "if I had
141//! dialed for a different distance than the true one, how far off would I land at the true
142//! range," which is what a rangefinder error actually does to a shot. See
143//! `target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim` in this
144//! module's tests, which pins both the `TargetDistance` "no effect" finding and the contrast
145//! against an axis that genuinely does move the impact but merely stays inside a large target.
146//!
147//! # Cost
148//!
149//! Per axis: one pre-flight solve, then up to [`crate::perturbation::derive::BISECTION_MAX_ITERATIONS`]
150//! solves for EACH of the two search directions (far fewer in practice -- an `Ok(None)` result
151//! costs only the two domain-endpoint evaluations, and a found crossing converges to this
152//! module's own domain-relative tolerance in on the order of twenty iterations for a typical
153//! domain width, not the full cap), plus one more solve for each direction that DID find a bound
154//! (to classify which edge of the target it crosses). A `requires_rezero` axis
155//! (`crate::perturbation::axis_meta`) multiplies each of those solves by the elevation search's
156//! own cost, exactly as it does for [`crate::perturbation::derive::central_difference`] and
157//! [`crate::error_budget::error_budget`] -- unavoidable here, not something this module changes.
158
159use serde::{Deserialize, Serialize};
160
161use crate::error_budget::{unavailable_reason, TargetGeometryV1, UnavailableReasonCodeV1};
162use crate::perturbation::access::{read_axis, with_axis, AxisValue, KernelError};
163use crate::perturbation::derive::bisect_axis;
164use crate::perturbation::taxonomy::{axis_meta, AxisKind, InputAxis};
165use crate::perturbation::{evaluate, Observation};
166use crate::solve_json::ResolvedSolveRequestV1;
167use crate::trajectory_observation::TrajectoryObservationError;
168
169/// Schema version for [`ToleranceReportV1`].
170pub const TOLERANCE_SCHEMA_VERSION_V1: u32 = 1;
171
172/// Which edge of a [`TargetGeometryV1::Rect`] a found bound crosses, from the shooter's own
173/// point of view: `Top`/`Bottom` along the drop axis, `Left`/`Right` along the windage axis.
174/// `drop_m` is positive BELOW the line of sight and `windage_m` is positive to the shooter's
175/// RIGHT (see [`crate::perturbation::Observation`]), so more drop than nominal crosses the
176/// `Bottom` edge and more rightward windage than nominal crosses the `Right` edge.
177/// [`TargetGeometryV1::Circle`] has no distinct edges and always reports `Radial`.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum LimitingBoundaryV1 {
181 Top,
182 Bottom,
183 Left,
184 Right,
185 Radial,
186}
187
188/// One axis's tolerance envelope: how far it may move from its own current value, in EACH
189/// direction independently, before the impact crosses `target`'s boundary.
190///
191/// # Reading these fields together
192///
193/// - **A real bound was found** in a direction: that direction's `_bound` field is `Some`
194/// (verified, in this module's tests, by re-solving AT the bound via a path independent of
195/// `with_axis`/`bisect_axis` and confirming it sits on the boundary).
196/// - **Confirmed to stay inside throughout the configured domain**: `nominal_inside_target` is
197/// `true` and the corresponding `_bound` field(s) are `None`. `unbounded_in_domain` is `true`
198/// exactly when this holds in BOTH directions.
199/// - **The nominal itself does not read as inside `target`**: `nominal_inside_target` is `false`.
200/// Neither direction is even searched -- both `_bound` fields are `None` and
201/// `unbounded_in_domain` is `false`, NOT `true`. This is the module's central distinction; see
202/// the module doc's "The central hazard" section.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct ToleranceAxisV1 {
205 pub axis: InputAxis,
206 /// This axis's own current (unperturbed) value, in its own physical unit
207 /// (`crate::perturbation::axis_meta(axis).kind`).
208 pub nominal: f64,
209 /// Whether the impact, with this axis pinned AT `nominal` via the identical
210 /// `with_axis`/`evaluate` reconstruction the bisection itself uses for the shared anchor
211 /// point, reads as inside `target`. Correctness requirement 1: bounds are only searched for
212 /// when this is `true`. Virtually always `true` in ordinary use -- perturbing an axis back
213 /// to its own current value must reproduce the request's own nominal impact -- `false` is
214 /// reserved for a target with no positive area (a [`TargetGeometryV1`] that cannot contain
215 /// any point, not even its own centre); see this module's tests for how that is exercised
216 /// without relying on self-consistency.
217 pub nominal_inside_target: bool,
218 /// The bound found searching FROM `nominal` TOWARD this axis's configured domain's lower
219 /// bound, or `None` -- see the struct doc for what `None` means here.
220 pub near_bound: Option<f64>,
221 /// The bound found searching FROM `nominal` TOWARD this axis's configured domain's upper
222 /// bound, or `None` -- see the struct doc for what `None` means here.
223 pub far_bound: Option<f64>,
224 /// Which edge of `target` `near_bound` crosses, or `None` exactly when `near_bound` is
225 /// `None`.
226 pub near_limiting_boundary: Option<LimitingBoundaryV1>,
227 /// Which edge of `target` `far_bound` crosses, or `None` exactly when `far_bound` is `None`.
228 pub far_limiting_boundary: Option<LimitingBoundaryV1>,
229 /// `true` exactly when `nominal_inside_target` is `true` AND both `near_bound` and
230 /// `far_bound` are `None`: the impact is confirmed to stay inside `target` across the WHOLE
231 /// configured domain (subject to `bisect_axis`'s own one-crossing assumption -- see the
232 /// module doc). Deliberately NOT simply `near_bound.is_none() && far_bound.is_none()`: that
233 /// would also be `true` when `nominal_inside_target` is `false`, which is the exact
234 /// conflation this ticket exists to prevent.
235 pub unbounded_in_domain: bool,
236 /// `true` exactly when `near_bound` is `None` AND the impact at the domain's own lower edge
237 /// is indistinguishable (within `1e-9` m on both drop and windage) from the nominal impact:
238 /// this axis has NO MEASURABLE EFFECT on the observed impact in this direction at all, as
239 /// distinct from an axis that DOES move the impact but never far enough to leave `target`.
240 /// See "`TargetDistance` cannot answer..." in the module doc for the motivating example
241 /// (`TargetDistance`'s `requires_rezero: false` means it never affects the impact at a fixed
242 /// `range_m` at all, so `unbounded_in_domain: true` for it means "this axis is provably
243 /// irrelevant here," not "any error in it is safe"). Always `false` when `near_bound` is
244 /// `Some` (a bound exists, so the axis clearly has SOME effect) or `nominal_inside_target` is
245 /// `false` (the direction was never searched).
246 pub near_has_no_effect: bool,
247 /// As `near_has_no_effect`, for the far direction.
248 pub far_has_no_effect: bool,
249 /// The smallest linear half-extent of `target` (`min(height_m, width_m) / 2.0` for a
250 /// [`TargetGeometryV1::Rect`], `radius_m` for a [`TargetGeometryV1::Circle`]) -- a
251 /// convenience summary of how tight the target is, identical across every axis in a report
252 /// since it depends only on `target`.
253 pub margin_linear_m: f64,
254}
255
256/// One requested axis [`tolerance_envelope`] could not search at all, and why -- distinct from an
257/// axis that WAS searched and found to have no bound (see [`ToleranceAxisV1`]). Never silently
258/// dropped: see this module's "Unavailable axes" doc section.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct UnavailableAxisV1 {
261 pub axis: InputAxis,
262 pub code: UnavailableReasonCodeV1,
263 pub reason: String,
264}
265
266/// One-variable tolerance envelope report (MBA-1350): how far each requested input may drift
267/// from its own current value, one at a time, before the impact leaves an explicit target.
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct ToleranceReportV1 {
270 pub schema_version: u32,
271 pub method: String,
272 /// Always states, in the payload itself (not only in prose documentation): bounds are one
273 /// axis at a time and may not be assumed to hold simultaneously; no probability is implied;
274 /// a bound is never extrapolated past the caller's configured domain; and the nominal is
275 /// confirmed inside the target before any bound is searched for. See
276 /// `assumptions_cover_all_four_correctness_requirements` in this module's tests.
277 pub assumptions: Vec<String>,
278 pub range_m: f64,
279 /// Axes [`tolerance_envelope`] could not search. See "Unavailable axes" in the module doc.
280 pub unavailable_axes: Vec<UnavailableAxisV1>,
281 /// One entry per requested axis that WAS searched, in the order requested.
282 pub axes: Vec<ToleranceAxisV1>,
283}
284
285/// Bisection tolerance for one axis's search: proportional to the CALLER's own configured domain
286/// width rather than a single fixed absolute number, so it neither wastes iterations converging
287/// an oversized domain (e.g. a pressure domain spanning tens of kPa) to an absurdly tight
288/// absolute tolerance, nor undershoots a narrow one (e.g. a `+/-0.05` rad cant domain). Mirrors
289/// the crate's existing step-size convention (`axis_meta`'s `h = (|x| * rel).max(min_abs)`,
290/// `crate::perturbation::taxonomy`) applied to a domain WIDTH instead of a value. Comfortably
291/// achievable within `BISECTION_MAX_ITERATIONS` for any domain width a caller would plausibly
292/// configure: halving a domain 80 times shrinks it by a factor of `2^80`, far more than the
293/// `1e6` this needs.
294fn bisection_tolerance(lo: f64, hi: f64) -> f64 {
295 ((hi - lo).abs() * 1e-6).max(1e-9)
296}
297
298/// The smallest linear half-extent of `target` -- see [`ToleranceAxisV1::margin_linear_m`].
299fn target_margin_linear_m(target: TargetGeometryV1) -> f64 {
300 match target {
301 TargetGeometryV1::Rect { width_m, height_m } => {
302 (height_m.max(0.0) / 2.0).min(width_m.max(0.0) / 2.0)
303 }
304 TargetGeometryV1::Circle { radius_m } => radius_m.max(0.0),
305 }
306}
307
308/// Whether `o`'s (drop, windage) deviation from `nominal` falls inside `target`, which is always
309/// centred on `nominal` -- see [`TargetGeometryV1`]'s own doc. A target with no positive area
310/// (non-positive width/height/radius) can never contain any point, not even `nominal` itself
311/// (`dy == dz == 0`): this is the mechanism behind [`ToleranceAxisV1::nominal_inside_target`]
312/// being `false` for a degenerate target, mirroring `crate::error_budget::p_hit_bivariate`'s
313/// identical guard for the identical type.
314fn inside(o: &Observation, nominal: &Observation, target: TargetGeometryV1) -> bool {
315 let dy = o.drop_m - nominal.drop_m;
316 let dz = o.windage_m - nominal.windage_m;
317 match target {
318 TargetGeometryV1::Rect { width_m, height_m } => {
319 width_m > 0.0
320 && height_m > 0.0
321 && dy.abs() <= height_m / 2.0
322 && dz.abs() <= width_m / 2.0
323 }
324 TargetGeometryV1::Circle { radius_m } => {
325 radius_m > 0.0 && (dy * dy + dz * dz).sqrt() <= radius_m
326 }
327 }
328}
329
330/// Whether `o`'s (drop, windage) is indistinguishable from `nominal`'s to within `1e-9` m on
331/// each -- see [`ToleranceAxisV1::near_has_no_effect`]/[`ToleranceAxisV1::far_has_no_effect`].
332/// `1e-9` m is far tighter than any genuine physical sensitivity this crate's taxonomy produces
333/// (see `crate::perturbation::taxonomy`'s `min_abs_step` values, none smaller than `1e-7`) and
334/// far looser than floating-point noise from an identical, deterministic re-solve (no
335/// axis/request combination this module reaches involves randomness), so this only fires for an
336/// axis that is truly, not merely negligibly, disconnected from the observed impact.
337fn observation_matches_nominal(o: &Observation, nominal: &Observation) -> bool {
338 (o.drop_m - nominal.drop_m).abs() < 1e-9 && (o.windage_m - nominal.windage_m).abs() < 1e-9
339}
340
341/// Which edge of `target` `o` (already known to be outside, or exactly on the boundary of,
342/// `target` relative to `nominal`) crosses. For a [`TargetGeometryV1::Rect`], compares
343/// `dy.abs() / (height_m / 2)` against `dz.abs() / (width_m / 2)` (whichever ratio is larger is
344/// the edge actually being exceeded), rewritten as `dy.abs() * width_m >= dz.abs() * height_m`
345/// to avoid dividing by a half-extent that could be zero.
346fn limiting_boundary(
347 o: &Observation,
348 nominal: &Observation,
349 target: TargetGeometryV1,
350) -> LimitingBoundaryV1 {
351 let dy = o.drop_m - nominal.drop_m;
352 let dz = o.windage_m - nominal.windage_m;
353 match target {
354 TargetGeometryV1::Circle { .. } => LimitingBoundaryV1::Radial,
355 TargetGeometryV1::Rect { width_m, height_m } => {
356 if dy.abs() * width_m.max(0.0) >= dz.abs() * height_m.max(0.0) {
357 if dy > 0.0 {
358 LimitingBoundaryV1::Bottom
359 } else {
360 LimitingBoundaryV1::Top
361 }
362 } else if dz > 0.0 {
363 LimitingBoundaryV1::Right
364 } else {
365 LimitingBoundaryV1::Left
366 }
367 }
368 }
369}
370
371/// How far may each of `axes` drift from its own current value, one at a time, before the impact
372/// leaves `target` -- see the module doc for the full contract, and especially "The central
373/// hazard" for what `unbounded_in_domain`/`nominal_inside_target` mean together.
374///
375/// `domains` supplies, for each axis in `axes`, the `(lower, upper)` bound to search within; see
376/// "Domains are validated up front, per axis" in the module doc.
377///
378/// # Errors
379///
380/// - [`KernelError::Observation`] immediately, before any solve, if `range_m` is not finite or
381/// falls outside `[0, base.shot.max_range_m]` -- the same check
382/// `crate::error_budget::error_budget` applies to its own `ranges_m`.
383/// - [`KernelError::InvalidDomain`] immediately, before any solve for that axis, if `domains` has
384/// no entry for one of `axes`, either bound is not finite, the lower bound is not strictly less
385/// than the upper, or the axis's own current value does not sit strictly between them.
386/// - Otherwise, propagates any [`KernelError`] that is not one of the four structural refusals
387/// recorded in [`ToleranceReportV1::unavailable_axes`] instead -- see "Unavailable axes" in the
388/// module doc.
389pub fn tolerance_envelope(
390 base: &ResolvedSolveRequestV1,
391 axes: &[InputAxis],
392 range_m: f64,
393 target: TargetGeometryV1,
394 domains: &[(InputAxis, (f64, f64))],
395) -> Result<ToleranceReportV1, KernelError> {
396 // Validate range_m up front, before any solve -- identical rationale and construction to
397 // error_budget's own check on its (plural) ranges_m.
398 if !range_m.is_finite() {
399 return Err(KernelError::Observation(TrajectoryObservationError::NonFiniteQuery {
400 distance_m: range_m,
401 }));
402 }
403 if range_m < 0.0 || range_m > base.shot.max_range_m {
404 return Err(KernelError::Observation(TrajectoryObservationError::OutOfRange {
405 requested_m: range_m,
406 minimum_m: 0.0,
407 maximum_m: base.shot.max_range_m,
408 }));
409 }
410
411 // The fixed reference point every axis's "inside" check is measured against: base's own
412 // resolved inputs, solved directly (not through with_axis), exactly once.
413 let nominal = evaluate(&base.into(), &[range_m])?[0];
414 let margin_linear_m = target_margin_linear_m(target);
415
416 let mut out = Vec::with_capacity(axes.len());
417 let mut unavailable = Vec::new();
418
419 for &axis in axes {
420 // 1. Categorical axes have no numeric domain to bisect (mirrors bisect_axis's own
421 // guard); record, do not abort the rest of the report.
422 if matches!(axis_meta(axis).kind, AxisKind::Categorical) {
423 let (code, reason) = unavailable_reason(&KernelError::CategoricalAxis(axis))
424 .expect("CategoricalAxis is always classified as unavailable");
425 unavailable.push(UnavailableAxisV1 { axis, code, reason });
426 continue;
427 }
428
429 // 2. Read the current value. `None` means the axis is structurally absent on this
430 // request (the three wind axes under segmented wind) -- record, do not abort.
431 let nominal_value = match read_axis(base, axis) {
432 Some(AxisValue::Scalar(x)) => x,
433 // Unreachable with the current taxonomy (every Continuous axis reads back as Scalar
434 // or None; Flag/DragModel/TwistDirection only ever come from Categorical axes,
435 // already handled above) -- kept as a defensive catch-all, mirroring
436 // central_difference's identical fallback (crate::perturbation::derive).
437 Some(_) => return Err(KernelError::TypeMismatch(axis)),
438 None => {
439 let (code, reason) = unavailable_reason(&KernelError::AxisAbsent(axis))
440 .expect("AxisAbsent is always classified as unavailable");
441 unavailable.push(UnavailableAxisV1 { axis, code, reason });
442 continue;
443 }
444 };
445
446 // 3. The domain must be explicitly configured and well-formed -- see "Domains are
447 // validated up front, per axis" in the module doc. Never an invented default.
448 let (lo, hi) = match domains.iter().find(|(a, _)| *a == axis).map(|(_, d)| *d) {
449 Some(d) => d,
450 None => {
451 return Err(KernelError::InvalidDomain {
452 axis,
453 reason: "no domain was configured for this axis",
454 })
455 }
456 };
457 if !(lo.is_finite() && hi.is_finite()) {
458 return Err(KernelError::InvalidDomain {
459 axis,
460 reason: "domain bounds must both be finite",
461 });
462 }
463 if lo >= hi {
464 return Err(KernelError::InvalidDomain {
465 axis,
466 reason: "the domain's lower bound must be strictly less than its upper bound",
467 });
468 }
469 if !(nominal_value > lo && nominal_value < hi) {
470 return Err(KernelError::InvalidDomain {
471 axis,
472 reason: "the axis's own current value must sit strictly inside the configured \
473 domain, or one search direction would be a zero-width probe",
474 });
475 }
476
477 // 4. Pre-flight: evaluate AT the current value via the IDENTICAL with_axis/evaluate path
478 // bisect_axis uses for the shared anchor point (domain.0 in both directions below).
479 // Surfaces AxisUnsupportedForRequest/AxisAbsent for this axis/request combination
480 // before any Ok(None) is trusted -- both depend only on axis/base, never on the
481 // specific value written (see with_axis's own doc), so a successful pre-flight here
482 // guarantees neither can recur below for ANY value bisect_axis might try -- AND
483 // supplies the reconstructed "at nominal" observation correctness requirement 1
484 // checks against.
485 let pre = with_axis(base, axis, AxisValue::Scalar(nominal_value))
486 .and_then(|req| evaluate(&req, &[range_m]));
487 let at_nominal = match pre {
488 Ok(obs) => obs[0],
489 Err(e) => match unavailable_reason(&e) {
490 Some((code, reason)) => {
491 unavailable.push(UnavailableAxisV1 { axis, code, reason });
492 continue;
493 }
494 None => return Err(e),
495 },
496 };
497
498 // Correctness requirement 1: the nominal must actually read as inside before any bound
499 // means anything -- checked via the SAME reconstruction path as domain.0, not merely
500 // assumed. See the module doc's "The central hazard" section.
501 let nominal_inside_target = inside(&at_nominal, &nominal, target);
502 if !nominal_inside_target {
503 out.push(ToleranceAxisV1 {
504 axis,
505 nominal: nominal_value,
506 nominal_inside_target: false,
507 near_bound: None,
508 far_bound: None,
509 near_limiting_boundary: None,
510 far_limiting_boundary: None,
511 unbounded_in_domain: false,
512 near_has_no_effect: false,
513 far_has_no_effect: false,
514 margin_linear_m,
515 });
516 continue;
517 }
518
519 // With the anchor confirmed inside, any Ok(None) bisect_axis returns below is now
520 // unambiguous: "stays inside throughout that direction," not "outside throughout" (the
521 // conflation this module exists to prevent -- see the module doc). Any error from these
522 // two calls, after a successful pre-flight, can only be a genuine Solve/Observation
523 // failure (AxisUnsupportedForRequest/AxisAbsent/CategoricalAxis are all already ruled
524 // out above), so it propagates directly via `?` -- see
525 // `a_genuine_observation_error_propagates_not_recorded_as_unavailable`.
526 let tol = bisection_tolerance(lo, hi);
527 let pred = |o: &Observation| inside(o, &nominal, target);
528 let near = bisect_axis(base, axis, range_m, (nominal_value, lo), &pred, tol)?;
529 let far = bisect_axis(base, axis, range_m, (nominal_value, hi), &pred, tol)?;
530
531 // Beyond finding a bound (or confirming its absence), each None direction is checked
532 // for whether the axis has ANY measurable effect on the impact at all -- see
533 // `near_has_no_effect`/`far_has_no_effect`'s own doc and the module doc's "TargetDistance
534 // cannot answer..." section for why this distinction matters. Both endpoint observations
535 // (`lo`/`hi`) are recomputed here rather than threaded out of `bisect_axis` (which does
536 // evaluate them internally to decide `Ok(None)`, but does not expose them) -- at most two
537 // extra solves per axis, only in the already-cheaper `None` case.
538 let (near_limiting_boundary, near_has_no_effect) = match near {
539 Some(v) => {
540 let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?[0];
541 (Some(limiting_boundary(&o, &nominal, target)), false)
542 }
543 None => {
544 let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(lo))?, &[range_m])?[0];
545 (None, observation_matches_nominal(&o, &nominal))
546 }
547 };
548 let (far_limiting_boundary, far_has_no_effect) = match far {
549 Some(v) => {
550 let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?[0];
551 (Some(limiting_boundary(&o, &nominal, target)), false)
552 }
553 None => {
554 let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(hi))?, &[range_m])?[0];
555 (None, observation_matches_nominal(&o, &nominal))
556 }
557 };
558
559 out.push(ToleranceAxisV1 {
560 axis,
561 nominal: nominal_value,
562 nominal_inside_target: true,
563 near_bound: near,
564 far_bound: far,
565 near_limiting_boundary,
566 far_limiting_boundary,
567 unbounded_in_domain: near.is_none() && far.is_none(),
568 near_has_no_effect,
569 far_has_no_effect,
570 margin_linear_m,
571 });
572 }
573
574 Ok(ToleranceReportV1 {
575 schema_version: TOLERANCE_SCHEMA_VERSION_V1,
576 method: "one_variable_deterministic_bisection".to_string(),
577 assumptions: vec![
578 "Each bound holds ONE input at its limit while every other input stays at its \
579 nominal value. Bounds from different axes may NOT be assumed to hold \
580 simultaneously: two inputs each at their own individual limit will generally miss \
581 even though neither alone would."
582 .to_string(),
583 "No probability distribution is assumed or implied by any bound here. These are \
584 deterministic limits of a one-variable search, not confidence intervals or a \
585 probability of hit."
586 .to_string(),
587 "A bound is reported only when found strictly within the axis's own configured \
588 search domain. It is never extrapolated beyond that domain: 'no bound within the \
589 domain' (unbounded_in_domain) is reported as exactly that fact, never as a guessed \
590 number."
591 .to_string(),
592 "Before any bound is searched for, the nominal solution's own impact is confirmed to \
593 read as inside the target (nominal_inside_target); an axis for which that check \
594 fails is reported as such instead of a fabricated or misleading bound."
595 .to_string(),
596 ],
597 range_m,
598 unavailable_axes: unavailable,
599 axes: out,
600 })
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::error_budget::TargetGeometryV1;
607 use crate::perturbation::InputAxis;
608
609 // The brief's own fixture literal uses a lowercase "g7"; the decoder requires exact-case
610 // "G1"/"G6"/"G7"/"G8" (confirmed against every other fixture in this crate's
611 // perturbation/error_budget test suites, which all use uppercase, and against
612 // `docs/SOLVE_JSON_V1.md`) and rejects lowercase with `InvalidValue` -- fixed to uppercase
613 // here rather than reproducing a decode failure the brief did not anticipate (the same fix
614 // Task 7's report records making to its own brief-supplied fixtures).
615 fn resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
616 let json = serde_json::json!({
617 "schema_version": 1,
618 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
619 "ballistic_coefficient": 0.243},
620 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
621 "shot": {"max_range_m": 900.0, "zero_distance_m": 600.0},
622 "atmosphere": {}, "wind": {"speed_mps": 3.0,
623 "direction_from_rad": std::f64::consts::FRAC_PI_2},
624 "solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
625 }).to_string();
626 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
627 crate::solve_v1::solve_v1(req).unwrap().resolved_request
628 }
629
630 /// A vacuum (drag-free), flat, unzeroed shot: `drop(v) = 0.5 * g * (x / v)^2` exactly (same
631 /// trick `central_difference`'s own analytic oracle test uses,
632 /// `src/perturbation/derive.rs`), and no wind at all so `windage_m` is exactly `0.0`
633 /// everywhere. No `zero_distance_m` at all, so `with_axis`'s rezero-clearing never fires for
634 /// `MuzzleVelocityMps` (a `requires_rezero` axis) -- the trajectory really is the closed
635 /// form, not an approximation of one perturbed by re-zero search noise.
636 fn vacuum_resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
637 let json = serde_json::json!({
638 "schema_version": 1,
639 "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
640 "ballistic_coefficient": 100.0},
641 "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
642 "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
643 "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
644 "sampling": {"interval_m": 5.0}
645 })
646 .to_string();
647 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
648 crate::solve_v1::solve_v1(req).unwrap().resolved_request
649 }
650
651 // ---- Step 1 tests, verbatim from the brief ----
652
653 /// Acceptance criterion: a bigger target can never shrink a one-variable bound.
654 #[test]
655 fn a_larger_target_never_shrinks_a_bound() {
656 let r = resolved();
657 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
658 let small = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
659 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
660 let big = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
661 TargetGeometryV1::Rect { width_m: 0.6, height_m: 0.9 }, &domains).unwrap();
662 let s = &small.axes[0];
663 let b = &big.axes[0];
664 if let (Some(sf), Some(bf)) = (s.far_bound, b.far_bound) {
665 assert!(bf >= sf - 1e-6, "larger target shrank the bound: {sf} -> {bf}");
666 }
667 }
668
669 /// A bound that does not exist in the domain is reported as such, not invented.
670 ///
671 /// Review fix: also confirms `near_has_no_effect`/`far_has_no_effect` are BOTH `false` here
672 /// -- WindSpeed genuinely does move the impact over this domain (see the probed sweep in
673 /// `windage_dominant_axis_reports_left_or_right_not_top_or_bottom`'s doc), it just never
674 /// moves it far enough to leave this deliberately huge target. Contrast
675 /// `target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim`, where the
676 /// SAME `unbounded_in_domain: true` shape comes from an axis that has genuinely zero effect
677 /// -- these two tests together are the discriminating pair for that new field.
678 #[test]
679 fn an_axis_that_never_exits_is_flagged_not_bounded() {
680 let r = resolved();
681 // A huge target cannot be missed by a small wind change.
682 let domains = [(InputAxis::WindSpeed, (2.9_f64, 3.1_f64))];
683 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
684 TargetGeometryV1::Rect { width_m: 50.0, height_m: 50.0 }, &domains).unwrap();
685 assert!(rep.axes[0].unbounded_in_domain);
686 assert!(rep.axes[0].near_bound.is_none() && rep.axes[0].far_bound.is_none());
687 assert!(
688 !rep.axes[0].near_has_no_effect && !rep.axes[0].far_has_no_effect,
689 "WindSpeed does move the impact within this domain -- it just never leaves this \
690 huge target -- so has_no_effect must be false in both directions"
691 );
692 }
693
694 /// Review fix #3: the ticket's own flagship example is a range-estimate question ("how far
695 /// off could my rangefinder reading be"), and the axis that superficially matches --
696 /// `TargetDistance` (`shot.max_range_m`) -- does not answer it: it is `requires_rezero:
697 /// false`, so perturbing it never changes the muzzle angle or any sight correction, and
698 /// therefore never changes the impact observed at a FIXED `range_m` at all (as long as the
699 /// perturbed value stays >= `range_m`). Both directions here report `unbounded_in_domain:
700 /// true`, and this test's whole point is that `near_has_no_effect`/`far_has_no_effect` being
701 /// ALSO `true` is what tells a caller this is "provably irrelevant," not "any range error is
702 /// safe." `ZeroDistance` (`shot.zero_distance_m`, `requires_rezero: true`) is the axis that
703 /// actually re-zeroes for a different assumed distance and DOES move the impact at the true,
704 /// fixed `range_m` -- checked here for contrast, with real, non-`None` bounds.
705 #[test]
706 fn target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim() {
707 let r = resolved(); // max_range_m = 900.0, zero_distance_m = 600.0
708 let target = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 };
709
710 let td_domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
711 let td = tolerance_envelope(&r, &[InputAxis::TargetDistance], 400.0, target, &td_domains)
712 .unwrap();
713 let a = &td.axes[0];
714 assert!(a.nominal_inside_target);
715 assert!(a.near_bound.is_none() && a.far_bound.is_none());
716 assert!(a.unbounded_in_domain);
717 assert!(
718 a.near_has_no_effect && a.far_has_no_effect,
719 "TargetDistance must show NO measurable effect in either direction, not merely an \
720 unbounded one -- it cannot change the impact observed at a fixed range_m at all"
721 );
722
723 // Contrast: ZeroDistance, over the SAME true observation range, DOES move the impact,
724 // and finds real bounds -- proving `has_no_effect` genuinely discriminates rather than
725 // being true for every `requires_rezero: false`-adjacent axis or every wide domain.
726 let zd_domains = [(InputAxis::ZeroDistance, (400.0_f64, 800.0_f64))];
727 let zd = tolerance_envelope(&r, &[InputAxis::ZeroDistance], 400.0, target, &zd_domains)
728 .unwrap();
729 let b = &zd.axes[0];
730 assert!(b.nominal_inside_target);
731 assert!(
732 b.near_bound.is_some() && b.far_bound.is_some(),
733 "ZeroDistance must produce real bounds: re-zeroing for a different assumed distance \
734 DOES move the impact observed at the true, fixed range_m"
735 );
736 assert!(!b.unbounded_in_domain);
737 }
738
739 #[test]
740 fn the_report_refuses_to_imply_probability() {
741 let r = resolved();
742 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
743 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
744 TargetGeometryV1::Circle { radius_m: 0.25 }, &domains).unwrap();
745 assert_eq!(rep.method, "one_variable_deterministic_bisection");
746 assert!(rep.assumptions.iter().any(|s| s.contains("probability")));
747 assert!(rep.assumptions.iter().any(|s| s.contains("simultaneously")));
748 }
749
750 // ---- Beyond the brief: the properties named in the task instructions ----
751
752 /// All four correctness requirements must be stated in the payload itself, not only in
753 /// prose documentation -- extends the brief's own probability/simultaneity check (which this
754 /// duplicates for a different fixture) to the two the brief's test does not cover: never
755 /// extrapolating past the configured domain, and confirming the nominal reads as inside
756 /// before any bound is searched for.
757 ///
758 /// Review fix: the fourth check previously asserted only `contains("nominal")`, which
759 /// `assumptions[0]` ("...stays at its **nominal** value...") already satisfies on its own --
760 /// deleting `assumptions[3]` entirely left this test green. Pinned per-index instead, on a
761 /// substring unique to each sentence (`"nominal_inside_target"`, the literal field name,
762 /// appears ONLY in `assumptions[3]`), plus an exact length so a deleted or reordered
763 /// assumption is caught even if its specific substring happened to still appear elsewhere.
764 #[test]
765 fn assumptions_cover_all_four_correctness_requirements() {
766 let r = resolved();
767 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
768 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
769 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
770 assert_eq!(rep.assumptions.len(), 4, "exactly four assumption sentences are expected");
771 assert!(
772 rep.assumptions[0].contains("simultaneously"),
773 "one-variable-at-a-time must be stated"
774 );
775 assert!(rep.assumptions[1].contains("probability"), "no probability must be stated");
776 assert!(
777 rep.assumptions[2].to_lowercase().contains("domain"),
778 "never-extrapolate-beyond-domain must be stated"
779 );
780 assert!(
781 rep.assumptions[3].contains("nominal_inside_target"),
782 "the nominal-inside precondition must be stated, discriminated from assumptions[0]'s \
783 own unrelated use of the word \"nominal\""
784 );
785 }
786
787 /// THE central distinction this ticket exists to enforce: "stays inside throughout" and
788 /// "the nominal itself is not inside" produce the IDENTICAL `near_bound`/`far_bound` shape
789 /// (`None`/`None`) from two completely different root causes. A degenerate (zero-area)
790 /// target can never contain any point, not even the exact nominal impact
791 /// (`dy == dz == 0.0`) -- contrast directly against `an_axis_that_never_exits_is_flagged_not_bounded`
792 /// above, which reaches the same `None`/`None` shape via the OPPOSITE fact (a huge target
793 /// that is never exited).
794 #[test]
795 fn a_degenerate_target_is_flagged_nominal_outside_not_confused_with_unbounded() {
796 let r = resolved();
797 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
798 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
799 TargetGeometryV1::Rect { width_m: 0.0, height_m: 0.3 }, &domains).unwrap();
800 let a = &rep.axes[0];
801 assert!(
802 !a.nominal_inside_target,
803 "a zero-area target can never contain even the nominal impact"
804 );
805 assert!(
806 a.near_bound.is_none() && a.far_bound.is_none(),
807 "bounds must not be fabricated when the nominal itself is not inside"
808 );
809 assert!(
810 !a.unbounded_in_domain,
811 "this is NOT the same fact as 'stays inside throughout' -- conflating the two is \
812 the exact failure this ticket exists to prevent"
813 );
814 }
815
816 /// Independent oracle #1 (analytic): against the vacuum closed form, BOTH the near and far
817 /// bound have an exact root, checked to a tight relative tolerance, with the domain's own
818 /// midpoint (900.0) chosen far from either root so a "return the midpoint" bug -- which
819 /// would still satisfy a bare `lo < x < hi` check -- is caught (mirrors
820 /// `bisect_axis_converges_to_the_vacuum_analytic_root_not_the_domain_midpoint`,
821 /// `src/perturbation/derive.rs`).
822 #[test]
823 fn bisection_bounds_match_the_vacuum_analytic_crossing() {
824 let r = vacuum_resolved();
825 let v0 = r.rifle.muzzle_velocity_mps;
826 let x = 400.0_f64;
827 const G: f64 = 9.80665;
828 let drop = |v: f64| 0.5 * G * (x / v) * (x / v);
829 let drop0 = drop(v0);
830
831 let half_height = 0.1_f64;
832 let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
833 let rep = tolerance_envelope(
834 &r,
835 &[InputAxis::MuzzleVelocityMps],
836 x,
837 TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 2.0 * half_height },
838 &domains,
839 )
840 .unwrap();
841 let a = &rep.axes[0];
842 assert!(a.nominal_inside_target);
843 // Review minor #5: every other test in this module happens to use range_m = 600.0, so
844 // `range_m` echoing the caller's input was never checked against any OTHER value --
845 // this fixture's own 400.0 closes that gap.
846 assert_eq!(rep.range_m, x);
847
848 // far (v > v0): drop DECREASES, crossing where drop(v) = drop0 - half_height.
849 let expected_far = x / (2.0 * (drop0 - half_height) / G).sqrt();
850 // near (v < v0): drop INCREASES, crossing where drop(v) = drop0 + half_height.
851 let expected_near = x / (2.0 * (drop0 + half_height) / G).sqrt();
852
853 let far = a.far_bound.expect("a crossing exists well within (700, 1100)");
854 let near = a.near_bound.expect("a crossing exists well within (700, 1100)");
855 let rel_far = ((far - expected_far) / expected_far).abs();
856 let rel_near = ((near - expected_near) / expected_near).abs();
857 assert!(rel_far < 0.02, "far: expected ~{expected_far}, got {far} (rel {rel_far})");
858 assert!(rel_near < 0.02, "near: expected ~{expected_near}, got {near} (rel {rel_near})");
859 assert!((far - 900.0).abs() > 30.0, "far bound must not be the domain midpoint");
860 assert!((near - 900.0).abs() > 30.0, "near bound must not be the domain midpoint");
861
862 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
863 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
864 }
865
866 /// Review fix #7 (part 1): pins `bisection_tolerance`'s formula directly, independent of any
867 /// downstream bisection -- catches a hardcoded constant or a different scaling factor.
868 #[test]
869 fn bisection_tolerance_scales_with_domain_width_not_a_flat_constant() {
870 assert_eq!(bisection_tolerance(0.0, 20.0), 20.0 * 1e-6);
871 assert_eq!(bisection_tolerance(700.0, 1100.0), 400.0 * 1e-6);
872 // Floor: a domain so narrow that width * 1e-6 would underflow below what 80 bisection
873 // iterations could usefully resolve is clamped to 1e-9, not left arbitrarily small.
874 assert_eq!(bisection_tolerance(800.0, 800.0 + 1e-5), 1e-9);
875 }
876
877 /// Review fix #7 (part 2): the domain-proportional formula is undefended end-to-end by every
878 /// OTHER test in this module, because every domain they use is wide enough that even a flat
879 /// `1e-4` tolerance would still run several real bisection iterations and land close to the
880 /// true crossing -- the two formulas are indistinguishable from the outside on a wide domain.
881 /// This test uses a domain SO narrow (`5e-5` per direction) that a flat `1e-4` tolerance
882 /// would satisfy `bisect_axis`'s very FIRST check (`(hi - lo).abs() <= tolerance`) with ZERO
883 /// bisection steps, returning the sub-domain's own exact algebraic midpoint verbatim --
884 /// verified by literally making that mutation (`bisection_tolerance` hardcoded to `1e-4`),
885 /// which reproducibly returns `far_bound` exactly equal to `800.000025` (`diff = 0e0`, not
886 /// even one ULP off) -- then reverting. Under the shipped domain-proportional formula
887 /// (`tol = 0.0001 * 1e-6`, floored to `1e-9`), real bisection runs and the result differs
888 /// from that same exact midpoint by `~8.6e-6` -- more than four orders of magnitude past any
889 /// floating-point noise floor, so `> 1e-7` below is a comfortable, non-flaky margin.
890 #[test]
891 fn a_narrow_domain_gets_real_bisection_not_an_immediate_midpoint_return() {
892 let r = vacuum_resolved();
893 let x = 400.0_f64;
894 let half_height = 5e-8_f64;
895 let domains = [(InputAxis::MuzzleVelocityMps, (799.99995_f64, 800.00005_f64))];
896 let rep = tolerance_envelope(
897 &r,
898 &[InputAxis::MuzzleVelocityMps],
899 x,
900 TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 2.0 * half_height },
901 &domains,
902 )
903 .unwrap();
904 let a = &rep.axes[0];
905 let far_midpoint = 0.5 * (800.0_f64 + 800.00005_f64);
906 let far = a.far_bound.expect("a crossing must exist this close to nominal");
907 assert!(
908 (far - far_midpoint).abs() > 1e-7,
909 "far_bound ({far}) must differ meaningfully from the domain's own exact midpoint \
910 ({far_midpoint}) -- an unchanged difference of ~0 would mean bisect_axis returned \
911 the midpoint verbatim without ever refining it, exactly what a flat 1e-4 tolerance \
912 does on a domain this narrow"
913 );
914 }
915
916 /// Independent oracle #2 (re-solve): a found bound is verified by re-solving at it, and at
917 /// points just inside and just outside it, through a path that does NOT go through
918 /// `with_axis`/`evaluate`/`bisect_axis` at all -- a hand-built request decoded and solved via
919 /// `solve_v1` directly (mirroring `mod.rs`'s own
920 /// `evaluate_matches_solve_v1_when_zero_distance_alone_searches_the_elevation` cross-check
921 /// pattern), reading the sample off `solve_v1`'s OWN wire output at an exact grid point
922 /// (600 m, an exact multiple of this fixture's 10 m sampling interval).
923 #[test]
924 fn a_found_bound_sits_on_the_target_boundary_verified_independently_of_with_axis() {
925 let r = resolved();
926 let target = TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 };
927 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
928 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
929 let a = &rep.axes[0];
930 assert!(a.nominal_inside_target);
931 let far = a.far_bound.expect("this target/domain combination must produce a far bound");
932
933 fn independent_deviation(wind_speed: f64) -> (f64, f64) {
934 let json = serde_json::json!({
935 "schema_version": 1,
936 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
937 "ballistic_coefficient": 0.243},
938 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
939 "shot": {"max_range_m": 900.0, "zero_distance_m": 600.0},
940 "atmosphere": {},
941 "wind": {"speed_mps": wind_speed,
942 "direction_from_rad": std::f64::consts::FRAC_PI_2},
943 "solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
944 })
945 .to_string();
946 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
947 let solved = crate::solve_v1::solve_v1(req).unwrap();
948 let sample = solved
949 .samples
950 .iter()
951 .find(|s| s.distance_m == 600.0)
952 .expect("600 m must be an exact grid point for this fixture");
953 (sample.drop_m, sample.windage_m)
954 }
955
956 let half_width = 0.2 / 2.0;
957 let half_height = 0.3 / 2.0;
958 let (nominal_drop, nominal_windage) = independent_deviation(3.0);
959 let ratio_at = |wind_speed: f64| -> f64 {
960 let (d, w) = independent_deviation(wind_speed);
961 let ry = (d - nominal_drop).abs() / half_height;
962 let rz = (w - nominal_windage).abs() / half_width;
963 ry.max(rz)
964 };
965
966 let ratio_far = ratio_at(far);
967 assert!(
968 (ratio_far - 1.0).abs() < 1e-3,
969 "found bound does not sit on the target boundary independently: ratio {ratio_far}"
970 );
971
972 let just_inside = 3.0 + (far - 3.0) * 0.99;
973 let just_outside = 3.0 + (far - 3.0) * 1.01;
974 assert!(
975 ratio_at(just_inside) < 1.0,
976 "a point just inside the found bound must independently read as inside the target"
977 );
978 assert!(
979 ratio_at(just_outside) > 1.0,
980 "a point just past the found bound must independently read as outside the target"
981 );
982 }
983
984 /// The brief's own acceptance criterion, generalized from two points to a genuine sweep:
985 /// growing the target can never shrink a one-variable bound, checked across many sizes,
986 /// consecutively -- two isolated points cannot catch a bound that stops being monotone only
987 /// partway through a range. Also asserts that once a direction becomes unbounded (no
988 /// crossing found within the domain) at some size, it stays unbounded at every LARGER size:
989 /// the literal meaning of "the crossing moved even farther away, past the domain edge."
990 /// WindSpeed's domain `(0, 20)` around a nominal of `3.0` is asymmetric (17 units of room
991 /// above, only 3 below), so the near direction saturates (becomes unbounded) at a much
992 /// smaller scale than the far direction -- the geometric sweep below is wide enough to
993 /// observe both transitions, confirmed by the closing assertion that both actually occurred.
994 #[test]
995 fn a_larger_target_never_shrinks_a_bound_across_a_size_sweep() {
996 let r = resolved();
997 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
998 let scales: Vec<f64> = (0..12).map(|i| 0.5 * 1.6_f64.powi(i)).collect();
999
1000 let mut prev_far: Option<f64> = None;
1001 let mut prev_near: Option<f64> = None;
1002 let mut far_became_unbounded = false;
1003 let mut near_became_unbounded = false;
1004
1005 for &scale in &scales {
1006 let target =
1007 TargetGeometryV1::Rect { width_m: 0.2 * scale, height_m: 0.3 * scale };
1008 let rep =
1009 tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
1010 let a = &rep.axes[0];
1011 assert!(
1012 a.nominal_inside_target,
1013 "scale {scale}: nominal must read as inside for any positive-size target"
1014 );
1015
1016 if far_became_unbounded {
1017 assert!(
1018 a.far_bound.is_none(),
1019 "scale {scale}: far bound reappeared after becoming unbounded at a smaller \
1020 scale"
1021 );
1022 } else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
1023 assert!(f >= pf - 1e-6, "far bound shrank at scale {scale}: {pf} -> {f}");
1024 }
1025 if a.far_bound.is_none() {
1026 far_became_unbounded = true;
1027 }
1028 prev_far = a.far_bound;
1029
1030 if near_became_unbounded {
1031 assert!(
1032 a.near_bound.is_none(),
1033 "scale {scale}: near bound reappeared after becoming unbounded at a smaller \
1034 scale"
1035 );
1036 } else if let (Some(pn), Some(n)) = (prev_near, a.near_bound) {
1037 assert!(
1038 n <= pn + 1e-6,
1039 "near bound shrank (moved toward nominal) at scale {scale}: {pn} -> {n}"
1040 );
1041 }
1042 if a.near_bound.is_none() {
1043 near_became_unbounded = true;
1044 }
1045 prev_near = a.near_bound;
1046 }
1047
1048 assert!(
1049 far_became_unbounded,
1050 "sweep never reached an unbounded far regime -- the monotonicity check on that \
1051 transition never actually ran"
1052 );
1053 assert!(
1054 near_became_unbounded,
1055 "sweep never reached an unbounded near regime -- the monotonicity check on that \
1056 transition never actually ran"
1057 );
1058 // Sanity: the sweep must also have exercised at least one genuinely bounded pair on each
1059 // side, or the "shrank" assertions above would never fire either.
1060 assert!(scales[0] < 1.0, "sweep must start comfortably inside the bounded regime");
1061 }
1062
1063 /// Same acceptance criterion, briefly, for the OTHER `TargetGeometryV1` shape -- a circle's
1064 /// single `radius_m` grows strictly with the same scale factor, so this is a smaller sweep
1065 /// than the rectangle's (which independently varies two dimensions), not a second full
1066 /// property test.
1067 #[test]
1068 fn a_larger_circle_never_shrinks_a_bound_across_a_size_sweep() {
1069 let r = resolved();
1070 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1071 let scales = [0.5_f64, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0];
1072 let mut prev_far: Option<f64> = None;
1073 let mut far_became_unbounded = false;
1074 for &scale in &scales {
1075 let target = TargetGeometryV1::Circle { radius_m: 0.15 * scale };
1076 let rep =
1077 tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
1078 let a = &rep.axes[0];
1079 if far_became_unbounded {
1080 assert!(a.far_bound.is_none(), "scale {scale}: far bound reappeared");
1081 } else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
1082 assert!(f >= pf - 1e-6, "far bound shrank at scale {scale}: {pf} -> {f}");
1083 }
1084 if a.far_bound.is_none() {
1085 far_became_unbounded = true;
1086 }
1087 prev_far = a.far_bound;
1088 }
1089 assert!(far_became_unbounded, "circle sweep never reached an unbounded far regime");
1090 }
1091
1092 /// `axis`/`nominal` must each be tied to the axis they describe, not swapped or copied from
1093 /// a sibling entry -- WindSpeed (nominal 3.0) and MuzzleVelocityMps (nominal 823.0) are far
1094 /// enough apart that a transposition is unmistakable. `margin_linear_m` is independently
1095 /// pinned as a TARGET-derived constant identical across both axes (`min(0.6, 0.4) / 2 =
1096 /// 0.2`), not a copy of `nominal` or any other axis-specific field.
1097 #[test]
1098 fn multiple_axes_are_each_independently_computed_and_correctly_tagged() {
1099 let r = resolved();
1100 let domains = [
1101 (InputAxis::WindSpeed, (0.0_f64, 20.0_f64)),
1102 (InputAxis::MuzzleVelocityMps, (400.0_f64, 1200.0_f64)),
1103 ];
1104 let rep = tolerance_envelope(
1105 &r,
1106 &[InputAxis::WindSpeed, InputAxis::MuzzleVelocityMps],
1107 600.0,
1108 TargetGeometryV1::Rect { width_m: 0.4, height_m: 0.6 },
1109 &domains,
1110 )
1111 .unwrap();
1112 assert_eq!(rep.axes.len(), 2);
1113 assert_eq!(rep.axes[0].axis, InputAxis::WindSpeed);
1114 assert_eq!(rep.axes[1].axis, InputAxis::MuzzleVelocityMps);
1115
1116 let expected_ws = match read_axis(&r, InputAxis::WindSpeed).unwrap() {
1117 AxisValue::Scalar(x) => x,
1118 other => panic!("WindSpeed must read back as a scalar, got {other:?}"),
1119 };
1120 let expected_mv = match read_axis(&r, InputAxis::MuzzleVelocityMps).unwrap() {
1121 AxisValue::Scalar(x) => x,
1122 other => panic!("MuzzleVelocityMps must read back as a scalar, got {other:?}"),
1123 };
1124 assert_eq!(rep.axes[0].nominal, expected_ws);
1125 assert_eq!(rep.axes[1].nominal, expected_mv);
1126 assert_ne!(
1127 rep.axes[0].nominal, rep.axes[1].nominal,
1128 "fixture sanity: the two axes must have DIFFERENT nominal values or a \
1129 transposition between them would be invisible"
1130 );
1131 assert!(rep.axes[0].nominal_inside_target);
1132 assert!(rep.axes[1].nominal_inside_target);
1133 assert!((rep.axes[0].margin_linear_m - 0.2).abs() < 1e-9);
1134 assert!((rep.axes[1].margin_linear_m - 0.2).abs() < 1e-9);
1135 assert_eq!(rep.range_m, 600.0);
1136 }
1137
1138 /// `near_limiting_boundary`/`far_limiting_boundary` must independently discriminate Left/
1139 /// Right from Top/Bottom, not default to one or the other. A pure crosswind's effect on
1140 /// windage dominates its (tiny, secondary) effect on drop by many orders of magnitude (see
1141 /// `windage_derivative_wrt_wind_speed_dominates_and_matches_the_baseline_sign`,
1142 /// `src/perturbation/derive.rs`), so any WindSpeed crossing here must be windage-driven: more
1143 /// wind pushes windage more negative (see this module's own probed sweep), so the far
1144 /// direction (more wind) exits `Left` and the near direction (less wind) exits `Right`.
1145 #[test]
1146 fn windage_dominant_axis_reports_left_or_right_not_top_or_bottom() {
1147 let r = resolved();
1148 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1149 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1150 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
1151 let a = &rep.axes[0];
1152 assert!(a.far_bound.is_some() && a.near_bound.is_some());
1153 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Left));
1154 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Right));
1155 }
1156
1157 /// The complementary case for the SAME field: `bisection_bounds_match_the_vacuum_analytic_crossing`
1158 /// above already asserts `Top`/`Bottom` for a wind-free vacuum fixture where `windage_m` is
1159 /// exactly `0.0` throughout, so a crossing driven by muzzle velocity alone (which barely
1160 /// moves windage at all -- see `d_windage_d_x` in the same vacuum context) cannot be
1161 /// misclassified as `Left`/`Right`. Restated here as its own assertion, independent of the
1162 /// closed-form numeric check, so a mutation that hardcodes `Radial`/`Left` for every `Rect`
1163 /// would be caught even if the numeric oracle above were skipped.
1164 #[test]
1165 fn drop_dominant_axis_reports_top_or_bottom_not_left_or_right() {
1166 let r = vacuum_resolved();
1167 let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
1168 let rep = tolerance_envelope(&r, &[InputAxis::MuzzleVelocityMps], 400.0,
1169 TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 0.2 }, &domains).unwrap();
1170 let a = &rep.axes[0];
1171 assert!(a.far_bound.is_some() && a.near_bound.is_some());
1172 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
1173 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
1174 }
1175
1176 /// A [`TargetGeometryV1::Circle`] has no distinct edges and must always report `Radial`,
1177 /// regardless of which direction (windage- or drop-dominant) the crossing came from. Also
1178 /// pins `target_margin_linear_m`'s `Circle` arm (review minor #6): previously only the
1179 /// `Rect` arm was checked numerically (`multiple_axes_are_each_independently_computed_...`),
1180 /// so a bug specific to the `Circle` branch (e.g. returning `radius_m` un-halved, or a
1181 /// hardcoded `0.0`) could have passed every existing test.
1182 #[test]
1183 fn a_circle_target_always_reports_radial() {
1184 let r = resolved();
1185 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1186 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1187 TargetGeometryV1::Circle { radius_m: 0.15 }, &domains).unwrap();
1188 let a = &rep.axes[0];
1189 assert!(a.far_bound.is_some() && a.near_bound.is_some());
1190 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Radial));
1191 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Radial));
1192 assert!(
1193 (a.margin_linear_m - 0.15).abs() < 1e-9,
1194 "Circle's margin_linear_m must be exactly its radius_m, got {}",
1195 a.margin_linear_m
1196 );
1197 }
1198
1199 /// Review fix #2: every existing fixture that reaches `limiting_boundary` has one deviation
1200 /// at (or indistinguishable from) zero -- the windage-dominant fixture has `dy ~ 0`, both
1201 /// drop-dominant fixtures use a wind-free vacuum with `dz` exactly `0`. Under any of those,
1202 /// swapping the cross-multiplication's `width_m`/`height_m` (`dy.abs() * height_m >=
1203 /// dz.abs() * width_m` instead of the correct `dy.abs() * width_m >= dz.abs() * height_m`)
1204 /// still passes, because one side of the comparison collapses to (approximately) zero either
1205 /// way. This calls `limiting_boundary` directly (it is private, and this test lives in the
1206 /// same module) with both deviations simultaneously non-trivial and an aspect-ratio-skewed
1207 /// target, so the two formulas disagree: `dy = 0.1` against `height_m = 0.2` (ratio `1.0`)
1208 /// and `dz = 0.5` against `width_m = 2.0` (ratio `0.5`) -- correct: `ratio_y > ratio_z` =>
1209 /// `Bottom`; swapped: `dy.abs()*height_m = 0.02 < dz.abs()*width_m = 1.0` => `Left`/`Right`
1210 /// branch entirely, i.e. a DIFFERENT edge family, not just a different edge.
1211 #[test]
1212 fn limiting_boundary_uses_the_correct_axis_for_each_deviation_not_swapped() {
1213 let nominal = Observation {
1214 range_m: 600.0,
1215 drop_m: 0.0,
1216 windage_m: 0.0,
1217 time_s: 1.0,
1218 velocity_mps: 500.0,
1219 };
1220 let o = Observation {
1221 range_m: 600.0,
1222 drop_m: 0.1,
1223 windage_m: 0.5,
1224 time_s: 1.0,
1225 velocity_mps: 500.0,
1226 };
1227 let target = TargetGeometryV1::Rect { width_m: 2.0, height_m: 0.2 };
1228 assert_eq!(
1229 limiting_boundary(&o, &nominal, target),
1230 LimitingBoundaryV1::Bottom,
1231 "dy/height_m ratio (1.0) exceeds dz/width_m ratio (0.5): must be a drop-family edge"
1232 );
1233 // Mirror on the drop side (dy < 0) to also confirm Top is reachable from this same
1234 // aspect-ratio-skewed target, not just Bottom.
1235 let o_top = Observation { drop_m: -0.1, ..o };
1236 assert_eq!(limiting_boundary(&o_top, &nominal, target), LimitingBoundaryV1::Top);
1237 }
1238
1239 /// A categorical axis has no numeric domain to bisect -- must be recorded, not silently
1240 /// dropped from the report and not a hard failure of the whole call.
1241 #[test]
1242 fn a_categorical_axis_is_recorded_unavailable_not_silently_dropped_or_hard_failed() {
1243 let r = resolved();
1244 let rep = tolerance_envelope(&r, &[InputAxis::CoriolisEnabled], 600.0,
1245 TargetGeometryV1::Circle { radius_m: 0.25 }, &[]).unwrap();
1246 assert!(rep.axes.is_empty(), "a categorical axis must never appear in `axes`");
1247 assert_eq!(rep.unavailable_axes.len(), 1);
1248 assert_eq!(rep.unavailable_axes[0].axis, InputAxis::CoriolisEnabled);
1249 assert_eq!(rep.unavailable_axes[0].code, UnavailableReasonCodeV1::CategoricalAxis);
1250 assert!(!rep.unavailable_axes[0].reason.is_empty());
1251 }
1252
1253 /// The three wind axes have no single scalar value under segmented wind -- `read_axis`
1254 /// returns `None` directly (never even reaching `bisect_axis`), and that must be recorded
1255 /// too, with no `domains` entry required for an axis that never gets that far.
1256 #[test]
1257 fn a_wind_axis_under_segmented_wind_is_recorded_unavailable() {
1258 let json = serde_json::json!({
1259 "schema_version": 1,
1260 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1261 "ballistic_coefficient": 0.243},
1262 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1263 "shot": {"max_range_m": 900.0},
1264 "atmosphere": {},
1265 "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
1266 "direction_from_rad": 1.0}]},
1267 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1268 })
1269 .to_string();
1270 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1271 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1272 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 300.0,
1273 TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 }, &[]).unwrap();
1274 assert!(rep.axes.is_empty());
1275 assert_eq!(rep.unavailable_axes.len(), 1);
1276 assert_eq!(rep.unavailable_axes[0].axis, InputAxis::WindSpeed);
1277 assert_eq!(rep.unavailable_axes[0].code, UnavailableReasonCodeV1::AxisAbsent);
1278 }
1279
1280 /// `Altitude` under a QNH-referenced atmosphere is refused by `with_axis` itself
1281 /// (`AxisUnsupportedForRequest`) -- reached through this module's pre-flight step, not a
1282 /// hard failure of the whole report.
1283 #[test]
1284 fn altitude_under_qnh_is_recorded_unavailable_not_hard_failed() {
1285 let json = serde_json::json!({
1286 "schema_version": 1,
1287 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1288 "ballistic_coefficient": 0.243},
1289 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1290 "shot": {"max_range_m": 900.0},
1291 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1292 "pressure_reference": "qnh"},
1293 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1294 })
1295 .to_string();
1296 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1297 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1298 let domains = [(InputAxis::Altitude, (0.0_f64, 1000.0_f64))];
1299 let rep = tolerance_envelope(&r, &[InputAxis::Altitude], 600.0,
1300 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap();
1301 assert!(rep.axes.is_empty());
1302 assert_eq!(rep.unavailable_axes.len(), 1);
1303 assert_eq!(rep.unavailable_axes[0].axis, InputAxis::Altitude);
1304 assert_eq!(
1305 rep.unavailable_axes[0].code,
1306 UnavailableReasonCodeV1::AxisUnsupportedForRequest
1307 );
1308 assert!(rep.unavailable_axes[0].reason.to_lowercase().contains("qnh"));
1309 }
1310
1311 /// A domain whose lower bound, for `TargetDistance`, dips below the caller's OWN `range_m`
1312 /// is an internally inconsistent request (bisecting "how close could the target be" past
1313 /// "the range I am asking about"): `bisect_axis` hits a genuine `Observation::OutOfRange`
1314 /// partway through the near-direction search, which must propagate as a hard failure of the
1315 /// whole call, never be swallowed into `unavailable_axes` (it is not one of the four
1316 /// structural refusals).
1317 #[test]
1318 fn a_genuine_observation_error_propagates_not_recorded_as_unavailable() {
1319 let r = resolved(); // max_range_m = 900.0, zero_distance_m = 600.0
1320 let domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
1321 let err = tolerance_envelope(&r, &[InputAxis::TargetDistance], 600.0,
1322 TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 }, &domains).unwrap_err();
1323 match err {
1324 KernelError::Observation(TrajectoryObservationError::OutOfRange { requested_m, .. }) => {
1325 assert_eq!(requested_m, 600.0);
1326 }
1327 other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
1328 }
1329 }
1330
1331 /// `domains` must supply an entry for every requested continuous, present axis -- no
1332 /// implicit fallback (see the module doc's "Domains are validated up front" section).
1333 #[test]
1334 fn a_missing_domain_is_reported_as_invalid_not_defaulted() {
1335 let r = resolved();
1336 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1337 TargetGeometryV1::Circle { radius_m: 0.3 }, &[]).unwrap_err();
1338 match err {
1339 KernelError::InvalidDomain { axis, .. } => assert_eq!(axis, InputAxis::WindSpeed),
1340 other => panic!("expected InvalidDomain, got {other:?}"),
1341 }
1342 }
1343
1344 /// The axis's own current value (`3.0`) must sit strictly inside the configured domain, or
1345 /// one search direction degenerates to a zero-width probe.
1346 #[test]
1347 fn nominal_outside_the_configured_domain_is_rejected() {
1348 let r = resolved();
1349 let domains = [(InputAxis::WindSpeed, (5.0_f64, 20.0_f64))];
1350 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1351 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1352 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1353 }
1354
1355 /// An inverted or zero-width domain (`lo >= hi`) is rejected outright.
1356 #[test]
1357 fn an_inverted_domain_is_rejected() {
1358 let r = resolved();
1359 let domains = [(InputAxis::WindSpeed, (20.0_f64, 0.0_f64))];
1360 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1361 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1362 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1363 }
1364
1365 /// Review fix (most important finding): `nominal_outside_the_configured_domain_is_rejected`
1366 /// puts the nominal (`3.0`) OUTSIDE its domain `(5.0, 20.0)` entirely, and
1367 /// `an_inverted_domain_is_rejected` is caught by the EARLIER `lo >= hi` check before the
1368 /// nominal-strictness check ever runs -- neither test can tell a strict `>`/`<` comparison
1369 /// apart from a relaxed `>=`/`<=` one. Relaxing `tolerance.rs`'s validation to `>=`/`<=`
1370 /// leaves ALL other tests in this module green: with `domains = [(WindSpeed, (3.0, 20.0))]`
1371 /// and nominal `3.0`, `bisect_axis` would be called with `(nominal_value, lo) = (3.0, 3.0)`
1372 /// -- a zero-width probe whose two endpoints trivially agree, so it would report `Ok(None)`
1373 /// for a direction that was never actually searched even one step into, and (if the far
1374 /// direction also found nothing) `unbounded_in_domain: true` -- "your wind call is robust in
1375 /// every direction," fabricated from a search that never moved. This test, and its mirror
1376 /// below for the upper edge, place the nominal EXACTLY at one edge and require
1377 /// `InvalidDomain` -- these fail under the `>=`/`<=` relaxation described above (verified by
1378 /// making that exact edit, confirming exactly these two tests fail with all others still
1379 /// green, then reverting to byte-identical -- see the task report).
1380 #[test]
1381 fn nominal_at_the_lower_domain_edge_is_rejected() {
1382 let r = resolved(); // WindSpeed nominal = 3.0
1383 let domains = [(InputAxis::WindSpeed, (3.0_f64, 20.0_f64))];
1384 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1385 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1386 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1387 }
1388
1389 /// Mirror of the above at the UPPER edge -- see that test's doc for the full rationale.
1390 #[test]
1391 fn nominal_at_the_upper_domain_edge_is_rejected() {
1392 let r = resolved(); // WindSpeed nominal = 3.0
1393 let domains = [(InputAxis::WindSpeed, (0.0_f64, 3.0_f64))];
1394 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1395 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1396 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1397 }
1398
1399 /// `range_m` itself must be a queryable point on the base trajectory, exactly as
1400 /// `error_budget` requires of its own `ranges_m` -- checked directly, before any per-axis
1401 /// work, rather than surfacing as a mysterious per-axis refusal.
1402 #[test]
1403 fn an_out_of_range_query_is_rejected_directly() {
1404 let r = resolved(); // max_range_m = 900.0
1405 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1406 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 5000.0,
1407 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1408 match err {
1409 KernelError::Observation(TrajectoryObservationError::OutOfRange { requested_m, .. }) => {
1410 assert_eq!(requested_m, 5000.0);
1411 }
1412 other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
1413 }
1414 }
1415
1416 /// Every public field on [`ToleranceReportV1`]/[`ToleranceAxisV1`]/[`UnavailableAxisV1`]
1417 /// round-trips through JSON, and the externally-tagged `snake_case` axis name is exactly
1418 /// what a consumer of this wire type would expect -- pins the `#[serde(rename_all =
1419 /// "snake_case")]` convention this crate uses throughout, not just that serialization
1420 /// succeeds at all.
1421 ///
1422 /// Compares fields individually with a `1e-9` tolerance on the bisection-derived `f64`s
1423 /// rather than a whole-struct `assert_eq!`: `serde_json` 1.0.149's float parser round-trips
1424 /// SOME specific `f64` bit patterns (arbitrary bisection results among them) one ULP off --
1425 /// serializing produces the shortest correctly-round-tripping decimal string, but re-parsing
1426 /// that exact string yields a different bit pattern one ULP away. Confirmed with a
1427 /// standalone reproduction outside this crate on the exact bit pattern this test's own
1428 /// `near_bound` produced (`serde_json::to_string`/`from_str` on a bare `f64`), matching the
1429 /// identical upstream characteristic `error_budget.rs`'s
1430 /// `p_hit_and_gain_round_trip_through_json_when_present` already documents -- not a defect
1431 /// in this module's `Serialize`/`Deserialize` derives.
1432 #[test]
1433 fn the_report_round_trips_through_json() {
1434 let r = resolved();
1435 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1436 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1437 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
1438 let json = serde_json::to_string(&rep).unwrap();
1439 assert!(json.contains("\"axis\":\"wind_speed\""));
1440 let back: ToleranceReportV1 = serde_json::from_str(&json).unwrap();
1441
1442 assert_eq!(back.schema_version, rep.schema_version);
1443 assert_eq!(back.method, rep.method);
1444 assert_eq!(back.assumptions, rep.assumptions);
1445 assert_eq!(back.range_m, rep.range_m);
1446 assert_eq!(back.unavailable_axes, rep.unavailable_axes);
1447 assert_eq!(back.axes.len(), rep.axes.len());
1448 let (ba, ra) = (&back.axes[0], &rep.axes[0]);
1449 assert_eq!(ba.axis, ra.axis);
1450 assert_eq!(ba.nominal_inside_target, ra.nominal_inside_target);
1451 assert_eq!(ba.unbounded_in_domain, ra.unbounded_in_domain);
1452 assert_eq!(ba.near_limiting_boundary, ra.near_limiting_boundary);
1453 assert_eq!(ba.far_limiting_boundary, ra.far_limiting_boundary);
1454 assert!((ba.nominal - ra.nominal).abs() < 1e-9);
1455 assert!((ba.margin_linear_m - ra.margin_linear_m).abs() < 1e-9);
1456 assert!((ba.near_bound.unwrap() - ra.near_bound.unwrap()).abs() < 1e-9);
1457 assert!((ba.far_bound.unwrap() - ra.far_bound.unwrap()).abs() < 1e-9);
1458 }
1459
1460 /// `schema_version` must be the crate's declared constant, not a stray literal that could
1461 /// silently drift from it.
1462 #[test]
1463 fn schema_version_matches_the_declared_constant() {
1464 let r = resolved();
1465 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1466 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1467 TargetGeometryV1::Circle { radius_m: 0.25 }, &domains).unwrap();
1468 assert_eq!(rep.schema_version, TOLERANCE_SCHEMA_VERSION_V1);
1469 }
1470}