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