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    /// Optional offline correction sources applied before the solve. Absent (the
142    /// historical shape) is byte-identical to every request from before the block
143    /// existed; present it is echoed at [`ResolvedSolveRequestV1::corrections`] so a
144    /// resolved request remains a complete description of the solve (the perturbation
145    /// kernel re-solves from it).
146    #[serde(
147        default,
148        skip_serializing_if = "Option::is_none",
149        deserialize_with = "deserialize_present"
150    )]
151    pub corrections: Option<CorrectionsV1>,
152}
153
154/// Offline correction sources for a solve (additive-optional).
155///
156/// `bc5d_table_path` names a caliber-specific BC5D correction table on the LOCAL
157/// filesystem (`bc5d_<caliber>.bin`, the exact dual-CRC binary format the CLI's
158/// `--bc-table-dir` consumes; mobile apps download tables themselves and hand the
159/// engine a path). The service CRC-verifies the file, generates the same
160/// velocity-keyed BC segment ladder the CLI does for this load, applies the table's
161/// muzzle correction to the scalar BC as the interior-gap fallback, and solves BOTH
162/// the zero search and the trajectory with that schedule. A table that carries no
163/// correction for the load (every sampled cell ~= 1.0) leaves the request's constant
164/// BC in place. On builds without filesystem access (wasm32) supplying the field is
165/// a structured `invalid_value` error, never a silent no-op.
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct CorrectionsV1 {
169    /// Path to a BC5D `.bin` correction table on the local filesystem.
170    #[serde(
171        default,
172        skip_serializing_if = "Option::is_none",
173        deserialize_with = "deserialize_present"
174    )]
175    pub bc5d_table_path: Option<String>,
176}
177
178/// Ask for a reticle hold point alongside the trajectory (MBA-1361).
179///
180/// `description` is the shared [`crate::reticle::ReticleDescription`] schema — the very same
181/// JSON `ballistics reticle generate -o json` emits — so a service and a CLI user exchange
182/// one representation. It is deliberately permissive about extra keys inside the
183/// description (front ends carry render metadata there); the envelope around it stays
184/// strict.
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ReticleRequestV1 {
188    /// Range at which the hold is evaluated, meters. Must be inside the sampled trajectory.
189    pub range_m: f64,
190    /// The optic's magnification in use. Must be finite and greater than zero on both
191    /// focal planes.
192    pub magnification: f64,
193    pub description: crate::reticle::ReticleDescription,
194}
195
196/// The reticle hold point for a solved trajectory (MBA-1361).
197///
198/// Angles are milliradians from the optical center: `down_mil` positive BELOW center,
199/// `right_mil` positive to the shooter's RIGHT.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ReticleHoldV1 {
203    /// Echo of the requested evaluation range, meters.
204    pub range_m: f64,
205    /// Echo of the requested magnification.
206    pub magnification: f64,
207    pub down_mil: f64,
208    pub right_mil: f64,
209    /// The subtension scale applied to the marks (`reference_magnification / magnification`
210    /// for SFP, exactly `1.0` for FFP).
211    pub mark_scale: f64,
212    /// Index into the request's `description.marks` of the nearest mark, in TRUE angular
213    /// space.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub nearest_mark_index: Option<usize>,
216    /// That mark's label, when it carries one.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub nearest_mark_label: Option<String>,
219    pub nearest_mark_distance_mil: f64,
220    /// True when the hold has run outside the marked area (see
221    /// [`crate::reticle::ReticleHold::off_reticle`] for the exact rule).
222    pub off_reticle: bool,
223}
224
225/// Projectile inputs supported by solve-json v1.
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct ProjectileV1 {
229    pub mass_kg: f64,
230    pub diameter_m: f64,
231    #[serde(
232        default,
233        skip_serializing_if = "Option::is_none",
234        deserialize_with = "deserialize_present"
235    )]
236    pub length_m: Option<f64>,
237    pub drag_model: DragModelV1,
238    pub ballistic_coefficient: f64,
239}
240
241/// Built-in reference-projectile drag models supported by solve-json v1.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243pub enum DragModelV1 {
244    #[serde(rename = "G1")]
245    G1,
246    #[serde(rename = "G6")]
247    G6,
248    #[serde(rename = "G7")]
249    G7,
250    #[serde(rename = "G8")]
251    G8,
252    #[serde(rename = "G2")]
253    G2,
254    #[serde(rename = "G5")]
255    G5,
256    #[serde(rename = "GI")]
257    GI,
258    #[serde(rename = "GS")]
259    GS,
260    #[serde(rename = "RA4")]
261    RA4,
262}
263
264/// Rifle and sight geometry.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct RifleV1 {
268    pub muzzle_velocity_mps: f64,
269    #[serde(
270        default,
271        skip_serializing_if = "Option::is_none",
272        deserialize_with = "deserialize_present"
273    )]
274    pub sight_height_m: Option<f64>,
275    #[serde(
276        default,
277        skip_serializing_if = "Option::is_none",
278        deserialize_with = "deserialize_present"
279    )]
280    pub muzzle_height_m: Option<f64>,
281    /// Rifling travel per full turn, meters.
282    ///
283    /// Optional, which reads like "leave the twist out of it" — it is not. `None` defaults to
284    /// 0.3048 m, exactly 1:12 inches, raises a `default_applied` assumption, and materializes
285    /// that value at [`ResolvedRifleV1::twist_rate_m_per_turn`], after which nothing
286    /// downstream can tell an assumed barrel from a stated one.
287    ///
288    /// Omitting it is never free. `summary.stability_factor` is a function of the twist and is
289    /// computed on EVERY solve, so an omitted field reports the Sg of a 1:12 barrel — 1.665 on
290    /// a .308 175 gr against 3.746 at a stated 1:8, with no effect enabled — and raises
291    /// `stability_factor_assumed_twist_rate`. What the omission does not move, absent a
292    /// spin-driven effect, is the trajectory: `drop_m` and `windage_m` are bit-identical for
293    /// every twist rate.
294    ///
295    /// `effects.magnus`, `effects.enhanced_spin_drift` and `effects.aerodynamic_jump` each
296    /// read it as well, and each raises its own assumed-barrel warning when it is absent
297    /// (MBA-1484). The codes are distinct so that each names its own consumer; they are not
298    /// mutually exclusive, and one omitted twist can raise three of them at once.
299    #[serde(
300        default,
301        skip_serializing_if = "Option::is_none",
302        deserialize_with = "deserialize_present"
303    )]
304    pub twist_rate_m_per_turn: Option<f64>,
305    /// Rifling hand, viewed from the breech toward the muzzle. `None` defaults to `Right`
306    /// with a `default_applied` assumption. It flips the SIGN of the gyroscopic spin drift
307    /// rather than its magnitude, so an assumed hand on a left-twist barrel puts the drift on
308    /// the wrong side of the target.
309    #[serde(
310        default,
311        skip_serializing_if = "Option::is_none",
312        deserialize_with = "deserialize_present"
313    )]
314    pub twist_direction: Option<TwistDirectionV1>,
315    /// Lateral offset between the sight axis and the bore axis, meters (MBA-1396,
316    /// offset-mounted optics): positive = the sight sits RIGHT of the bore. The bore
317    /// starts that far left of the line of sight, and a solved zero adds the windage
318    /// convergence (`offset / zero_distance`) so the trajectory crosses the LOS laterally
319    /// at the zero range. Omitted (the default) is byte-identical to pre-MBA-1396
320    /// behavior. Echoed at [`ResolvedRifleV1::sight_offset_lateral_m`] when supplied
321    /// (0.33.0 decision-support Task 1).
322    #[serde(
323        default,
324        skip_serializing_if = "Option::is_none",
325        deserialize_with = "deserialize_present"
326    )]
327    pub sight_offset_lateral_m: Option<f64>,
328}
329
330/// Direction of rifling twist as viewed from the breech toward the muzzle.
331#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum TwistDirectionV1 {
334    Left,
335    #[default]
336    Right,
337}
338
339/// Shot geometry and termination range.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341#[serde(deny_unknown_fields)]
342pub struct ShotV1 {
343    pub max_range_m: f64,
344    #[serde(
345        default,
346        skip_serializing_if = "Option::is_none",
347        deserialize_with = "deserialize_present"
348    )]
349    pub zero_distance_m: Option<f64>,
350    #[serde(
351        default,
352        skip_serializing_if = "Option::is_none",
353        deserialize_with = "deserialize_present"
354    )]
355    pub muzzle_angle_rad: Option<f64>,
356    #[serde(
357        default,
358        skip_serializing_if = "Option::is_none",
359        deserialize_with = "deserialize_present"
360    )]
361    pub aim_azimuth_rad: Option<f64>,
362    #[serde(
363        default,
364        skip_serializing_if = "Option::is_none",
365        deserialize_with = "deserialize_present"
366    )]
367    pub shot_azimuth_rad: Option<f64>,
368    #[serde(
369        default,
370        skip_serializing_if = "Option::is_none",
371        deserialize_with = "deserialize_present"
372    )]
373    pub shooting_angle_rad: Option<f64>,
374    #[serde(
375        default,
376        skip_serializing_if = "Option::is_none",
377        deserialize_with = "deserialize_present"
378    )]
379    pub cant_angle_rad: Option<f64>,
380    /// World-vertical height above the local ground datum that the elevation search converges
381    /// the bullet to at `zero_distance_m`.
382    ///
383    /// Omitting it while the search runs (`zero_distance_m` present, `muzzle_angle_rad` absent)
384    /// defaults it to the LINE OF SIGHT of a level rifle — [`RifleV1::muzzle_height_m`] plus
385    /// [`RifleV1::sight_height_m`] — so a level shot's solved trajectory crosses the line of
386    /// sight at the zero distance, which is what a stated zero means and what the CLI and C ABI
387    /// zero surfaces already did (MBA-1537). A supplied value always wins, `0.0` included: that
388    /// is how a caller asks for a zero against the ground datum instead. The default raises a
389    /// `default_applied` assumption naming the height it used.
390    ///
391    /// The default is a LEVEL line of sight in a world-vertical field, so an inclined zero
392    /// still needs this stated — see `docs/SOLVE_JSON_V1.md` for the projection to supply.
393    ///
394    /// With no elevation search this still defaults to `0`, where it feeds only the
395    /// `drops_reference: "target"` sampler datum — which references `max_range_m`, not
396    /// `zero_distance_m`.
397    #[serde(
398        default,
399        skip_serializing_if = "Option::is_none",
400        deserialize_with = "deserialize_present"
401    )]
402    pub target_height_m: Option<f64>,
403    #[serde(
404        default,
405        skip_serializing_if = "Option::is_none",
406        deserialize_with = "deserialize_present"
407    )]
408    pub ground_threshold_m: Option<f64>,
409    /// Deliberate vertical POI offset AT the zero range, meters (MBA-1359, Kestrel "zero
410    /// height"): positive = the rifle is deliberately zeroed to impact HIGH by this much at
411    /// `zero_distance_m`. Meaningful only when `zero_distance_m` is supplied. Omitted (the
412    /// default) is byte-identical to pre-MBA-1359 behavior. Echoed at
413    /// [`ResolvedShotV1::zero_poi_up_m`] when supplied (0.33.0 decision-support Task 1).
414    #[serde(
415        default,
416        skip_serializing_if = "Option::is_none",
417        deserialize_with = "deserialize_present"
418    )]
419    pub zero_poi_up_m: Option<f64>,
420    /// Deliberate horizontal POI offset AT the zero range, meters (MBA-1359, Kestrel "zero
421    /// offset"): positive = impacts RIGHT by this much at `zero_distance_m`. Same contract
422    /// as `zero_poi_up_m`.
423    #[serde(
424        default,
425        skip_serializing_if = "Option::is_none",
426        deserialize_with = "deserialize_present"
427    )]
428    pub zero_poi_right_m: Option<f64>,
429    /// Which plane sampled `drop_m` values are referenced to (MBA-1403). `"los"` (the
430    /// default when omitted) keeps the historical LOS-perpendicular drop byte-identically;
431    /// `"target"` reports drop as vertical in the target plane — the LOS-perpendicular
432    /// drop scaled by `1 / cos(shooting_angle_rad)` (JBM's "target plane" reference).
433    /// Output-mode toggle only: the solved trajectory, `windage_m`, the summary block and
434    /// zeroing semantics are unchanged. Echoed at [`ResolvedShotV1::drops_reference`]
435    /// when supplied (0.33.0 decision-support Task 1).
436    #[serde(
437        default,
438        skip_serializing_if = "Option::is_none",
439        deserialize_with = "deserialize_present"
440    )]
441    pub drops_reference: Option<DropsReferenceV1>,
442}
443
444/// Wire values for [`ShotV1::drops_reference`] (MBA-1403).
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case")]
447pub enum DropsReferenceV1 {
448    /// Drop measured perpendicular to the line of sight (the historical default).
449    #[default]
450    Los,
451    /// Drop measured vertically in the target plane: LOS-perpendicular drop scaled by
452    /// `1 / cos(shooting_angle_rad)`.
453    Target,
454}
455
456/// Atmospheric station conditions.
457///
458/// Every field is `Option` behind a presence-preserving deserializer, so omission is stated
459/// directly on the wire and a SUPPLIED value is always authoritative — solve-json builds
460/// through [`crate::cli_api::TrajectorySolver::new_with_resolved_station_atmosphere`] and
461/// never re-interprets it (MBA-1397). In particular `temperature_k` of exactly 288.15 K or
462/// `pressure_pa` of exactly 101325 Pa mean those readings, not "omitted".
463///
464/// The C ABI (`crate::ffi`, behind the `ffi` feature) deliberately does NOT behave this way:
465/// `FFIAtmosphericConditions` is a `repr(C)` struct of plain doubles with no
466/// presence channel, so it keeps the legacy default-sentinel heuristic in which those same
467/// values at a nonzero altitude mean "resolve the ICAO standard here". The two surfaces
468/// therefore disagree on identical nominal inputs (~0.29 MOA on a 300 m zero at 2000 m); see
469/// that module's documentation for the full account.
470#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct AtmosphereV1 {
473    #[serde(
474        default,
475        skip_serializing_if = "Option::is_none",
476        deserialize_with = "deserialize_present"
477    )]
478    pub altitude_m: Option<f64>,
479    /// Authoritative station temperature, or `None` to resolve ICAO standard temperature at
480    /// `altitude_m`.
481    #[serde(
482        default,
483        skip_serializing_if = "Option::is_none",
484        deserialize_with = "deserialize_present"
485    )]
486    pub temperature_k: Option<f64>,
487    /// Authoritative station pressure, or `None` to resolve ICAO standard pressure at
488    /// `altitude_m`. Interpreted per `pressure_reference` (MBA-1397): `absolute` (the
489    /// default) means this value already IS station pressure; `qnh` means it is a
490    /// sea-level-corrected altimeter setting that must be reduced to station pressure at
491    /// `altitude_m` before use.
492    #[serde(
493        default,
494        skip_serializing_if = "Option::is_none",
495        deserialize_with = "deserialize_present"
496    )]
497    pub pressure_pa: Option<f64>,
498    /// Whether `pressure_pa` is absolute station pressure or a sea-level-corrected altimeter
499    /// setting (QNH, MBA-1397). `None` (the omitted-field default, and every request from
500    /// before this field existed) means [`PressureReferenceV1::Absolute`] — byte-identical to
501    /// pre-MBA-1397 behavior. Has no effect when `pressure_pa` is omitted: an omitted pressure
502    /// resolves to the ICAO standard station pressure either way.
503    #[serde(
504        default,
505        skip_serializing_if = "Option::is_none",
506        deserialize_with = "deserialize_present"
507    )]
508    pub pressure_reference: Option<PressureReferenceV1>,
509    #[serde(
510        default,
511        skip_serializing_if = "Option::is_none",
512        deserialize_with = "deserialize_present"
513    )]
514    pub relative_humidity: Option<f64>,
515    #[serde(
516        default,
517        skip_serializing_if = "Option::is_none",
518        deserialize_with = "deserialize_present"
519    )]
520    pub latitude_rad: Option<f64>,
521}
522
523/// Whether an `AtmosphereV1.pressure_pa` value is absolute station pressure or a sea-level-
524/// corrected altimeter setting (QNH) that must be reduced to station pressure before use
525/// (MBA-1397). Mirrors [`crate::atmosphere::PressureReferenceMode`].
526#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum PressureReferenceV1 {
529    #[default]
530    Absolute,
531    Qnh,
532}
533
534/// Constant or downrange-segmented wind.
535///
536/// Omit all fields for still air. A constant wind supplies both `speed_mps` and
537/// `direction_from_rad`. Segmented wind supplies `segments` and no constant-wind fields.
538#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
539#[serde(deny_unknown_fields)]
540pub struct WindV1 {
541    #[serde(
542        default,
543        skip_serializing_if = "Option::is_none",
544        deserialize_with = "deserialize_present"
545    )]
546    pub speed_mps: Option<f64>,
547    #[serde(
548        default,
549        skip_serializing_if = "Option::is_none",
550        deserialize_with = "deserialize_present"
551    )]
552    pub direction_from_rad: Option<f64>,
553    #[serde(
554        default,
555        skip_serializing_if = "Option::is_none",
556        deserialize_with = "deserialize_present"
557    )]
558    pub vertical_speed_mps: Option<f64>,
559    #[serde(
560        default,
561        skip_serializing_if = "Option::is_none",
562        deserialize_with = "deserialize_present"
563    )]
564    pub segments: Option<Vec<WindSegmentV1>>,
565    /// Which frame every wind direction in this request is entered in (MBA-1368).
566    /// Omitted (or `"shooter"`) = shooter-relative wind-FROM radians, byte-identical to
567    /// pre-1368 behavior. `"compass"` = earth-fixed bearings (0 = north) — the constant
568    /// `direction_from_rad` AND every segment's — derived shooter-relative at resolve
569    /// time as `bearing - shot.shot_azimuth_rad` (normalized to [0, 2π)); the RESOLVED
570    /// wind echo therefore reports the converted shooter-relative direction (the QNH
571    /// fold-into-resolved-value precedent). Compass mode requires an explicit
572    /// `shot.shot_azimuth_rad` (a hard error otherwise, never a silent
573    /// treat-as-shooter-relative). The mode itself is echoed at
574    /// [`ResolvedConstantWindV1::wind_reference`] /
575    /// [`ResolvedSegmentedWindV1::wind_reference`] when supplied (0.33.0
576    /// decision-support Task 1).
577    #[serde(
578        default,
579        skip_serializing_if = "Option::is_none",
580        deserialize_with = "deserialize_present"
581    )]
582    pub wind_reference: Option<WindReferenceV1>,
583}
584
585/// Wire values for [`WindV1::wind_reference`] (MBA-1368).
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
587#[serde(rename_all = "snake_case")]
588pub enum WindReferenceV1 {
589    /// Shooter-relative wind-FROM directions (the historical default).
590    #[default]
591    Shooter,
592    /// Earth-fixed compass bearings, re-referenced against `shot.shot_azimuth_rad`.
593    Compass,
594}
595
596/// One wind segment, active through `until_distance_m`.
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598#[serde(deny_unknown_fields)]
599pub struct WindSegmentV1 {
600    pub until_distance_m: f64,
601    pub speed_mps: f64,
602    pub direction_from_rad: f64,
603    #[serde(
604        default,
605        skip_serializing_if = "Option::is_none",
606        deserialize_with = "deserialize_present"
607    )]
608    pub vertical_speed_mps: Option<f64>,
609}
610
611/// Numerical integration configuration.
612#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub struct SolverV1 {
615    #[serde(
616        default,
617        skip_serializing_if = "Option::is_none",
618        deserialize_with = "deserialize_present"
619    )]
620    pub method: Option<SolverMethodV1>,
621    /// Fixed step for RK4 and Euler. RK45 owns its adaptive step size.
622    #[serde(
623        default,
624        skip_serializing_if = "Option::is_none",
625        deserialize_with = "deserialize_present"
626    )]
627    pub time_step_s: Option<f64>,
628}
629
630/// Integration algorithms exposed by solve-json v1.
631#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
632#[serde(rename_all = "snake_case")]
633pub enum SolverMethodV1 {
634    Euler,
635    Rk4,
636    #[default]
637    Rk45,
638}
639
640/// Optional physical effects supported by solve-json v1.
641#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
642#[serde(deny_unknown_fields)]
643pub struct EffectsV1 {
644    /// Magnus side force and the yaw of repose it drives. EXPERIMENTAL: enabling it raises an
645    /// `experimental_effect` warning.
646    ///
647    /// Driven by the spin the rifling imparts, so it reads `rifle.twist_rate_m_per_turn`.
648    /// Omitting that field does not disable it — the 1:12 default is substituted and the force
649    /// is computed for that barrel — so enabling this without a stated twist raises
650    /// `spin_effect_assumed_twist_rate` (MBA-1484).
651    ///
652    /// Mutually exclusive with [`EffectsV1::enhanced_spin_drift`]: the legacy solver silently
653    /// suppresses Magnus in that combination, and the decoder reports `conflicting_fields`
654    /// rather than let the resolved request misstate which physics ran.
655    #[serde(
656        default,
657        skip_serializing_if = "Option::is_none",
658        deserialize_with = "deserialize_present"
659    )]
660    pub magnus: Option<bool>,
661    /// Earth-rotation (Coriolis and Eötvös) accelerations. Requires
662    /// `atmosphere.latitude_rad`, which is an `invalid_value` error when absent rather than a
663    /// default. Reads no barrel geometry.
664    #[serde(
665        default,
666        skip_serializing_if = "Option::is_none",
667        deserialize_with = "deserialize_present"
668    )]
669    pub coriolis: Option<bool>,
670    /// Gyroscopic spin drift via the empirical Litz model. EXPERIMENTAL: enabling it raises an
671    /// `experimental_effect` warning.
672    ///
673    /// The drift scales with the muzzle gyroscopic stability factor, which is a function of the
674    /// twist, so it reads `rifle.twist_rate_m_per_turn` (and `rifle.twist_direction`, which
675    /// sets its sign). Omitting the rate does not disable the effect and does not zero it: the
676    /// 1:12 default is substituted and the drift is computed for that barrel, which is why
677    /// enabling this without a stated twist raises `spin_effect_assumed_twist_rate`
678    /// (MBA-1484). Measured on a .308 175 gr at 800 m, `summary.spin_drift_m` is 0.183 m with
679    /// the twist omitted — bit-identical to a stated 1:12 — against 0.316 m at a stated 1:8.
680    ///
681    /// Mutually exclusive with [`EffectsV1::magnus`]; see that field.
682    #[serde(
683        default,
684        skip_serializing_if = "Option::is_none",
685        deserialize_with = "deserialize_present"
686    )]
687    pub enhanced_spin_drift: Option<bool>,
688    /// Altitude-dependent wind shear model (0.36.0). `None` (the omitted-field default, and
689    /// every request from before this field existed) means no shear at all — byte-identical
690    /// to pre-0.36.0 behavior, exactly as an explicit `"none"` solves. The model is echoed at
691    /// [`ResolvedEffectsV1::wind_shear_model`] whenever the raw request supplies one,
692    /// including an explicit `"none"`.
693    ///
694    /// An unrecognized model name is an `invalid_value` error at
695    /// `$.effects.wind_shear_model`, never a silent fall back to no shear: a typo'd model
696    /// must not quietly produce unsheared numbers that look like sheared ones.
697    #[serde(
698        default,
699        skip_serializing_if = "Option::is_none",
700        deserialize_with = "deserialize_present"
701    )]
702    pub wind_shear_model: Option<WindShearModelV1>,
703    /// Crosswind aerodynamic (gyroscopic) jump, as a muzzle launch-angle perturbation
704    /// (MBA-959). `None` — the omitted-field default, and every request from before this
705    /// field existed — means no jump, byte-identical to earlier behavior.
706    ///
707    /// EXPERIMENTAL, and the bridge says so: enabling it raises an `experimental_effect`
708    /// warning, the same as `magnus` and `enhanced_spin_drift`. The model is Bryan Litz's
709    /// regression `Y = 0.01*Sg - 0.0024*L + 0.032` MOA per mph of crosswind, a fit that is
710    /// best near Sg ~ 1.75 rather than a first-principles derivation.
711    ///
712    /// Two consequences a caller has to know, because neither is visible in the numbers:
713    /// the jump is VERTICAL and is computed from the crosswind AT THE MUZZLE, so a shot with
714    /// no crosswind there is unaffected however much wind it meets downrange; and its
715    /// magnitude is a function of stability, so it reads `rifle.twist_rate_m_per_turn` and
716    /// `projectile.length_m`. Omitting either does not disable the correction — it computes
717    /// one from an assumed barrel, which is why enabling this flag without them raises
718    /// `aerodynamic_jump_assumed_geometry`.
719    #[serde(
720        default,
721        skip_serializing_if = "Option::is_none",
722        deserialize_with = "deserialize_present"
723    )]
724    pub aerodynamic_jump: Option<bool>,
725}
726
727/// Wire values for [`EffectsV1::wind_shear_model`] (0.36.0).
728///
729/// These are the engine's own model names (see [`crate::wind_shear`]), so a v1 request and the
730/// CLI's `--wind-shear-model` name the same physics with the same spelling.
731/// [`crate::wind_shear::WindShearModel::CustomLayers`] is deliberately NOT exposed: it needs a
732/// caller-supplied layer table that this contract has no field for, and would silently degrade
733/// to the surface wind.
734#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
735#[serde(rename_all = "snake_case")]
736pub enum WindShearModelV1 {
737    /// No altitude dependence: the request's wind is used at every height (the historical
738    /// default, and what an omitted field means).
739    #[default]
740    None,
741    /// Logarithmic boundary-layer profile, `ln(z / z0) / ln(z_ref / z0)`.
742    Logarithmic,
743    /// 1/7 power-law boundary-layer profile, `(z / z_ref)^(1/7)`.
744    PowerLaw,
745    /// Ekman spiral. Accepted for parity with the CLI and the engine's model names, but this
746    /// solve path's boundary-layer evaluator has no near-ground closed form for it, so it
747    /// leaves the wind at the operative value. Enabling it emits a
748    /// `wind_shear_model_not_modeled` warning rather than passing silently.
749    #[serde(alias = "ekman")]
750    EkmanSpiral,
751}
752
753impl WindShearModelV1 {
754    /// The canonical lower-snake name the engine's `wind_shear` / `cli_api` code understands.
755    ///
756    /// Aliases resolve to their canonical spelling here, which is also what the resolved
757    /// request echoes: `"ekman"` in, `"ekman_spiral"` out.
758    pub fn as_engine_str(self) -> &'static str {
759        match self {
760            WindShearModelV1::None => "none",
761            WindShearModelV1::Logarithmic => "logarithmic",
762            WindShearModelV1::PowerLaw => "power_law",
763            WindShearModelV1::EkmanSpiral => "ekman_spiral",
764        }
765    }
766
767    /// Whether this model asks the solver for altitude-dependent wind at all.
768    ///
769    /// `"none"` must map to `enable_wind_shear: false` rather than to an enabled solver
770    /// holding the string `"none"`: [`crate::cli_api`]'s shear branch maps any unrecognized
771    /// model name — `"none"` included — to the power law, so an enabled-but-`"none"` solve
772    /// would quietly run power-law shear.
773    pub fn is_enabled(self) -> bool {
774        !matches!(self, WindShearModelV1::None)
775    }
776}
777
778/// Regular downrange output sampling configuration.
779#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
780#[serde(deny_unknown_fields)]
781pub struct SamplingV1 {
782    #[serde(
783        default,
784        skip_serializing_if = "Option::is_none",
785        deserialize_with = "deserialize_present"
786    )]
787    pub interval_m: Option<f64>,
788}
789
790/// A solve request after the service has applied every documented v1 default.
791///
792/// This is intentionally distinct from [`SolveRequestV1`]. Request DTOs retain whether a
793/// defaulted field was omitted, while resolved DTOs require a concrete value for every default
794/// that affected the solve. Semantically optional inputs remain optional.
795#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
796#[serde(deny_unknown_fields)]
797pub struct ResolvedSolveRequestV1 {
798    pub schema_version: SchemaVersionV1,
799    pub projectile: ResolvedProjectileV1,
800    pub rifle: ResolvedRifleV1,
801    pub shot: ResolvedShotV1,
802    pub atmosphere: ResolvedAtmosphereV1,
803    pub wind: ResolvedWindV1,
804    pub solver: ResolvedSolverV1,
805    pub effects: ResolvedEffectsV1,
806    pub sampling: ResolvedSamplingV1,
807    /// Echo of the reticle hold-point request (MBA-1361), when one was supplied. Present
808    /// only when the raw request supplied it — completes the resolved request as a
809    /// full description of the solve.
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub reticle: Option<ReticleRequestV1>,
812    /// Echo of the offline-corrections block, when one was supplied. Present only when
813    /// the raw request supplied it, so a resolved request re-solve (perturbation,
814    /// round-trip) re-applies the same table rather than silently dropping the
815    /// correction. Re-applying is idempotent: segments are always generated from the
816    /// request's PUBLISHED `ballistic_coefficient`, never from an already-corrected one.
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    pub corrections: Option<CorrectionsV1>,
819}
820
821/// Resolved projectile inputs.
822#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
823#[serde(deny_unknown_fields)]
824pub struct ResolvedProjectileV1 {
825    pub mass_kg: f64,
826    pub diameter_m: f64,
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub length_m: Option<f64>,
829    pub drag_model: DragModelV1,
830    pub ballistic_coefficient: f64,
831}
832
833/// Resolved rifle and sight geometry.
834#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
835#[serde(deny_unknown_fields)]
836pub struct ResolvedRifleV1 {
837    pub muzzle_velocity_mps: f64,
838    pub sight_height_m: f64,
839    pub muzzle_height_m: f64,
840    pub twist_rate_m_per_turn: f64,
841    pub twist_direction: TwistDirectionV1,
842    /// Lateral sight offset for offset-mounted optics. Echoed so the resolved request
843    /// is a complete description of the solve (required for counterfactual re-solve).
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub sight_offset_lateral_m: Option<f64>,
846}
847
848/// Resolved shot geometry.
849///
850/// `zero_distance_m` records caller zeroing intent, while `muzzle_angle_rad` is always the
851/// effective angle used by the engine. A zero-distance solve therefore populates both fields.
852/// A raw request may also supply both together (0.33.0 decision-support Task 2's
853/// `From<&ResolvedSolveRequestV1> for SolveRequestV1` always does, to round-trip a resolved
854/// request): `muzzle_angle_rad` then wins outright for elevation and no elevation search
855/// runs, but `zero_distance_m` is not otherwise inert -- it still drives the
856/// windage-convergence bias (`sight_offset_lateral_m` / `zero_poi_right_m`), still gates
857/// `equivalent_horizontal_range_m`, and still widens the required wind coverage. A
858/// `zero_distance_elevation_not_resolved` warning names this whenever both are supplied.
859#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
860#[serde(deny_unknown_fields)]
861pub struct ResolvedShotV1 {
862    pub max_range_m: f64,
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub zero_distance_m: Option<f64>,
865    pub muzzle_angle_rad: f64,
866    pub aim_azimuth_rad: f64,
867    pub shot_azimuth_rad: f64,
868    pub shooting_angle_rad: f64,
869    pub cant_angle_rad: f64,
870    pub target_height_m: f64,
871    pub ground_threshold_m: f64,
872    /// Echo of the requested deliberate vertical POI offset at the zero range, meters
873    /// (MBA-1359). Present only when the raw request supplied it.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub zero_poi_up_m: Option<f64>,
876    /// Echo of the requested deliberate horizontal POI offset at the zero range, meters
877    /// (MBA-1359). Present only when the raw request supplied it.
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    pub zero_poi_right_m: Option<f64>,
880    /// Echo of the requested drops-reference plane (MBA-1403). Present only when the raw
881    /// request supplied it.
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub drops_reference: Option<DropsReferenceV1>,
884}
885
886/// Resolved station conditions.
887#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct ResolvedAtmosphereV1 {
890    pub altitude_m: f64,
891    pub temperature_k: f64,
892    pub pressure_pa: f64,
893    pub relative_humidity: f64,
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub latitude_rad: Option<f64>,
896    /// Echo of the requested pressure reference mode (MBA-1397). Present only when the raw
897    /// request supplied it.
898    #[serde(default, skip_serializing_if = "Option::is_none")]
899    pub pressure_reference: Option<PressureReferenceV1>,
900}
901
902/// Resolved constant or segmented wind.
903///
904/// The untagged representation retains the request's object shape while making the selected wind
905/// variant explicit in Rust. Still air is a constant wind with three zero values.
906#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
907#[serde(untagged)]
908pub enum ResolvedWindV1 {
909    Constant(ResolvedConstantWindV1),
910    Segmented(ResolvedSegmentedWindV1),
911}
912
913/// Resolved still-air or constant-wind values.
914#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
915#[serde(deny_unknown_fields)]
916pub struct ResolvedConstantWindV1 {
917    pub speed_mps: f64,
918    pub direction_from_rad: f64,
919    pub vertical_speed_mps: f64,
920    /// Echo of the requested wind-direction reference frame (MBA-1368). Present only when
921    /// the raw request supplied it. `direction_from_rad` above is always already converted
922    /// to shooter-relative, regardless of this value.
923    #[serde(default, skip_serializing_if = "Option::is_none")]
924    pub wind_reference: Option<WindReferenceV1>,
925}
926
927/// Resolved downrange wind segments.
928#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
929#[serde(deny_unknown_fields)]
930pub struct ResolvedSegmentedWindV1 {
931    pub segments: Vec<ResolvedWindSegmentV1>,
932    /// Echo of the requested wind-direction reference frame (MBA-1368). Present only when
933    /// the raw request supplied it. Each segment's `direction_from_rad` above is always
934    /// already converted to shooter-relative, regardless of this value.
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub wind_reference: Option<WindReferenceV1>,
937}
938
939/// One resolved wind segment.
940#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
941#[serde(deny_unknown_fields)]
942pub struct ResolvedWindSegmentV1 {
943    pub until_distance_m: f64,
944    pub speed_mps: f64,
945    pub direction_from_rad: f64,
946    pub vertical_speed_mps: f64,
947}
948
949/// Resolved numerical integration configuration.
950#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
951#[serde(deny_unknown_fields)]
952pub struct ResolvedSolverV1 {
953    pub method: SolverMethodV1,
954    pub time_step_s: f64,
955}
956
957/// Resolved physical-effect switches.
958#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
959#[serde(deny_unknown_fields)]
960pub struct ResolvedEffectsV1 {
961    pub magnus: bool,
962    pub coriolis: bool,
963    pub enhanced_spin_drift: bool,
964    /// Echo of the wind shear model that was actually applied (0.36.0), in its canonical
965    /// spelling. Present only when the raw request supplied one — an omitted field leaves
966    /// this absent, so responses to pre-0.36.0 requests serialize byte-identically.
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub wind_shear_model: Option<WindShearModelV1>,
969    /// Echo of the aerodynamic-jump switch (MBA-959). Present only when the raw request
970    /// supplied one, so a response to a request written before this field existed serializes
971    /// byte-identically.
972    ///
973    /// `Option<bool>` rather than the bare `bool` its three older siblings use, and
974    /// deliberately: those are always echoed, so a `false` on them is indistinguishable from
975    /// a default, whereas this field keeps supplied-`false` and omitted apart. That is the
976    /// same additive-echo shape `wind_shear_model` above it uses, and it is the one that lets
977    /// an echo round-trip back into an identical request.
978    #[serde(default, skip_serializing_if = "Option::is_none")]
979    pub aerodynamic_jump: Option<bool>,
980}
981
982/// Resolved result-sampling configuration.
983#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
984#[serde(deny_unknown_fields)]
985pub struct ResolvedSamplingV1 {
986    pub interval_m: f64,
987}
988
989/// A successful solve-json v1 response.
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
991#[serde(deny_unknown_fields)]
992pub struct SolveSuccessV1 {
993    pub schema_version: SchemaVersionV1,
994    pub engine_version: String,
995    pub status: SuccessStatusV1,
996    pub resolved_request: ResolvedSolveRequestV1,
997    #[serde(default)]
998    pub assumptions: Vec<SolveNoticeV1>,
999    #[serde(default)]
1000    pub warnings: Vec<SolveNoticeV1>,
1001    pub summary: SolveSummaryV1,
1002    #[serde(default, serialize_with = "serialize_solve_samples_v1")]
1003    pub samples: Vec<TrajectorySampleV1>,
1004    /// The reticle hold point (MBA-1361), present only when the request carried a
1005    /// `reticle` block. Every response that predates the field, and every request without
1006    /// one, is byte-identical.
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub reticle_hold: Option<ReticleHoldV1>,
1009}
1010
1011fn serialize_solve_samples_v1<S>(
1012    samples: &[TrajectorySampleV1],
1013    serializer: S,
1014) -> Result<S::Ok, S::Error>
1015where
1016    S: Serializer,
1017{
1018    if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
1019        return Err(serde::ser::Error::custom(format_args!(
1020            "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1021            samples.len()
1022        )));
1023    }
1024    samples.serialize(serializer)
1025}
1026
1027impl SolveSuccessV1 {
1028    /// Validate service-level response limits immediately before serialization.
1029    ///
1030    /// The exact limit is accepted. A response with more samples returns a structured
1031    /// [`SolveErrorCodeV1::ResourceLimit`] error at the sampling interval that requested the
1032    /// oversized result. The response is never silently truncated.
1033    pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
1034        if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
1035            return Ok(());
1036        }
1037
1038        Err(SolveErrorEnvelopeV1::new(
1039            SolveErrorV1::new(
1040                SolveErrorCodeV1::ResourceLimit,
1041                format!(
1042                    "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1043                    self.samples.len()
1044                ),
1045            )
1046            .at_path("$.sampling.interval_m"),
1047        ))
1048    }
1049}
1050
1051/// The success discriminator serialized in a response envelope.
1052#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1053#[serde(rename_all = "snake_case")]
1054pub enum SuccessStatusV1 {
1055    Ok,
1056}
1057
1058/// A machine-readable assumption or warning.
1059#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1060#[serde(deny_unknown_fields)]
1061pub struct SolveNoticeV1 {
1062    pub code: String,
1063    pub message: String,
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub path: Option<String>,
1066}
1067
1068/// Aggregate observations for a completed solve.
1069#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1070#[serde(deny_unknown_fields)]
1071pub struct SolveSummaryV1 {
1072    pub actual_range_m: f64,
1073    /// Greatest world-vertical projectile height above the request's local ground/reference
1074    /// datum. This is not shot-frame Y and is not height above the line of sight.
1075    pub maximum_height_m: f64,
1076    pub time_of_flight_s: f64,
1077    pub terminal_speed_mps: f64,
1078    pub terminal_energy_j: f64,
1079    /// Muzzle gyroscopic stability factor Sg evaluated with the resolved projectile, muzzle
1080    /// velocity, twist, and station atmosphere; absent only when it cannot be calculated.
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub stability_factor: Option<f64>,
1083    /// Signed gyroscopic spin-drift contribution at the terminal sample, positive to the
1084    /// shooter's right. This excludes wind drift and is absent when the effect is disabled or
1085    /// cannot be calculated.
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub spin_drift_m: Option<f64>,
1088    /// Equivalent horizontal range for an inclined shot (MBA-1395): the flat-fire range
1089    /// whose angular elevation correction against the same zero matches the inclined
1090    /// solution's at the terminal range — the BDC "shoot-to" range (SIG AMR / Leica EHR
1091    /// style, angular-match inversion, not the rifleman's-rule cosine). Present only when
1092    /// `shot.shooting_angle_rad != 0`, a `zero_distance_m` was solved, and the inverse is
1093    /// well-defined (terminal past the zero range, positive correction); absent otherwise
1094    /// — responses that predate the field, and every flat solve, are byte-identical.
1095    #[serde(default, skip_serializing_if = "Option::is_none")]
1096    pub equivalent_horizontal_range_m: Option<f64>,
1097    /// Vertical crosswind aerodynamic jump actually applied at the muzzle, in MOA, positive
1098    /// up (MBA-959). Present only when `effects.aerodynamic_jump` was enabled AND the solver
1099    /// produced components; absent otherwise, so every response that predates the field is
1100    /// byte-identical.
1101    ///
1102    /// This exists because the jump is otherwise unobservable from the response. It is a
1103    /// fixed launch-angle offset, so it does not appear as its own column anywhere — it is
1104    /// folded into every drop in the table, and a caller comparing two solves cannot tell a
1105    /// jump that applied from one that silently came out at zero. A present `0.0` says the
1106    /// flag took effect and the muzzle crosswind was nil; an absent field says the effect
1107    /// never ran.
1108    #[serde(default, skip_serializing_if = "Option::is_none")]
1109    pub aerodynamic_jump_moa: Option<f64>,
1110    pub termination: TerminationReasonV1,
1111}
1112
1113/// Why the trajectory ended.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1115#[serde(rename_all = "snake_case")]
1116pub enum TerminationReasonV1 {
1117    MaxRange,
1118    GroundThreshold,
1119    TimeLimit,
1120    VelocityFloor,
1121}
1122
1123/// One regularly sampled trajectory observation.
1124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1125#[serde(deny_unknown_fields)]
1126pub struct TrajectorySampleV1 {
1127    pub distance_m: f64,
1128    pub time_s: f64,
1129    pub speed_mps: f64,
1130    pub energy_j: f64,
1131    /// Positive means below the line of sight.
1132    pub drop_m: f64,
1133    /// Positive means right of the line of sight from the shooter's perspective.
1134    pub windage_m: f64,
1135    pub mach: f64,
1136    #[serde(default)]
1137    pub flags: Vec<SampleFlagV1>,
1138}
1139
1140/// Stable annotations for trajectory samples.
1141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1142#[serde(rename_all = "snake_case")]
1143pub enum SampleFlagV1 {
1144    Transonic,
1145    Subsonic,
1146    Terminal,
1147    GroundThreshold,
1148}
1149
1150/// A failed solve-json v1 response.
1151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1152#[serde(deny_unknown_fields)]
1153pub struct SolveErrorEnvelopeV1 {
1154    pub schema_version: SchemaVersionV1,
1155    pub status: ErrorStatusV1,
1156    pub error: SolveErrorV1,
1157}
1158
1159impl SolveErrorEnvelopeV1 {
1160    /// Wrap a protocol error in a v1 envelope.
1161    pub fn new(error: SolveErrorV1) -> Self {
1162        Self {
1163            schema_version: SchemaVersionV1,
1164            status: ErrorStatusV1::Error,
1165            error,
1166        }
1167    }
1168}
1169
1170/// The error discriminator serialized in a response envelope.
1171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1172#[serde(rename_all = "snake_case")]
1173pub enum ErrorStatusV1 {
1174    Error,
1175}
1176
1177/// A stable, machine-readable protocol error.
1178///
1179/// Location state is private so callers cannot construct a path together with a source location,
1180/// a partial line/column pair, or a zero source coordinate. The JSON representation retains the
1181/// flat `path`, `line`, and `column` fields.
1182#[derive(Debug, Clone, PartialEq, Eq)]
1183pub struct SolveErrorV1 {
1184    pub code: SolveErrorCodeV1,
1185    pub message: String,
1186    location: SolveErrorLocationV1,
1187}
1188
1189#[derive(Debug, Clone, PartialEq, Eq)]
1190enum SolveErrorLocationV1 {
1191    None,
1192    Path(String),
1193    Source {
1194        line: NonZeroUsize,
1195        column: NonZeroUsize,
1196    },
1197}
1198
1199/// Why a source location could not be attached to a protocol error.
1200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1201pub enum SolveErrorLocationErrorV1 {
1202    ZeroLine,
1203    ZeroColumn,
1204}
1205
1206impl fmt::Display for SolveErrorLocationErrorV1 {
1207    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1208        match self {
1209            Self::ZeroLine => formatter.write_str("error line must be one-based"),
1210            Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1211        }
1212    }
1213}
1214
1215impl std::error::Error for SolveErrorLocationErrorV1 {}
1216
1217impl SolveErrorV1 {
1218    /// Construct an error without input-location information.
1219    pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1220        Self {
1221            code,
1222            message: message.into(),
1223            location: SolveErrorLocationV1::None,
1224        }
1225    }
1226
1227    /// Attach a JSONPath-like input location, replacing any source location.
1228    pub fn at_path(mut self, path: impl Into<String>) -> Self {
1229        self.location = SolveErrorLocationV1::Path(path.into());
1230        self
1231    }
1232
1233    /// Attach a one-based malformed-JSON source location, replacing any JSON path.
1234    pub fn at_location(
1235        mut self,
1236        line: usize,
1237        column: usize,
1238    ) -> Result<Self, SolveErrorLocationErrorV1> {
1239        let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1240        let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1241        self.location = SolveErrorLocationV1::Source { line, column };
1242        Ok(self)
1243    }
1244
1245    /// Attach a parser location while keeping the public wire coordinates one-based.
1246    ///
1247    /// `serde_json` reports column zero for some end-of-file errors. Normalize either zero
1248    /// coordinate to one instead of allowing malformed input to trigger a panic.
1249    fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1250        self.location = SolveErrorLocationV1::Source {
1251            line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1252            column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1253        };
1254        self
1255    }
1256
1257    /// Return the JSONPath-like input location, when present.
1258    pub fn path(&self) -> Option<&str> {
1259        match &self.location {
1260            SolveErrorLocationV1::Path(path) => Some(path),
1261            SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1262        }
1263    }
1264
1265    /// Return the one-based malformed-JSON source line, when present.
1266    pub fn line(&self) -> Option<usize> {
1267        match &self.location {
1268            SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1269            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1270        }
1271    }
1272
1273    /// Return the one-based malformed-JSON source column, when present.
1274    pub fn column(&self) -> Option<usize> {
1275        match &self.location {
1276            SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1277            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1278        }
1279    }
1280}
1281
1282impl Serialize for SolveErrorV1 {
1283    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1284    where
1285        S: Serializer,
1286    {
1287        let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1288        state.serialize_field("code", &self.code)?;
1289        state.serialize_field("message", &self.message)?;
1290        state.serialize_field("path", &self.path())?;
1291        state.serialize_field("line", &self.line())?;
1292        state.serialize_field("column", &self.column())?;
1293        state.end()
1294    }
1295}
1296
1297impl<'de> Deserialize<'de> for SolveErrorV1 {
1298    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1299    where
1300        D: Deserializer<'de>,
1301    {
1302        #[derive(Deserialize)]
1303        #[serde(deny_unknown_fields)]
1304        struct SolveErrorWireV1 {
1305            code: SolveErrorCodeV1,
1306            message: String,
1307            path: Option<String>,
1308            line: Option<usize>,
1309            column: Option<usize>,
1310        }
1311
1312        let wire = SolveErrorWireV1::deserialize(deserializer)?;
1313        let location = match (wire.path, wire.line, wire.column) {
1314            (None, None, None) => SolveErrorLocationV1::None,
1315            (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1316            (None, Some(line), Some(column)) => {
1317                let line = NonZeroUsize::new(line)
1318                    .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1319                let column = NonZeroUsize::new(column)
1320                    .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1321                SolveErrorLocationV1::Source { line, column }
1322            }
1323            _ => {
1324                return Err(de::Error::custom(
1325                    "error location must contain either path or both line and column",
1326                ));
1327            }
1328        };
1329
1330        Ok(Self {
1331            code: wire.code,
1332            message: wire.message,
1333            location,
1334        })
1335    }
1336}
1337
1338/// Stable solve-json v1 error categories.
1339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1340#[serde(rename_all = "snake_case")]
1341pub enum SolveErrorCodeV1 {
1342    InvalidJson,
1343    UnsupportedSchemaVersion,
1344    UnknownField,
1345    MissingField,
1346    InvalidValue,
1347    ConflictingFields,
1348    ResourceLimit,
1349    SolveFailed,
1350    IoError,
1351    InternalError,
1352}
1353
1354/// Decode and structurally validate one solve-json v1 request.
1355///
1356/// In addition to Serde's `deny_unknown_fields` enforcement, this entry point attaches exact
1357/// JSONPath-like locations to unknown fields, missing required fields, invalid enums, and an
1358/// unsupported schema version. Transport implementations should use this function rather than
1359/// deserializing [`SolveRequestV1`] directly when they need a protocol error envelope.
1360pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1361    let value: Value = serde_json::from_str(input).map_err(|error| {
1362        let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1363            .at_parser_location(error.line(), error.column());
1364        envelope(error)
1365    })?;
1366
1367    validate_request_shape(&value)?;
1368
1369    let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1370        envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1371    })?;
1372
1373    validate_request_ranges(&request)?;
1374
1375    Ok(request)
1376}
1377
1378/// Physically meaningful bounds for solve-json v1's numeric inputs (MBA-1413).
1379///
1380/// Deliberately enormous — the point is to exclude values that are not projectiles at all, not
1381/// to police calibers. Every bound below admits everything from an airgun pellet to naval
1382/// artillery, so a request rejected here was never going to produce a meaningful answer.
1383///
1384/// Found by fuzzing, which reached a request declaring a `mass_kg` of 1.1e-66 — about
1385/// 10^40 times lighter than a proton. The engine accepted it and solved. The visible symptom was
1386/// narrower: at that magnitude `serde_json`'s float parser is a bit off re-reading its own
1387/// output, so the request did not survive a JSON round trip and broke the protocol's stability
1388/// invariant. Tightening the float handling would have addressed the symptom; the actual defect
1389/// is that a number no projectile could have was accepted as a projectile.
1390mod limits {
1391    /// 1 mg to 100 kg. A .17 cal pellet is ~500 mg; a 16-inch naval shell is ~1200 kg, so the
1392    /// upper bound is the one a caller might conceivably reach — it is set past small artillery
1393    /// on purpose and can be raised without ceremony.
1394    pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1395    /// 0.1 mm to 1 m.
1396    pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1397    /// 0.1 mm to 10 m. Only checked when supplied.
1398    pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1399    /// Ballistic coefficient, lb/in². Real values run ~0.1–1.5; the bound is far past both ends.
1400    pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1401
1402    // Deliberately NOT bounded here: muzzle_velocity_mps and max_range_m. The MCP server
1403    // documents — and tests — a split where a structurally valid request the engine cannot
1404    // solve returns a tool error rather than a protocol error, and an absurd muzzle velocity is
1405    // its worked example of that case. Bounding those two fields here would reclassify that
1406    // example as invalid params and change a contract MCP clients may rely on, which is a
1407    // separate decision from rejecting values that cannot describe a projectile.
1408}
1409
1410fn require_range(
1411    value: f64,
1412    (min, max): (f64, f64),
1413    path: &str,
1414) -> Result<(), SolveErrorEnvelopeV1> {
1415    if !value.is_finite() {
1416        return Err(protocol_error(
1417            SolveErrorCodeV1::InvalidValue,
1418            format!("{path} must be a finite number"),
1419            path,
1420        ));
1421    }
1422    if value < min || value > max {
1423        return Err(protocol_error(
1424            SolveErrorCodeV1::InvalidValue,
1425            format!("{path} must be between {min} and {max}, got {value}"),
1426            path,
1427        ));
1428    }
1429    Ok(())
1430}
1431
1432/// Reject requests whose numbers cannot describe a projectile, before any solve runs.
1433///
1434/// Runs after deserialization so the fields are typed and each error can carry the exact
1435/// JSONPath the caller used, matching how the shape errors above report.
1436fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1437    require_range(
1438        request.projectile.mass_kg,
1439        limits::MASS_KG,
1440        "$.projectile.mass_kg",
1441    )?;
1442    require_range(
1443        request.projectile.diameter_m,
1444        limits::DIAMETER_M,
1445        "$.projectile.diameter_m",
1446    )?;
1447    require_range(
1448        request.projectile.ballistic_coefficient,
1449        limits::BALLISTIC_COEFFICIENT,
1450        "$.projectile.ballistic_coefficient",
1451    )?;
1452    if let Some(length_m) = request.projectile.length_m {
1453        require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1454    }
1455    Ok(())
1456}
1457
1458fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1459    SolveErrorEnvelopeV1::new(error)
1460}
1461
1462fn protocol_error(
1463    code: SolveErrorCodeV1,
1464    message: impl Into<String>,
1465    path: impl Into<String>,
1466) -> SolveErrorEnvelopeV1 {
1467    envelope(SolveErrorV1::new(code, message).at_path(path))
1468}
1469
1470fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1471    let root = require_object(value, "$")?;
1472
1473    // Dispatch on the version before applying any v1-only shape rules. A future-version
1474    // request may legitimately contain fields unknown to v1 and must still receive the stable
1475    // unsupported-version error rather than an order-dependent unknown-field error.
1476    validate_schema_version(root)?;
1477    validate_members(
1478        root,
1479        "$",
1480        &[
1481            "schema_version",
1482            "projectile",
1483            "rifle",
1484            "shot",
1485            "atmosphere",
1486            "wind",
1487            "solver",
1488            "effects",
1489            "sampling",
1490            // MBA-1361: optional, additive. Omitting it is the historical shape.
1491            "reticle",
1492            // Offline corrections (BC5D table path): optional, additive.
1493            "corrections",
1494        ],
1495        &[
1496            "schema_version",
1497            "projectile",
1498            "rifle",
1499            "shot",
1500            "atmosphere",
1501            "wind",
1502            "solver",
1503            "effects",
1504            "sampling",
1505        ],
1506    )?;
1507
1508    validate_projectile(required_value(root, "projectile", "$")?)?;
1509    validate_rifle(required_value(root, "rifle", "$")?)?;
1510    validate_shot(required_value(root, "shot", "$")?)?;
1511    validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1512    validate_wind(required_value(root, "wind", "$")?)?;
1513    validate_solver(required_value(root, "solver", "$")?)?;
1514    validate_effects(required_value(root, "effects", "$")?)?;
1515    validate_sampling(required_value(root, "sampling", "$")?)?;
1516    if let Some(reticle) = root.get("reticle") {
1517        validate_reticle(reticle)?;
1518    }
1519    if let Some(corrections) = root.get("corrections") {
1520        validate_corrections(corrections)?;
1521    }
1522    Ok(())
1523}
1524
1525/// Shape-validate an optional `corrections` block: a strict envelope whose only
1526/// member today is `bc5d_table_path`, a string. The path's existence, format, and
1527/// CRC are solve-time concerns (an `invalid_value`/`io_error` from the service),
1528/// not protocol-shape ones — same split the reticle description uses.
1529fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1530    let path = "$.corrections";
1531    let object = require_object(value, path)?;
1532    validate_members(object, path, &["bc5d_table_path"], &[])?;
1533    if let Some(table_path) = object.get("bc5d_table_path") {
1534        if !table_path.is_string() {
1535            return Err(protocol_error(
1536                SolveErrorCodeV1::InvalidValue,
1537                "expected a string",
1538                "$.corrections.bc5d_table_path",
1539            ));
1540        }
1541    }
1542    Ok(())
1543}
1544
1545/// Shape-validate an optional `reticle` block (MBA-1361).
1546///
1547/// The ENVELOPE is strict (exactly `range_m`, `magnification`, `description`, all
1548/// required); the description itself is handed to the shared reticle schema, whose own
1549/// `validate()` runs at solve time. Splitting it that way keeps the wire contract tight
1550/// without making the engine the arbiter of a front end's render metadata.
1551fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1552    let path = "$.reticle";
1553    let object = require_object(value, path)?;
1554    validate_members(
1555        object,
1556        path,
1557        &["range_m", "magnification", "description"],
1558        &["range_m", "magnification", "description"],
1559    )?;
1560    validate_required_numbers(object, path, &["range_m", "magnification"])?;
1561    require_object(
1562        required_value(object, "description", path)?,
1563        "$.reticle.description",
1564    )?;
1565    Ok(())
1566}
1567
1568fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1569    let value = required_value(root, "schema_version", "$")?;
1570    let version = if let Some(version) = value.as_i64() {
1571        i128::from(version)
1572    } else if let Some(version) = value.as_u64() {
1573        i128::from(version)
1574    } else {
1575        return Err(protocol_error(
1576            SolveErrorCodeV1::InvalidValue,
1577            "schema_version must be the integer 1",
1578            "$.schema_version",
1579        ));
1580    };
1581    if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1582        return Err(protocol_error(
1583            SolveErrorCodeV1::UnsupportedSchemaVersion,
1584            format!(
1585                "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1586            ),
1587            "$.schema_version",
1588        ));
1589    }
1590    Ok(())
1591}
1592
1593fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1594    let path = "$.projectile";
1595    let object = require_object(value, path)?;
1596    validate_members(
1597        object,
1598        path,
1599        &[
1600            "mass_kg",
1601            "diameter_m",
1602            "length_m",
1603            "drag_model",
1604            "ballistic_coefficient",
1605        ],
1606        &[
1607            "mass_kg",
1608            "diameter_m",
1609            "drag_model",
1610            "ballistic_coefficient",
1611        ],
1612    )?;
1613    validate_required_numbers(
1614        object,
1615        path,
1616        &["mass_kg", "diameter_m", "ballistic_coefficient"],
1617    )?;
1618    validate_optional_number(object, path, "length_m")?;
1619    validate_string_enum(
1620        required_value(object, "drag_model", path)?,
1621        "$.projectile.drag_model",
1622        &DRAG_MODEL_WIRE_NAMES_V1,
1623    )
1624}
1625
1626fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1627    let path = "$.rifle";
1628    let object = require_object(value, path)?;
1629    validate_members(
1630        object,
1631        path,
1632        &[
1633            "muzzle_velocity_mps",
1634            "sight_height_m",
1635            "muzzle_height_m",
1636            "twist_rate_m_per_turn",
1637            "twist_direction",
1638            "sight_offset_lateral_m",
1639        ],
1640        &["muzzle_velocity_mps"],
1641    )?;
1642    validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1643    validate_optional_numbers(
1644        object,
1645        path,
1646        &[
1647            "sight_height_m",
1648            "muzzle_height_m",
1649            "twist_rate_m_per_turn",
1650            "sight_offset_lateral_m",
1651        ],
1652    )?;
1653    if let Some(direction) = object.get("twist_direction") {
1654        validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1655    }
1656    Ok(())
1657}
1658
1659fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1660    let path = "$.shot";
1661    let object = require_object(value, path)?;
1662    validate_members(
1663        object,
1664        path,
1665        &[
1666            "max_range_m",
1667            "zero_distance_m",
1668            "muzzle_angle_rad",
1669            "aim_azimuth_rad",
1670            "shot_azimuth_rad",
1671            "shooting_angle_rad",
1672            "cant_angle_rad",
1673            "target_height_m",
1674            "ground_threshold_m",
1675            "zero_poi_up_m",
1676            "zero_poi_right_m",
1677            "drops_reference",
1678        ],
1679        &["max_range_m"],
1680    )?;
1681    validate_required_numbers(object, path, &["max_range_m"])?;
1682    validate_optional_number(object, path, "zero_distance_m")?;
1683    validate_optional_number(object, path, "muzzle_angle_rad")?;
1684    validate_optional_numbers(
1685        object,
1686        path,
1687        &[
1688            "aim_azimuth_rad",
1689            "shot_azimuth_rad",
1690            "shooting_angle_rad",
1691            "cant_angle_rad",
1692            "target_height_m",
1693            "ground_threshold_m",
1694            "zero_poi_up_m",
1695            "zero_poi_right_m",
1696        ],
1697    )?;
1698    // MBA-1403: string enum, not a number — same shape-validation pattern as
1699    // $.rifle.twist_direction.
1700    if let Some(reference) = object.get("drops_reference") {
1701        validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1702    }
1703    Ok(())
1704}
1705
1706pub(crate) fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1707    let path = "$.atmosphere";
1708    let object = require_object(value, path)?;
1709    validate_members(
1710        object,
1711        path,
1712        &[
1713            "altitude_m",
1714            "temperature_k",
1715            "pressure_pa",
1716            "pressure_reference",
1717            "relative_humidity",
1718            "latitude_rad",
1719        ],
1720        &[],
1721    )?;
1722    validate_optional_numbers(
1723        object,
1724        path,
1725        &[
1726            "altitude_m",
1727            "temperature_k",
1728            "pressure_pa",
1729            "relative_humidity",
1730            "latitude_rad",
1731        ],
1732    )?;
1733    // MBA-1397: string enum, not a number -- same shape-validation pattern as
1734    // $.shot.drops_reference / $.wind.wind_reference. This field has existed on
1735    // AtmosphereV1 and been consumed by resolve_atmosphere since MBA-1397, but was never
1736    // added to this hand-maintained allowlist, so decode_solve_request_v1 rejected it as
1737    // an unknown field even though direct SolveRequestV1 construction always accepted it.
1738    if let Some(reference) = object.get("pressure_reference") {
1739        validate_string_enum(
1740            reference,
1741            "$.atmosphere.pressure_reference",
1742            &["absolute", "qnh"],
1743        )?;
1744    }
1745    Ok(())
1746}
1747
1748fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1749    let path = "$.wind";
1750    let object = require_object(value, path)?;
1751    validate_members(
1752        object,
1753        path,
1754        &[
1755            "speed_mps",
1756            "direction_from_rad",
1757            "vertical_speed_mps",
1758            "segments",
1759            "wind_reference",
1760        ],
1761        &[],
1762    )?;
1763    for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1764        validate_optional_number(object, path, field)?;
1765    }
1766    // MBA-1368: wind_reference is a closed string enum (shooter | compass).
1767    if let Some(reference) = object.get("wind_reference") {
1768        validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1769    }
1770
1771    if let Some(segments) = object.get("segments") {
1772        let Some(segments) = segments.as_array() else {
1773            return Err(protocol_error(
1774                SolveErrorCodeV1::InvalidValue,
1775                "segments must be an array",
1776                "$.wind.segments",
1777            ));
1778        };
1779        for (index, segment) in segments.iter().enumerate() {
1780            let segment_path = format!("$.wind.segments[{index}]");
1781            let segment = require_object(segment, &segment_path)?;
1782            validate_members(
1783                segment,
1784                &segment_path,
1785                &[
1786                    "until_distance_m",
1787                    "speed_mps",
1788                    "direction_from_rad",
1789                    "vertical_speed_mps",
1790                ],
1791                &["until_distance_m", "speed_mps", "direction_from_rad"],
1792            )?;
1793            validate_required_numbers(
1794                segment,
1795                &segment_path,
1796                &["until_distance_m", "speed_mps", "direction_from_rad"],
1797            )?;
1798            validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1799        }
1800    }
1801    Ok(())
1802}
1803
1804fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1805    let path = "$.solver";
1806    let object = require_object(value, path)?;
1807    validate_members(object, path, &["method", "time_step_s"], &[])?;
1808    validate_optional_number(object, path, "time_step_s")?;
1809    if let Some(method) = object.get("method") {
1810        validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1811    }
1812    Ok(())
1813}
1814
1815/// Every member name `effects` accepts on the v1 wire, in declaration order.
1816///
1817/// One list, because there are two readers: `validate_effects` rejects anything outside it,
1818/// and the MCP `solve` tool publishes an `inputSchema` with `additionalProperties: false` over
1819/// the same vocabulary. Those drifted apart once already — `wind_shear_model` reached the JSON
1820/// wire in 0.36.0 and never reached the MCP schema, so an MCP caller could not ask for shear
1821/// that the bridge underneath was perfectly willing to apply. `mcp_effect_schema_matches_the_wire`
1822/// in `src/mcp_command.rs` pins them together.
1823pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
1824    "magnus",
1825    "coriolis",
1826    "enhanced_spin_drift",
1827    "wind_shear_model",
1828    "aerodynamic_jump",
1829];
1830
1831fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1832    let path = "$.effects";
1833    let object = require_object(value, path)?;
1834    validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
1835    validate_optional_booleans(
1836        object,
1837        path,
1838        &[
1839            "magnus",
1840            "coriolis",
1841            "enhanced_spin_drift",
1842            "aerodynamic_jump",
1843        ],
1844    )?;
1845
1846    // An unknown shear model is rejected here, with the exact path and the accepted spellings,
1847    // rather than deserializing to the `none` default. Silently unsheared numbers are
1848    // indistinguishable from sheared ones downstream.
1849    if let Some(model) = object.get("wind_shear_model") {
1850        validate_string_enum(
1851            model,
1852            "$.effects.wind_shear_model",
1853            &[
1854                "none",
1855                "logarithmic",
1856                "power_law",
1857                "ekman_spiral",
1858                // Alias, canonicalized to `ekman_spiral` in the resolved echo.
1859                "ekman",
1860            ],
1861        )?;
1862    }
1863
1864    if object.get("magnus").and_then(Value::as_bool) == Some(true)
1865        && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1866    {
1867        return Err(protocol_error(
1868            SolveErrorCodeV1::ConflictingFields,
1869            "magnus and enhanced_spin_drift cannot both be enabled",
1870            "$.effects",
1871        ));
1872    }
1873
1874    Ok(())
1875}
1876
1877fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1878    let path = "$.sampling";
1879    let object = require_object(value, path)?;
1880    validate_members(object, path, &["interval_m"], &[])?;
1881    validate_optional_number(object, path, "interval_m")
1882}
1883
1884pub(crate) fn require_object<'a>(
1885    value: &'a Value,
1886    path: &str,
1887) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1888    value
1889        .as_object()
1890        .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1891}
1892
1893fn required_value<'a>(
1894    object: &'a Map<String, Value>,
1895    field: &str,
1896    parent_path: &str,
1897) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1898    object.get(field).ok_or_else(|| {
1899        protocol_error(
1900            SolveErrorCodeV1::MissingField,
1901            format!("missing required field `{field}`"),
1902            child_path(parent_path, field),
1903        )
1904    })
1905}
1906
1907pub(crate) fn validate_members(
1908    object: &Map<String, Value>,
1909    path: &str,
1910    allowed: &[&str],
1911    required: &[&str],
1912) -> Result<(), SolveErrorEnvelopeV1> {
1913    if let Some(field) = object
1914        .keys()
1915        .find(|field| !allowed.contains(&field.as_str()))
1916    {
1917        return Err(protocol_error(
1918            SolveErrorCodeV1::UnknownField,
1919            format!("unknown field `{field}`"),
1920            child_path(path, field),
1921        ));
1922    }
1923
1924    if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1925        return Err(protocol_error(
1926            SolveErrorCodeV1::MissingField,
1927            format!("missing required field `{field}`"),
1928            child_path(path, field),
1929        ));
1930    }
1931    Ok(())
1932}
1933
1934fn validate_string_enum(
1935    value: &Value,
1936    path: &str,
1937    allowed: &[&str],
1938) -> Result<(), SolveErrorEnvelopeV1> {
1939    let Some(value) = value.as_str() else {
1940        return Err(protocol_error(
1941            SolveErrorCodeV1::InvalidValue,
1942            "expected a string enum value",
1943            path,
1944        ));
1945    };
1946    if !allowed.contains(&value) {
1947        return Err(protocol_error(
1948            SolveErrorCodeV1::InvalidValue,
1949            format!(
1950                "invalid value `{value}`; expected one of {}",
1951                allowed.join(", ")
1952            ),
1953            path,
1954        ));
1955    }
1956    Ok(())
1957}
1958
1959fn validate_required_numbers(
1960    object: &Map<String, Value>,
1961    parent_path: &str,
1962    fields: &[&str],
1963) -> Result<(), SolveErrorEnvelopeV1> {
1964    for field in fields {
1965        let value = required_value(object, field, parent_path)?;
1966        validate_number(value, &child_path(parent_path, field))?;
1967    }
1968    Ok(())
1969}
1970
1971fn validate_optional_numbers(
1972    object: &Map<String, Value>,
1973    parent_path: &str,
1974    fields: &[&str],
1975) -> Result<(), SolveErrorEnvelopeV1> {
1976    for field in fields {
1977        validate_optional_number(object, parent_path, field)?;
1978    }
1979    Ok(())
1980}
1981
1982fn validate_optional_number(
1983    object: &Map<String, Value>,
1984    parent_path: &str,
1985    field: &str,
1986) -> Result<(), SolveErrorEnvelopeV1> {
1987    if let Some(value) = object.get(field) {
1988        validate_number(value, &child_path(parent_path, field))?;
1989    }
1990    Ok(())
1991}
1992
1993fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1994    if value.is_number() {
1995        Ok(())
1996    } else {
1997        Err(protocol_error(
1998            SolveErrorCodeV1::InvalidValue,
1999            "expected a number",
2000            path,
2001        ))
2002    }
2003}
2004
2005fn validate_optional_booleans(
2006    object: &Map<String, Value>,
2007    parent_path: &str,
2008    fields: &[&str],
2009) -> Result<(), SolveErrorEnvelopeV1> {
2010    for field in fields {
2011        if let Some(value) = object.get(*field) {
2012            if !value.is_boolean() {
2013                return Err(protocol_error(
2014                    SolveErrorCodeV1::InvalidValue,
2015                    "expected a boolean",
2016                    child_path(parent_path, field),
2017                ));
2018            }
2019        }
2020    }
2021    Ok(())
2022}
2023
2024fn child_path(parent: &str, field: &str) -> String {
2025    format!("{parent}.{field}")
2026}
2027
2028/// MBA-1413: physical bounds on the projectile fields.
2029#[cfg(test)]
2030mod request_range_tests {
2031    use super::*;
2032
2033    /// A complete, ordinary request. Every range test below starts from this and perturbs one
2034    /// field, so a bound that accidentally rejects real data fails loudly here first.
2035    fn valid_request_json() -> String {
2036        r#"{"schema_version":1,
2037            "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2038                          "drag_model":"G7","ballistic_coefficient":0.243},
2039            "rifle":{"muzzle_velocity_mps":823.0},
2040            "shot":{"max_range_m":1000.0},
2041            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2042            .to_string()
2043    }
2044
2045    fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2046        decode_solve_request_v1(json).expect_err("request should have been rejected")
2047    }
2048
2049    #[test]
2050    fn an_ordinary_request_still_decodes() {
2051        decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2052    }
2053
2054    /// The exact input cargo-fuzz found (MBA-1413). It declared a projectile mass of about
2055    /// 1.1e-66 kg — roughly 10^40 times lighter than a proton — and the engine accepted and
2056    /// solved it. The visible symptom was that the request did not survive a JSON round trip,
2057    /// because serde_json's float parser is a bit off re-reading its own output at that
2058    /// magnitude; the actual defect was accepting the number at all.
2059    #[test]
2060    fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2061        let reproducer = r#"{"schema_version":1,
2062            "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2063                          "drag_model":"G7","ballistic_coefficient":1.2e2},
2064            "rifle":{"muzzle_velocity_mps":823.0},
2065            "shot":{"max_range_m":100.0},
2066            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2067
2068        let envelope = decode_err(reproducer);
2069        assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2070        assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2071    }
2072
2073    /// An error envelope must itself round-trip, since that is the invariant the fuzz target
2074    /// asserts on the rejection branch.
2075    #[test]
2076    fn the_rejection_envelope_round_trips() {
2077        let envelope = decode_err(
2078            r#"{"schema_version":1,
2079                "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2080                              "drag_model":"G7","ballistic_coefficient":0.243},
2081                "rifle":{"muzzle_velocity_mps":823.0},
2082                "shot":{"max_range_m":100.0},
2083                "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2084        );
2085        let encoded = serde_json::to_string(&envelope).expect("serialize");
2086        let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2087        assert_eq!(decoded, envelope);
2088    }
2089
2090    #[test]
2091    fn each_bounded_field_reports_its_own_path() {
2092        for (field, bad_value, path) in [
2093            ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2094            ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2095            (
2096                "ballistic_coefficient",
2097                "1.0e6",
2098                "$.projectile.ballistic_coefficient",
2099            ),
2100            ("length_m", "1.0e-9", "$.projectile.length_m"),
2101        ] {
2102            let json = valid_request_json().replace(
2103                &format!("\"{field}\":{}", default_for(field)),
2104                &format!("\"{field}\":{bad_value}"),
2105            );
2106            let envelope = decode_err(&json);
2107            assert_eq!(envelope.error.path(), Some(path), "wrong path for {field}");
2108            assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2109        }
2110    }
2111
2112    fn default_for(field: &str) -> &'static str {
2113        match field {
2114            "mass_kg" => "0.01134",
2115            "diameter_m" => "0.00782",
2116            "ballistic_coefficient" => "0.243",
2117            "length_m" => "0.031",
2118            other => panic!("no default recorded for {other}"),
2119        }
2120    }
2121
2122    /// Muzzle velocity is deliberately unbounded: the MCP server documents and tests a split
2123    /// where a structurally valid request the engine cannot solve returns a tool error rather
2124    /// than a protocol error, using an absurd muzzle velocity as its example.
2125    #[test]
2126    fn muzzle_velocity_is_deliberately_left_unbounded() {
2127        let json = valid_request_json().replace("823.0", "1.0e308");
2128        decode_solve_request_v1(&json)
2129            .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2130    }
2131}