Skip to main content

ballistics_engine/perturbation/
mod.rs

1//! Request-level perturbation kernel (Phase 1 of the 0.33.0 decision-support train).
2pub mod taxonomy;
3pub use taxonomy::{axes_in_group, axis_meta, AxisKind, AxisMeta, InputAxis, InputGroup};
4
5// 0.33.0 decision-support Task 5: read/write access to a single taxonomy axis on a canonical
6// request. No feature gate: must compile for wasm32 (depends only on solve_json/solve_v1,
7// both unconditional).
8pub mod access;
9pub use access::{read_axis, with_axis, AxisValue, KernelError};
10
11// 0.33.0 decision-support Task 6: the OUTPUT side of the kernel -- solve a request once and
12// read checked observations (drop, windage, time, velocity) at caller-selected ranges. No
13// feature gate: must compile for wasm32 (depends only on solve_json/solve_v1/
14// trajectory_observation, all unconditional; solve_v1 itself depends on cli_api, likewise
15// unconditional).
16use crate::solve_json::{SolveErrorEnvelopeV1, SolveRequestV1};
17
18/// One solved observation at one requested range.
19///
20/// SI throughout. `drop_m` is positive BELOW the line of sight and `windage_m` is positive to
21/// the RIGHT of the line of sight from the shooter's perspective -- the same convention as
22/// [`crate::TrajectoryResult::observation_at_range_checked`]'s
23/// [`crate::trajectory_observation::TrajectoryObservation`], whose `drop_m`/`windage_m` are
24/// copied here unchanged, and as every other consumer of that type in this crate.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Observation {
27    pub range_m: f64,
28    pub drop_m: f64,
29    pub windage_m: f64,
30    pub time_s: f64,
31    pub velocity_mps: f64,
32}
33
34/// Solve `req` once and read a checked observation at each of `ranges_m`, in the order given.
35///
36/// # Which `TrajectoryResult` path this uses, and why
37///
38/// This task's brief assumed a `SolveSuccessV1::trajectory_result_for_observation()` accessor;
39/// no such method exists. [`crate::solve_json::SolveSuccessV1`] does not expose a raw
40/// [`crate::TrajectoryResult`] at all -- only the wire-shaped, already-downsampled
41/// `samples: Vec<TrajectorySampleV1>` (the brief's option (a), ruled out for that reason).
42///
43/// Instead this follows the brief's fallback (option (b)), sharing `solve_v1`'s own building
44/// blocks rather than re-implementing them: `solve_v1::prepare_request` resolves `req`
45/// exactly as `solve_v1` does, and `solve_v1::build_zeroed_solver` builds and zeroes a
46/// `TrajectorySolver` exactly as `solve_v1` does (both widened from module-private to
47/// `pub(crate)` for this task). This function then calls `TrajectorySolver::solve` itself and
48/// reads observations straight off the result: one solve per `evaluate` call, not two, and not
49/// `solve_v1` internally.
50///
51/// (Revision note: an earlier version of this function hand-duplicated `solve_v1`'s
52/// zero-handling `match` here instead of sharing `build_zeroed_solver`, kept honest only by an
53/// outboard cross-check test. Review judged that duplication risk too costly to keep -- see
54/// "Drop reference plane" below for a related place this function deliberately does NOT mirror
55/// `solve_v1` -- so it was replaced with this structural share.)
56///
57/// # Drop reference plane
58///
59/// `evaluate` always reports `drop_m` perpendicular to the line of sight, regardless of the
60/// request's `shot.drops_reference`. `solve_v1` applies the MBA-1403 target-plane transform
61/// (`drop_m /= shooting_angle_rad.cos()`) as a wire-only rescaling at its own boundary
62/// (`src/solve_v1.rs`, applied once to each sample after `TrajectoryResult::sample_observations`
63/// runs); `evaluate` deliberately does not reproduce it, so the kernel always speaks one
64/// geometry no matter which reference plane the original request asked for on output. This is a
65/// decision, not an oversight -- see `evaluate_ignores_drops_reference_and_always_reports_los_drop`
66/// in this module's tests, which pins it down. A caller that wants the target-plane number can
67/// still derive it from `drop_m / shooting_angle_rad.cos()`, using the SAME `shooting_angle_rad`
68/// this request resolved to (`ResolvedShotV1::shooting_angle_rad`) -- and should do that
69/// explicitly rather than have it baked silently into `drop_m` here, because a later task
70/// differentiates observations with respect to taxonomy axes including `ShootingAngle`
71/// (`InputAxis::ShootingAngle`, `taxonomy.rs`): folding the target-plane transform into `drop_m`
72/// in this function would silently add an extra `d/dtheta[sec(theta)]` term to that derivative,
73/// and only for target-referenced requests.
74///
75/// # Errors
76///
77/// - [`KernelError::Solve`] if resolving or solving `req` fails -- the same failure modes
78///   `solve_v1` itself reports (invalid or conflicting fields, a zero search that does not
79///   converge, a non-finite effective muzzle angle), uniformly converted from
80///   [`SolveErrorEnvelopeV1`] regardless of which stage (`prepare_request`,
81///   `build_zeroed_solver`, or `TrajectorySolver::solve`) produced it.
82/// - [`KernelError::Observation`] if any requested range cannot be read off the resulting
83///   trajectory -- most notably a range outside `[0, actual_range]` -- via
84///   [`crate::TrajectoryResult::observation_at_range_checked`], used specifically because it
85///   ERRORS on an out-of-range query instead of clamping to the nearest sample (the pattern the
86///   CLI card commands use, `src/main.rs:17734`).
87pub fn evaluate(req: &SolveRequestV1, ranges_m: &[f64]) -> Result<Vec<Observation>, KernelError> {
88    let prepared = crate::solve_v1::prepare_request(req).map_err(kernel_solve_error)?;
89
90    let max_range_m = prepared.resolved_request.shot.max_range_m;
91    let time_step_s = prepared.resolved_request.solver.time_step_s;
92    let zero_distance_m = prepared.resolved_request.shot.zero_distance_m;
93    let target_height_m = prepared.resolved_request.shot.target_height_m;
94
95    let (solver, _effective_angle) = crate::solve_v1::build_zeroed_solver(
96        prepared.inputs,
97        prepared.wind,
98        prepared.atmosphere,
99        prepared.wind_segments,
100        max_range_m,
101        time_step_s,
102        zero_distance_m,
103        target_height_m,
104        req.shot.muzzle_angle_rad,
105    )
106    .map_err(kernel_solve_error)?;
107
108    let result = solver
109        .solve()
110        .map_err(crate::solve_v1::solve_failed)
111        .map_err(kernel_solve_error)?;
112
113    let mut out = Vec::with_capacity(ranges_m.len());
114    for &range_m in ranges_m {
115        let o = result
116            .observation_at_range_checked(range_m)
117            .map_err(KernelError::Observation)?;
118        out.push(Observation {
119            range_m,
120            drop_m: o.drop_m,
121            windage_m: o.windage_m,
122            time_s: o.time_s,
123            velocity_mps: o.speed_mps,
124        });
125    }
126    Ok(out)
127}
128
129/// Convert a v1 solve-error envelope into a `KernelError::Solve`, uniformly across every stage
130/// `evaluate` can fail at (M3 review fix): `prepare_request`, `build_zeroed_solver`, and
131/// `TrajectorySolver::solve` (via `solve_v1::solve_failed`) all go through this one conversion
132/// instead of three independently hand-formatted strings. Carries `e.error.code` through
133/// alongside the message (review fix I4(a)) rather than only formatting it into the string, so
134/// `KernelError::is_domain_rejection` can tell an `InvalidValue` domain rejection apart from a
135/// genuine solve failure without parsing text.
136///
137/// `pub(crate)` (0.33.0 decision-support Task 9): `explain.rs`'s group-swap re-resolve
138/// (`swap_group`) needs the identical conversion when a `solve_v1` call made mid-swap fails --
139/// the same reasoning Task 6 already applied to widen `prepare_request`/`build_zeroed_solver`
140/// above, a structural share rather than a second hand-rolled copy.
141pub(crate) fn kernel_solve_error(e: SolveErrorEnvelopeV1) -> KernelError {
142    KernelError::Solve { code: e.error.code, message: e.error.message }
143}
144
145// 0.33.0 decision-support Task 7: derived numerics over the kernel -- central-difference
146// derivatives (for an error budget) and monotone bisection (for tolerance envelopes), built
147// only on evaluate/read_axis/with_axis from Tasks 5-6. No feature gate: must compile for wasm32
148// (same unconditional dependency chain as Task 6).
149pub mod derive;
150pub use derive::{
151    bisect_axis, central_difference, DifferenceScheme, Derivative, BISECTION_MAX_ITERATIONS,
152};
153
154#[cfg(test)]
155mod eval_tests {
156    use super::*;
157
158    /// Shared fixture: a G7 .308-class projectile, zeroed at `zero_distance_m`, with a
159    /// 90-degree (full) crosswind so wind-drift-dependent assertions have something nonzero to
160    /// check.
161    fn base_request_json(max_range_m: f64, zero_distance_m: f64, interval_m: f64) -> String {
162        serde_json::json!({
163            "schema_version": 1,
164            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
165                           "ballistic_coefficient": 0.243},
166            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
167            "shot": {"max_range_m": max_range_m, "zero_distance_m": zero_distance_m},
168            "atmosphere": {},
169            "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
170            "solver": {}, "effects": {}, "sampling": {"interval_m": interval_m}
171        })
172        .to_string()
173    }
174
175    #[test]
176    fn evaluate_returns_one_observation_per_requested_range() {
177        let req =
178            crate::solve_json::decode_solve_request_v1(&base_request_json(900.0, 100.0, 25.0))
179                .unwrap();
180        let obs = evaluate(&req, &[300.0, 600.0, 800.0]).expect("evaluate");
181        assert_eq!(obs.len(), 3);
182        assert!((obs[0].range_m - 300.0).abs() < 1e-6);
183        assert!((obs[1].range_m - 600.0).abs() < 1e-6);
184        assert!((obs[2].range_m - 800.0).abs() < 1e-6);
185        // Drop grows with range (positive = below line of sight) once past the zero.
186        assert!(obs[2].drop_m > obs[0].drop_m);
187        // Velocity decays.
188        assert!(obs[2].velocity_mps < obs[0].velocity_mps);
189        assert!(obs
190            .iter()
191            .all(|o| o.time_s.is_finite() && o.windage_m.is_finite()));
192    }
193
194    #[test]
195    fn a_range_beyond_the_trajectory_is_an_error_not_a_clamp() {
196        let req =
197            crate::solve_json::decode_solve_request_v1(&base_request_json(300.0, 100.0, 25.0))
198                .unwrap();
199        // M1 review fix: `KernelError::Observation` can carry any `TrajectoryObservationError`
200        // variant, so `matches!(.., Err(Observation(_)))` alone cannot distinguish an
201        // out-of-range query from, say, `NonMonotonicTrajectory`. Match the specific `OutOfRange`
202        // variant directly (I4(a) review fix: `Observation` now wraps the structured error
203        // itself rather than a pre-formatted string, so this checks the actual variant instead
204        // of substring-matching its rendered message).
205        match evaluate(&req, &[5000.0]) {
206            Err(KernelError::Observation(
207                crate::trajectory_observation::TrajectoryObservationError::OutOfRange {
208                    requested_m,
209                    ..
210                },
211            )) => {
212                assert_eq!(requested_m, 5000.0);
213            }
214            other => panic!(
215                "expected Err(KernelError::Observation(OutOfRange {{ .. }})) naming an \
216                 out-of-range query, got {other:?}"
217            ),
218        }
219    }
220
221    /// Independent oracle, `(Some(zero_distance_m), None)` arm (elevation search runs):
222    /// `solve_v1`'s OWN wire samples, computed via a completely different call path
223    /// (`TrajectoryResult::sample_observations` on a regular grid, not a targeted per-range
224    /// query), must describe the identical physical point that `evaluate` reports for the same
225    /// range on the same request. `solve_v1`'s mapping from `TrajectoryObservation` to
226    /// `TrajectorySampleV1` is unchanged, existing, and load-bearing for the whole solve-json v1
227    /// wire format, so it is a trustworthy check for `drop_m`/`windage_m` being swapped. See
228    /// `evaluate_matches_solve_v1_when_an_explicit_angle_and_zero_distance_are_both_present` and
229    /// `evaluate_matches_solve_v1_when_no_zero_distance_is_present_at_all` below for the other
230    /// two zero-handling arms (I2 review fix -- this file previously tested only this one).
231    #[test]
232    fn evaluate_matches_solve_v1_when_zero_distance_alone_searches_the_elevation() {
233        let json = base_request_json(900.0, 100.0, 300.0);
234        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
235        assert_eq!(
236            req.shot.muzzle_angle_rad, None,
237            "fixture assumption: no explicit angle, exercising the (Some, None) arm"
238        );
239        let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
240        // 300.0 is an exact regular-grid point for interval_m=300 starting at the muzzle
241        // (x=0.0): grid index 1, strictly before the ~900 m terminal, so it is read straight
242        // off the trajectory rather than needing end-of-grid special-casing.
243        let sample_at_300 = via_solve_v1
244            .samples
245            .iter()
246            .find(|s| s.distance_m == 300.0)
247            .expect("300 m must be an exact grid point for this fixture");
248        // Sanity check that this fixture actually distinguishes the two fields: with a
249        // 90-degree crosswind and a zero well short of 300 m, drop and windage here must both
250        // be unambiguously nonzero, AND (M2 review fix) far enough apart from EACH OTHER that a
251        // drop/windage swap could not hide behind two coincidentally-similar numbers -- the
252        // original two independent-threshold checks alone could not catch that.
253        assert!(sample_at_300.drop_m.abs() > 0.1);
254        assert!(sample_at_300.windage_m.abs() > 0.01);
255        assert!(
256            (sample_at_300.drop_m - sample_at_300.windage_m).abs() > 0.05,
257            "drop_m ({}) and windage_m ({}) must differ meaningfully, or a swap between them \
258             would be invisible to the two independent-threshold checks above",
259            sample_at_300.drop_m,
260            sample_at_300.windage_m
261        );
262
263        let obs = evaluate(&req, &[300.0]).expect("evaluate");
264        assert_eq!(obs.len(), 1);
265        assert_eq!(
266            obs[0].drop_m, sample_at_300.drop_m,
267            "drop_m diverged from solve_v1's own reported sample"
268        );
269        assert_eq!(
270            obs[0].windage_m, sample_at_300.windage_m,
271            "windage_m diverged from solve_v1's own reported sample (possible field swap)"
272        );
273        assert_eq!(obs[0].time_s, sample_at_300.time_s);
274        assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
275    }
276
277    /// Independent oracle, `(Some(zero_distance_m), Some(explicit_angle))` arm (I2 review fix):
278    /// the original test suite only ever exercised the `(Some, None)` arm above --
279    /// `base_request_json` always supplies `zero_distance_m`, and the one fixture omitting it
280    /// fails inside `prepare_request` before the zero-handling match ever runs, so `(Some,
281    /// Some)` and `(None, _)` were both silently untested despite the report claiming otherwise.
282    /// This arm matters because it is exactly the shape `From<&ResolvedSolveRequestV1> for
283    /// SolveRequestV1` (`request_roundtrip.rs`) produces on every zeroed solve: the elevation
284    /// search does NOT run (the supplied angle is used directly); only the windage-zero bias
285    /// (from `sight_offset_lateral_m`/`zero_poi_right_m`) applies. The fixture makes that bias
286    /// non-negligible so a dropped `apply_windage_zero_bias` call would show up as a
287    /// `windage_m` mismatch against `solve_v1`, not a coincidental match against zero.
288    #[test]
289    fn evaluate_matches_solve_v1_when_an_explicit_angle_and_zero_distance_are_both_present() {
290        let json = serde_json::json!({
291            "schema_version": 1,
292            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
293                           "ballistic_coefficient": 0.243},
294            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05,
295                      "sight_offset_lateral_m": 0.03},
296            "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "muzzle_angle_rad": 0.01,
297                     "zero_poi_right_m": 0.02},
298            "atmosphere": {},
299            "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
300            "solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
301        })
302        .to_string();
303        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
304        assert!(
305            req.shot.zero_distance_m.is_some() && req.shot.muzzle_angle_rad.is_some(),
306            "fixture assumption: both fields present, exercising the (Some, Some) arm"
307        );
308
309        let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
310        let sample_at_300 = via_solve_v1
311            .samples
312            .iter()
313            .find(|s| s.distance_m == 300.0)
314            .expect("300 m must be an exact grid point for this fixture");
315        assert!(
316            sample_at_300.windage_m.abs() > 0.005,
317            "fixture must produce a non-negligible windage-zero bias, got {}",
318            sample_at_300.windage_m
319        );
320
321        let obs = evaluate(&req, &[300.0]).expect("evaluate");
322        assert_eq!(obs.len(), 1);
323        assert_eq!(obs[0].drop_m, sample_at_300.drop_m);
324        assert_eq!(
325            obs[0].windage_m, sample_at_300.windage_m,
326            "windage-zero bias diverged from solve_v1 -- apply_windage_zero_bias may not have \
327             run for this arm"
328        );
329        assert_eq!(obs[0].time_s, sample_at_300.time_s);
330        assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
331    }
332
333    /// Independent oracle, `(None, _)` arm (I2 review fix, continued): no `zero_distance_m` at
334    /// all, with an explicit `muzzle_angle_rad` -- the elevation search does not run, and there
335    /// is no `zero_distance_m` to gate a windage-zero bias on either, so the match falls through
336    /// to its no-op arm entirely.
337    #[test]
338    fn evaluate_matches_solve_v1_when_no_zero_distance_is_present_at_all() {
339        let json = serde_json::json!({
340            "schema_version": 1,
341            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
342                           "ballistic_coefficient": 0.243},
343            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
344            "shot": {"max_range_m": 900.0, "muzzle_angle_rad": 0.01},
345            "atmosphere": {},
346            "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
347            "solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
348        })
349        .to_string();
350        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
351        assert_eq!(
352            req.shot.zero_distance_m, None,
353            "fixture assumption: no zero distance at all, exercising the (None, _) arm"
354        );
355
356        let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
357        let sample_at_300 = via_solve_v1
358            .samples
359            .iter()
360            .find(|s| s.distance_m == 300.0)
361            .expect("300 m must be an exact grid point for this fixture");
362
363        let obs = evaluate(&req, &[300.0]).expect("evaluate");
364        assert_eq!(obs.len(), 1);
365        assert_eq!(obs[0].drop_m, sample_at_300.drop_m);
366        assert_eq!(obs[0].windage_m, sample_at_300.windage_m);
367        assert_eq!(obs[0].time_s, sample_at_300.time_s);
368        assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
369    }
370
371    /// `evaluate` must return observations in the CALLER's requested order, not sorted, and
372    /// must not deduplicate a repeated range. Each output element is tied to its TRUE range via
373    /// an independent physical fact (velocity strictly decreases with range for a supersonic
374    /// flat-fire rifle round over this bracket) rather than only checking that `range_m` echoes
375    /// the query, which a bug that transposes a value together with its label would still pass.
376    #[test]
377    fn evaluate_preserves_the_caller_supplied_range_order_and_repeats() {
378        let req =
379            crate::solve_json::decode_solve_request_v1(&base_request_json(900.0, 100.0, 25.0))
380                .unwrap();
381        let requested = [800.0, 300.0, 800.0, 500.0];
382        let obs = evaluate(&req, &requested).expect("evaluate");
383        assert_eq!(obs.len(), requested.len());
384        for (o, &want) in obs.iter().zip(requested.iter()) {
385            assert_eq!(o.range_m, want);
386        }
387        assert!(
388            obs[0].velocity_mps < obs[1].velocity_mps,
389            "800 m must be slower than 300 m"
390        );
391        assert_eq!(
392            obs[0].velocity_mps, obs[2].velocity_mps,
393            "two queries at the same 800 m range must agree exactly"
394        );
395        assert!(
396            obs[1].velocity_mps > obs[3].velocity_mps,
397            "300 m must be faster than 500 m"
398        );
399    }
400
401    /// `evaluate`'s zero-handling must actually RUN the zero search when `zero_distance_m` is
402    /// present and no explicit `muzzle_angle_rad` was supplied -- not skip it, and not converge
403    /// it against the wrong distance.
404    ///
405    /// Independent physical check: the zero search converges the bullet's absolute
406    /// world-vertical height at `zero_distance_m` to `shot.target_height_m` (default `0`; see
407    /// `docs/SOLVE_JSON_V1.md`'s `target_height_m` row -- it is a height above the ground
408    /// datum, NOT automatically the sight line). `drop_m` is `line_of_sight_height_m -
409    /// vertical_m`, and `line_of_sight_height_m` is `muzzle_height_m + sight_height_m`. This
410    /// fixture sets `sight_height_m: 0.0` (no bore/sight offset) specifically so
411    /// `line_of_sight_height_m == 0 == target_height_m`: with the offset eliminated, a
412    /// converged zero and a near-zero `drop_m` at that range are the same fact, so this
413    /// isolates "did the elevation search run and converge at the right distance" without
414    /// dragging in the separate sight-height-vs-target-height relationship (a nonzero
415    /// `sight_height_m` alone, with `target_height_m` still `0`, leaves a `drop_m` residual of
416    /// approximately `sight_height_m` at the zero range by design -- that is not this test).
417    /// `find_zero_angle` (`src/cli_api.rs`) converges once the height error is under 1e-4 m.
418    #[test]
419    fn evaluate_actually_zeroes_drop_is_near_zero_at_the_zero_distance() {
420        let json = serde_json::json!({
421            "schema_version": 1,
422            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
423                           "ballistic_coefficient": 0.243},
424            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.0},
425            "shot": {"max_range_m": 900.0, "zero_distance_m": 137.0},
426            "atmosphere": {}, "wind": {},
427            "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
428        })
429        .to_string();
430        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
431        let obs = evaluate(&req, &[137.0]).expect("evaluate");
432        assert_eq!(obs.len(), 1);
433        assert!(
434            obs[0].drop_m.abs() < 1e-3,
435            "a rifle zeroed at 137 m must show ~0 drop there, got {}",
436            obs[0].drop_m
437        );
438    }
439
440    /// A request that fails at the RESOLVE stage (not the observation-lookup stage) must
441    /// surface as `KernelError::Solve`, exercising the `prepare_request` error path rather than
442    /// only `observation_at_range_checked`'s. `decode_solve_request_v1` does not itself bound
443    /// `relative_humidity` (only mass/diameter/length/BC are checked at decode time -- see
444    /// `validate_request_ranges`), so this fixture reaches `evaluate` and fails only once
445    /// `prepare_request` -> `resolve_atmosphere` validates the physical range.
446    #[test]
447    fn an_invalid_request_is_a_solve_error_not_a_panic_or_silent_success() {
448        let json = serde_json::json!({
449            "schema_version": 1,
450            "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
451                           "ballistic_coefficient": 0.243},
452            "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
453            "shot": {"max_range_m": 300.0},
454            "atmosphere": {"relative_humidity": 1.5},
455            "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
456        })
457        .to_string();
458        let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
459        // I4(a) review fix: `Solve` now carries the structured `SolveErrorCodeV1` alongside the
460        // message; an out-of-range relative humidity is exactly the `InvalidValue` domain
461        // rejection `require_range` produces (`solve_v1.rs`), so pin that down too rather than
462        // only the outer variant.
463        match evaluate(&req, &[100.0]) {
464            Err(KernelError::Solve { code, .. }) => {
465                assert_eq!(code, crate::solve_json::SolveErrorCodeV1::InvalidValue);
466            }
467            other => panic!("expected Err(KernelError::Solve {{ .. }}), got {other:?}"),
468        }
469    }
470
471    /// I1 review fix: `evaluate` must ignore `shot.drops_reference` and always report the
472    /// LOS-perpendicular `drop_m` (see `evaluate`'s doc comment, "Drop reference plane"), rather
473    /// than silently reproducing (or half-reproducing) `solve_v1`'s MBA-1403 target-plane
474    /// rescaling. Uses a 30-degree shooting angle so `cos(shooting_angle_rad)` (~0.866) is far
475    /// enough from `1.0` that a target-plane rescaling would be an obvious ~15.5% disagreement,
476    /// not noise.
477    #[test]
478    fn evaluate_ignores_drops_reference_and_always_reports_los_drop() {
479        let shooting_angle_rad: f64 = 30.0_f64.to_radians();
480        // An explicit muzzle_angle_rad rather than zero_distance_m: this test is about the
481        // drops_reference transform, not zeroing, and a 30-degree incline combined with a
482        // WorldVertical zero search at only 100 m is not guaranteed to bracket a convergent
483        // angle (the trial trajectory is integrated AT the incline -- see
484        // build_zeroed_solver/zero_trial_height_at -- so it is a materially different search
485        // than a level zero). Supplying the angle directly sidesteps that entirely.
486        let build = |drops_reference: Option<&str>| -> String {
487            let mut shot = serde_json::json!({
488                "max_range_m": 900.0,
489                "muzzle_angle_rad": 0.02,
490                "shooting_angle_rad": shooting_angle_rad,
491            });
492            if let Some(dr) = drops_reference {
493                shot["drops_reference"] = serde_json::json!(dr);
494            }
495            serde_json::json!({
496                "schema_version": 1,
497                "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
498                               "ballistic_coefficient": 0.243},
499                "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
500                "shot": shot,
501                "atmosphere": {}, "wind": {},
502                "solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
503            })
504            .to_string()
505        };
506
507        let req_los = crate::solve_json::decode_solve_request_v1(&build(None)).unwrap();
508        let req_target =
509            crate::solve_json::decode_solve_request_v1(&build(Some("target"))).unwrap();
510
511        let obs_los = evaluate(&req_los, &[300.0]).expect("evaluate los");
512        let obs_target = evaluate(&req_target, &[300.0]).expect("evaluate target");
513        assert_eq!(
514            obs_los[0].drop_m, obs_target[0].drop_m,
515            "evaluate must report the same LOS-perpendicular drop_m regardless of \
516             shot.drops_reference"
517        );
518
519        // Cross-check against solve_v1's OWN target-plane transform: its wire sample under
520        // "target" must equal evaluate's LOS drop_m divided by cos(shooting_angle_rad) --
521        // confirming evaluate's number really is the untransformed LOS quantity, not an
522        // accidental match.
523        let via_solve_v1_target =
524            crate::solve_v1::solve_v1(req_target.clone()).expect("solve_v1 target");
525        let resolved_angle = via_solve_v1_target.resolved_request.shot.shooting_angle_rad;
526        let sample = via_solve_v1_target
527            .samples
528            .iter()
529            .find(|s| s.distance_m == 300.0)
530            .expect("300 m must be an exact grid point for this fixture");
531        let expected_target_drop = obs_los[0].drop_m / resolved_angle.cos();
532        assert!(
533            (sample.drop_m - expected_target_drop).abs() < 1e-9,
534            "solve_v1's target-plane drop_m ({}) should equal evaluate's LOS drop_m ({}) \
535             divided by cos(shooting_angle_rad) ({})",
536            sample.drop_m,
537            obs_los[0].drop_m,
538            resolved_angle.cos()
539        );
540        // Sanity: the two planes actually differ measurably at this angle, or the test would
541        // pass trivially even with a broken transform on either side.
542        assert!(
543            (sample.drop_m - obs_los[0].drop_m).abs() > 0.05,
544            "LOS and target-plane drop must differ meaningfully at a 30 degree shooting angle"
545        );
546    }
547}