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`, which an omitted
407 /// field resolves to the line of sight, `muzzle_height_m + sight_height_m` (MBA-1537; see
408 /// `docs/SOLVE_JSON_V1.md`'s `target_height_m` row). `drop_m` is `line_of_sight_height_m -
409 /// vertical_m`, over the same `line_of_sight_height_m`. This fixture sets
410 /// `sight_height_m: 0.0` (no bore/sight offset) specifically so that sum is `0`: with the
411 /// offset eliminated, a converged zero and a near-zero `drop_m` at that range are the same
412 /// fact whichever frame the zero is solved in, so this isolates "did the elevation search
413 /// run and converge at the right distance" from the frame question that MBA-1537 settled.
414 /// `find_zero_angle` (`src/cli_api.rs`) converges once the height error is under 1e-4 m.
415 #[test]
416 fn evaluate_actually_zeroes_drop_is_near_zero_at_the_zero_distance() {
417 let json = serde_json::json!({
418 "schema_version": 1,
419 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
420 "ballistic_coefficient": 0.243},
421 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.0},
422 "shot": {"max_range_m": 900.0, "zero_distance_m": 137.0},
423 "atmosphere": {}, "wind": {},
424 "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
425 })
426 .to_string();
427 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
428 let obs = evaluate(&req, &[137.0]).expect("evaluate");
429 assert_eq!(obs.len(), 1);
430 assert!(
431 obs[0].drop_m.abs() < 1e-3,
432 "a rifle zeroed at 137 m must show ~0 drop there, got {}",
433 obs[0].drop_m
434 );
435 }
436
437 /// A request that fails at the RESOLVE stage (not the observation-lookup stage) must
438 /// surface as `KernelError::Solve`, exercising the `prepare_request` error path rather than
439 /// only `observation_at_range_checked`'s. `decode_solve_request_v1` does not itself bound
440 /// `relative_humidity` (only mass/diameter/length/BC are checked at decode time -- see
441 /// `validate_request_ranges`), so this fixture reaches `evaluate` and fails only once
442 /// `prepare_request` -> `resolve_atmosphere` validates the physical range.
443 #[test]
444 fn an_invalid_request_is_a_solve_error_not_a_panic_or_silent_success() {
445 let json = serde_json::json!({
446 "schema_version": 1,
447 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
448 "ballistic_coefficient": 0.243},
449 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
450 "shot": {"max_range_m": 300.0},
451 "atmosphere": {"relative_humidity": 1.5},
452 "wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
453 })
454 .to_string();
455 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
456 // I4(a) review fix: `Solve` now carries the structured `SolveErrorCodeV1` alongside the
457 // message; an out-of-range relative humidity is exactly the `InvalidValue` domain
458 // rejection `require_range` produces (`solve_v1.rs`), so pin that down too rather than
459 // only the outer variant.
460 match evaluate(&req, &[100.0]) {
461 Err(KernelError::Solve { code, .. }) => {
462 assert_eq!(code, crate::solve_json::SolveErrorCodeV1::InvalidValue);
463 }
464 other => panic!("expected Err(KernelError::Solve {{ .. }}), got {other:?}"),
465 }
466 }
467
468 /// I1 review fix: `evaluate` must ignore `shot.drops_reference` and always report the
469 /// LOS-perpendicular `drop_m` (see `evaluate`'s doc comment, "Drop reference plane"), rather
470 /// than silently reproducing (or half-reproducing) `solve_v1`'s MBA-1403 target-plane
471 /// rescaling. Uses a 30-degree shooting angle so `cos(shooting_angle_rad)` (~0.866) is far
472 /// enough from `1.0` that a target-plane rescaling would be an obvious ~15.5% disagreement,
473 /// not noise.
474 #[test]
475 fn evaluate_ignores_drops_reference_and_always_reports_los_drop() {
476 let shooting_angle_rad: f64 = 30.0_f64.to_radians();
477 // An explicit muzzle_angle_rad rather than zero_distance_m: this test is about the
478 // drops_reference transform, not zeroing, and a 30-degree incline combined with a
479 // WorldVertical zero search at only 100 m is not guaranteed to bracket a convergent
480 // angle (the trial trajectory is integrated AT the incline -- see
481 // build_zeroed_solver/zero_trial_height_at -- so it is a materially different search
482 // than a level zero). Supplying the angle directly sidesteps that entirely.
483 let build = |drops_reference: Option<&str>| -> String {
484 let mut shot = serde_json::json!({
485 "max_range_m": 900.0,
486 "muzzle_angle_rad": 0.02,
487 "shooting_angle_rad": shooting_angle_rad,
488 });
489 if let Some(dr) = drops_reference {
490 shot["drops_reference"] = serde_json::json!(dr);
491 }
492 serde_json::json!({
493 "schema_version": 1,
494 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
495 "ballistic_coefficient": 0.243},
496 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
497 "shot": shot,
498 "atmosphere": {}, "wind": {},
499 "solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
500 })
501 .to_string()
502 };
503
504 let req_los = crate::solve_json::decode_solve_request_v1(&build(None)).unwrap();
505 let req_target =
506 crate::solve_json::decode_solve_request_v1(&build(Some("target"))).unwrap();
507
508 let obs_los = evaluate(&req_los, &[300.0]).expect("evaluate los");
509 let obs_target = evaluate(&req_target, &[300.0]).expect("evaluate target");
510 assert_eq!(
511 obs_los[0].drop_m, obs_target[0].drop_m,
512 "evaluate must report the same LOS-perpendicular drop_m regardless of \
513 shot.drops_reference"
514 );
515
516 // Cross-check against solve_v1's OWN target-plane transform: its wire sample under
517 // "target" must equal evaluate's LOS drop_m divided by cos(shooting_angle_rad) --
518 // confirming evaluate's number really is the untransformed LOS quantity, not an
519 // accidental match.
520 let via_solve_v1_target =
521 crate::solve_v1::solve_v1(req_target.clone()).expect("solve_v1 target");
522 let resolved_angle = via_solve_v1_target.resolved_request.shot.shooting_angle_rad;
523 let sample = via_solve_v1_target
524 .samples
525 .iter()
526 .find(|s| s.distance_m == 300.0)
527 .expect("300 m must be an exact grid point for this fixture");
528 let expected_target_drop = obs_los[0].drop_m / resolved_angle.cos();
529 assert!(
530 (sample.drop_m - expected_target_drop).abs() < 1e-9,
531 "solve_v1's target-plane drop_m ({}) should equal evaluate's LOS drop_m ({}) \
532 divided by cos(shooting_angle_rad) ({})",
533 sample.drop_m,
534 obs_los[0].drop_m,
535 resolved_angle.cos()
536 );
537 // Sanity: the two planes actually differ measurably at this angle, or the test would
538 // pass trivially even with a broken transform on either side.
539 assert!(
540 (sample.drop_m - obs_los[0].drop_m).abs() > 0.05,
541 "LOS and target-plane drop must differ meaningfully at a 30 degree shooting angle"
542 );
543 }
544}