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}