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