Skip to main content

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(
400            TrajectoryObservationError::NonFiniteQuery {
401                distance_m: range_m,
402            },
403        ));
404    }
405    if range_m < 0.0 || range_m > base.shot.max_range_m {
406        return Err(KernelError::Observation(
407            TrajectoryObservationError::OutOfRange {
408                requested_m: range_m,
409                minimum_m: 0.0,
410                maximum_m: base.shot.max_range_m,
411            },
412        ));
413    }
414
415    // The fixed reference point every axis's "inside" check is measured against: base's own
416    // resolved inputs, solved directly (not through with_axis), exactly once.
417    let nominal = evaluate(&base.into(), &[range_m])?[0];
418    let margin_linear_m = target_margin_linear_m(target);
419
420    let mut out = Vec::with_capacity(axes.len());
421    let mut unavailable = Vec::new();
422
423    for &axis in axes {
424        // 1. Categorical axes have no numeric domain to bisect (mirrors bisect_axis's own
425        //    guard); record, do not abort the rest of the report.
426        if matches!(axis_meta(axis).kind, AxisKind::Categorical) {
427            let (code, reason) = unavailable_reason(&KernelError::CategoricalAxis(axis))
428                .expect("CategoricalAxis is always classified as unavailable");
429            unavailable.push(UnavailableAxisV1 { axis, code, reason });
430            continue;
431        }
432
433        // 2. Read the current value. `None` means the axis is structurally absent on this
434        //    request (the three wind axes under segmented wind) -- record, do not abort.
435        let nominal_value = match read_axis(base, axis) {
436            Some(AxisValue::Scalar(x)) => x,
437            // Unreachable with the current taxonomy (every Continuous axis reads back as Scalar
438            // or None; Flag/DragModel/TwistDirection only ever come from Categorical axes,
439            // already handled above) -- kept as a defensive catch-all, mirroring
440            // central_difference's identical fallback (crate::perturbation::derive).
441            Some(_) => return Err(KernelError::TypeMismatch(axis)),
442            None => {
443                let (code, reason) = unavailable_reason(&KernelError::AxisAbsent(axis))
444                    .expect("AxisAbsent is always classified as unavailable");
445                unavailable.push(UnavailableAxisV1 { axis, code, reason });
446                continue;
447            }
448        };
449
450        // 3. The domain must be explicitly configured and well-formed -- see "Domains are
451        //    validated up front, per axis" in the module doc. Never an invented default.
452        let (lo, hi) = match domains.iter().find(|(a, _)| *a == axis).map(|(_, d)| *d) {
453            Some(d) => d,
454            None => {
455                return Err(KernelError::InvalidDomain {
456                    axis,
457                    reason: "no domain was configured for this axis",
458                })
459            }
460        };
461        if !(lo.is_finite() && hi.is_finite()) {
462            return Err(KernelError::InvalidDomain {
463                axis,
464                reason: "domain bounds must both be finite",
465            });
466        }
467        if lo >= hi {
468            return Err(KernelError::InvalidDomain {
469                axis,
470                reason: "the domain's lower bound must be strictly less than its upper bound",
471            });
472        }
473        if !(nominal_value > lo && nominal_value < hi) {
474            return Err(KernelError::InvalidDomain {
475                axis,
476                reason: "the axis's own current value must sit strictly inside the configured \
477                         domain, or one search direction would be a zero-width probe",
478            });
479        }
480
481        // 4. Pre-flight: evaluate AT the current value via the IDENTICAL with_axis/evaluate path
482        //    bisect_axis uses for the shared anchor point (domain.0 in both directions below).
483        //    Surfaces AxisUnsupportedForRequest/AxisAbsent for this axis/request combination
484        //    before any Ok(None) is trusted -- both depend only on axis/base, never on the
485        //    specific value written (see with_axis's own doc), so a successful pre-flight here
486        //    guarantees neither can recur below for ANY value bisect_axis might try -- AND
487        //    supplies the reconstructed "at nominal" observation correctness requirement 1
488        //    checks against.
489        let pre = with_axis(base, axis, AxisValue::Scalar(nominal_value))
490            .and_then(|req| evaluate(&req, &[range_m]));
491        let at_nominal = match pre {
492            Ok(obs) => obs[0],
493            Err(e) => match unavailable_reason(&e) {
494                Some((code, reason)) => {
495                    unavailable.push(UnavailableAxisV1 { axis, code, reason });
496                    continue;
497                }
498                None => return Err(e),
499            },
500        };
501
502        // Correctness requirement 1: the nominal must actually read as inside before any bound
503        // means anything -- checked via the SAME reconstruction path as domain.0, not merely
504        // assumed. See the module doc's "The central hazard" section.
505        let nominal_inside_target = inside(&at_nominal, &nominal, target);
506        if !nominal_inside_target {
507            out.push(ToleranceAxisV1 {
508                axis,
509                nominal: nominal_value,
510                nominal_inside_target: false,
511                near_bound: None,
512                far_bound: None,
513                near_limiting_boundary: None,
514                far_limiting_boundary: None,
515                unbounded_in_domain: false,
516                near_has_no_effect: false,
517                far_has_no_effect: false,
518                margin_linear_m,
519            });
520            continue;
521        }
522
523        // With the anchor confirmed inside, any Ok(None) bisect_axis returns below is now
524        // unambiguous: "stays inside throughout that direction," not "outside throughout" (the
525        // conflation this module exists to prevent -- see the module doc). Any error from these
526        // two calls, after a successful pre-flight, can only be a genuine Solve/Observation
527        // failure (AxisUnsupportedForRequest/AxisAbsent/CategoricalAxis are all already ruled
528        // out above), so it propagates directly via `?` -- see
529        // `a_genuine_observation_error_propagates_not_recorded_as_unavailable`.
530        let tol = bisection_tolerance(lo, hi);
531        let pred = |o: &Observation| inside(o, &nominal, target);
532        let near = bisect_axis(base, axis, range_m, (nominal_value, lo), &pred, tol)?;
533        let far = bisect_axis(base, axis, range_m, (nominal_value, hi), &pred, tol)?;
534
535        // Beyond finding a bound (or confirming its absence), each None direction is checked
536        // for whether the axis has ANY measurable effect on the impact at all -- see
537        // `near_has_no_effect`/`far_has_no_effect`'s own doc and the module doc's "TargetDistance
538        // cannot answer..." section for why this distinction matters. Both endpoint observations
539        // (`lo`/`hi`) are recomputed here rather than threaded out of `bisect_axis` (which does
540        // evaluate them internally to decide `Ok(None)`, but does not expose them) -- at most two
541        // extra solves per axis, only in the already-cheaper `None` case.
542        let (near_limiting_boundary, near_has_no_effect) = match near {
543            Some(v) => {
544                let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?[0];
545                (Some(limiting_boundary(&o, &nominal, target)), false)
546            }
547            None => {
548                let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(lo))?, &[range_m])?[0];
549                (None, observation_matches_nominal(&o, &nominal))
550            }
551        };
552        let (far_limiting_boundary, far_has_no_effect) = match far {
553            Some(v) => {
554                let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?[0];
555                (Some(limiting_boundary(&o, &nominal, target)), false)
556            }
557            None => {
558                let o = evaluate(&with_axis(base, axis, AxisValue::Scalar(hi))?, &[range_m])?[0];
559                (None, observation_matches_nominal(&o, &nominal))
560            }
561        };
562
563        out.push(ToleranceAxisV1 {
564            axis,
565            nominal: nominal_value,
566            nominal_inside_target: true,
567            near_bound: near,
568            far_bound: far,
569            near_limiting_boundary,
570            far_limiting_boundary,
571            unbounded_in_domain: near.is_none() && far.is_none(),
572            near_has_no_effect,
573            far_has_no_effect,
574            margin_linear_m,
575        });
576    }
577
578    Ok(ToleranceReportV1 {
579        schema_version: TOLERANCE_SCHEMA_VERSION_V1,
580        method: "one_variable_deterministic_bisection".to_string(),
581        assumptions: vec![
582            "Each bound holds ONE input at its limit while every other input stays at its \
583             nominal value. Bounds from different axes may NOT be assumed to hold \
584             simultaneously: two inputs each at their own individual limit will generally miss \
585             even though neither alone would."
586                .to_string(),
587            "No probability distribution is assumed or implied by any bound here. These are \
588             deterministic limits of a one-variable search, not confidence intervals or a \
589             probability of hit."
590                .to_string(),
591            "A bound is reported only when found strictly within the axis's own configured \
592             search domain. It is never extrapolated beyond that domain: 'no bound within the \
593             domain' (unbounded_in_domain) is reported as exactly that fact, never as a guessed \
594             number."
595                .to_string(),
596            "Before any bound is searched for, the nominal solution's own impact is confirmed to \
597             read as inside the target (nominal_inside_target); an axis for which that check \
598             fails is reported as such instead of a fabricated or misleading bound."
599                .to_string(),
600        ],
601        range_m,
602        unavailable_axes: unavailable,
603        axes: out,
604    })
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::error_budget::TargetGeometryV1;
611    use crate::perturbation::InputAxis;
612
613    // The brief's own fixture literal uses a lowercase "g7"; the decoder requires the
614    // exact-case built-in spellings ("G1"/"G2"/"G5"/"G6"/"G7"/"G8"/"GI"/"GS"/"RA4"),
615    // confirmed against the perturbation/error_budget fixtures and `docs/SOLVE_JSON_V1.md`.
616    // Keep this fixture uppercase rather than reproducing the brief's unintended
617    // `InvalidValue` decode failure.
618    fn resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
619        let json = serde_json::json!({
620            "schema_version": 1,
621            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
622                           "ballistic_coefficient": 0.243},
623            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
624            "shot": {"max_range_m": 900.0, "zero_distance_m": 600.0},
625            "atmosphere": {}, "wind": {"speed_mps": 3.0,
626                                       "direction_from_rad": std::f64::consts::FRAC_PI_2},
627            "solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
628        })
629        .to_string();
630        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
631        crate::solve_v1::solve_v1(req).unwrap().resolved_request
632    }
633
634    /// A vacuum (drag-free), flat, unzeroed shot: `drop(v) = 0.5 * g * (x / v)^2` exactly (same
635    /// trick `central_difference`'s own analytic oracle test uses,
636    /// `src/perturbation/derive.rs`), and no wind at all so `windage_m` is exactly `0.0`
637    /// everywhere. No `zero_distance_m` at all, so `with_axis`'s rezero-clearing never fires for
638    /// `MuzzleVelocityMps` (a `requires_rezero` axis) -- the trajectory really is the closed
639    /// form, not an approximation of one perturbed by re-zero search noise.
640    fn vacuum_resolved() -> crate::solve_json::ResolvedSolveRequestV1 {
641        let json = serde_json::json!({
642            "schema_version": 1,
643            "projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
644                           "ballistic_coefficient": 100.0},
645            "rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
646            "shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
647            "atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
648            "sampling": {"interval_m": 5.0}
649        })
650        .to_string();
651        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
652        crate::solve_v1::solve_v1(req).unwrap().resolved_request
653    }
654
655    // ---- Step 1 tests, verbatim from the brief ----
656
657    /// Acceptance criterion: a bigger target can never shrink a one-variable bound.
658    #[test]
659    fn a_larger_target_never_shrinks_a_bound() {
660        let r = resolved();
661        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
662        let small = tolerance_envelope(
663            &r,
664            &[InputAxis::WindSpeed],
665            600.0,
666            TargetGeometryV1::Rect {
667                width_m: 0.2,
668                height_m: 0.3,
669            },
670            &domains,
671        )
672        .unwrap();
673        let big = tolerance_envelope(
674            &r,
675            &[InputAxis::WindSpeed],
676            600.0,
677            TargetGeometryV1::Rect {
678                width_m: 0.6,
679                height_m: 0.9,
680            },
681            &domains,
682        )
683        .unwrap();
684        let s = &small.axes[0];
685        let b = &big.axes[0];
686        if let (Some(sf), Some(bf)) = (s.far_bound, b.far_bound) {
687            assert!(
688                bf >= sf - 1e-6,
689                "larger target shrank the bound: {sf} -> {bf}"
690            );
691        }
692    }
693
694    /// A bound that does not exist in the domain is reported as such, not invented.
695    ///
696    /// Review fix: also confirms `near_has_no_effect`/`far_has_no_effect` are BOTH `false` here
697    /// -- WindSpeed genuinely does move the impact over this domain (see the probed sweep in
698    /// `windage_dominant_axis_reports_left_or_right_not_top_or_bottom`'s doc), it just never
699    /// moves it far enough to leave this deliberately huge target. Contrast
700    /// `target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim`, where the
701    /// SAME `unbounded_in_domain: true` shape comes from an axis that has genuinely zero effect
702    /// -- these two tests together are the discriminating pair for that new field.
703    #[test]
704    fn an_axis_that_never_exits_is_flagged_not_bounded() {
705        let r = resolved();
706        // A huge target cannot be missed by a small wind change.
707        let domains = [(InputAxis::WindSpeed, (2.9_f64, 3.1_f64))];
708        let rep = tolerance_envelope(
709            &r,
710            &[InputAxis::WindSpeed],
711            600.0,
712            TargetGeometryV1::Rect {
713                width_m: 50.0,
714                height_m: 50.0,
715            },
716            &domains,
717        )
718        .unwrap();
719        assert!(rep.axes[0].unbounded_in_domain);
720        assert!(rep.axes[0].near_bound.is_none() && rep.axes[0].far_bound.is_none());
721        assert!(
722            !rep.axes[0].near_has_no_effect && !rep.axes[0].far_has_no_effect,
723            "WindSpeed does move the impact within this domain -- it just never leaves this \
724             huge target -- so has_no_effect must be false in both directions"
725        );
726    }
727
728    /// Review fix #3: the ticket's own flagship example is a range-estimate question ("how far
729    /// off could my rangefinder reading be"), and the axis that superficially matches --
730    /// `TargetDistance` (`shot.max_range_m`) -- does not answer it: it is `requires_rezero:
731    /// false`, so perturbing it never changes the muzzle angle or any sight correction, and
732    /// therefore never changes the impact observed at a FIXED `range_m` at all (as long as the
733    /// perturbed value stays >= `range_m`). Both directions here report `unbounded_in_domain:
734    /// true`, and this test's whole point is that `near_has_no_effect`/`far_has_no_effect` being
735    /// ALSO `true` is what tells a caller this is "provably irrelevant," not "any range error is
736    /// safe." `ZeroDistance` (`shot.zero_distance_m`, `requires_rezero: true`) is the axis that
737    /// actually re-zeroes for a different assumed distance and DOES move the impact at the true,
738    /// fixed `range_m` -- checked here for contrast, with real, non-`None` bounds.
739    #[test]
740    fn target_distance_axis_shows_no_measurable_effect_not_a_generic_unbounded_claim() {
741        let r = resolved(); // max_range_m = 900.0, zero_distance_m = 600.0
742        let target = TargetGeometryV1::Rect {
743            width_m: 0.5,
744            height_m: 0.5,
745        };
746
747        let td_domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
748        let td = tolerance_envelope(&r, &[InputAxis::TargetDistance], 400.0, target, &td_domains)
749            .unwrap();
750        let a = &td.axes[0];
751        assert!(a.nominal_inside_target);
752        assert!(a.near_bound.is_none() && a.far_bound.is_none());
753        assert!(a.unbounded_in_domain);
754        assert!(
755            a.near_has_no_effect && a.far_has_no_effect,
756            "TargetDistance must show NO measurable effect in either direction, not merely an \
757             unbounded one -- it cannot change the impact observed at a fixed range_m at all"
758        );
759
760        // Contrast: ZeroDistance, over the SAME true observation range, DOES move the impact,
761        // and finds real bounds -- proving `has_no_effect` genuinely discriminates rather than
762        // being true for every `requires_rezero: false`-adjacent axis or every wide domain.
763        let zd_domains = [(InputAxis::ZeroDistance, (400.0_f64, 800.0_f64))];
764        let zd =
765            tolerance_envelope(&r, &[InputAxis::ZeroDistance], 400.0, target, &zd_domains).unwrap();
766        let b = &zd.axes[0];
767        assert!(b.nominal_inside_target);
768        assert!(
769            b.near_bound.is_some() && b.far_bound.is_some(),
770            "ZeroDistance must produce real bounds: re-zeroing for a different assumed distance \
771             DOES move the impact observed at the true, fixed range_m"
772        );
773        assert!(!b.unbounded_in_domain);
774    }
775
776    #[test]
777    fn the_report_refuses_to_imply_probability() {
778        let r = resolved();
779        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
780        let rep = tolerance_envelope(
781            &r,
782            &[InputAxis::WindSpeed],
783            600.0,
784            TargetGeometryV1::Circle { radius_m: 0.25 },
785            &domains,
786        )
787        .unwrap();
788        assert_eq!(rep.method, "one_variable_deterministic_bisection");
789        assert!(rep.assumptions.iter().any(|s| s.contains("probability")));
790        assert!(rep.assumptions.iter().any(|s| s.contains("simultaneously")));
791    }
792
793    // ---- Beyond the brief: the properties named in the task instructions ----
794
795    /// All four correctness requirements must be stated in the payload itself, not only in
796    /// prose documentation -- extends the brief's own probability/simultaneity check (which this
797    /// duplicates for a different fixture) to the two the brief's test does not cover: never
798    /// extrapolating past the configured domain, and confirming the nominal reads as inside
799    /// before any bound is searched for.
800    ///
801    /// Review fix: the fourth check previously asserted only `contains("nominal")`, which
802    /// `assumptions[0]` ("...stays at its **nominal** value...") already satisfies on its own --
803    /// deleting `assumptions[3]` entirely left this test green. Pinned per-index instead, on a
804    /// substring unique to each sentence (`"nominal_inside_target"`, the literal field name,
805    /// appears ONLY in `assumptions[3]`), plus an exact length so a deleted or reordered
806    /// assumption is caught even if its specific substring happened to still appear elsewhere.
807    #[test]
808    fn assumptions_cover_all_four_correctness_requirements() {
809        let r = resolved();
810        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
811        let rep = tolerance_envelope(
812            &r,
813            &[InputAxis::WindSpeed],
814            600.0,
815            TargetGeometryV1::Rect {
816                width_m: 0.2,
817                height_m: 0.3,
818            },
819            &domains,
820        )
821        .unwrap();
822        assert_eq!(
823            rep.assumptions.len(),
824            4,
825            "exactly four assumption sentences are expected"
826        );
827        assert!(
828            rep.assumptions[0].contains("simultaneously"),
829            "one-variable-at-a-time must be stated"
830        );
831        assert!(
832            rep.assumptions[1].contains("probability"),
833            "no probability must be stated"
834        );
835        assert!(
836            rep.assumptions[2].to_lowercase().contains("domain"),
837            "never-extrapolate-beyond-domain must be stated"
838        );
839        assert!(
840            rep.assumptions[3].contains("nominal_inside_target"),
841            "the nominal-inside precondition must be stated, discriminated from assumptions[0]'s \
842             own unrelated use of the word \"nominal\""
843        );
844    }
845
846    /// THE central distinction this ticket exists to enforce: "stays inside throughout" and
847    /// "the nominal itself is not inside" produce the IDENTICAL `near_bound`/`far_bound` shape
848    /// (`None`/`None`) from two completely different root causes. A degenerate (zero-area)
849    /// target can never contain any point, not even the exact nominal impact
850    /// (`dy == dz == 0.0`) -- contrast directly against `an_axis_that_never_exits_is_flagged_not_bounded`
851    /// above, which reaches the same `None`/`None` shape via the OPPOSITE fact (a huge target
852    /// that is never exited).
853    #[test]
854    fn a_degenerate_target_is_flagged_nominal_outside_not_confused_with_unbounded() {
855        let r = resolved();
856        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
857        let rep = tolerance_envelope(
858            &r,
859            &[InputAxis::WindSpeed],
860            600.0,
861            TargetGeometryV1::Rect {
862                width_m: 0.0,
863                height_m: 0.3,
864            },
865            &domains,
866        )
867        .unwrap();
868        let a = &rep.axes[0];
869        assert!(
870            !a.nominal_inside_target,
871            "a zero-area target can never contain even the nominal impact"
872        );
873        assert!(
874            a.near_bound.is_none() && a.far_bound.is_none(),
875            "bounds must not be fabricated when the nominal itself is not inside"
876        );
877        assert!(
878            !a.unbounded_in_domain,
879            "this is NOT the same fact as 'stays inside throughout' -- conflating the two is \
880             the exact failure this ticket exists to prevent"
881        );
882    }
883
884    /// Independent oracle #1 (analytic): against the vacuum closed form, BOTH the near and far
885    /// bound have an exact root, checked to a tight relative tolerance, with the domain's own
886    /// midpoint (900.0) chosen far from either root so a "return the midpoint" bug -- which
887    /// would still satisfy a bare `lo < x < hi` check -- is caught (mirrors
888    /// `bisect_axis_converges_to_the_vacuum_analytic_root_not_the_domain_midpoint`,
889    /// `src/perturbation/derive.rs`).
890    #[test]
891    fn bisection_bounds_match_the_vacuum_analytic_crossing() {
892        let r = vacuum_resolved();
893        let v0 = r.rifle.muzzle_velocity_mps;
894        let x = 400.0_f64;
895        const G: f64 = 9.80665;
896        let drop = |v: f64| 0.5 * G * (x / v) * (x / v);
897        let drop0 = drop(v0);
898
899        let half_height = 0.1_f64;
900        let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
901        let rep = tolerance_envelope(
902            &r,
903            &[InputAxis::MuzzleVelocityMps],
904            x,
905            TargetGeometryV1::Rect {
906                width_m: 1.0e6,
907                height_m: 2.0 * half_height,
908            },
909            &domains,
910        )
911        .unwrap();
912        let a = &rep.axes[0];
913        assert!(a.nominal_inside_target);
914        // Review minor #5: every other test in this module happens to use range_m = 600.0, so
915        // `range_m` echoing the caller's input was never checked against any OTHER value --
916        // this fixture's own 400.0 closes that gap.
917        assert_eq!(rep.range_m, x);
918
919        // far (v > v0): drop DECREASES, crossing where drop(v) = drop0 - half_height.
920        let expected_far = x / (2.0 * (drop0 - half_height) / G).sqrt();
921        // near (v < v0): drop INCREASES, crossing where drop(v) = drop0 + half_height.
922        let expected_near = x / (2.0 * (drop0 + half_height) / G).sqrt();
923
924        let far = a
925            .far_bound
926            .expect("a crossing exists well within (700, 1100)");
927        let near = a
928            .near_bound
929            .expect("a crossing exists well within (700, 1100)");
930        let rel_far = ((far - expected_far) / expected_far).abs();
931        let rel_near = ((near - expected_near) / expected_near).abs();
932        assert!(
933            rel_far < 0.02,
934            "far: expected ~{expected_far}, got {far} (rel {rel_far})"
935        );
936        assert!(
937            rel_near < 0.02,
938            "near: expected ~{expected_near}, got {near} (rel {rel_near})"
939        );
940        assert!(
941            (far - 900.0).abs() > 30.0,
942            "far bound must not be the domain midpoint"
943        );
944        assert!(
945            (near - 900.0).abs() > 30.0,
946            "near bound must not be the domain midpoint"
947        );
948
949        assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
950        assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
951    }
952
953    /// Review fix #7 (part 1): pins `bisection_tolerance`'s formula directly, independent of any
954    /// downstream bisection -- catches a hardcoded constant or a different scaling factor.
955    #[test]
956    fn bisection_tolerance_scales_with_domain_width_not_a_flat_constant() {
957        assert_eq!(bisection_tolerance(0.0, 20.0), 20.0 * 1e-6);
958        assert_eq!(bisection_tolerance(700.0, 1100.0), 400.0 * 1e-6);
959        // Floor: a domain so narrow that width * 1e-6 would underflow below what 80 bisection
960        // iterations could usefully resolve is clamped to 1e-9, not left arbitrarily small.
961        assert_eq!(bisection_tolerance(800.0, 800.0 + 1e-5), 1e-9);
962    }
963
964    /// Review fix #7 (part 2): the domain-proportional formula is undefended end-to-end by every
965    /// OTHER test in this module, because every domain they use is wide enough that even a flat
966    /// `1e-4` tolerance would still run several real bisection iterations and land close to the
967    /// true crossing -- the two formulas are indistinguishable from the outside on a wide domain.
968    /// This test uses a domain SO narrow (`5e-5` per direction) that a flat `1e-4` tolerance
969    /// would satisfy `bisect_axis`'s very FIRST check (`(hi - lo).abs() <= tolerance`) with ZERO
970    /// bisection steps, returning the sub-domain's own exact algebraic midpoint verbatim --
971    /// verified by literally making that mutation (`bisection_tolerance` hardcoded to `1e-4`),
972    /// which reproducibly returns `far_bound` exactly equal to `800.000025` (`diff = 0e0`, not
973    /// even one ULP off) -- then reverting. Under the shipped domain-proportional formula
974    /// (`tol = 0.0001 * 1e-6`, floored to `1e-9`), real bisection runs and the result differs
975    /// from that same exact midpoint by `~8.6e-6` -- more than four orders of magnitude past any
976    /// floating-point noise floor, so `> 1e-7` below is a comfortable, non-flaky margin.
977    #[test]
978    fn a_narrow_domain_gets_real_bisection_not_an_immediate_midpoint_return() {
979        let r = vacuum_resolved();
980        let x = 400.0_f64;
981        let half_height = 5e-8_f64;
982        let domains = [(InputAxis::MuzzleVelocityMps, (799.99995_f64, 800.00005_f64))];
983        let rep = tolerance_envelope(
984            &r,
985            &[InputAxis::MuzzleVelocityMps],
986            x,
987            TargetGeometryV1::Rect {
988                width_m: 1.0e6,
989                height_m: 2.0 * half_height,
990            },
991            &domains,
992        )
993        .unwrap();
994        let a = &rep.axes[0];
995        let far_midpoint = 0.5 * (800.0_f64 + 800.00005_f64);
996        let far = a
997            .far_bound
998            .expect("a crossing must exist this close to nominal");
999        assert!(
1000            (far - far_midpoint).abs() > 1e-7,
1001            "far_bound ({far}) must differ meaningfully from the domain's own exact midpoint \
1002             ({far_midpoint}) -- an unchanged difference of ~0 would mean bisect_axis returned \
1003             the midpoint verbatim without ever refining it, exactly what a flat 1e-4 tolerance \
1004             does on a domain this narrow"
1005        );
1006    }
1007
1008    /// Independent oracle #2 (re-solve): a found bound is verified by re-solving at it, and at
1009    /// points just inside and just outside it, through a path that does NOT go through
1010    /// `with_axis`/`evaluate`/`bisect_axis` at all -- a hand-built request decoded and solved via
1011    /// `solve_v1` directly (mirroring `mod.rs`'s own
1012    /// `evaluate_matches_solve_v1_when_zero_distance_alone_searches_the_elevation` cross-check
1013    /// pattern), reading the sample off `solve_v1`'s OWN wire output at an exact grid point
1014    /// (600 m, an exact multiple of this fixture's 10 m sampling interval).
1015    #[test]
1016    fn a_found_bound_sits_on_the_target_boundary_verified_independently_of_with_axis() {
1017        let r = resolved();
1018        let target = TargetGeometryV1::Rect {
1019            width_m: 0.2,
1020            height_m: 0.3,
1021        };
1022        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1023        let rep = tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
1024        let a = &rep.axes[0];
1025        assert!(a.nominal_inside_target);
1026        let far = a
1027            .far_bound
1028            .expect("this target/domain combination must produce a far bound");
1029
1030        fn independent_deviation(wind_speed: f64) -> (f64, f64) {
1031            let json = serde_json::json!({
1032                "schema_version": 1,
1033                "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1034                               "ballistic_coefficient": 0.243},
1035                "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1036                "shot": {"max_range_m": 900.0, "zero_distance_m": 600.0},
1037                "atmosphere": {},
1038                "wind": {"speed_mps": wind_speed,
1039                         "direction_from_rad": std::f64::consts::FRAC_PI_2},
1040                "solver": {}, "effects": {}, "sampling": {"interval_m": 10.0}
1041            })
1042            .to_string();
1043            let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1044            let solved = crate::solve_v1::solve_v1(req).unwrap();
1045            let sample = solved
1046                .samples
1047                .iter()
1048                .find(|s| s.distance_m == 600.0)
1049                .expect("600 m must be an exact grid point for this fixture");
1050            (sample.drop_m, sample.windage_m)
1051        }
1052
1053        let half_width = 0.2 / 2.0;
1054        let half_height = 0.3 / 2.0;
1055        let (nominal_drop, nominal_windage) = independent_deviation(3.0);
1056        let ratio_at = |wind_speed: f64| -> f64 {
1057            let (d, w) = independent_deviation(wind_speed);
1058            let ry = (d - nominal_drop).abs() / half_height;
1059            let rz = (w - nominal_windage).abs() / half_width;
1060            ry.max(rz)
1061        };
1062
1063        let ratio_far = ratio_at(far);
1064        assert!(
1065            (ratio_far - 1.0).abs() < 1e-3,
1066            "found bound does not sit on the target boundary independently: ratio {ratio_far}"
1067        );
1068
1069        let just_inside = 3.0 + (far - 3.0) * 0.99;
1070        let just_outside = 3.0 + (far - 3.0) * 1.01;
1071        assert!(
1072            ratio_at(just_inside) < 1.0,
1073            "a point just inside the found bound must independently read as inside the target"
1074        );
1075        assert!(
1076            ratio_at(just_outside) > 1.0,
1077            "a point just past the found bound must independently read as outside the target"
1078        );
1079    }
1080
1081    /// The brief's own acceptance criterion, generalized from two points to a genuine sweep:
1082    /// growing the target can never shrink a one-variable bound, checked across many sizes,
1083    /// consecutively -- two isolated points cannot catch a bound that stops being monotone only
1084    /// partway through a range. Also asserts that once a direction becomes unbounded (no
1085    /// crossing found within the domain) at some size, it stays unbounded at every LARGER size:
1086    /// the literal meaning of "the crossing moved even farther away, past the domain edge."
1087    /// WindSpeed's domain `(0, 20)` around a nominal of `3.0` is asymmetric (17 units of room
1088    /// above, only 3 below), so the near direction saturates (becomes unbounded) at a much
1089    /// smaller scale than the far direction -- the geometric sweep below is wide enough to
1090    /// observe both transitions, confirmed by the closing assertion that both actually occurred.
1091    #[test]
1092    fn a_larger_target_never_shrinks_a_bound_across_a_size_sweep() {
1093        let r = resolved();
1094        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1095        let scales: Vec<f64> = (0..12).map(|i| 0.5 * 1.6_f64.powi(i)).collect();
1096
1097        let mut prev_far: Option<f64> = None;
1098        let mut prev_near: Option<f64> = None;
1099        let mut far_became_unbounded = false;
1100        let mut near_became_unbounded = false;
1101
1102        for &scale in &scales {
1103            let target = TargetGeometryV1::Rect {
1104                width_m: 0.2 * scale,
1105                height_m: 0.3 * scale,
1106            };
1107            let rep =
1108                tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
1109            let a = &rep.axes[0];
1110            assert!(
1111                a.nominal_inside_target,
1112                "scale {scale}: nominal must read as inside for any positive-size target"
1113            );
1114
1115            if far_became_unbounded {
1116                assert!(
1117                    a.far_bound.is_none(),
1118                    "scale {scale}: far bound reappeared after becoming unbounded at a smaller \
1119                     scale"
1120                );
1121            } else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
1122                assert!(
1123                    f >= pf - 1e-6,
1124                    "far bound shrank at scale {scale}: {pf} -> {f}"
1125                );
1126            }
1127            if a.far_bound.is_none() {
1128                far_became_unbounded = true;
1129            }
1130            prev_far = a.far_bound;
1131
1132            if near_became_unbounded {
1133                assert!(
1134                    a.near_bound.is_none(),
1135                    "scale {scale}: near bound reappeared after becoming unbounded at a smaller \
1136                     scale"
1137                );
1138            } else if let (Some(pn), Some(n)) = (prev_near, a.near_bound) {
1139                assert!(
1140                    n <= pn + 1e-6,
1141                    "near bound shrank (moved toward nominal) at scale {scale}: {pn} -> {n}"
1142                );
1143            }
1144            if a.near_bound.is_none() {
1145                near_became_unbounded = true;
1146            }
1147            prev_near = a.near_bound;
1148        }
1149
1150        assert!(
1151            far_became_unbounded,
1152            "sweep never reached an unbounded far regime -- the monotonicity check on that \
1153             transition never actually ran"
1154        );
1155        assert!(
1156            near_became_unbounded,
1157            "sweep never reached an unbounded near regime -- the monotonicity check on that \
1158             transition never actually ran"
1159        );
1160        // Sanity: the sweep must also have exercised at least one genuinely bounded pair on each
1161        // side, or the "shrank" assertions above would never fire either.
1162        assert!(
1163            scales[0] < 1.0,
1164            "sweep must start comfortably inside the bounded regime"
1165        );
1166    }
1167
1168    /// Same acceptance criterion, briefly, for the OTHER `TargetGeometryV1` shape -- a circle's
1169    /// single `radius_m` grows strictly with the same scale factor, so this is a smaller sweep
1170    /// than the rectangle's (which independently varies two dimensions), not a second full
1171    /// property test.
1172    #[test]
1173    fn a_larger_circle_never_shrinks_a_bound_across_a_size_sweep() {
1174        let r = resolved();
1175        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1176        let scales = [0.5_f64, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0];
1177        let mut prev_far: Option<f64> = None;
1178        let mut far_became_unbounded = false;
1179        for &scale in &scales {
1180            let target = TargetGeometryV1::Circle {
1181                radius_m: 0.15 * scale,
1182            };
1183            let rep =
1184                tolerance_envelope(&r, &[InputAxis::WindSpeed], 600.0, target, &domains).unwrap();
1185            let a = &rep.axes[0];
1186            if far_became_unbounded {
1187                assert!(a.far_bound.is_none(), "scale {scale}: far bound reappeared");
1188            } else if let (Some(pf), Some(f)) = (prev_far, a.far_bound) {
1189                assert!(
1190                    f >= pf - 1e-6,
1191                    "far bound shrank at scale {scale}: {pf} -> {f}"
1192                );
1193            }
1194            if a.far_bound.is_none() {
1195                far_became_unbounded = true;
1196            }
1197            prev_far = a.far_bound;
1198        }
1199        assert!(
1200            far_became_unbounded,
1201            "circle sweep never reached an unbounded far regime"
1202        );
1203    }
1204
1205    /// `axis`/`nominal` must each be tied to the axis they describe, not swapped or copied from
1206    /// a sibling entry -- WindSpeed (nominal 3.0) and MuzzleVelocityMps (nominal 823.0) are far
1207    /// enough apart that a transposition is unmistakable. `margin_linear_m` is independently
1208    /// pinned as a TARGET-derived constant identical across both axes (`min(0.6, 0.4) / 2 =
1209    /// 0.2`), not a copy of `nominal` or any other axis-specific field.
1210    #[test]
1211    fn multiple_axes_are_each_independently_computed_and_correctly_tagged() {
1212        let r = resolved();
1213        let domains = [
1214            (InputAxis::WindSpeed, (0.0_f64, 20.0_f64)),
1215            (InputAxis::MuzzleVelocityMps, (400.0_f64, 1200.0_f64)),
1216        ];
1217        let rep = tolerance_envelope(
1218            &r,
1219            &[InputAxis::WindSpeed, InputAxis::MuzzleVelocityMps],
1220            600.0,
1221            TargetGeometryV1::Rect {
1222                width_m: 0.4,
1223                height_m: 0.6,
1224            },
1225            &domains,
1226        )
1227        .unwrap();
1228        assert_eq!(rep.axes.len(), 2);
1229        assert_eq!(rep.axes[0].axis, InputAxis::WindSpeed);
1230        assert_eq!(rep.axes[1].axis, InputAxis::MuzzleVelocityMps);
1231
1232        let expected_ws = match read_axis(&r, InputAxis::WindSpeed).unwrap() {
1233            AxisValue::Scalar(x) => x,
1234            other => panic!("WindSpeed must read back as a scalar, got {other:?}"),
1235        };
1236        let expected_mv = match read_axis(&r, InputAxis::MuzzleVelocityMps).unwrap() {
1237            AxisValue::Scalar(x) => x,
1238            other => panic!("MuzzleVelocityMps must read back as a scalar, got {other:?}"),
1239        };
1240        assert_eq!(rep.axes[0].nominal, expected_ws);
1241        assert_eq!(rep.axes[1].nominal, expected_mv);
1242        assert_ne!(
1243            rep.axes[0].nominal, rep.axes[1].nominal,
1244            "fixture sanity: the two axes must have DIFFERENT nominal values or a \
1245             transposition between them would be invisible"
1246        );
1247        assert!(rep.axes[0].nominal_inside_target);
1248        assert!(rep.axes[1].nominal_inside_target);
1249        assert!((rep.axes[0].margin_linear_m - 0.2).abs() < 1e-9);
1250        assert!((rep.axes[1].margin_linear_m - 0.2).abs() < 1e-9);
1251        assert_eq!(rep.range_m, 600.0);
1252    }
1253
1254    /// `near_limiting_boundary`/`far_limiting_boundary` must independently discriminate Left/
1255    /// Right from Top/Bottom, not default to one or the other. A pure crosswind's effect on
1256    /// windage dominates its (tiny, secondary) effect on drop by many orders of magnitude (see
1257    /// `windage_derivative_wrt_wind_speed_dominates_and_matches_the_baseline_sign`,
1258    /// `src/perturbation/derive.rs`), so any WindSpeed crossing here must be windage-driven: more
1259    /// wind pushes windage more negative (see this module's own probed sweep), so the far
1260    /// direction (more wind) exits `Left` and the near direction (less wind) exits `Right`.
1261    #[test]
1262    fn windage_dominant_axis_reports_left_or_right_not_top_or_bottom() {
1263        let r = resolved();
1264        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1265        let rep = tolerance_envelope(
1266            &r,
1267            &[InputAxis::WindSpeed],
1268            600.0,
1269            TargetGeometryV1::Rect {
1270                width_m: 0.2,
1271                height_m: 0.3,
1272            },
1273            &domains,
1274        )
1275        .unwrap();
1276        let a = &rep.axes[0];
1277        assert!(a.far_bound.is_some() && a.near_bound.is_some());
1278        assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Left));
1279        assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Right));
1280    }
1281
1282    /// The complementary case for the SAME field: `bisection_bounds_match_the_vacuum_analytic_crossing`
1283    /// above already asserts `Top`/`Bottom` for a wind-free vacuum fixture where `windage_m` is
1284    /// exactly `0.0` throughout, so a crossing driven by muzzle velocity alone (which barely
1285    /// moves windage at all -- see `d_windage_d_x` in the same vacuum context) cannot be
1286    /// misclassified as `Left`/`Right`. Restated here as its own assertion, independent of the
1287    /// closed-form numeric check, so a mutation that hardcodes `Radial`/`Left` for every `Rect`
1288    /// would be caught even if the numeric oracle above were skipped.
1289    #[test]
1290    fn drop_dominant_axis_reports_top_or_bottom_not_left_or_right() {
1291        let r = vacuum_resolved();
1292        let domains = [(InputAxis::MuzzleVelocityMps, (700.0_f64, 1100.0_f64))];
1293        let rep = tolerance_envelope(
1294            &r,
1295            &[InputAxis::MuzzleVelocityMps],
1296            400.0,
1297            TargetGeometryV1::Rect {
1298                width_m: 1.0e6,
1299                height_m: 0.2,
1300            },
1301            &domains,
1302        )
1303        .unwrap();
1304        let a = &rep.axes[0];
1305        assert!(a.far_bound.is_some() && a.near_bound.is_some());
1306        assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Top));
1307        assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Bottom));
1308    }
1309
1310    /// A [`TargetGeometryV1::Circle`] has no distinct edges and must always report `Radial`,
1311    /// regardless of which direction (windage- or drop-dominant) the crossing came from. Also
1312    /// pins `target_margin_linear_m`'s `Circle` arm (review minor #6): previously only the
1313    /// `Rect` arm was checked numerically (`multiple_axes_are_each_independently_computed_...`),
1314    /// so a bug specific to the `Circle` branch (e.g. returning `radius_m` un-halved, or a
1315    /// hardcoded `0.0`) could have passed every existing test.
1316    #[test]
1317    fn a_circle_target_always_reports_radial() {
1318        let r = resolved();
1319        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1320        let rep = tolerance_envelope(
1321            &r,
1322            &[InputAxis::WindSpeed],
1323            600.0,
1324            TargetGeometryV1::Circle { radius_m: 0.15 },
1325            &domains,
1326        )
1327        .unwrap();
1328        let a = &rep.axes[0];
1329        assert!(a.far_bound.is_some() && a.near_bound.is_some());
1330        assert_eq!(a.far_limiting_boundary, Some(LimitingBoundaryV1::Radial));
1331        assert_eq!(a.near_limiting_boundary, Some(LimitingBoundaryV1::Radial));
1332        assert!(
1333            (a.margin_linear_m - 0.15).abs() < 1e-9,
1334            "Circle's margin_linear_m must be exactly its radius_m, got {}",
1335            a.margin_linear_m
1336        );
1337    }
1338
1339    /// Review fix #2: every existing fixture that reaches `limiting_boundary` has one deviation
1340    /// at (or indistinguishable from) zero -- the windage-dominant fixture has `dy ~ 0`, both
1341    /// drop-dominant fixtures use a wind-free vacuum with `dz` exactly `0`. Under any of those,
1342    /// swapping the cross-multiplication's `width_m`/`height_m` (`dy.abs() * height_m >=
1343    /// dz.abs() * width_m` instead of the correct `dy.abs() * width_m >= dz.abs() * height_m`)
1344    /// still passes, because one side of the comparison collapses to (approximately) zero either
1345    /// way. This calls `limiting_boundary` directly (it is private, and this test lives in the
1346    /// same module) with both deviations simultaneously non-trivial and an aspect-ratio-skewed
1347    /// target, so the two formulas disagree: `dy = 0.1` against `height_m = 0.2` (ratio `1.0`)
1348    /// and `dz = 0.5` against `width_m = 2.0` (ratio `0.5`) -- correct: `ratio_y > ratio_z` =>
1349    /// `Bottom`; swapped: `dy.abs()*height_m = 0.02 < dz.abs()*width_m = 1.0` => `Left`/`Right`
1350    /// branch entirely, i.e. a DIFFERENT edge family, not just a different edge.
1351    #[test]
1352    fn limiting_boundary_uses_the_correct_axis_for_each_deviation_not_swapped() {
1353        let nominal = Observation {
1354            range_m: 600.0,
1355            drop_m: 0.0,
1356            windage_m: 0.0,
1357            time_s: 1.0,
1358            velocity_mps: 500.0,
1359        };
1360        let o = Observation {
1361            range_m: 600.0,
1362            drop_m: 0.1,
1363            windage_m: 0.5,
1364            time_s: 1.0,
1365            velocity_mps: 500.0,
1366        };
1367        let target = TargetGeometryV1::Rect {
1368            width_m: 2.0,
1369            height_m: 0.2,
1370        };
1371        assert_eq!(
1372            limiting_boundary(&o, &nominal, target),
1373            LimitingBoundaryV1::Bottom,
1374            "dy/height_m ratio (1.0) exceeds dz/width_m ratio (0.5): must be a drop-family edge"
1375        );
1376        // Mirror on the drop side (dy < 0) to also confirm Top is reachable from this same
1377        // aspect-ratio-skewed target, not just Bottom.
1378        let o_top = Observation { drop_m: -0.1, ..o };
1379        assert_eq!(
1380            limiting_boundary(&o_top, &nominal, target),
1381            LimitingBoundaryV1::Top
1382        );
1383    }
1384
1385    /// A categorical axis has no numeric domain to bisect -- must be recorded, not silently
1386    /// dropped from the report and not a hard failure of the whole call.
1387    #[test]
1388    fn a_categorical_axis_is_recorded_unavailable_not_silently_dropped_or_hard_failed() {
1389        let r = resolved();
1390        let rep = tolerance_envelope(
1391            &r,
1392            &[InputAxis::CoriolisEnabled],
1393            600.0,
1394            TargetGeometryV1::Circle { radius_m: 0.25 },
1395            &[],
1396        )
1397        .unwrap();
1398        assert!(
1399            rep.axes.is_empty(),
1400            "a categorical axis must never appear in `axes`"
1401        );
1402        assert_eq!(rep.unavailable_axes.len(), 1);
1403        assert_eq!(rep.unavailable_axes[0].axis, InputAxis::CoriolisEnabled);
1404        assert_eq!(
1405            rep.unavailable_axes[0].code,
1406            UnavailableReasonCodeV1::CategoricalAxis
1407        );
1408        assert!(!rep.unavailable_axes[0].reason.is_empty());
1409    }
1410
1411    /// The three wind axes have no single scalar value under segmented wind -- `read_axis`
1412    /// returns `None` directly (never even reaching `bisect_axis`), and that must be recorded
1413    /// too, with no `domains` entry required for an axis that never gets that far.
1414    #[test]
1415    fn a_wind_axis_under_segmented_wind_is_recorded_unavailable() {
1416        let json = serde_json::json!({
1417            "schema_version": 1,
1418            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1419                           "ballistic_coefficient": 0.243},
1420            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1421            "shot": {"max_range_m": 900.0},
1422            "atmosphere": {},
1423            "wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
1424                                    "direction_from_rad": 1.0}]},
1425            "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1426        })
1427        .to_string();
1428        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1429        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1430        let rep = tolerance_envelope(
1431            &r,
1432            &[InputAxis::WindSpeed],
1433            300.0,
1434            TargetGeometryV1::Rect {
1435                width_m: 0.5,
1436                height_m: 0.5,
1437            },
1438            &[],
1439        )
1440        .unwrap();
1441        assert!(rep.axes.is_empty());
1442        assert_eq!(rep.unavailable_axes.len(), 1);
1443        assert_eq!(rep.unavailable_axes[0].axis, InputAxis::WindSpeed);
1444        assert_eq!(
1445            rep.unavailable_axes[0].code,
1446            UnavailableReasonCodeV1::AxisAbsent
1447        );
1448    }
1449
1450    /// `Altitude` under a QNH-referenced atmosphere is refused by `with_axis` itself
1451    /// (`AxisUnsupportedForRequest`) -- reached through this module's pre-flight step, not a
1452    /// hard failure of the whole report.
1453    #[test]
1454    fn altitude_under_qnh_is_recorded_unavailable_not_hard_failed() {
1455        let json = serde_json::json!({
1456            "schema_version": 1,
1457            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
1458                           "ballistic_coefficient": 0.243},
1459            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
1460            "shot": {"max_range_m": 900.0},
1461            "atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
1462                           "pressure_reference": "qnh"},
1463            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
1464        })
1465        .to_string();
1466        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
1467        let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
1468        let domains = [(InputAxis::Altitude, (0.0_f64, 1000.0_f64))];
1469        let rep = tolerance_envelope(
1470            &r,
1471            &[InputAxis::Altitude],
1472            600.0,
1473            TargetGeometryV1::Circle { radius_m: 0.3 },
1474            &domains,
1475        )
1476        .unwrap();
1477        assert!(rep.axes.is_empty());
1478        assert_eq!(rep.unavailable_axes.len(), 1);
1479        assert_eq!(rep.unavailable_axes[0].axis, InputAxis::Altitude);
1480        assert_eq!(
1481            rep.unavailable_axes[0].code,
1482            UnavailableReasonCodeV1::AxisUnsupportedForRequest
1483        );
1484        assert!(rep.unavailable_axes[0]
1485            .reason
1486            .to_lowercase()
1487            .contains("qnh"));
1488    }
1489
1490    /// A domain whose lower bound, for `TargetDistance`, dips below the caller's OWN `range_m`
1491    /// is an internally inconsistent request (bisecting "how close could the target be" past
1492    /// "the range I am asking about"): `bisect_axis` hits a genuine `Observation::OutOfRange`
1493    /// partway through the near-direction search, which must propagate as a hard failure of the
1494    /// whole call, never be swallowed into `unavailable_axes` (it is not one of the four
1495    /// structural refusals).
1496    #[test]
1497    fn a_genuine_observation_error_propagates_not_recorded_as_unavailable() {
1498        let r = resolved(); // max_range_m = 900.0, zero_distance_m = 600.0
1499        let domains = [(InputAxis::TargetDistance, (500.0_f64, 1100.0_f64))];
1500        let err = tolerance_envelope(
1501            &r,
1502            &[InputAxis::TargetDistance],
1503            600.0,
1504            TargetGeometryV1::Rect {
1505                width_m: 0.5,
1506                height_m: 0.5,
1507            },
1508            &domains,
1509        )
1510        .unwrap_err();
1511        match err {
1512            KernelError::Observation(TrajectoryObservationError::OutOfRange {
1513                requested_m,
1514                ..
1515            }) => {
1516                assert_eq!(requested_m, 600.0);
1517            }
1518            other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
1519        }
1520    }
1521
1522    /// `domains` must supply an entry for every requested continuous, present axis -- no
1523    /// implicit fallback (see the module doc's "Domains are validated up front" section).
1524    #[test]
1525    fn a_missing_domain_is_reported_as_invalid_not_defaulted() {
1526        let r = resolved();
1527        let err = tolerance_envelope(
1528            &r,
1529            &[InputAxis::WindSpeed],
1530            600.0,
1531            TargetGeometryV1::Circle { radius_m: 0.3 },
1532            &[],
1533        )
1534        .unwrap_err();
1535        match err {
1536            KernelError::InvalidDomain { axis, .. } => assert_eq!(axis, InputAxis::WindSpeed),
1537            other => panic!("expected InvalidDomain, got {other:?}"),
1538        }
1539    }
1540
1541    /// The axis's own current value (`3.0`) must sit strictly inside the configured domain, or
1542    /// one search direction degenerates to a zero-width probe.
1543    #[test]
1544    fn nominal_outside_the_configured_domain_is_rejected() {
1545        let r = resolved();
1546        let domains = [(InputAxis::WindSpeed, (5.0_f64, 20.0_f64))];
1547        let err = tolerance_envelope(
1548            &r,
1549            &[InputAxis::WindSpeed],
1550            600.0,
1551            TargetGeometryV1::Circle { radius_m: 0.3 },
1552            &domains,
1553        )
1554        .unwrap_err();
1555        assert!(matches!(
1556            err,
1557            KernelError::InvalidDomain {
1558                axis: InputAxis::WindSpeed,
1559                ..
1560            }
1561        ));
1562    }
1563
1564    /// An inverted or zero-width domain (`lo >= hi`) is rejected outright.
1565    #[test]
1566    fn an_inverted_domain_is_rejected() {
1567        let r = resolved();
1568        let domains = [(InputAxis::WindSpeed, (20.0_f64, 0.0_f64))];
1569        let err = tolerance_envelope(
1570            &r,
1571            &[InputAxis::WindSpeed],
1572            600.0,
1573            TargetGeometryV1::Circle { radius_m: 0.3 },
1574            &domains,
1575        )
1576        .unwrap_err();
1577        assert!(matches!(
1578            err,
1579            KernelError::InvalidDomain {
1580                axis: InputAxis::WindSpeed,
1581                ..
1582            }
1583        ));
1584    }
1585
1586    /// Review fix (most important finding): `nominal_outside_the_configured_domain_is_rejected`
1587    /// puts the nominal (`3.0`) OUTSIDE its domain `(5.0, 20.0)` entirely, and
1588    /// `an_inverted_domain_is_rejected` is caught by the EARLIER `lo >= hi` check before the
1589    /// nominal-strictness check ever runs -- neither test can tell a strict `>`/`<` comparison
1590    /// apart from a relaxed `>=`/`<=` one. Relaxing `tolerance.rs`'s validation to `>=`/`<=`
1591    /// leaves ALL other tests in this module green: with `domains = [(WindSpeed, (3.0, 20.0))]`
1592    /// and nominal `3.0`, `bisect_axis` would be called with `(nominal_value, lo) = (3.0, 3.0)`
1593    /// -- a zero-width probe whose two endpoints trivially agree, so it would report `Ok(None)`
1594    /// for a direction that was never actually searched even one step into, and (if the far
1595    /// direction also found nothing) `unbounded_in_domain: true` -- "your wind call is robust in
1596    /// every direction," fabricated from a search that never moved. This test, and its mirror
1597    /// below for the upper edge, place the nominal EXACTLY at one edge and require
1598    /// `InvalidDomain` -- these fail under the `>=`/`<=` relaxation described above (verified by
1599    /// making that exact edit, confirming exactly these two tests fail with all others still
1600    /// green, then reverting to byte-identical -- see the task report).
1601    #[test]
1602    fn nominal_at_the_lower_domain_edge_is_rejected() {
1603        let r = resolved(); // WindSpeed nominal = 3.0
1604        let domains = [(InputAxis::WindSpeed, (3.0_f64, 20.0_f64))];
1605        let err = tolerance_envelope(
1606            &r,
1607            &[InputAxis::WindSpeed],
1608            600.0,
1609            TargetGeometryV1::Circle { radius_m: 0.3 },
1610            &domains,
1611        )
1612        .unwrap_err();
1613        assert!(matches!(
1614            err,
1615            KernelError::InvalidDomain {
1616                axis: InputAxis::WindSpeed,
1617                ..
1618            }
1619        ));
1620    }
1621
1622    /// Mirror of the above at the UPPER edge -- see that test's doc for the full rationale.
1623    #[test]
1624    fn nominal_at_the_upper_domain_edge_is_rejected() {
1625        let r = resolved(); // WindSpeed nominal = 3.0
1626        let domains = [(InputAxis::WindSpeed, (0.0_f64, 3.0_f64))];
1627        let err = tolerance_envelope(
1628            &r,
1629            &[InputAxis::WindSpeed],
1630            600.0,
1631            TargetGeometryV1::Circle { radius_m: 0.3 },
1632            &domains,
1633        )
1634        .unwrap_err();
1635        assert!(matches!(
1636            err,
1637            KernelError::InvalidDomain {
1638                axis: InputAxis::WindSpeed,
1639                ..
1640            }
1641        ));
1642    }
1643
1644    /// `range_m` itself must be a queryable point on the base trajectory, exactly as
1645    /// `error_budget` requires of its own `ranges_m` -- checked directly, before any per-axis
1646    /// work, rather than surfacing as a mysterious per-axis refusal.
1647    #[test]
1648    fn an_out_of_range_query_is_rejected_directly() {
1649        let r = resolved(); // max_range_m = 900.0
1650        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1651        let err = tolerance_envelope(
1652            &r,
1653            &[InputAxis::WindSpeed],
1654            5000.0,
1655            TargetGeometryV1::Circle { radius_m: 0.3 },
1656            &domains,
1657        )
1658        .unwrap_err();
1659        match err {
1660            KernelError::Observation(TrajectoryObservationError::OutOfRange {
1661                requested_m,
1662                ..
1663            }) => {
1664                assert_eq!(requested_m, 5000.0);
1665            }
1666            other => panic!("expected Observation(OutOfRange {{ .. }}), got {other:?}"),
1667        }
1668    }
1669
1670    /// Every public field on [`ToleranceReportV1`]/[`ToleranceAxisV1`]/[`UnavailableAxisV1`]
1671    /// round-trips through JSON, and the externally-tagged `snake_case` axis name is exactly
1672    /// what a consumer of this wire type would expect -- pins the `#[serde(rename_all =
1673    /// "snake_case")]` convention this crate uses throughout, not just that serialization
1674    /// succeeds at all.
1675    ///
1676    /// Compares fields individually with a `1e-9` tolerance on the bisection-derived `f64`s
1677    /// rather than a whole-struct `assert_eq!`: `serde_json` 1.0.149's float parser round-trips
1678    /// SOME specific `f64` bit patterns (arbitrary bisection results among them) one ULP off --
1679    /// serializing produces the shortest correctly-round-tripping decimal string, but re-parsing
1680    /// that exact string yields a different bit pattern one ULP away. Confirmed with a
1681    /// standalone reproduction outside this crate on the exact bit pattern this test's own
1682    /// `near_bound` produced (`serde_json::to_string`/`from_str` on a bare `f64`), matching the
1683    /// identical upstream characteristic `error_budget.rs`'s
1684    /// `p_hit_and_gain_round_trip_through_json_when_present` already documents -- not a defect
1685    /// in this module's `Serialize`/`Deserialize` derives.
1686    #[test]
1687    fn the_report_round_trips_through_json() {
1688        let r = resolved();
1689        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1690        let rep = tolerance_envelope(
1691            &r,
1692            &[InputAxis::WindSpeed],
1693            600.0,
1694            TargetGeometryV1::Rect {
1695                width_m: 0.2,
1696                height_m: 0.3,
1697            },
1698            &domains,
1699        )
1700        .unwrap();
1701        let json = serde_json::to_string(&rep).unwrap();
1702        assert!(json.contains("\"axis\":\"wind_speed\""));
1703        let back: ToleranceReportV1 = serde_json::from_str(&json).unwrap();
1704
1705        assert_eq!(back.schema_version, rep.schema_version);
1706        assert_eq!(back.method, rep.method);
1707        assert_eq!(back.assumptions, rep.assumptions);
1708        assert_eq!(back.range_m, rep.range_m);
1709        assert_eq!(back.unavailable_axes, rep.unavailable_axes);
1710        assert_eq!(back.axes.len(), rep.axes.len());
1711        let (ba, ra) = (&back.axes[0], &rep.axes[0]);
1712        assert_eq!(ba.axis, ra.axis);
1713        assert_eq!(ba.nominal_inside_target, ra.nominal_inside_target);
1714        assert_eq!(ba.unbounded_in_domain, ra.unbounded_in_domain);
1715        assert_eq!(ba.near_limiting_boundary, ra.near_limiting_boundary);
1716        assert_eq!(ba.far_limiting_boundary, ra.far_limiting_boundary);
1717        assert!((ba.nominal - ra.nominal).abs() < 1e-9);
1718        assert!((ba.margin_linear_m - ra.margin_linear_m).abs() < 1e-9);
1719        assert!((ba.near_bound.unwrap() - ra.near_bound.unwrap()).abs() < 1e-9);
1720        assert!((ba.far_bound.unwrap() - ra.far_bound.unwrap()).abs() < 1e-9);
1721    }
1722
1723    /// `schema_version` must be the crate's declared constant, not a stray literal that could
1724    /// silently drift from it.
1725    #[test]
1726    fn schema_version_matches_the_declared_constant() {
1727        let r = resolved();
1728        let domains = [(InputAxis::WindSpeed, (0.0_f64, 20.0_f64))];
1729        let rep = tolerance_envelope(
1730            &r,
1731            &[InputAxis::WindSpeed],
1732            600.0,
1733            TargetGeometryV1::Circle { radius_m: 0.25 },
1734            &domains,
1735        )
1736        .unwrap();
1737        assert_eq!(rep.schema_version, TOLERANCE_SCHEMA_VERSION_V1);
1738    }
1739}