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