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 the
610 // exact-case built-in spellings ("G1"/"G2"/"G5"/"G6"/"G7"/"G8"/"GI"/"GS"/"RA4"),
611 // confirmed against the perturbation/error_budget fixtures and `docs/SOLVE_JSON_V1.md`.
612 // Keep this fixture uppercase rather than reproducing the brief's unintended
613 // `InvalidValue` decode failure.
614 fn resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
615 let json = serde_json::json!({
616 "schema_version": 1,
617 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
618 "ballistic_coefficient": 0.243},
619 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
620 "shot": {"max_range_m": 900.0, "zero_distance_m": 600.0},
621 "atmosphere": {}, "wind": {"speed_mps": 3.0,
622 "direction_from_rad": std::f64::consts::FRAC_PI_2},
623 "solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
624 }).to_string();
625 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
626 crate::solve_v1::solve_v1(req).unwrap().resolved_request
627 }
628
629 /// A vacuum (drag-free), flat, unzeroed shot: `drop(v) = 0.5 * g * (x / v)^2` exactly (same
630 /// trick `central_difference`'s own analytic oracle test uses,
631 /// `src/perturbation/derive.rs`), and no wind at all so `windage_m` is exactly `0.0`
632 /// everywhere. No `zero_distance_m` at all, so `with_axis`'s rezero-clearing never fires for
633 /// `MuzzleVelocityMps` (a `requires_rezero` axis) -- the trajectory really is the closed
634 /// form, not an approximation of one perturbed by re-zero search noise.
635 fn vacuum_resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
636 let json = serde_json::json!({
637 "schema_version": 1,
638 "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
639 "ballistic_coefficient": 100.0},
640 "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
641 "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
642 "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
643 "sampling": {"interval_m": 5.0}
644 })
645 .to_string();
646 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
647 crate::solve_v1::solve_v1(req).unwrap().resolved_request
648 }
649
650 // ---- Step 1 tests, verbatim from the brief ----
651
652 /// Acceptance criterion: a bigger target can never shrink a one-variable bound.
653 #[test]
654 fn a_larger_target_never_shrinks_a_bound() {
655 let r = resolved();
656 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
657 let small = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
658 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
659 let big = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
660 TargetGeometryV1::Rect { width_m: 0.6, height_m: 0.9 }, &domains).unwrap();
661 let s = &small.axes[0];
662 let b = &big.axes[0];
663 if let (Some(sf), Some(bf)) = (s.far_bound, b.far_bound) {
664 assert!(bf >= sf - 1e-6, "larger target shrank the bound: {sf} -> {bf}");
665 }
666 }
667
668 /// A bound that does not exist in the domain is reported as such, not invented.
669 ///
670 /// Review fix: also confirms `near_has_no_effect`/`far_has_no_effect` are BOTH `false` here
671 /// -- WindSpeed genuinely does move the impact over this domain (see the probed sweep in
672 /// `windage_dominant_axis_reports_left_or_right_not_top_or_bottom`'s doc), it just never
673 /// moves it far enough to leave this deliberately huge target. Contrast
674 /// `target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim`, where the
675 /// SAME `unbounded_in_domain: true` shape comes from an axis that has genuinely zero effect
676 /// -- these two tests together are the discriminating pair for that new field.
677 #[test]
678 fn an_axis_that_never_exits_is_flagged_not_bounded() {
679 let r = resolved();
680 // A huge target cannot be missed by a small wind change.
681 let domains = [(InputAxis::WindSpeed, (2.9_f64, 3.1_f64))];
682 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
683 TargetGeometryV1::Rect { width_m: 50.0, height_m: 50.0 }, &domains).unwrap();
684 assert!(rep.axes[0].unbounded_in_domain);
685 assert!(rep.axes[0].near_bound.is_none() && rep.axes[0].far_bound.is_none());
686 assert!(
687 !rep.axes[0].near_has_no_effect && !rep.axes[0].far_has_no_effect,
688 "WindSpeed does move the impact within this domain -- it just never leaves this \
689 huge target -- so has_no_effect must be false in both directions"
690 );
691 }
692
693 /// Review fix #3: the ticket's own flagship example is a range-estimate question ("how far
694 /// off could my rangefinder reading be"), and the axis that superficially matches --
695 /// `TargetDistance` (`shot.max_range_m`) -- does not answer it: it is `requires_rezero:
696 /// false`, so perturbing it never changes the muzzle angle or any sight correction, and
697 /// therefore never changes the impact observed at a FIXED `range_m` at all (as long as the
698 /// perturbed value stays >= `range_m`). Both directions here report `unbounded_in_domain:
699 /// true`, and this test's whole point is that `near_has_no_effect`/`far_has_no_effect` being
700 /// ALSO `true` is what tells a caller this is "provably irrelevant," not "any range error is
701 /// safe." `ZeroDistance` (`shot.zero_distance_m`, `requires_rezero: true`) is the axis that
702 /// actually re-zeroes for a different assumed distance and DOES move the impact at the true,
703 /// fixed `range_m` -- checked here for contrast, with real, non-`None` bounds.
704 #[test]
705 fn target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim() {
706 let r = resolved(); // max_range_m = 900.0, zero_distance_m = 600.0
707 let target = TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 };
708
709 let td_domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
710 let td = tolerance_envelope(&r, &[InputAxis::TargetDistance], 400.0, target, &td_domains)
711 .unwrap();
712 let a = &td.axes[0];
713 assert!(a.nominal_inside_target);
714 assert!(a.near_bound.is_none() && a.far_bound.is_none());
715 assert!(a.unbounded_in_domain);
716 assert!(
717 a.near_has_no_effect && a.far_has_no_effect,
718 "TargetDistance must show NO measurable effect in either direction, not merely an \
719 unbounded one -- it cannot change the impact observed at a fixed range_m at all"
720 );
721
722 // Contrast: ZeroDistance, over the SAME true observation range, DOES move the impact,
723 // and finds real bounds -- proving `has_no_effect` genuinely discriminates rather than
724 // being true for every `requires_rezero: false`-adjacent axis or every wide domain.
725 let zd_domains = [(InputAxis::ZeroDistance, (400.0_f64, 800.0_f64))];
726 let zd = tolerance_envelope(&r, &[InputAxis::ZeroDistance], 400.0, target, &zd_domains)
727 .unwrap();
728 let b = &zd.axes[0];
729 assert!(b.nominal_inside_target);
730 assert!(
731 b.near_bound.is_some() && b.far_bound.is_some(),
732 "ZeroDistance must produce real bounds: re-zeroing for a different assumed distance \
733 DOES move the impact observed at the true, fixed range_m"
734 );
735 assert!(!b.unbounded_in_domain);
736 }
737
738 #[test]
739 fn the_report_refuses_to_imply_probability() {
740 let r = resolved();
741 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
742 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
743 TargetGeometryV1::Circle { radius_m: 0.25 }, &domains).unwrap();
744 assert_eq!(rep.method, "one_variable_deterministic_bisection");
745 assert!(rep.assumptions.iter().any(|s| s.contains("probability")));
746 assert!(rep.assumptions.iter().any(|s| s.contains("simultaneously")));
747 }
748
749 // ---- Beyond the brief: the properties named in the task instructions ----
750
751 /// All four correctness requirements must be stated in the payload itself, not only in
752 /// prose documentation -- extends the brief's own probability/simultaneity check (which this
753 /// duplicates for a different fixture) to the two the brief's test does not cover: never
754 /// extrapolating past the configured domain, and confirming the nominal reads as inside
755 /// before any bound is searched for.
756 ///
757 /// Review fix: the fourth check previously asserted only `contains("nominal")`, which
758 /// `assumptions[0]` ("...stays at its **nominal** value...") already satisfies on its own --
759 /// deleting `assumptions[3]` entirely left this test green. Pinned per-index instead, on a
760 /// substring unique to each sentence (`"nominal_inside_target"`, the literal field name,
761 /// appears ONLY in `assumptions[3]`), plus an exact length so a deleted or reordered
762 /// assumption is caught even if its specific substring happened to still appear elsewhere.
763 #[test]
764 fn assumptions_cover_all_four_correctness_requirements() {
765 let r = resolved();
766 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
767 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
768 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
769 assert_eq!(rep.assumptions.len(), 4, "exactly four assumption sentences are expected");
770 assert!(
771 rep.assumptions[0].contains("simultaneously"),
772 "one-variable-at-a-time must be stated"
773 );
774 assert!(rep.assumptions[1].contains("probability"), "no probability must be stated");
775 assert!(
776 rep.assumptions[2].to_lowercase().contains("domain"),
777 "never-extrapolate-beyond-domain must be stated"
778 );
779 assert!(
780 rep.assumptions[3].contains("nominal_inside_target"),
781 "the nominal-inside precondition must be stated, discriminated from assumptions[0]'s \
782 own unrelated use of the word \"nominal\""
783 );
784 }
785
786 /// THE central distinction this ticket exists to enforce: "stays inside throughout" and
787 /// "the nominal itself is not inside" produce the IDENTICAL `near_bound`/`far_bound` shape
788 /// (`None`/`None`) from two completely different root causes. A degenerate (zero-area)
789 /// target can never contain any point, not even the exact nominal impact
790 /// (`dy == dz == 0.0`) -- contrast directly against `an_axis_that_never_exits_is_flagged_not_bounded`
791 /// above, which reaches the same `None`/`None` shape via the OPPOSITE fact (a huge target
792 /// that is never exited).
793 #[test]
794 fn a_degenerate_target_is_flagged_nominal_outside_not_confused_with_unbounded() {
795 let r = resolved();
796 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
797 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
798 TargetGeometryV1::Rect { width_m: 0.0, height_m: 0.3 }, &domains).unwrap();
799 let a = &rep.axes[0];
800 assert!(
801 !a.nominal_inside_target,
802 "a zero-area target can never contain even the nominal impact"
803 );
804 assert!(
805 a.near_bound.is_none() && a.far_bound.is_none(),
806 "bounds must not be fabricated when the nominal itself is not inside"
807 );
808 assert!(
809 !a.unbounded_in_domain,
810 "this is NOT the same fact as 'stays inside throughout' -- conflating the two is \
811 the exact failure this ticket exists to prevent"
812 );
813 }
814
815 /// Independent oracle #1 (analytic): against the vacuum closed form, BOTH the near and far
816 /// bound have an exact root, checked to a tight relative tolerance, with the domain's own
817 /// midpoint (900.0) chosen far from either root so a "return the midpoint" bug -- which
818 /// would still satisfy a bare `lo < x < hi` check -- is caught (mirrors
819 /// `bisect_axis_converges_to_the_vacuum_analytic_root_not_the_domain_midpoint`,
820 /// `src/perturbation/derive.rs`).
821 #[test]
822 fn bisection_bounds_match_the_vacuum_analytic_crossing() {
823 let r = vacuum_resolved();
824 let v0 = r.rifle.muzzle_velocity_mps;
825 let x = 400.0_f64;
826 const G: f64 = 9.80665;
827 let drop = |v: f64| 0.5 * G * (x / v) * (x / v);
828 let drop0 = drop(v0);
829
830 let half_height = 0.1_f64;
831 let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
832 let rep = tolerance_envelope(
833 &r,
834 &[InputAxis::MuzzleVelocityMps],
835 x,
836 TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 2.0 * half_height },
837 &domains,
838 )
839 .unwrap();
840 let a = &rep.axes[0];
841 assert!(a.nominal_inside_target);
842 // Review minor #5: every other test in this module happens to use range_m = 600.0, so
843 // `range_m` echoing the caller's input was never checked against any OTHER value --
844 // this fixture's own 400.0 closes that gap.
845 assert_eq!(rep.range_m, x);
846
847 // far (v > v0): drop DECREASES, crossing where drop(v) = drop0 - half_height.
848 let expected_far = x / (2.0 * (drop0 - half_height) / G).sqrt();
849 // near (v < v0): drop INCREASES, crossing where drop(v) = drop0 + half_height.
850 let expected_near = x / (2.0 * (drop0 + half_height) / G).sqrt();
851
852 let far = a.far_bound.expect("a crossing exists well within (700, 1100)");
853 let near = a.near_bound.expect("a crossing exists well within (700, 1100)");
854 let rel_far = ((far - expected_far) / expected_far).abs();
855 let rel_near = ((near - expected_near) / expected_near).abs();
856 assert!(rel_far < 0.02, "far: expected ~{expected_far}, got {far} (rel {rel_far})");
857 assert!(rel_near < 0.02, "near: expected ~{expected_near}, got {near} (rel {rel_near})");
858 assert!((far - 900.0).abs() > 30.0, "far bound must not be the domain midpoint");
859 assert!((near - 900.0).abs() > 30.0, "near bound must not be the domain midpoint");
860
861 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
862 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
863 }
864
865 /// Review fix #7 (part 1): pins `bisection_tolerance`'s formula directly, independent of any
866 /// downstream bisection -- catches a hardcoded constant or a different scaling factor.
867 #[test]
868 fn bisection_tolerance_scales_with_domain_width_not_a_flat_constant() {
869 assert_eq!(bisection_tolerance(0.0, 20.0), 20.0 * 1e-6);
870 assert_eq!(bisection_tolerance(700.0, 1100.0), 400.0 * 1e-6);
871 // Floor: a domain so narrow that width * 1e-6 would underflow below what 80 bisection
872 // iterations could usefully resolve is clamped to 1e-9, not left arbitrarily small.
873 assert_eq!(bisection_tolerance(800.0, 800.0 + 1e-5), 1e-9);
874 }
875
876 /// Review fix #7 (part 2): the domain-proportional formula is undefended end-to-end by every
877 /// OTHER test in this module, because every domain they use is wide enough that even a flat
878 /// `1e-4` tolerance would still run several real bisection iterations and land close to the
879 /// true crossing -- the two formulas are indistinguishable from the outside on a wide domain.
880 /// This test uses a domain SO narrow (`5e-5` per direction) that a flat `1e-4` tolerance
881 /// would satisfy `bisect_axis`'s very FIRST check (`(hi - lo).abs() <= tolerance`) with ZERO
882 /// bisection steps, returning the sub-domain's own exact algebraic midpoint verbatim --
883 /// verified by literally making that mutation (`bisection_tolerance` hardcoded to `1e-4`),
884 /// which reproducibly returns `far_bound` exactly equal to `800.000025` (`diff = 0e0`, not
885 /// even one ULP off) -- then reverting. Under the shipped domain-proportional formula
886 /// (`tol = 0.0001 * 1e-6`, floored to `1e-9`), real bisection runs and the result differs
887 /// from that same exact midpoint by `~8.6e-6` -- more than four orders of magnitude past any
888 /// floating-point noise floor, so `> 1e-7` below is a comfortable, non-flaky margin.
889 #[test]
890 fn a_narrow_domain_gets_real_bisection_not_an_immediate_midpoint_return() {
891 let r = vacuum_resolved();
892 let x = 400.0_f64;
893 let half_height = 5e-8_f64;
894 let domains = [(InputAxis::MuzzleVelocityMps, (799.99995_f64, 800.00005_f64))];
895 let rep = tolerance_envelope(
896 &r,
897 &[InputAxis::MuzzleVelocityMps],
898 x,
899 TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 2.0 * half_height },
900 &domains,
901 )
902 .unwrap();
903 let a = &rep.axes[0];
904 let far_midpoint = 0.5 * (800.0_f64 + 800.00005_f64);
905 let far = a.far_bound.expect("a crossing must exist this close to nominal");
906 assert!(
907 (far - far_midpoint).abs() > 1e-7,
908 "far_bound ({far}) must differ meaningfully from the domain's own exact midpoint \
909 ({far_midpoint}) -- an unchanged difference of ~0 would mean bisect_axis returned \
910 the midpoint verbatim without ever refining it, exactly what a flat 1e-4 tolerance \
911 does on a domain this narrow"
912 );
913 }
914
915 /// Independent oracle #2 (re-solve): a found bound is verified by re-solving at it, and at
916 /// points just inside and just outside it, through a path that does NOT go through
917 /// `with_axis`/`evaluate`/`bisect_axis` at all -- a hand-built request decoded and solved via
918 /// `solve_v1` directly (mirroring `mod.rs`'s own
919 /// `evaluate_matches_solve_v1_when_zero_distance_alone_searches_the_elevation` cross-check
920 /// pattern), reading the sample off `solve_v1`'s OWN wire output at an exact grid point
921 /// (600 m, an exact multiple of this fixture's 10 m sampling interval).
922 #[test]
923 fn a_found_bound_sits_on_the_target_boundary_verified_independently_of_with_axis() {
924 let r = resolved();
925 let target = TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 };
926 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
927 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
928 let a = &rep.axes[0];
929 assert!(a.nominal_inside_target);
930 let far = a.far_bound.expect("this target/domain combination must produce a far bound");
931
932 fn independent_deviation(wind_speed: f64) -> (f64, f64) {
933 let json = serde_json::json!({
934 "schema_version": 1,
935 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
936 "ballistic_coefficient": 0.243},
937 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
938 "shot": {"max_range_m": 900.0, "zero_distance_m": 600.0},
939 "atmosphere": {},
940 "wind": {"speed_mps": wind_speed,
941 "direction_from_rad": std::f64::consts::FRAC_PI_2},
942 "solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
943 })
944 .to_string();
945 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
946 let solved = crate::solve_v1::solve_v1(req).unwrap();
947 let sample = solved
948 .samples
949 .iter()
950 .find(|s| s.distance_m == 600.0)
951 .expect("600 m must be an exact grid point for this fixture");
952 (sample.drop_m, sample.windage_m)
953 }
954
955 let half_width = 0.2 / 2.0;
956 let half_height = 0.3 / 2.0;
957 let (nominal_drop, nominal_windage) = independent_deviation(3.0);
958 let ratio_at = |wind_speed: f64| -> f64 {
959 let (d, w) = independent_deviation(wind_speed);
960 let ry = (d - nominal_drop).abs() / half_height;
961 let rz = (w - nominal_windage).abs() / half_width;
962 ry.max(rz)
963 };
964
965 let ratio_far = ratio_at(far);
966 assert!(
967 (ratio_far - 1.0).abs() < 1e-3,
968 "found bound does not sit on the target boundary independently: ratio {ratio_far}"
969 );
970
971 let just_inside = 3.0 + (far - 3.0) * 0.99;
972 let just_outside = 3.0 + (far - 3.0) * 1.01;
973 assert!(
974 ratio_at(just_inside) < 1.0,
975 "a point just inside the found bound must independently read as inside the target"
976 );
977 assert!(
978 ratio_at(just_outside) > 1.0,
979 "a point just past the found bound must independently read as outside the target"
980 );
981 }
982
983 /// The brief's own acceptance criterion, generalized from two points to a genuine sweep:
984 /// growing the target can never shrink a one-variable bound, checked across many sizes,
985 /// consecutively -- two isolated points cannot catch a bound that stops being monotone only
986 /// partway through a range. Also asserts that once a direction becomes unbounded (no
987 /// crossing found within the domain) at some size, it stays unbounded at every LARGER size:
988 /// the literal meaning of "the crossing moved even farther away, past the domain edge."
989 /// WindSpeed's domain `(0, 20)` around a nominal of `3.0` is asymmetric (17 units of room
990 /// above, only 3 below), so the near direction saturates (becomes unbounded) at a much
991 /// smaller scale than the far direction -- the geometric sweep below is wide enough to
992 /// observe both transitions, confirmed by the closing assertion that both actually occurred.
993 #[test]
994 fn a_larger_target_never_shrinks_a_bound_across_a_size_sweep() {
995 let r = resolved();
996 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
997 let scales: Vec<f64> = (0..12).map(|i| 0.5 * 1.6_f64.powi(i)).collect();
998
999 let mut prev_far: Option<f64> = None;
1000 let mut prev_near: Option<f64> = None;
1001 let mut far_became_unbounded = false;
1002 let mut near_became_unbounded = false;
1003
1004 for &scale in &scales {
1005 let target =
1006 TargetGeometryV1::Rect { width_m: 0.2 * scale, height_m: 0.3 * scale };
1007 let rep =
1008 tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
1009 let a = &rep.axes[0];
1010 assert!(
1011 a.nominal_inside_target,
1012 "scale {scale}: nominal must read as inside for any positive-size target"
1013 );
1014
1015 if far_became_unbounded {
1016 assert!(
1017 a.far_bound.is_none(),
1018 "scale {scale}: far bound reappeared after becoming unbounded at a smaller \
1019 scale"
1020 );
1021 } else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
1022 assert!(f >= pf - 1e-6, "far bound shrank at scale {scale}: {pf} -> {f}");
1023 }
1024 if a.far_bound.is_none() {
1025 far_became_unbounded = true;
1026 }
1027 prev_far = a.far_bound;
1028
1029 if near_became_unbounded {
1030 assert!(
1031 a.near_bound.is_none(),
1032 "scale {scale}: near bound reappeared after becoming unbounded at a smaller \
1033 scale"
1034 );
1035 } else if let (Some(pn), Some(n)) = (prev_near, a.near_bound) {
1036 assert!(
1037 n <= pn + 1e-6,
1038 "near bound shrank (moved toward nominal) at scale {scale}: {pn} -> {n}"
1039 );
1040 }
1041 if a.near_bound.is_none() {
1042 near_became_unbounded = true;
1043 }
1044 prev_near = a.near_bound;
1045 }
1046
1047 assert!(
1048 far_became_unbounded,
1049 "sweep never reached an unbounded far regime -- the monotonicity check on that \
1050 transition never actually ran"
1051 );
1052 assert!(
1053 near_became_unbounded,
1054 "sweep never reached an unbounded near regime -- the monotonicity check on that \
1055 transition never actually ran"
1056 );
1057 // Sanity: the sweep must also have exercised at least one genuinely bounded pair on each
1058 // side, or the "shrank" assertions above would never fire either.
1059 assert!(scales[0] < 1.0, "sweep must start comfortably inside the bounded regime");
1060 }
1061
1062 /// Same acceptance criterion, briefly, for the OTHER `TargetGeometryV1` shape -- a circle's
1063 /// single `radius_m` grows strictly with the same scale factor, so this is a smaller sweep
1064 /// than the rectangle's (which independently varies two dimensions), not a second full
1065 /// property test.
1066 #[test]
1067 fn a_larger_circle_never_shrinks_a_bound_across_a_size_sweep() {
1068 let r = resolved();
1069 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1070 let scales = [0.5_f64, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0];
1071 let mut prev_far: Option<f64> = None;
1072 let mut far_became_unbounded = false;
1073 for &scale in &scales {
1074 let target = TargetGeometryV1::Circle { radius_m: 0.15 * scale };
1075 let rep =
1076 tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
1077 let a = &rep.axes[0];
1078 if far_became_unbounded {
1079 assert!(a.far_bound.is_none(), "scale {scale}: far bound reappeared");
1080 } else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
1081 assert!(f >= pf - 1e-6, "far bound shrank at scale {scale}: {pf} -> {f}");
1082 }
1083 if a.far_bound.is_none() {
1084 far_became_unbounded = true;
1085 }
1086 prev_far = a.far_bound;
1087 }
1088 assert!(far_became_unbounded, "circle sweep never reached an unbounded far regime");
1089 }
1090
1091 /// `axis`/`nominal` must each be tied to the axis they describe, not swapped or copied from
1092 /// a sibling entry -- WindSpeed (nominal 3.0) and MuzzleVelocityMps (nominal 823.0) are far
1093 /// enough apart that a transposition is unmistakable. `margin_linear_m` is independently
1094 /// pinned as a TARGET-derived constant identical across both axes (`min(0.6, 0.4) / 2 =
1095 /// 0.2`), not a copy of `nominal` or any other axis-specific field.
1096 #[test]
1097 fn multiple_axes_are_each_independently_computed_and_correctly_tagged() {
1098 let r = resolved();
1099 let domains = [
1100 (InputAxis::WindSpeed, (0.0_f64, 20.0_f64)),
1101 (InputAxis::MuzzleVelocityMps, (400.0_f64, 1200.0_f64)),
1102 ];
1103 let rep = tolerance_envelope(
1104 &r,
1105 &[InputAxis::WindSpeed, InputAxis::MuzzleVelocityMps],
1106 600.0,
1107 TargetGeometryV1::Rect { width_m: 0.4, height_m: 0.6 },
1108 &domains,
1109 )
1110 .unwrap();
1111 assert_eq!(rep.axes.len(), 2);
1112 assert_eq!(rep.axes[0].axis, InputAxis::WindSpeed);
1113 assert_eq!(rep.axes[1].axis, InputAxis::MuzzleVelocityMps);
1114
1115 let expected_ws = match read_axis(&r, InputAxis::WindSpeed).unwrap() {
1116 AxisValue::Scalar(x) => x,
1117 other => panic!("WindSpeed must read back as a scalar, got {other:?}"),
1118 };
1119 let expected_mv = match read_axis(&r, InputAxis::MuzzleVelocityMps).unwrap() {
1120 AxisValue::Scalar(x) => x,
1121 other => panic!("MuzzleVelocityMps must read back as a scalar, got {other:?}"),
1122 };
1123 assert_eq!(rep.axes[0].nominal, expected_ws);
1124 assert_eq!(rep.axes[1].nominal, expected_mv);
1125 assert_ne!(
1126 rep.axes[0].nominal, rep.axes[1].nominal,
1127 "fixture sanity: the two axes must have DIFFERENT nominal values or a \
1128 transposition between them would be invisible"
1129 );
1130 assert!(rep.axes[0].nominal_inside_target);
1131 assert!(rep.axes[1].nominal_inside_target);
1132 assert!((rep.axes[0].margin_linear_m - 0.2).abs() < 1e-9);
1133 assert!((rep.axes[1].margin_linear_m - 0.2).abs() < 1e-9);
1134 assert_eq!(rep.range_m, 600.0);
1135 }
1136
1137 /// `near_limiting_boundary`/`far_limiting_boundary` must independently discriminate Left/
1138 /// Right from Top/Bottom, not default to one or the other. A pure crosswind's effect on
1139 /// windage dominates its (tiny, secondary) effect on drop by many orders of magnitude (see
1140 /// `windage_derivative_wrt_wind_speed_dominates_and_matches_the_baseline_sign`,
1141 /// `src/perturbation/derive.rs`), so any WindSpeed crossing here must be windage-driven: more
1142 /// wind pushes windage more negative (see this module's own probed sweep), so the far
1143 /// direction (more wind) exits `Left` and the near direction (less wind) exits `Right`.
1144 #[test]
1145 fn windage_dominant_axis_reports_left_or_right_not_top_or_bottom() {
1146 let r = resolved();
1147 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1148 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1149 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
1150 let a = &rep.axes[0];
1151 assert!(a.far_bound.is_some() && a.near_bound.is_some());
1152 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Left));
1153 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Right));
1154 }
1155
1156 /// The complementary case for the SAME field: `bisection_bounds_match_the_vacuum_analytic_crossing`
1157 /// above already asserts `Top`/`Bottom` for a wind-free vacuum fixture where `windage_m` is
1158 /// exactly `0.0` throughout, so a crossing driven by muzzle velocity alone (which barely
1159 /// moves windage at all -- see `d_windage_d_x` in the same vacuum context) cannot be
1160 /// misclassified as `Left`/`Right`. Restated here as its own assertion, independent of the
1161 /// closed-form numeric check, so a mutation that hardcodes `Radial`/`Left` for every `Rect`
1162 /// would be caught even if the numeric oracle above were skipped.
1163 #[test]
1164 fn drop_dominant_axis_reports_top_or_bottom_not_left_or_right() {
1165 let r = vacuum_resolved();
1166 let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
1167 let rep = tolerance_envelope(&r, &[InputAxis::MuzzleVelocityMps], 400.0,
1168 TargetGeometryV1::Rect { width_m: 1.0e6, height_m: 0.2 }, &domains).unwrap();
1169 let a = &rep.axes[0];
1170 assert!(a.far_bound.is_some() && a.near_bound.is_some());
1171 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
1172 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
1173 }
1174
1175 /// A [`TargetGeometryV1::Circle`] has no distinct edges and must always report `Radial`,
1176 /// regardless of which direction (windage- or drop-dominant) the crossing came from. Also
1177 /// pins `target_margin_linear_m`'s `Circle` arm (review minor #6): previously only the
1178 /// `Rect` arm was checked numerically (`multiple_axes_are_each_independently_computed_...`),
1179 /// so a bug specific to the `Circle` branch (e.g. returning `radius_m` un-halved, or a
1180 /// hardcoded `0.0`) could have passed every existing test.
1181 #[test]
1182 fn a_circle_target_always_reports_radial() {
1183 let r = resolved();
1184 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1185 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1186 TargetGeometryV1::Circle { radius_m: 0.15 }, &domains).unwrap();
1187 let a = &rep.axes[0];
1188 assert!(a.far_bound.is_some() && a.near_bound.is_some());
1189 assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Radial));
1190 assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Radial));
1191 assert!(
1192 (a.margin_linear_m - 0.15).abs() < 1e-9,
1193 "Circle's margin_linear_m must be exactly its radius_m, got {}",
1194 a.margin_linear_m
1195 );
1196 }
1197
1198 /// Review fix #2: every existing fixture that reaches `limiting_boundary` has one deviation
1199 /// at (or indistinguishable from) zero -- the windage-dominant fixture has `dy ~ 0`, both
1200 /// drop-dominant fixtures use a wind-free vacuum with `dz` exactly `0`. Under any of those,
1201 /// swapping the cross-multiplication's `width_m`/`height_m` (`dy.abs() * height_m >=
1202 /// dz.abs() * width_m` instead of the correct `dy.abs() * width_m >= dz.abs() * height_m`)
1203 /// still passes, because one side of the comparison collapses to (approximately) zero either
1204 /// way. This calls `limiting_boundary` directly (it is private, and this test lives in the
1205 /// same module) with both deviations simultaneously non-trivial and an aspect-ratio-skewed
1206 /// target, so the two formulas disagree: `dy = 0.1` against `height_m = 0.2` (ratio `1.0`)
1207 /// and `dz = 0.5` against `width_m = 2.0` (ratio `0.5`) -- correct: `ratio_y > ratio_z` =>
1208 /// `Bottom`; swapped: `dy.abs()*height_m = 0.02 < dz.abs()*width_m = 1.0` => `Left`/`Right`
1209 /// branch entirely, i.e. a DIFFERENT edge family, not just a different edge.
1210 #[test]
1211 fn limiting_boundary_uses_the_correct_axis_for_each_deviation_not_swapped() {
1212 let nominal = Observation {
1213 range_m: 600.0,
1214 drop_m: 0.0,
1215 windage_m: 0.0,
1216 time_s: 1.0,
1217 velocity_mps: 500.0,
1218 };
1219 let o = Observation {
1220 range_m: 600.0,
1221 drop_m: 0.1,
1222 windage_m: 0.5,
1223 time_s: 1.0,
1224 velocity_mps: 500.0,
1225 };
1226 let target = TargetGeometryV1::Rect { width_m: 2.0, height_m: 0.2 };
1227 assert_eq!(
1228 limiting_boundary(&o, &nominal, target),
1229 LimitingBoundaryV1::Bottom,
1230 "dy/height_m ratio (1.0) exceeds dz/width_m ratio (0.5): must be a drop-family edge"
1231 );
1232 // Mirror on the drop side (dy < 0) to also confirm Top is reachable from this same
1233 // aspect-ratio-skewed target, not just Bottom.
1234 let o_top = Observation { drop_m: -0.1, ..o };
1235 assert_eq!(limiting_boundary(&o_top, &nominal, target), LimitingBoundaryV1::Top);
1236 }
1237
1238 /// A categorical axis has no numeric domain to bisect -- must be recorded, not silently
1239 /// dropped from the report and not a hard failure of the whole call.
1240 #[test]
1241 fn a_categorical_axis_is_recorded_unavailable_not_silently_dropped_or_hard_failed() {
1242 let r = resolved();
1243 let rep = tolerance_envelope(&r, &[InputAxis::CoriolisEnabled], 600.0,
1244 TargetGeometryV1::Circle { radius_m: 0.25 }, &[]).unwrap();
1245 assert!(rep.axes.is_empty(), "a categorical axis must never appear in `axes`");
1246 assert_eq!(rep.unavailable_axes.len(), 1);
1247 assert_eq!(rep.unavailable_axes[0].axis, InputAxis::CoriolisEnabled);
1248 assert_eq!(rep.unavailable_axes[0].code, UnavailableReasonCodeV1::CategoricalAxis);
1249 assert!(!rep.unavailable_axes[0].reason.is_empty());
1250 }
1251
1252 /// The three wind axes have no single scalar value under segmented wind -- `read_axis`
1253 /// returns `None` directly (never even reaching `bisect_axis`), and that must be recorded
1254 /// too, with no `domains` entry required for an axis that never gets that far.
1255 #[test]
1256 fn a_wind_axis_under_segmented_wind_is_recorded_unavailable() {
1257 let json = serde_json::json!({
1258 "schema_version": 1,
1259 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1260 "ballistic_coefficient": 0.243},
1261 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1262 "shot": {"max_range_m": 900.0},
1263 "atmosphere": {},
1264 "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
1265 "direction_from_rad": 1.0}]},
1266 "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1267 })
1268 .to_string();
1269 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1270 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1271 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 300.0,
1272 TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 }, &[]).unwrap();
1273 assert!(rep.axes.is_empty());
1274 assert_eq!(rep.unavailable_axes.len(), 1);
1275 assert_eq!(rep.unavailable_axes[0].axis, InputAxis::WindSpeed);
1276 assert_eq!(rep.unavailable_axes[0].code, UnavailableReasonCodeV1::AxisAbsent);
1277 }
1278
1279 /// `Altitude` under a QNH-referenced atmosphere is refused by `with_axis` itself
1280 /// (`AxisUnsupportedForRequest`) -- reached through this module's pre-flight step, not a
1281 /// hard failure of the whole report.
1282 #[test]
1283 fn altitude_under_qnh_is_recorded_unavailable_not_hard_failed() {
1284 let json = serde_json::json!({
1285 "schema_version": 1,
1286 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1287 "ballistic_coefficient": 0.243},
1288 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1289 "shot": {"max_range_m": 900.0},
1290 "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1291 "pressure_reference": "qnh"},
1292 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1293 })
1294 .to_string();
1295 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1296 let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1297 let domains = [(InputAxis::Altitude, (0.0_f64, 1000.0_f64))];
1298 let rep = tolerance_envelope(&r, &[InputAxis::Altitude], 600.0,
1299 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap();
1300 assert!(rep.axes.is_empty());
1301 assert_eq!(rep.unavailable_axes.len(), 1);
1302 assert_eq!(rep.unavailable_axes[0].axis, InputAxis::Altitude);
1303 assert_eq!(
1304 rep.unavailable_axes[0].code,
1305 UnavailableReasonCodeV1::AxisUnsupportedForRequest
1306 );
1307 assert!(rep.unavailable_axes[0].reason.to_lowercase().contains("qnh"));
1308 }
1309
1310 /// A domain whose lower bound, for `TargetDistance`, dips below the caller's OWN `range_m`
1311 /// is an internally inconsistent request (bisecting "how close could the target be" past
1312 /// "the range I am asking about"): `bisect_axis` hits a genuine `Observation::OutOfRange`
1313 /// partway through the near-direction search, which must propagate as a hard failure of the
1314 /// whole call, never be swallowed into `unavailable_axes` (it is not one of the four
1315 /// structural refusals).
1316 #[test]
1317 fn a_genuine_observation_error_propagates_not_recorded_as_unavailable() {
1318 let r = resolved(); // max_range_m = 900.0, zero_distance_m = 600.0
1319 let domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
1320 let err = tolerance_envelope(&r, &[InputAxis::TargetDistance], 600.0,
1321 TargetGeometryV1::Rect { width_m: 0.5, height_m: 0.5 }, &domains).unwrap_err();
1322 match err {
1323 KernelError::Observation(TrajectoryObservationError::OutOfRange { requested_m, .. }) => {
1324 assert_eq!(requested_m, 600.0);
1325 }
1326 other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
1327 }
1328 }
1329
1330 /// `domains` must supply an entry for every requested continuous, present axis -- no
1331 /// implicit fallback (see the module doc's "Domains are validated up front" section).
1332 #[test]
1333 fn a_missing_domain_is_reported_as_invalid_not_defaulted() {
1334 let r = resolved();
1335 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1336 TargetGeometryV1::Circle { radius_m: 0.3 }, &[]).unwrap_err();
1337 match err {
1338 KernelError::InvalidDomain { axis, .. } => assert_eq!(axis, InputAxis::WindSpeed),
1339 other => panic!("expected InvalidDomain, got {other:?}"),
1340 }
1341 }
1342
1343 /// The axis's own current value (`3.0`) must sit strictly inside the configured domain, or
1344 /// one search direction degenerates to a zero-width probe.
1345 #[test]
1346 fn nominal_outside_the_configured_domain_is_rejected() {
1347 let r = resolved();
1348 let domains = [(InputAxis::WindSpeed, (5.0_f64, 20.0_f64))];
1349 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1350 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1351 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1352 }
1353
1354 /// An inverted or zero-width domain (`lo >= hi`) is rejected outright.
1355 #[test]
1356 fn an_inverted_domain_is_rejected() {
1357 let r = resolved();
1358 let domains = [(InputAxis::WindSpeed, (20.0_f64, 0.0_f64))];
1359 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1360 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1361 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1362 }
1363
1364 /// Review fix (most important finding): `nominal_outside_the_configured_domain_is_rejected`
1365 /// puts the nominal (`3.0`) OUTSIDE its domain `(5.0, 20.0)` entirely, and
1366 /// `an_inverted_domain_is_rejected` is caught by the EARLIER `lo >= hi` check before the
1367 /// nominal-strictness check ever runs -- neither test can tell a strict `>`/`<` comparison
1368 /// apart from a relaxed `>=`/`<=` one. Relaxing `tolerance.rs`'s validation to `>=`/`<=`
1369 /// leaves ALL other tests in this module green: with `domains = [(WindSpeed, (3.0, 20.0))]`
1370 /// and nominal `3.0`, `bisect_axis` would be called with `(nominal_value, lo) = (3.0, 3.0)`
1371 /// -- a zero-width probe whose two endpoints trivially agree, so it would report `Ok(None)`
1372 /// for a direction that was never actually searched even one step into, and (if the far
1373 /// direction also found nothing) `unbounded_in_domain: true` -- "your wind call is robust in
1374 /// every direction," fabricated from a search that never moved. This test, and its mirror
1375 /// below for the upper edge, place the nominal EXACTLY at one edge and require
1376 /// `InvalidDomain` -- these fail under the `>=`/`<=` relaxation described above (verified by
1377 /// making that exact edit, confirming exactly these two tests fail with all others still
1378 /// green, then reverting to byte-identical -- see the task report).
1379 #[test]
1380 fn nominal_at_the_lower_domain_edge_is_rejected() {
1381 let r = resolved(); // WindSpeed nominal = 3.0
1382 let domains = [(InputAxis::WindSpeed, (3.0_f64, 20.0_f64))];
1383 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1384 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1385 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1386 }
1387
1388 /// Mirror of the above at the UPPER edge -- see that test's doc for the full rationale.
1389 #[test]
1390 fn nominal_at_the_upper_domain_edge_is_rejected() {
1391 let r = resolved(); // WindSpeed nominal = 3.0
1392 let domains = [(InputAxis::WindSpeed, (0.0_f64, 3.0_f64))];
1393 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1394 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1395 assert!(matches!(err, KernelError::InvalidDomain { axis: InputAxis::WindSpeed, .. }));
1396 }
1397
1398 /// `range_m` itself must be a queryable point on the base trajectory, exactly as
1399 /// `error_budget` requires of its own `ranges_m` -- checked directly, before any per-axis
1400 /// work, rather than surfacing as a mysterious per-axis refusal.
1401 #[test]
1402 fn an_out_of_range_query_is_rejected_directly() {
1403 let r = resolved(); // max_range_m = 900.0
1404 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1405 let err = tolerance_envelope(&r, &[InputAxis::WindSpeed], 5000.0,
1406 TargetGeometryV1::Circle { radius_m: 0.3 }, &domains).unwrap_err();
1407 match err {
1408 KernelError::Observation(TrajectoryObservationError::OutOfRange { requested_m, .. }) => {
1409 assert_eq!(requested_m, 5000.0);
1410 }
1411 other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
1412 }
1413 }
1414
1415 /// Every public field on [`ToleranceReportV1`]/[`ToleranceAxisV1`]/[`UnavailableAxisV1`]
1416 /// round-trips through JSON, and the externally-tagged `snake_case` axis name is exactly
1417 /// what a consumer of this wire type would expect -- pins the `#[serde(rename_all =
1418 /// "snake_case")]` convention this crate uses throughout, not just that serialization
1419 /// succeeds at all.
1420 ///
1421 /// Compares fields individually with a `1e-9` tolerance on the bisection-derived `f64`s
1422 /// rather than a whole-struct `assert_eq!`: `serde_json` 1.0.149's float parser round-trips
1423 /// SOME specific `f64` bit patterns (arbitrary bisection results among them) one ULP off --
1424 /// serializing produces the shortest correctly-round-tripping decimal string, but re-parsing
1425 /// that exact string yields a different bit pattern one ULP away. Confirmed with a
1426 /// standalone reproduction outside this crate on the exact bit pattern this test's own
1427 /// `near_bound` produced (`serde_json::to_string`/`from_str` on a bare `f64`), matching the
1428 /// identical upstream characteristic `error_budget.rs`'s
1429 /// `p_hit_and_gain_round_trip_through_json_when_present` already documents -- not a defect
1430 /// in this module's `Serialize`/`Deserialize` derives.
1431 #[test]
1432 fn the_report_round_trips_through_json() {
1433 let r = resolved();
1434 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1435 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1436 TargetGeometryV1::Rect { width_m: 0.2, height_m: 0.3 }, &domains).unwrap();
1437 let json = serde_json::to_string(&rep).unwrap();
1438 assert!(json.contains("\"axis\":\"wind_speed\""));
1439 let back: ToleranceReportV1 = serde_json::from_str(&json).unwrap();
1440
1441 assert_eq!(back.schema_version, rep.schema_version);
1442 assert_eq!(back.method, rep.method);
1443 assert_eq!(back.assumptions, rep.assumptions);
1444 assert_eq!(back.range_m, rep.range_m);
1445 assert_eq!(back.unavailable_axes, rep.unavailable_axes);
1446 assert_eq!(back.axes.len(), rep.axes.len());
1447 let (ba, ra) = (&back.axes[0], &rep.axes[0]);
1448 assert_eq!(ba.axis, ra.axis);
1449 assert_eq!(ba.nominal_inside_target, ra.nominal_inside_target);
1450 assert_eq!(ba.unbounded_in_domain, ra.unbounded_in_domain);
1451 assert_eq!(ba.near_limiting_boundary, ra.near_limiting_boundary);
1452 assert_eq!(ba.far_limiting_boundary, ra.far_limiting_boundary);
1453 assert!((ba.nominal - ra.nominal).abs() < 1e-9);
1454 assert!((ba.margin_linear_m - ra.margin_linear_m).abs() < 1e-9);
1455 assert!((ba.near_bound.unwrap() - ra.near_bound.unwrap()).abs() < 1e-9);
1456 assert!((ba.far_bound.unwrap() - ra.far_bound.unwrap()).abs() < 1e-9);
1457 }
1458
1459 /// `schema_version` must be the crate's declared constant, not a stray literal that could
1460 /// silently drift from it.
1461 #[test]
1462 fn schema_version_matches_the_declared_constant() {
1463 let r = resolved();
1464 let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1465 let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0,
1466 TargetGeometryV1::Circle { radius_m: 0.25 }, &domains).unwrap();
1467 assert_eq!(rep.schema_version, TOLERANCE_SCHEMA_VERSION_V1);
1468 }
1469}