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