Skip to main content

ballistics_engine/
solve_json.rs

1//! Versioned, binding-neutral JSON data-transfer objects for trajectory solves.
2//!
3//! These types deliberately do not expose [`crate::BallisticInputs`]. The public JSON contract
4//! has its own names, units, defaults, and compatibility policy so engine internals can evolve
5//! without silently changing requests produced by language bindings.
6
7use serde::{
8    de::{self, Visitor},
9    ser::SerializeStruct,
10    Deserialize, Deserializer, Serialize, Serializer,
11};
12use serde_json::{Map, Value};
13use std::{fmt, num::NonZeroUsize};
14
15/// The only solve-json schema version understood by this module.
16pub const SOLVE_JSON_SCHEMA_VERSION_V1: u32 = 1;
17
18/// Maximum number of trajectory observations in one solve-json v1 success response.
19///
20/// Service implementations must reject a response above this limit with
21/// [`SolveSuccessV1::validate_for_serialization`] instead of truncating it.
22pub const MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
23
24/// Deserialize a request member that may be omitted but may not be JSON `null`.
25///
26/// Serde supplies [`Option::None`] from the field's `default` only when the member is absent.
27/// When the member is present, deserialize `T` directly so `null` is rejected for scalar,
28/// enum, object, and array values alike.
29fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
30where
31    D: Deserializer<'de>,
32    T: Deserialize<'de>,
33{
34    T::deserialize(deserializer).map(Some)
35}
36
37/// An invariant solve-json v1 schema discriminator.
38///
39/// This unit type has no invalid public state: it always serializes as the JSON integer `1` and
40/// deserializes only from that integer.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
42pub struct SchemaVersionV1;
43
44impl SchemaVersionV1 {
45    /// Return the integer represented on the wire.
46    pub const fn get(self) -> u32 {
47        SOLVE_JSON_SCHEMA_VERSION_V1
48    }
49}
50
51impl Serialize for SchemaVersionV1 {
52    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
53    where
54        S: Serializer,
55    {
56        serializer.serialize_u32(SOLVE_JSON_SCHEMA_VERSION_V1)
57    }
58}
59
60impl<'de> Deserialize<'de> for SchemaVersionV1 {
61    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62    where
63        D: Deserializer<'de>,
64    {
65        struct SchemaVersionVisitor;
66
67        impl<'de> Visitor<'de> for SchemaVersionVisitor {
68            type Value = SchemaVersionV1;
69
70            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71                formatter.write_str("the integer 1")
72            }
73
74            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
75            where
76                E: de::Error,
77            {
78                if value == u64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
79                    Ok(SchemaVersionV1)
80                } else {
81                    Err(E::custom(format!(
82                        "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
83                    )))
84                }
85            }
86
87            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
88            where
89                E: de::Error,
90            {
91                if value == i64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
92                    Ok(SchemaVersionV1)
93                } else {
94                    Err(E::custom(format!(
95                        "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
96                    )))
97                }
98            }
99        }
100
101        deserializer.deserialize_any(SchemaVersionVisitor)
102    }
103}
104
105/// A complete v1 trajectory-solve request.
106///
107/// All dimensional values use SI units named in the field. Every section is mandatory, even
108/// when all fields in a section have defaults; this keeps the top-level request shape explicit.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct SolveRequestV1 {
112    /// Must be exactly [`SOLVE_JSON_SCHEMA_VERSION_V1`].
113    pub schema_version: SchemaVersionV1,
114    pub projectile: ProjectileV1,
115    pub rifle: RifleV1,
116    pub shot: ShotV1,
117    pub atmosphere: AtmosphereV1,
118    pub wind: WindV1,
119    pub solver: SolverV1,
120    pub effects: EffectsV1,
121    pub sampling: SamplingV1,
122    /// Optional reticle hold-point request (MBA-1361). Absent (the historical shape) leaves
123    /// both the solve and the response byte-identical; present adds
124    /// [`SolveSuccessV1::reticle_hold`] and nothing else. It is a pure post-processing read
125    /// of the solved samples — it cannot change the trajectory.
126    #[serde(
127        default,
128        skip_serializing_if = "Option::is_none",
129        deserialize_with = "deserialize_present"
130    )]
131    pub reticle: Option<ReticleRequestV1>,
132}
133
134/// Ask for a reticle hold point alongside the trajectory (MBA-1361).
135///
136/// `description` is the shared [`crate::reticle::ReticleDescription`] schema — the very same
137/// JSON `ballistics reticle generate -o json` emits — so a service and a CLI user exchange
138/// one representation. It is deliberately permissive about extra keys inside the
139/// description (front ends carry render metadata there); the envelope around it stays
140/// strict.
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct ReticleRequestV1 {
144    /// Range at which the hold is evaluated, meters. Must be inside the sampled trajectory.
145    pub range_m: f64,
146    /// The optic's magnification in use. Must be finite and greater than zero on both
147    /// focal planes.
148    pub magnification: f64,
149    pub description: crate::reticle::ReticleDescription,
150}
151
152/// The reticle hold point for a solved trajectory (MBA-1361).
153///
154/// Angles are milliradians from the optical center: `down_mil` positive BELOW center,
155/// `right_mil` positive to the shooter's RIGHT.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct ReticleHoldV1 {
159    /// Echo of the requested evaluation range, meters.
160    pub range_m: f64,
161    /// Echo of the requested magnification.
162    pub magnification: f64,
163    pub down_mil: f64,
164    pub right_mil: f64,
165    /// The subtension scale applied to the marks (`reference_magnification / magnification`
166    /// for SFP, exactly `1.0` for FFP).
167    pub mark_scale: f64,
168    /// Index into the request's `description.marks` of the nearest mark, in TRUE angular
169    /// space.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub nearest_mark_index: Option<usize>,
172    /// That mark's label, when it carries one.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub nearest_mark_label: Option<String>,
175    pub nearest_mark_distance_mil: f64,
176    /// True when the hold has run outside the marked area (see
177    /// [`crate::reticle::ReticleHold::off_reticle`] for the exact rule).
178    pub off_reticle: bool,
179}
180
181/// Projectile inputs supported by solve-json v1.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct ProjectileV1 {
185    pub mass_kg: f64,
186    pub diameter_m: f64,
187    #[serde(
188        default,
189        skip_serializing_if = "Option::is_none",
190        deserialize_with = "deserialize_present"
191    )]
192    pub length_m: Option<f64>,
193    pub drag_model: DragModelV1,
194    pub ballistic_coefficient: f64,
195}
196
197/// Built-in reference-projectile drag models supported by solve-json v1.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199pub enum DragModelV1 {
200    #[serde(rename = "G1")]
201    G1,
202    #[serde(rename = "G6")]
203    G6,
204    #[serde(rename = "G7")]
205    G7,
206    #[serde(rename = "G8")]
207    G8,
208}
209
210/// Rifle and sight geometry.
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct RifleV1 {
214    pub muzzle_velocity_mps: f64,
215    #[serde(
216        default,
217        skip_serializing_if = "Option::is_none",
218        deserialize_with = "deserialize_present"
219    )]
220    pub sight_height_m: Option<f64>,
221    #[serde(
222        default,
223        skip_serializing_if = "Option::is_none",
224        deserialize_with = "deserialize_present"
225    )]
226    pub muzzle_height_m: Option<f64>,
227    #[serde(
228        default,
229        skip_serializing_if = "Option::is_none",
230        deserialize_with = "deserialize_present"
231    )]
232    pub twist_rate_m_per_turn: Option<f64>,
233    #[serde(
234        default,
235        skip_serializing_if = "Option::is_none",
236        deserialize_with = "deserialize_present"
237    )]
238    pub twist_direction: Option<TwistDirectionV1>,
239    /// Lateral offset between the sight axis and the bore axis, meters (MBA-1396,
240    /// offset-mounted optics): positive = the sight sits RIGHT of the bore. The bore
241    /// starts that far left of the line of sight, and a solved zero adds the windage
242    /// convergence (`offset / zero_distance`) so the trajectory crosses the LOS laterally
243    /// at the zero range. Omitted (the default) is byte-identical to pre-MBA-1396
244    /// behavior; there is no resolved-DTO echo (request-side additive field — the
245    /// response shape is unchanged).
246    #[serde(
247        default,
248        skip_serializing_if = "Option::is_none",
249        deserialize_with = "deserialize_present"
250    )]
251    pub sight_offset_lateral_m: Option<f64>,
252}
253
254/// Direction of rifling twist as viewed from the breech toward the muzzle.
255#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum TwistDirectionV1 {
258    Left,
259    #[default]
260    Right,
261}
262
263/// Shot geometry and termination range.
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct ShotV1 {
267    pub max_range_m: f64,
268    #[serde(
269        default,
270        skip_serializing_if = "Option::is_none",
271        deserialize_with = "deserialize_present"
272    )]
273    pub zero_distance_m: Option<f64>,
274    #[serde(
275        default,
276        skip_serializing_if = "Option::is_none",
277        deserialize_with = "deserialize_present"
278    )]
279    pub muzzle_angle_rad: Option<f64>,
280    #[serde(
281        default,
282        skip_serializing_if = "Option::is_none",
283        deserialize_with = "deserialize_present"
284    )]
285    pub aim_azimuth_rad: Option<f64>,
286    #[serde(
287        default,
288        skip_serializing_if = "Option::is_none",
289        deserialize_with = "deserialize_present"
290    )]
291    pub shot_azimuth_rad: Option<f64>,
292    #[serde(
293        default,
294        skip_serializing_if = "Option::is_none",
295        deserialize_with = "deserialize_present"
296    )]
297    pub shooting_angle_rad: Option<f64>,
298    #[serde(
299        default,
300        skip_serializing_if = "Option::is_none",
301        deserialize_with = "deserialize_present"
302    )]
303    pub cant_angle_rad: Option<f64>,
304    #[serde(
305        default,
306        skip_serializing_if = "Option::is_none",
307        deserialize_with = "deserialize_present"
308    )]
309    pub target_height_m: Option<f64>,
310    #[serde(
311        default,
312        skip_serializing_if = "Option::is_none",
313        deserialize_with = "deserialize_present"
314    )]
315    pub ground_threshold_m: Option<f64>,
316    /// Deliberate vertical POI offset AT the zero range, meters (MBA-1359, Kestrel "zero
317    /// height"): positive = the rifle is deliberately zeroed to impact HIGH by this much at
318    /// `zero_distance_m`. Meaningful only when `zero_distance_m` is supplied. Omitted (the
319    /// default) is byte-identical to pre-MBA-1359 behavior; there is no resolved-DTO echo
320    /// (request-side additive field — the response shape is unchanged).
321    #[serde(
322        default,
323        skip_serializing_if = "Option::is_none",
324        deserialize_with = "deserialize_present"
325    )]
326    pub zero_poi_up_m: Option<f64>,
327    /// Deliberate horizontal POI offset AT the zero range, meters (MBA-1359, Kestrel "zero
328    /// offset"): positive = impacts RIGHT by this much at `zero_distance_m`. Same contract
329    /// as `zero_poi_up_m`.
330    #[serde(
331        default,
332        skip_serializing_if = "Option::is_none",
333        deserialize_with = "deserialize_present"
334    )]
335    pub zero_poi_right_m: Option<f64>,
336    /// Which plane sampled `drop_m` values are referenced to (MBA-1403). `"los"` (the
337    /// default when omitted) keeps the historical LOS-perpendicular drop byte-identically;
338    /// `"target"` reports drop as vertical in the target plane — the LOS-perpendicular
339    /// drop scaled by `1 / cos(shooting_angle_rad)` (JBM's "target plane" reference).
340    /// Output-mode toggle only: the solved trajectory, `windage_m`, the summary block and
341    /// zeroing semantics are unchanged, and there is no resolved-DTO echo (request-side
342    /// additive field — the response shape is unchanged).
343    #[serde(
344        default,
345        skip_serializing_if = "Option::is_none",
346        deserialize_with = "deserialize_present"
347    )]
348    pub drops_reference: Option<DropsReferenceV1>,
349}
350
351/// Wire values for [`ShotV1::drops_reference`] (MBA-1403).
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum DropsReferenceV1 {
355    /// Drop measured perpendicular to the line of sight (the historical default).
356    #[default]
357    Los,
358    /// Drop measured vertically in the target plane: LOS-perpendicular drop scaled by
359    /// `1 / cos(shooting_angle_rad)`.
360    Target,
361}
362
363/// Atmospheric station conditions.
364#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
365#[serde(deny_unknown_fields)]
366pub struct AtmosphereV1 {
367    #[serde(
368        default,
369        skip_serializing_if = "Option::is_none",
370        deserialize_with = "deserialize_present"
371    )]
372    pub altitude_m: Option<f64>,
373    /// Authoritative station temperature, or `None` to resolve ICAO standard temperature at
374    /// `altitude_m`.
375    #[serde(
376        default,
377        skip_serializing_if = "Option::is_none",
378        deserialize_with = "deserialize_present"
379    )]
380    pub temperature_k: Option<f64>,
381    /// Authoritative station pressure, or `None` to resolve ICAO standard pressure at
382    /// `altitude_m`. Interpreted per `pressure_reference` (MBA-1397): `absolute` (the
383    /// default) means this value already IS station pressure; `qnh` means it is a
384    /// sea-level-corrected altimeter setting that must be reduced to station pressure at
385    /// `altitude_m` before use.
386    #[serde(
387        default,
388        skip_serializing_if = "Option::is_none",
389        deserialize_with = "deserialize_present"
390    )]
391    pub pressure_pa: Option<f64>,
392    /// Whether `pressure_pa` is absolute station pressure or a sea-level-corrected altimeter
393    /// setting (QNH, MBA-1397). `None` (the omitted-field default, and every request from
394    /// before this field existed) means [`PressureReferenceV1::Absolute`] — byte-identical to
395    /// pre-MBA-1397 behavior. Has no effect when `pressure_pa` is omitted: an omitted pressure
396    /// resolves to the ICAO standard station pressure either way.
397    #[serde(
398        default,
399        skip_serializing_if = "Option::is_none",
400        deserialize_with = "deserialize_present"
401    )]
402    pub pressure_reference: Option<PressureReferenceV1>,
403    #[serde(
404        default,
405        skip_serializing_if = "Option::is_none",
406        deserialize_with = "deserialize_present"
407    )]
408    pub relative_humidity: Option<f64>,
409    #[serde(
410        default,
411        skip_serializing_if = "Option::is_none",
412        deserialize_with = "deserialize_present"
413    )]
414    pub latitude_rad: Option<f64>,
415}
416
417/// Whether an `AtmosphereV1.pressure_pa` value is absolute station pressure or a sea-level-
418/// corrected altimeter setting (QNH) that must be reduced to station pressure before use
419/// (MBA-1397). Mirrors [`crate::atmosphere::PressureReferenceMode`].
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "snake_case")]
422pub enum PressureReferenceV1 {
423    #[default]
424    Absolute,
425    Qnh,
426}
427
428/// Constant or downrange-segmented wind.
429///
430/// Omit all fields for still air. A constant wind supplies both `speed_mps` and
431/// `direction_from_rad`. Segmented wind supplies `segments` and no constant-wind fields.
432#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct WindV1 {
435    #[serde(
436        default,
437        skip_serializing_if = "Option::is_none",
438        deserialize_with = "deserialize_present"
439    )]
440    pub speed_mps: Option<f64>,
441    #[serde(
442        default,
443        skip_serializing_if = "Option::is_none",
444        deserialize_with = "deserialize_present"
445    )]
446    pub direction_from_rad: Option<f64>,
447    #[serde(
448        default,
449        skip_serializing_if = "Option::is_none",
450        deserialize_with = "deserialize_present"
451    )]
452    pub vertical_speed_mps: Option<f64>,
453    #[serde(
454        default,
455        skip_serializing_if = "Option::is_none",
456        deserialize_with = "deserialize_present"
457    )]
458    pub segments: Option<Vec<WindSegmentV1>>,
459    /// Which frame every wind direction in this request is entered in (MBA-1368).
460    /// Omitted (or `"shooter"`) = shooter-relative wind-FROM radians, byte-identical to
461    /// pre-1368 behavior. `"compass"` = earth-fixed bearings (0 = north) — the constant
462    /// `direction_from_rad` AND every segment's — derived shooter-relative at resolve
463    /// time as `bearing - shot.shot_azimuth_rad` (normalized to [0, 2π)); the RESOLVED
464    /// wind echo therefore reports the converted shooter-relative direction (the QNH
465    /// fold-into-resolved-value precedent). Compass mode requires an explicit
466    /// `shot.shot_azimuth_rad` (a hard error otherwise, never a silent
467    /// treat-as-shooter-relative), and there is no resolved-DTO echo of the mode
468    /// itself (request-side additive field — the response shape is unchanged).
469    #[serde(
470        default,
471        skip_serializing_if = "Option::is_none",
472        deserialize_with = "deserialize_present"
473    )]
474    pub wind_reference: Option<WindReferenceV1>,
475}
476
477/// Wire values for [`WindV1::wind_reference`] (MBA-1368).
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
479#[serde(rename_all = "snake_case")]
480pub enum WindReferenceV1 {
481    /// Shooter-relative wind-FROM directions (the historical default).
482    #[default]
483    Shooter,
484    /// Earth-fixed compass bearings, re-referenced against `shot.shot_azimuth_rad`.
485    Compass,
486}
487
488/// One wind segment, active through `until_distance_m`.
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
490#[serde(deny_unknown_fields)]
491pub struct WindSegmentV1 {
492    pub until_distance_m: f64,
493    pub speed_mps: f64,
494    pub direction_from_rad: f64,
495    #[serde(
496        default,
497        skip_serializing_if = "Option::is_none",
498        deserialize_with = "deserialize_present"
499    )]
500    pub vertical_speed_mps: Option<f64>,
501}
502
503/// Numerical integration configuration.
504#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
505#[serde(deny_unknown_fields)]
506pub struct SolverV1 {
507    #[serde(
508        default,
509        skip_serializing_if = "Option::is_none",
510        deserialize_with = "deserialize_present"
511    )]
512    pub method: Option<SolverMethodV1>,
513    /// Fixed step for RK4 and Euler. RK45 owns its adaptive step size.
514    #[serde(
515        default,
516        skip_serializing_if = "Option::is_none",
517        deserialize_with = "deserialize_present"
518    )]
519    pub time_step_s: Option<f64>,
520}
521
522/// Integration algorithms exposed by solve-json v1.
523#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum SolverMethodV1 {
526    Euler,
527    Rk4,
528    #[default]
529    Rk45,
530}
531
532/// Optional physical effects supported by solve-json v1.
533#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(deny_unknown_fields)]
535pub struct EffectsV1 {
536    #[serde(
537        default,
538        skip_serializing_if = "Option::is_none",
539        deserialize_with = "deserialize_present"
540    )]
541    pub magnus: Option<bool>,
542    #[serde(
543        default,
544        skip_serializing_if = "Option::is_none",
545        deserialize_with = "deserialize_present"
546    )]
547    pub coriolis: Option<bool>,
548    #[serde(
549        default,
550        skip_serializing_if = "Option::is_none",
551        deserialize_with = "deserialize_present"
552    )]
553    pub enhanced_spin_drift: Option<bool>,
554}
555
556/// Regular downrange output sampling configuration.
557#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
558#[serde(deny_unknown_fields)]
559pub struct SamplingV1 {
560    #[serde(
561        default,
562        skip_serializing_if = "Option::is_none",
563        deserialize_with = "deserialize_present"
564    )]
565    pub interval_m: Option<f64>,
566}
567
568/// A solve request after the service has applied every documented v1 default.
569///
570/// This is intentionally distinct from [`SolveRequestV1`]. Request DTOs retain whether a
571/// defaulted field was omitted, while resolved DTOs require a concrete value for every default
572/// that affected the solve. Semantically optional inputs remain optional.
573#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
574#[serde(deny_unknown_fields)]
575pub struct ResolvedSolveRequestV1 {
576    pub schema_version: SchemaVersionV1,
577    pub projectile: ResolvedProjectileV1,
578    pub rifle: ResolvedRifleV1,
579    pub shot: ResolvedShotV1,
580    pub atmosphere: ResolvedAtmosphereV1,
581    pub wind: ResolvedWindV1,
582    pub solver: ResolvedSolverV1,
583    pub effects: ResolvedEffectsV1,
584    pub sampling: ResolvedSamplingV1,
585}
586
587/// Resolved projectile inputs.
588#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct ResolvedProjectileV1 {
591    pub mass_kg: f64,
592    pub diameter_m: f64,
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub length_m: Option<f64>,
595    pub drag_model: DragModelV1,
596    pub ballistic_coefficient: f64,
597}
598
599/// Resolved rifle and sight geometry.
600#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct ResolvedRifleV1 {
603    pub muzzle_velocity_mps: f64,
604    pub sight_height_m: f64,
605    pub muzzle_height_m: f64,
606    pub twist_rate_m_per_turn: f64,
607    pub twist_direction: TwistDirectionV1,
608}
609
610/// Resolved shot geometry.
611///
612/// `zero_distance_m` records caller zeroing intent, while `muzzle_angle_rad` is always the
613/// effective angle used by the engine. A zero-distance solve therefore populates both fields.
614#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615#[serde(deny_unknown_fields)]
616pub struct ResolvedShotV1 {
617    pub max_range_m: f64,
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub zero_distance_m: Option<f64>,
620    pub muzzle_angle_rad: f64,
621    pub aim_azimuth_rad: f64,
622    pub shot_azimuth_rad: f64,
623    pub shooting_angle_rad: f64,
624    pub cant_angle_rad: f64,
625    pub target_height_m: f64,
626    pub ground_threshold_m: f64,
627}
628
629/// Resolved station conditions.
630#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
631#[serde(deny_unknown_fields)]
632pub struct ResolvedAtmosphereV1 {
633    pub altitude_m: f64,
634    pub temperature_k: f64,
635    pub pressure_pa: f64,
636    pub relative_humidity: f64,
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub latitude_rad: Option<f64>,
639}
640
641/// Resolved constant or segmented wind.
642///
643/// The untagged representation retains the request's object shape while making the selected wind
644/// variant explicit in Rust. Still air is a constant wind with three zero values.
645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
646#[serde(untagged)]
647pub enum ResolvedWindV1 {
648    Constant(ResolvedConstantWindV1),
649    Segmented(ResolvedSegmentedWindV1),
650}
651
652/// Resolved still-air or constant-wind values.
653#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
654#[serde(deny_unknown_fields)]
655pub struct ResolvedConstantWindV1 {
656    pub speed_mps: f64,
657    pub direction_from_rad: f64,
658    pub vertical_speed_mps: f64,
659}
660
661/// Resolved downrange wind segments.
662#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
663#[serde(deny_unknown_fields)]
664pub struct ResolvedSegmentedWindV1 {
665    pub segments: Vec<ResolvedWindSegmentV1>,
666}
667
668/// One resolved wind segment.
669#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
670#[serde(deny_unknown_fields)]
671pub struct ResolvedWindSegmentV1 {
672    pub until_distance_m: f64,
673    pub speed_mps: f64,
674    pub direction_from_rad: f64,
675    pub vertical_speed_mps: f64,
676}
677
678/// Resolved numerical integration configuration.
679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
680#[serde(deny_unknown_fields)]
681pub struct ResolvedSolverV1 {
682    pub method: SolverMethodV1,
683    pub time_step_s: f64,
684}
685
686/// Resolved physical-effect switches.
687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
688#[serde(deny_unknown_fields)]
689pub struct ResolvedEffectsV1 {
690    pub magnus: bool,
691    pub coriolis: bool,
692    pub enhanced_spin_drift: bool,
693}
694
695/// Resolved result-sampling configuration.
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
697#[serde(deny_unknown_fields)]
698pub struct ResolvedSamplingV1 {
699    pub interval_m: f64,
700}
701
702/// A successful solve-json v1 response.
703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704#[serde(deny_unknown_fields)]
705pub struct SolveSuccessV1 {
706    pub schema_version: SchemaVersionV1,
707    pub engine_version: String,
708    pub status: SuccessStatusV1,
709    pub resolved_request: ResolvedSolveRequestV1,
710    #[serde(default)]
711    pub assumptions: Vec<SolveNoticeV1>,
712    #[serde(default)]
713    pub warnings: Vec<SolveNoticeV1>,
714    pub summary: SolveSummaryV1,
715    #[serde(default, serialize_with = "serialize_solve_samples_v1")]
716    pub samples: Vec<TrajectorySampleV1>,
717    /// The reticle hold point (MBA-1361), present only when the request carried a
718    /// `reticle` block. Every response that predates the field, and every request without
719    /// one, is byte-identical.
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub reticle_hold: Option<ReticleHoldV1>,
722}
723
724fn serialize_solve_samples_v1<S>(
725    samples: &[TrajectorySampleV1],
726    serializer: S,
727) -> Result<S::Ok, S::Error>
728where
729    S: Serializer,
730{
731    if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
732        return Err(serde::ser::Error::custom(format_args!(
733            "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
734            samples.len()
735        )));
736    }
737    samples.serialize(serializer)
738}
739
740impl SolveSuccessV1 {
741    /// Validate service-level response limits immediately before serialization.
742    ///
743    /// The exact limit is accepted. A response with more samples returns a structured
744    /// [`SolveErrorCodeV1::ResourceLimit`] error at the sampling interval that requested the
745    /// oversized result. The response is never silently truncated.
746    pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
747        if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
748            return Ok(());
749        }
750
751        Err(SolveErrorEnvelopeV1::new(
752            SolveErrorV1::new(
753                SolveErrorCodeV1::ResourceLimit,
754                format!(
755                    "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
756                    self.samples.len()
757                ),
758            )
759            .at_path("$.sampling.interval_m"),
760        ))
761    }
762}
763
764/// The success discriminator serialized in a response envelope.
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
766#[serde(rename_all = "snake_case")]
767pub enum SuccessStatusV1 {
768    Ok,
769}
770
771/// A machine-readable assumption or warning.
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(deny_unknown_fields)]
774pub struct SolveNoticeV1 {
775    pub code: String,
776    pub message: String,
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub path: Option<String>,
779}
780
781/// Aggregate observations for a completed solve.
782#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784pub struct SolveSummaryV1 {
785    pub actual_range_m: f64,
786    /// Greatest world-vertical projectile height above the request's local ground/reference
787    /// datum. This is not shot-frame Y and is not height above the line of sight.
788    pub maximum_height_m: f64,
789    pub time_of_flight_s: f64,
790    pub terminal_speed_mps: f64,
791    pub terminal_energy_j: f64,
792    /// Muzzle gyroscopic stability factor Sg evaluated with the resolved projectile, muzzle
793    /// velocity, twist, and station atmosphere; absent only when it cannot be calculated.
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub stability_factor: Option<f64>,
796    /// Signed gyroscopic spin-drift contribution at the terminal sample, positive to the
797    /// shooter's right. This excludes wind drift and is absent when the effect is disabled or
798    /// cannot be calculated.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub spin_drift_m: Option<f64>,
801    /// Equivalent horizontal range for an inclined shot (MBA-1395): the flat-fire range
802    /// whose angular elevation correction against the same zero matches the inclined
803    /// solution's at the terminal range — the BDC "shoot-to" range (SIG AMR / Leica EHR
804    /// style, angular-match inversion, not the rifleman's-rule cosine). Present only when
805    /// `shot.shooting_angle_rad != 0`, a `zero_distance_m` was solved, and the inverse is
806    /// well-defined (terminal past the zero range, positive correction); absent otherwise
807    /// — responses that predate the field, and every flat solve, are byte-identical.
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub equivalent_horizontal_range_m: Option<f64>,
810    pub termination: TerminationReasonV1,
811}
812
813/// Why the trajectory ended.
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
815#[serde(rename_all = "snake_case")]
816pub enum TerminationReasonV1 {
817    MaxRange,
818    GroundThreshold,
819    TimeLimit,
820    VelocityFloor,
821}
822
823/// One regularly sampled trajectory observation.
824#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
825#[serde(deny_unknown_fields)]
826pub struct TrajectorySampleV1 {
827    pub distance_m: f64,
828    pub time_s: f64,
829    pub speed_mps: f64,
830    pub energy_j: f64,
831    /// Positive means below the line of sight.
832    pub drop_m: f64,
833    /// Positive means right of the line of sight from the shooter's perspective.
834    pub windage_m: f64,
835    pub mach: f64,
836    #[serde(default)]
837    pub flags: Vec<SampleFlagV1>,
838}
839
840/// Stable annotations for trajectory samples.
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
842#[serde(rename_all = "snake_case")]
843pub enum SampleFlagV1 {
844    Transonic,
845    Subsonic,
846    Terminal,
847    GroundThreshold,
848}
849
850/// A failed solve-json v1 response.
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
852#[serde(deny_unknown_fields)]
853pub struct SolveErrorEnvelopeV1 {
854    pub schema_version: SchemaVersionV1,
855    pub status: ErrorStatusV1,
856    pub error: SolveErrorV1,
857}
858
859impl SolveErrorEnvelopeV1 {
860    /// Wrap a protocol error in a v1 envelope.
861    pub fn new(error: SolveErrorV1) -> Self {
862        Self {
863            schema_version: SchemaVersionV1,
864            status: ErrorStatusV1::Error,
865            error,
866        }
867    }
868}
869
870/// The error discriminator serialized in a response envelope.
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872#[serde(rename_all = "snake_case")]
873pub enum ErrorStatusV1 {
874    Error,
875}
876
877/// A stable, machine-readable protocol error.
878///
879/// Location state is private so callers cannot construct a path together with a source location,
880/// a partial line/column pair, or a zero source coordinate. The JSON representation retains the
881/// flat `path`, `line`, and `column` fields.
882#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct SolveErrorV1 {
884    pub code: SolveErrorCodeV1,
885    pub message: String,
886    location: SolveErrorLocationV1,
887}
888
889#[derive(Debug, Clone, PartialEq, Eq)]
890enum SolveErrorLocationV1 {
891    None,
892    Path(String),
893    Source {
894        line: NonZeroUsize,
895        column: NonZeroUsize,
896    },
897}
898
899/// Why a source location could not be attached to a protocol error.
900#[derive(Debug, Clone, Copy, PartialEq, Eq)]
901pub enum SolveErrorLocationErrorV1 {
902    ZeroLine,
903    ZeroColumn,
904}
905
906impl fmt::Display for SolveErrorLocationErrorV1 {
907    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
908        match self {
909            Self::ZeroLine => formatter.write_str("error line must be one-based"),
910            Self::ZeroColumn => formatter.write_str("error column must be one-based"),
911        }
912    }
913}
914
915impl std::error::Error for SolveErrorLocationErrorV1 {}
916
917impl SolveErrorV1 {
918    /// Construct an error without input-location information.
919    pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
920        Self {
921            code,
922            message: message.into(),
923            location: SolveErrorLocationV1::None,
924        }
925    }
926
927    /// Attach a JSONPath-like input location, replacing any source location.
928    pub fn at_path(mut self, path: impl Into<String>) -> Self {
929        self.location = SolveErrorLocationV1::Path(path.into());
930        self
931    }
932
933    /// Attach a one-based malformed-JSON source location, replacing any JSON path.
934    pub fn at_location(
935        mut self,
936        line: usize,
937        column: usize,
938    ) -> Result<Self, SolveErrorLocationErrorV1> {
939        let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
940        let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
941        self.location = SolveErrorLocationV1::Source { line, column };
942        Ok(self)
943    }
944
945    /// Attach a parser location while keeping the public wire coordinates one-based.
946    ///
947    /// `serde_json` reports column zero for some end-of-file errors. Normalize either zero
948    /// coordinate to one instead of allowing malformed input to trigger a panic.
949    fn at_parser_location(mut self, line: usize, column: usize) -> Self {
950        self.location = SolveErrorLocationV1::Source {
951            line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
952            column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
953        };
954        self
955    }
956
957    /// Return the JSONPath-like input location, when present.
958    pub fn path(&self) -> Option<&str> {
959        match &self.location {
960            SolveErrorLocationV1::Path(path) => Some(path),
961            SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
962        }
963    }
964
965    /// Return the one-based malformed-JSON source line, when present.
966    pub fn line(&self) -> Option<usize> {
967        match &self.location {
968            SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
969            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
970        }
971    }
972
973    /// Return the one-based malformed-JSON source column, when present.
974    pub fn column(&self) -> Option<usize> {
975        match &self.location {
976            SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
977            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
978        }
979    }
980}
981
982impl Serialize for SolveErrorV1 {
983    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
984    where
985        S: Serializer,
986    {
987        let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
988        state.serialize_field("code", &self.code)?;
989        state.serialize_field("message", &self.message)?;
990        state.serialize_field("path", &self.path())?;
991        state.serialize_field("line", &self.line())?;
992        state.serialize_field("column", &self.column())?;
993        state.end()
994    }
995}
996
997impl<'de> Deserialize<'de> for SolveErrorV1 {
998    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
999    where
1000        D: Deserializer<'de>,
1001    {
1002        #[derive(Deserialize)]
1003        #[serde(deny_unknown_fields)]
1004        struct SolveErrorWireV1 {
1005            code: SolveErrorCodeV1,
1006            message: String,
1007            path: Option<String>,
1008            line: Option<usize>,
1009            column: Option<usize>,
1010        }
1011
1012        let wire = SolveErrorWireV1::deserialize(deserializer)?;
1013        let location = match (wire.path, wire.line, wire.column) {
1014            (None, None, None) => SolveErrorLocationV1::None,
1015            (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1016            (None, Some(line), Some(column)) => {
1017                let line = NonZeroUsize::new(line)
1018                    .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1019                let column = NonZeroUsize::new(column)
1020                    .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1021                SolveErrorLocationV1::Source { line, column }
1022            }
1023            _ => {
1024                return Err(de::Error::custom(
1025                    "error location must contain either path or both line and column",
1026                ));
1027            }
1028        };
1029
1030        Ok(Self {
1031            code: wire.code,
1032            message: wire.message,
1033            location,
1034        })
1035    }
1036}
1037
1038/// Stable solve-json v1 error categories.
1039#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1040#[serde(rename_all = "snake_case")]
1041pub enum SolveErrorCodeV1 {
1042    InvalidJson,
1043    UnsupportedSchemaVersion,
1044    UnknownField,
1045    MissingField,
1046    InvalidValue,
1047    ConflictingFields,
1048    ResourceLimit,
1049    SolveFailed,
1050    IoError,
1051    InternalError,
1052}
1053
1054/// Decode and structurally validate one solve-json v1 request.
1055///
1056/// In addition to Serde's `deny_unknown_fields` enforcement, this entry point attaches exact
1057/// JSONPath-like locations to unknown fields, missing required fields, invalid enums, and an
1058/// unsupported schema version. Transport implementations should use this function rather than
1059/// deserializing [`SolveRequestV1`] directly when they need a protocol error envelope.
1060pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1061    let value: Value = serde_json::from_str(input).map_err(|error| {
1062        let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1063            .at_parser_location(error.line(), error.column());
1064        envelope(error)
1065    })?;
1066
1067    validate_request_shape(&value)?;
1068
1069    let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1070        envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1071    })?;
1072
1073    validate_request_ranges(&request)?;
1074
1075    Ok(request)
1076}
1077
1078/// Physically meaningful bounds for solve-json v1's numeric inputs (MBA-1413).
1079///
1080/// Deliberately enormous — the point is to exclude values that are not projectiles at all, not
1081/// to police calibers. Every bound below admits everything from an airgun pellet to naval
1082/// artillery, so a request rejected here was never going to produce a meaningful answer.
1083///
1084/// Found by fuzzing, which reached a request declaring a `mass_kg` of 1.1e-66 — about
1085/// 10^40 times lighter than a proton. The engine accepted it and solved. The visible symptom was
1086/// narrower: at that magnitude `serde_json`'s float parser is a bit off re-reading its own
1087/// output, so the request did not survive a JSON round trip and broke the protocol's stability
1088/// invariant. Tightening the float handling would have addressed the symptom; the actual defect
1089/// is that a number no projectile could have was accepted as a projectile.
1090mod limits {
1091    /// 1 mg to 100 kg. A .17 cal pellet is ~500 mg; a 16-inch naval shell is ~1200 kg, so the
1092    /// upper bound is the one a caller might conceivably reach — it is set past small artillery
1093    /// on purpose and can be raised without ceremony.
1094    pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1095    /// 0.1 mm to 1 m.
1096    pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1097    /// 0.1 mm to 10 m. Only checked when supplied.
1098    pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1099    /// Ballistic coefficient, lb/in². Real values run ~0.1–1.5; the bound is far past both ends.
1100    pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1101
1102    // Deliberately NOT bounded here: muzzle_velocity_mps and max_range_m. The MCP server
1103    // documents — and tests — a split where a structurally valid request the engine cannot
1104    // solve returns a tool error rather than a protocol error, and an absurd muzzle velocity is
1105    // its worked example of that case. Bounding those two fields here would reclassify that
1106    // example as invalid params and change a contract MCP clients may rely on, which is a
1107    // separate decision from rejecting values that cannot describe a projectile.
1108}
1109
1110fn require_range(
1111    value: f64,
1112    (min, max): (f64, f64),
1113    path: &str,
1114) -> Result<(), SolveErrorEnvelopeV1> {
1115    if !value.is_finite() {
1116        return Err(protocol_error(
1117            SolveErrorCodeV1::InvalidValue,
1118            format!("{path} must be a finite number"),
1119            path,
1120        ));
1121    }
1122    if value < min || value > max {
1123        return Err(protocol_error(
1124            SolveErrorCodeV1::InvalidValue,
1125            format!("{path} must be between {min} and {max}, got {value}"),
1126            path,
1127        ));
1128    }
1129    Ok(())
1130}
1131
1132/// Reject requests whose numbers cannot describe a projectile, before any solve runs.
1133///
1134/// Runs after deserialization so the fields are typed and each error can carry the exact
1135/// JSONPath the caller used, matching how the shape errors above report.
1136fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1137    require_range(
1138        request.projectile.mass_kg,
1139        limits::MASS_KG,
1140        "$.projectile.mass_kg",
1141    )?;
1142    require_range(
1143        request.projectile.diameter_m,
1144        limits::DIAMETER_M,
1145        "$.projectile.diameter_m",
1146    )?;
1147    require_range(
1148        request.projectile.ballistic_coefficient,
1149        limits::BALLISTIC_COEFFICIENT,
1150        "$.projectile.ballistic_coefficient",
1151    )?;
1152    if let Some(length_m) = request.projectile.length_m {
1153        require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1154    }
1155    Ok(())
1156}
1157
1158fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1159    SolveErrorEnvelopeV1::new(error)
1160}
1161
1162fn protocol_error(
1163    code: SolveErrorCodeV1,
1164    message: impl Into<String>,
1165    path: impl Into<String>,
1166) -> SolveErrorEnvelopeV1 {
1167    envelope(SolveErrorV1::new(code, message).at_path(path))
1168}
1169
1170fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1171    let root = require_object(value, "$")?;
1172
1173    // Dispatch on the version before applying any v1-only shape rules. A future-version
1174    // request may legitimately contain fields unknown to v1 and must still receive the stable
1175    // unsupported-version error rather than an order-dependent unknown-field error.
1176    validate_schema_version(root)?;
1177    validate_members(
1178        root,
1179        "$",
1180        &[
1181            "schema_version",
1182            "projectile",
1183            "rifle",
1184            "shot",
1185            "atmosphere",
1186            "wind",
1187            "solver",
1188            "effects",
1189            "sampling",
1190            // MBA-1361: optional, additive. Omitting it is the historical shape.
1191            "reticle",
1192        ],
1193        &[
1194            "schema_version",
1195            "projectile",
1196            "rifle",
1197            "shot",
1198            "atmosphere",
1199            "wind",
1200            "solver",
1201            "effects",
1202            "sampling",
1203        ],
1204    )?;
1205
1206    validate_projectile(required_value(root, "projectile", "$")?)?;
1207    validate_rifle(required_value(root, "rifle", "$")?)?;
1208    validate_shot(required_value(root, "shot", "$")?)?;
1209    validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1210    validate_wind(required_value(root, "wind", "$")?)?;
1211    validate_solver(required_value(root, "solver", "$")?)?;
1212    validate_effects(required_value(root, "effects", "$")?)?;
1213    validate_sampling(required_value(root, "sampling", "$")?)?;
1214    if let Some(reticle) = root.get("reticle") {
1215        validate_reticle(reticle)?;
1216    }
1217    Ok(())
1218}
1219
1220/// Shape-validate an optional `reticle` block (MBA-1361).
1221///
1222/// The ENVELOPE is strict (exactly `range_m`, `magnification`, `description`, all
1223/// required); the description itself is handed to the shared reticle schema, whose own
1224/// `validate()` runs at solve time. Splitting it that way keeps the wire contract tight
1225/// without making the engine the arbiter of a front end's render metadata.
1226fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1227    let path = "$.reticle";
1228    let object = require_object(value, path)?;
1229    validate_members(
1230        object,
1231        path,
1232        &["range_m", "magnification", "description"],
1233        &["range_m", "magnification", "description"],
1234    )?;
1235    validate_required_numbers(object, path, &["range_m", "magnification"])?;
1236    require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1237    Ok(())
1238}
1239
1240fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1241    let value = required_value(root, "schema_version", "$")?;
1242    let version = if let Some(version) = value.as_i64() {
1243        i128::from(version)
1244    } else if let Some(version) = value.as_u64() {
1245        i128::from(version)
1246    } else {
1247        return Err(protocol_error(
1248            SolveErrorCodeV1::InvalidValue,
1249            "schema_version must be the integer 1",
1250            "$.schema_version",
1251        ));
1252    };
1253    if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1254        return Err(protocol_error(
1255            SolveErrorCodeV1::UnsupportedSchemaVersion,
1256            format!(
1257                "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1258            ),
1259            "$.schema_version",
1260        ));
1261    }
1262    Ok(())
1263}
1264
1265fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1266    let path = "$.projectile";
1267    let object = require_object(value, path)?;
1268    validate_members(
1269        object,
1270        path,
1271        &[
1272            "mass_kg",
1273            "diameter_m",
1274            "length_m",
1275            "drag_model",
1276            "ballistic_coefficient",
1277        ],
1278        &[
1279            "mass_kg",
1280            "diameter_m",
1281            "drag_model",
1282            "ballistic_coefficient",
1283        ],
1284    )?;
1285    validate_required_numbers(
1286        object,
1287        path,
1288        &["mass_kg", "diameter_m", "ballistic_coefficient"],
1289    )?;
1290    validate_optional_number(object, path, "length_m")?;
1291    validate_string_enum(
1292        required_value(object, "drag_model", path)?,
1293        "$.projectile.drag_model",
1294        &["G1", "G6", "G7", "G8"],
1295    )
1296}
1297
1298fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1299    let path = "$.rifle";
1300    let object = require_object(value, path)?;
1301    validate_members(
1302        object,
1303        path,
1304        &[
1305            "muzzle_velocity_mps",
1306            "sight_height_m",
1307            "muzzle_height_m",
1308            "twist_rate_m_per_turn",
1309            "twist_direction",
1310            "sight_offset_lateral_m",
1311        ],
1312        &["muzzle_velocity_mps"],
1313    )?;
1314    validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1315    validate_optional_numbers(
1316        object,
1317        path,
1318        &[
1319            "sight_height_m",
1320            "muzzle_height_m",
1321            "twist_rate_m_per_turn",
1322            "sight_offset_lateral_m",
1323        ],
1324    )?;
1325    if let Some(direction) = object.get("twist_direction") {
1326        validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1327    }
1328    Ok(())
1329}
1330
1331fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1332    let path = "$.shot";
1333    let object = require_object(value, path)?;
1334    validate_members(
1335        object,
1336        path,
1337        &[
1338            "max_range_m",
1339            "zero_distance_m",
1340            "muzzle_angle_rad",
1341            "aim_azimuth_rad",
1342            "shot_azimuth_rad",
1343            "shooting_angle_rad",
1344            "cant_angle_rad",
1345            "target_height_m",
1346            "ground_threshold_m",
1347            "zero_poi_up_m",
1348            "zero_poi_right_m",
1349            "drops_reference",
1350        ],
1351        &["max_range_m"],
1352    )?;
1353    validate_required_numbers(object, path, &["max_range_m"])?;
1354    validate_optional_number(object, path, "zero_distance_m")?;
1355    validate_optional_number(object, path, "muzzle_angle_rad")?;
1356    validate_optional_numbers(
1357        object,
1358        path,
1359        &[
1360            "aim_azimuth_rad",
1361            "shot_azimuth_rad",
1362            "shooting_angle_rad",
1363            "cant_angle_rad",
1364            "target_height_m",
1365            "ground_threshold_m",
1366            "zero_poi_up_m",
1367            "zero_poi_right_m",
1368        ],
1369    )?;
1370    // MBA-1403: string enum, not a number — same shape-validation pattern as
1371    // $.rifle.twist_direction.
1372    if let Some(reference) = object.get("drops_reference") {
1373        validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1374    }
1375    Ok(())
1376}
1377
1378fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1379    let path = "$.atmosphere";
1380    let object = require_object(value, path)?;
1381    validate_members(
1382        object,
1383        path,
1384        &[
1385            "altitude_m",
1386            "temperature_k",
1387            "pressure_pa",
1388            "relative_humidity",
1389            "latitude_rad",
1390        ],
1391        &[],
1392    )?;
1393    validate_optional_numbers(
1394        object,
1395        path,
1396        &[
1397            "altitude_m",
1398            "temperature_k",
1399            "pressure_pa",
1400            "relative_humidity",
1401            "latitude_rad",
1402        ],
1403    )
1404}
1405
1406fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1407    let path = "$.wind";
1408    let object = require_object(value, path)?;
1409    validate_members(
1410        object,
1411        path,
1412        &[
1413            "speed_mps",
1414            "direction_from_rad",
1415            "vertical_speed_mps",
1416            "segments",
1417            "wind_reference",
1418        ],
1419        &[],
1420    )?;
1421    for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1422        validate_optional_number(object, path, field)?;
1423    }
1424    // MBA-1368: wind_reference is a closed string enum (shooter | compass).
1425    if let Some(reference) = object.get("wind_reference") {
1426        validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1427    }
1428
1429    if let Some(segments) = object.get("segments") {
1430        let Some(segments) = segments.as_array() else {
1431            return Err(protocol_error(
1432                SolveErrorCodeV1::InvalidValue,
1433                "segments must be an array",
1434                "$.wind.segments",
1435            ));
1436        };
1437        for (index, segment) in segments.iter().enumerate() {
1438            let segment_path = format!("$.wind.segments[{index}]");
1439            let segment = require_object(segment, &segment_path)?;
1440            validate_members(
1441                segment,
1442                &segment_path,
1443                &[
1444                    "until_distance_m",
1445                    "speed_mps",
1446                    "direction_from_rad",
1447                    "vertical_speed_mps",
1448                ],
1449                &["until_distance_m", "speed_mps", "direction_from_rad"],
1450            )?;
1451            validate_required_numbers(
1452                segment,
1453                &segment_path,
1454                &["until_distance_m", "speed_mps", "direction_from_rad"],
1455            )?;
1456            validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1457        }
1458    }
1459    Ok(())
1460}
1461
1462fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1463    let path = "$.solver";
1464    let object = require_object(value, path)?;
1465    validate_members(object, path, &["method", "time_step_s"], &[])?;
1466    validate_optional_number(object, path, "time_step_s")?;
1467    if let Some(method) = object.get("method") {
1468        validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1469    }
1470    Ok(())
1471}
1472
1473fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1474    let path = "$.effects";
1475    let object = require_object(value, path)?;
1476    validate_members(
1477        object,
1478        path,
1479        &["magnus", "coriolis", "enhanced_spin_drift"],
1480        &[],
1481    )?;
1482    validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1483
1484    if object.get("magnus").and_then(Value::as_bool) == Some(true)
1485        && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1486    {
1487        return Err(protocol_error(
1488            SolveErrorCodeV1::ConflictingFields,
1489            "magnus and enhanced_spin_drift cannot both be enabled",
1490            "$.effects",
1491        ));
1492    }
1493
1494    Ok(())
1495}
1496
1497fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1498    let path = "$.sampling";
1499    let object = require_object(value, path)?;
1500    validate_members(object, path, &["interval_m"], &[])?;
1501    validate_optional_number(object, path, "interval_m")
1502}
1503
1504fn require_object<'a>(
1505    value: &'a Value,
1506    path: &str,
1507) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1508    value
1509        .as_object()
1510        .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1511}
1512
1513fn required_value<'a>(
1514    object: &'a Map<String, Value>,
1515    field: &str,
1516    parent_path: &str,
1517) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1518    object.get(field).ok_or_else(|| {
1519        protocol_error(
1520            SolveErrorCodeV1::MissingField,
1521            format!("missing required field `{field}`"),
1522            child_path(parent_path, field),
1523        )
1524    })
1525}
1526
1527fn validate_members(
1528    object: &Map<String, Value>,
1529    path: &str,
1530    allowed: &[&str],
1531    required: &[&str],
1532) -> Result<(), SolveErrorEnvelopeV1> {
1533    if let Some(field) = object
1534        .keys()
1535        .find(|field| !allowed.contains(&field.as_str()))
1536    {
1537        return Err(protocol_error(
1538            SolveErrorCodeV1::UnknownField,
1539            format!("unknown field `{field}`"),
1540            child_path(path, field),
1541        ));
1542    }
1543
1544    if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1545        return Err(protocol_error(
1546            SolveErrorCodeV1::MissingField,
1547            format!("missing required field `{field}`"),
1548            child_path(path, field),
1549        ));
1550    }
1551    Ok(())
1552}
1553
1554fn validate_string_enum(
1555    value: &Value,
1556    path: &str,
1557    allowed: &[&str],
1558) -> Result<(), SolveErrorEnvelopeV1> {
1559    let Some(value) = value.as_str() else {
1560        return Err(protocol_error(
1561            SolveErrorCodeV1::InvalidValue,
1562            "expected a string enum value",
1563            path,
1564        ));
1565    };
1566    if !allowed.contains(&value) {
1567        return Err(protocol_error(
1568            SolveErrorCodeV1::InvalidValue,
1569            format!(
1570                "invalid value `{value}`; expected one of {}",
1571                allowed.join(", ")
1572            ),
1573            path,
1574        ));
1575    }
1576    Ok(())
1577}
1578
1579fn validate_required_numbers(
1580    object: &Map<String, Value>,
1581    parent_path: &str,
1582    fields: &[&str],
1583) -> Result<(), SolveErrorEnvelopeV1> {
1584    for field in fields {
1585        let value = required_value(object, field, parent_path)?;
1586        validate_number(value, &child_path(parent_path, field))?;
1587    }
1588    Ok(())
1589}
1590
1591fn validate_optional_numbers(
1592    object: &Map<String, Value>,
1593    parent_path: &str,
1594    fields: &[&str],
1595) -> Result<(), SolveErrorEnvelopeV1> {
1596    for field in fields {
1597        validate_optional_number(object, parent_path, field)?;
1598    }
1599    Ok(())
1600}
1601
1602fn validate_optional_number(
1603    object: &Map<String, Value>,
1604    parent_path: &str,
1605    field: &str,
1606) -> Result<(), SolveErrorEnvelopeV1> {
1607    if let Some(value) = object.get(field) {
1608        validate_number(value, &child_path(parent_path, field))?;
1609    }
1610    Ok(())
1611}
1612
1613fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1614    if value.is_number() {
1615        Ok(())
1616    } else {
1617        Err(protocol_error(
1618            SolveErrorCodeV1::InvalidValue,
1619            "expected a number",
1620            path,
1621        ))
1622    }
1623}
1624
1625fn validate_optional_booleans(
1626    object: &Map<String, Value>,
1627    parent_path: &str,
1628    fields: &[&str],
1629) -> Result<(), SolveErrorEnvelopeV1> {
1630    for field in fields {
1631        if let Some(value) = object.get(*field) {
1632            if !value.is_boolean() {
1633                return Err(protocol_error(
1634                    SolveErrorCodeV1::InvalidValue,
1635                    "expected a boolean",
1636                    child_path(parent_path, field),
1637                ));
1638            }
1639        }
1640    }
1641    Ok(())
1642}
1643
1644fn child_path(parent: &str, field: &str) -> String {
1645    format!("{parent}.{field}")
1646}
1647
1648/// MBA-1413: physical bounds on the projectile fields.
1649#[cfg(test)]
1650mod request_range_tests {
1651    use super::*;
1652
1653    /// A complete, ordinary request. Every range test below starts from this and perturbs one
1654    /// field, so a bound that accidentally rejects real data fails loudly here first.
1655    fn valid_request_json() -> String {
1656        r#"{"schema_version":1,
1657            "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1658                          "drag_model":"G7","ballistic_coefficient":0.243},
1659            "rifle":{"muzzle_velocity_mps":823.0},
1660            "shot":{"max_range_m":1000.0},
1661            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1662            .to_string()
1663    }
1664
1665    fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1666        decode_solve_request_v1(json).expect_err("request should have been rejected")
1667    }
1668
1669    #[test]
1670    fn an_ordinary_request_still_decodes() {
1671        decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1672    }
1673
1674    /// The exact input cargo-fuzz found (MBA-1413). It declared a projectile mass of about
1675    /// 1.1e-66 kg — roughly 10^40 times lighter than a proton — and the engine accepted and
1676    /// solved it. The visible symptom was that the request did not survive a JSON round trip,
1677    /// because serde_json's float parser is a bit off re-reading its own output at that
1678    /// magnitude; the actual defect was accepting the number at all.
1679    #[test]
1680    fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1681        let reproducer = r#"{"schema_version":1,
1682            "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1683                          "drag_model":"G7","ballistic_coefficient":1.2e2},
1684            "rifle":{"muzzle_velocity_mps":823.0},
1685            "shot":{"max_range_m":100.0},
1686            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1687
1688        let envelope = decode_err(reproducer);
1689        assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1690        assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1691    }
1692
1693    /// An error envelope must itself round-trip, since that is the invariant the fuzz target
1694    /// asserts on the rejection branch.
1695    #[test]
1696    fn the_rejection_envelope_round_trips() {
1697        let envelope = decode_err(
1698            r#"{"schema_version":1,
1699                "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1700                              "drag_model":"G7","ballistic_coefficient":0.243},
1701                "rifle":{"muzzle_velocity_mps":823.0},
1702                "shot":{"max_range_m":100.0},
1703                "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1704        );
1705        let encoded = serde_json::to_string(&envelope).expect("serialize");
1706        let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1707        assert_eq!(decoded, envelope);
1708    }
1709
1710    #[test]
1711    fn each_bounded_field_reports_its_own_path() {
1712        for (field, bad_value, path) in [
1713            ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1714            ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1715            (
1716                "ballistic_coefficient",
1717                "1.0e6",
1718                "$.projectile.ballistic_coefficient",
1719            ),
1720            ("length_m", "1.0e-9", "$.projectile.length_m"),
1721        ] {
1722            let json = valid_request_json().replace(
1723                &format!("\"{field}\":{}", default_for(field)),
1724                &format!("\"{field}\":{bad_value}"),
1725            );
1726            let envelope = decode_err(&json);
1727            assert_eq!(
1728                envelope.error.path(),
1729                Some(path),
1730                "wrong path for {field}"
1731            );
1732            assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1733        }
1734    }
1735
1736    fn default_for(field: &str) -> &'static str {
1737        match field {
1738            "mass_kg" => "0.01134",
1739            "diameter_m" => "0.00782",
1740            "ballistic_coefficient" => "0.243",
1741            "length_m" => "0.031",
1742            other => panic!("no default recorded for {other}"),
1743        }
1744    }
1745
1746    /// Muzzle velocity is deliberately unbounded: the MCP server documents and tests a split
1747    /// where a structurally valid request the engine cannot solve returns a tool error rather
1748    /// than a protocol error, using an absurd muzzle velocity as its example.
1749    #[test]
1750    fn muzzle_velocity_is_deliberately_left_unbounded() {
1751        let json = valid_request_json().replace("823.0", "1.0e308");
1752        decode_solve_request_v1(&json)
1753            .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1754    }
1755}