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 {
143 code: e.error.code,
144 message: e.error.message,
145 }
146}
147
148// 0.33.0 decision-support Task 7: derived numerics over the kernel -- central-difference
149// derivatives (for an error budget) and monotone bisection (for tolerance envelopes), built
150// only on evaluate/read_axis/with_axis from Tasks 5-6. No feature gate: must compile for wasm32
151// (same unconditional dependency chain as Task 6).
152pub mod derive;
153pub use derive::{
154 bisect_axis, central_difference, Derivative, DifferenceScheme, BISECTION_MAX_ITERATIONS,
155};
156
157#[cfg(test)]
158mod eval_tests {
159 use super::*;
160
161 /// Shared fixture: a G7 .308-class projectile, zeroed at `zero_distance_m`, with a
162 /// 90-degree (full) crosswind so wind-drift-dependent assertions have something nonzero to
163 /// check.
164 fn base_request_json(max_range_m: f64, zero_distance_m: f64, interval_m: f64) -> String {
165 serde_json::json!({
166 "schema_version": 1,
167 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
168 "ballistic_coefficient": 0.243},
169 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
170 "shot": {"max_range_m": max_range_m, "zero_distance_m": zero_distance_m},
171 "atmosphere": {},
172 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
173 "solver": {}, "effects": {}, "sampling": {"interval_m": interval_m}
174 })
175 .to_string()
176 }
177
178 #[test]
179 fn evaluate_returns_one_observation_per_requested_range() {
180 let req =
181 crate::solve_json::decode_solve_request_v1(&base_request_json(900.0, 100.0, 25.0))
182 .unwrap();
183 let obs = evaluate(&req, &[300.0, 600.0, 800.0]).expect("evaluate");
184 assert_eq!(obs.len(), 3);
185 assert!((obs[0].range_m - 300.0).abs() < 1e-6);
186 assert!((obs[1].range_m - 600.0).abs() < 1e-6);
187 assert!((obs[2].range_m - 800.0).abs() < 1e-6);
188 // Drop grows with range (positive = below line of sight) once past the zero.
189 assert!(obs[2].drop_m > obs[0].drop_m);
190 // Velocity decays.
191 assert!(obs[2].velocity_mps < obs[0].velocity_mps);
192 assert!(obs
193 .iter()
194 .all(|o| o.time_s.is_finite() && o.windage_m.is_finite()));
195 }
196
197 #[test]
198 fn a_range_beyond_the_trajectory_is_an_error_not_a_clamp() {
199 let req =
200 crate::solve_json::decode_solve_request_v1(&base_request_json(300.0, 100.0, 25.0))
201 .unwrap();
202 // M1 review fix: `KernelError::Observation` can carry any `TrajectoryObservationError`
203 // variant, so `matches!(.., Err(Observation(_)))` alone cannot distinguish an
204 // out-of-range query from, say, `NonMonotonicTrajectory`. Match the specific `OutOfRange`
205 // variant directly (I4(a) review fix: `Observation` now wraps the structured error
206 // itself rather than a pre-formatted string, so this checks the actual variant instead
207 // of substring-matching its rendered message).
208 match evaluate(&req, &[5000.0]) {
209 Err(KernelError::Observation(
210 crate::trajectory_observation::TrajectoryObservationError::OutOfRange {
211 requested_m,
212 ..
213 },
214 )) => {
215 assert_eq!(requested_m, 5000.0);
216 }
217 other => panic!(
218 "expected Err(KernelError::Observation(OutOfRange {{ .. }})) naming an \
219 out-of-range query, got {other:?}"
220 ),
221 }
222 }
223
224 /// Independent oracle, `(Some(zero_distance_m), None)` arm (elevation search runs):
225 /// `solve_v1`'s OWN wire samples, computed via a completely different call path
226 /// (`TrajectoryResult::sample_observations` on a regular grid, not a targeted per-range
227 /// query), must describe the identical physical point that `evaluate` reports for the same
228 /// range on the same request. `solve_v1`'s mapping from `TrajectoryObservation` to
229 /// `TrajectorySampleV1` is unchanged, existing, and load-bearing for the whole solve-json v1
230 /// wire format, so it is a trustworthy check for `drop_m`/`windage_m` being swapped. See
231 /// `evaluate_matches_solve_v1_when_an_explicit_angle_and_zero_distance_are_both_present` and
232 /// `evaluate_matches_solve_v1_when_no_zero_distance_is_present_at_all` below for the other
233 /// two zero-handling arms (I2 review fix -- this file previously tested only this one).
234 #[test]
235 fn evaluate_matches_solve_v1_when_zero_distance_alone_searches_the_elevation() {
236 let json = base_request_json(900.0, 100.0, 300.0);
237 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
238 assert_eq!(
239 req.shot.muzzle_angle_rad, None,
240 "fixture assumption: no explicit angle, exercising the (Some, None) arm"
241 );
242 let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
243 // 300.0 is an exact regular-grid point for interval_m=300 starting at the muzzle
244 // (x=0.0): grid index 1, strictly before the ~900 m terminal, so it is read straight
245 // off the trajectory rather than needing end-of-grid special-casing.
246 let sample_at_300 = via_solve_v1
247 .samples
248 .iter()
249 .find(|s| s.distance_m == 300.0)
250 .expect("300 m must be an exact grid point for this fixture");
251 // Sanity check that this fixture actually distinguishes the two fields: with a
252 // 90-degree crosswind and a zero well short of 300 m, drop and windage here must both
253 // be unambiguously nonzero, AND (M2 review fix) far enough apart from EACH OTHER that a
254 // drop/windage swap could not hide behind two coincidentally-similar numbers -- the
255 // original two independent-threshold checks alone could not catch that.
256 assert!(sample_at_300.drop_m.abs() > 0.1);
257 assert!(sample_at_300.windage_m.abs() > 0.01);
258 assert!(
259 (sample_at_300.drop_m - sample_at_300.windage_m).abs() > 0.05,
260 "drop_m ({}) and windage_m ({}) must differ meaningfully, or a swap between them \
261 would be invisible to the two independent-threshold checks above",
262 sample_at_300.drop_m,
263 sample_at_300.windage_m
264 );
265
266 let obs = evaluate(&req, &[300.0]).expect("evaluate");
267 assert_eq!(obs.len(), 1);
268 assert_eq!(
269 obs[0].drop_m, sample_at_300.drop_m,
270 "drop_m diverged from solve_v1's own reported sample"
271 );
272 assert_eq!(
273 obs[0].windage_m, sample_at_300.windage_m,
274 "windage_m diverged from solve_v1's own reported sample (possible field swap)"
275 );
276 assert_eq!(obs[0].time_s, sample_at_300.time_s);
277 assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
278 }
279
280 /// Independent oracle, `(Some(zero_distance_m), Some(explicit_angle))` arm (I2 review fix):
281 /// the original test suite only ever exercised the `(Some, None)` arm above --
282 /// `base_request_json` always supplies `zero_distance_m`, and the one fixture omitting it
283 /// fails inside `prepare_request` before the zero-handling match ever runs, so `(Some,
284 /// Some)` and `(None, _)` were both silently untested despite the report claiming otherwise.
285 /// This arm matters because it is exactly the shape `From<&ResolvedSolveRequestV1> for
286 /// SolveRequestV1` (`request_roundtrip.rs`) produces on every zeroed solve: the elevation
287 /// search does NOT run (the supplied angle is used directly); only the windage-zero bias
288 /// (from `sight_offset_lateral_m`/`zero_poi_right_m`) applies. The fixture makes that bias
289 /// non-negligible so a dropped `apply_windage_zero_bias` call would show up as a
290 /// `windage_m` mismatch against `solve_v1`, not a coincidental match against zero.
291 #[test]
292 fn evaluate_matches_solve_v1_when_an_explicit_angle_and_zero_distance_are_both_present() {
293 let json = serde_json::json!({
294 "schema_version": 1,
295 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
296 "ballistic_coefficient": 0.243},
297 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05,
298 "sight_offset_lateral_m": 0.03},
299 "shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "muzzle_angle_rad": 0.01,
300 "zero_poi_right_m": 0.02},
301 "atmosphere": {},
302 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
303 "solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
304 })
305 .to_string();
306 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
307 assert!(
308 req.shot.zero_distance_m.is_some() && req.shot.muzzle_angle_rad.is_some(),
309 "fixture assumption: both fields present, exercising the (Some, Some) arm"
310 );
311
312 let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
313 let sample_at_300 = via_solve_v1
314 .samples
315 .iter()
316 .find(|s| s.distance_m == 300.0)
317 .expect("300 m must be an exact grid point for this fixture");
318 assert!(
319 sample_at_300.windage_m.abs() > 0.005,
320 "fixture must produce a non-negligible windage-zero bias, got {}",
321 sample_at_300.windage_m
322 );
323
324 let obs = evaluate(&req, &[300.0]).expect("evaluate");
325 assert_eq!(obs.len(), 1);
326 assert_eq!(obs[0].drop_m, sample_at_300.drop_m);
327 assert_eq!(
328 obs[0].windage_m, sample_at_300.windage_m,
329 "windage-zero bias diverged from solve_v1 -- apply_windage_zero_bias may not have \
330 run for this arm"
331 );
332 assert_eq!(obs[0].time_s, sample_at_300.time_s);
333 assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
334 }
335
336 /// Independent oracle, `(None, _)` arm (I2 review fix, continued): no `zero_distance_m` at
337 /// all, with an explicit `muzzle_angle_rad` -- the elevation search does not run, and there
338 /// is no `zero_distance_m` to gate a windage-zero bias on either, so the match falls through
339 /// to its no-op arm entirely.
340 #[test]
341 fn evaluate_matches_solve_v1_when_no_zero_distance_is_present_at_all() {
342 let json = serde_json::json!({
343 "schema_version": 1,
344 "projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
345 "ballistic_coefficient": 0.243},
346 "rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
347 "shot": {"max_range_m": 900.0, "muzzle_angle_rad": 0.01},
348 "atmosphere": {},
349 "wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
350 "solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
351 })
352 .to_string();
353 let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
354 assert_eq!(
355 req.shot.zero_distance_m, None,
356 "fixture assumption: no zero distance at all, exercising the (None, _) arm"
357 );
358
359 let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
360 let sample_at_300 = via_solve_v1
361 .samples
362 .iter()
363 .find(|s| s.distance_m == 300.0)
364 .expect("300 m must be an exact grid point for this fixture");
365
366 let obs = evaluate(&req, &[300.0]).expect("evaluate");
367 assert_eq!(obs.len(), 1);
368 assert_eq!(obs[0].drop_m, sample_at_300.drop_m);
369 assert_eq!(obs[0].windage_m, sample_at_300.windage_m);
370 assert_eq!(obs[0].time_s, sample_at_300.time_s);
371 assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
372 }
373
374 /// `evaluate` must return observations in the CALLER's requested order, not sorted, and
375 /// must not deduplicate a repeated range. Each output element is tied to its TRUE range via
376 /// an independent physical fact (velocity strictly decreases with range for a supersonic
377 /// flat-fire rifle round over this bracket) rather than only checking that `range_m` echoes
378 /// the query, which a bug that transposes a value together with its label would still pass.
379 #[test]
380 fn evaluate_preserves_the_caller_supplied_range_order_and_repeats() {
381 let req =
382 crate::solve_json::decode_solve_request_v1(&base_request_json(900.0, 100.0, 25.0))
383 .unwrap();
384 let requested = [800.0, 300.0, 800.0, 500.0];
385 let obs = evaluate(&req, &requested).expect("evaluate");
386 assert_eq!(obs.len(), requested.len());
387 for (o, &want) in obs.iter().zip(requested.iter()) {
388 assert_eq!(o.range_m, want);
389 }
390 assert!(
391 obs[0].velocity_mps < obs[1].velocity_mps,
392 "800 m must be slower than 300 m"
393 );
394 assert_eq!(
395 obs[0].velocity_mps, obs[2].velocity_mps,
396 "two queries at the same 800 m range must agree exactly"
397 );
398 assert!(
399 obs[1].velocity_mps > obs[3].velocity_mps,
400 "300 m must be faster than 500 m"
401 );
402 }
403
404 /// `evaluate`'s zero-handling must actually RUN the zero search when `zero_distance_m` is
405 /// present and no explicit `muzzle_angle_rad` was supplied -- not skip it, and not converge
406 /// it against the wrong distance.
407 ///
408 /// Independent physical check: the zero search converges the bullet's absolute
409 /// world-vertical height at `zero_distance_m` to `shot.target_height_m`, which an omitted
410 /// field resolves to the line of sight, `muzzle_height_m + sight_height_m` (MBA-1537; see
411 /// `docs/SOLVE_JSON_V1.md`'s `target_height_m` row). `drop_m` is `line_of_sight_height_m -
412 /// vertical_m`, over the same `line_of_sight_height_m`. This fixture sets
413 /// `sight_height_m: 0.0` (no bore/sight offset) specifically so that sum is `0`: with the
414 /// offset eliminated, a converged zero and a near-zero `drop_m` at that range are the same
415 /// fact whichever frame the zero is solved in, so this isolates "did the elevation search
416 /// run and converge at the right distance" from the frame question that MBA-1537 settled.
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}