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(required_value(object, "description", path)?, "$.reticle.description")?;
1562    Ok(())
1563}
1564
1565fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1566    let value = required_value(root, "schema_version", "$")?;
1567    let version = if let Some(version) = value.as_i64() {
1568        i128::from(version)
1569    } else if let Some(version) = value.as_u64() {
1570        i128::from(version)
1571    } else {
1572        return Err(protocol_error(
1573            SolveErrorCodeV1::InvalidValue,
1574            "schema_version must be the integer 1",
1575            "$.schema_version",
1576        ));
1577    };
1578    if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1579        return Err(protocol_error(
1580            SolveErrorCodeV1::UnsupportedSchemaVersion,
1581            format!(
1582                "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1583            ),
1584            "$.schema_version",
1585        ));
1586    }
1587    Ok(())
1588}
1589
1590fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1591    let path = "$.projectile";
1592    let object = require_object(value, path)?;
1593    validate_members(
1594        object,
1595        path,
1596        &[
1597            "mass_kg",
1598            "diameter_m",
1599            "length_m",
1600            "drag_model",
1601            "ballistic_coefficient",
1602        ],
1603        &[
1604            "mass_kg",
1605            "diameter_m",
1606            "drag_model",
1607            "ballistic_coefficient",
1608        ],
1609    )?;
1610    validate_required_numbers(
1611        object,
1612        path,
1613        &["mass_kg", "diameter_m", "ballistic_coefficient"],
1614    )?;
1615    validate_optional_number(object, path, "length_m")?;
1616    validate_string_enum(
1617        required_value(object, "drag_model", path)?,
1618        "$.projectile.drag_model",
1619        &DRAG_MODEL_WIRE_NAMES_V1,
1620    )
1621}
1622
1623fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1624    let path = "$.rifle";
1625    let object = require_object(value, path)?;
1626    validate_members(
1627        object,
1628        path,
1629        &[
1630            "muzzle_velocity_mps",
1631            "sight_height_m",
1632            "muzzle_height_m",
1633            "twist_rate_m_per_turn",
1634            "twist_direction",
1635            "sight_offset_lateral_m",
1636        ],
1637        &["muzzle_velocity_mps"],
1638    )?;
1639    validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1640    validate_optional_numbers(
1641        object,
1642        path,
1643        &[
1644            "sight_height_m",
1645            "muzzle_height_m",
1646            "twist_rate_m_per_turn",
1647            "sight_offset_lateral_m",
1648        ],
1649    )?;
1650    if let Some(direction) = object.get("twist_direction") {
1651        validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1652    }
1653    Ok(())
1654}
1655
1656fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1657    let path = "$.shot";
1658    let object = require_object(value, path)?;
1659    validate_members(
1660        object,
1661        path,
1662        &[
1663            "max_range_m",
1664            "zero_distance_m",
1665            "muzzle_angle_rad",
1666            "aim_azimuth_rad",
1667            "shot_azimuth_rad",
1668            "shooting_angle_rad",
1669            "cant_angle_rad",
1670            "target_height_m",
1671            "ground_threshold_m",
1672            "zero_poi_up_m",
1673            "zero_poi_right_m",
1674            "drops_reference",
1675        ],
1676        &["max_range_m"],
1677    )?;
1678    validate_required_numbers(object, path, &["max_range_m"])?;
1679    validate_optional_number(object, path, "zero_distance_m")?;
1680    validate_optional_number(object, path, "muzzle_angle_rad")?;
1681    validate_optional_numbers(
1682        object,
1683        path,
1684        &[
1685            "aim_azimuth_rad",
1686            "shot_azimuth_rad",
1687            "shooting_angle_rad",
1688            "cant_angle_rad",
1689            "target_height_m",
1690            "ground_threshold_m",
1691            "zero_poi_up_m",
1692            "zero_poi_right_m",
1693        ],
1694    )?;
1695    // MBA-1403: string enum, not a number — same shape-validation pattern as
1696    // $.rifle.twist_direction.
1697    if let Some(reference) = object.get("drops_reference") {
1698        validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1699    }
1700    Ok(())
1701}
1702
1703fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1704    let path = "$.atmosphere";
1705    let object = require_object(value, path)?;
1706    validate_members(
1707        object,
1708        path,
1709        &[
1710            "altitude_m",
1711            "temperature_k",
1712            "pressure_pa",
1713            "pressure_reference",
1714            "relative_humidity",
1715            "latitude_rad",
1716        ],
1717        &[],
1718    )?;
1719    validate_optional_numbers(
1720        object,
1721        path,
1722        &[
1723            "altitude_m",
1724            "temperature_k",
1725            "pressure_pa",
1726            "relative_humidity",
1727            "latitude_rad",
1728        ],
1729    )?;
1730    // MBA-1397: string enum, not a number -- same shape-validation pattern as
1731    // $.shot.drops_reference / $.wind.wind_reference. This field has existed on
1732    // AtmosphereV1 and been consumed by resolve_atmosphere since MBA-1397, but was never
1733    // added to this hand-maintained allowlist, so decode_solve_request_v1 rejected it as
1734    // an unknown field even though direct SolveRequestV1 construction always accepted it.
1735    if let Some(reference) = object.get("pressure_reference") {
1736        validate_string_enum(
1737            reference,
1738            "$.atmosphere.pressure_reference",
1739            &["absolute", "qnh"],
1740        )?;
1741    }
1742    Ok(())
1743}
1744
1745fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1746    let path = "$.wind";
1747    let object = require_object(value, path)?;
1748    validate_members(
1749        object,
1750        path,
1751        &[
1752            "speed_mps",
1753            "direction_from_rad",
1754            "vertical_speed_mps",
1755            "segments",
1756            "wind_reference",
1757        ],
1758        &[],
1759    )?;
1760    for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1761        validate_optional_number(object, path, field)?;
1762    }
1763    // MBA-1368: wind_reference is a closed string enum (shooter | compass).
1764    if let Some(reference) = object.get("wind_reference") {
1765        validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1766    }
1767
1768    if let Some(segments) = object.get("segments") {
1769        let Some(segments) = segments.as_array() else {
1770            return Err(protocol_error(
1771                SolveErrorCodeV1::InvalidValue,
1772                "segments must be an array",
1773                "$.wind.segments",
1774            ));
1775        };
1776        for (index, segment) in segments.iter().enumerate() {
1777            let segment_path = format!("$.wind.segments[{index}]");
1778            let segment = require_object(segment, &segment_path)?;
1779            validate_members(
1780                segment,
1781                &segment_path,
1782                &[
1783                    "until_distance_m",
1784                    "speed_mps",
1785                    "direction_from_rad",
1786                    "vertical_speed_mps",
1787                ],
1788                &["until_distance_m", "speed_mps", "direction_from_rad"],
1789            )?;
1790            validate_required_numbers(
1791                segment,
1792                &segment_path,
1793                &["until_distance_m", "speed_mps", "direction_from_rad"],
1794            )?;
1795            validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1796        }
1797    }
1798    Ok(())
1799}
1800
1801fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1802    let path = "$.solver";
1803    let object = require_object(value, path)?;
1804    validate_members(object, path, &["method", "time_step_s"], &[])?;
1805    validate_optional_number(object, path, "time_step_s")?;
1806    if let Some(method) = object.get("method") {
1807        validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1808    }
1809    Ok(())
1810}
1811
1812/// Every member name `effects` accepts on the v1 wire, in declaration order.
1813///
1814/// One list, because there are two readers: `validate_effects` rejects anything outside it,
1815/// and the MCP `solve` tool publishes an `inputSchema` with `additionalProperties: false` over
1816/// the same vocabulary. Those drifted apart once already — `wind_shear_model` reached the JSON
1817/// wire in 0.36.0 and never reached the MCP schema, so an MCP caller could not ask for shear
1818/// that the bridge underneath was perfectly willing to apply. `mcp_effect_schema_matches_the_wire`
1819/// in `src/mcp_command.rs` pins them together.
1820pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
1821    "magnus",
1822    "coriolis",
1823    "enhanced_spin_drift",
1824    "wind_shear_model",
1825    "aerodynamic_jump",
1826];
1827
1828fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1829    let path = "$.effects";
1830    let object = require_object(value, path)?;
1831    validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
1832    validate_optional_booleans(
1833        object,
1834        path,
1835        &[
1836            "magnus",
1837            "coriolis",
1838            "enhanced_spin_drift",
1839            "aerodynamic_jump",
1840        ],
1841    )?;
1842
1843    // An unknown shear model is rejected here, with the exact path and the accepted spellings,
1844    // rather than deserializing to the `none` default. Silently unsheared numbers are
1845    // indistinguishable from sheared ones downstream.
1846    if let Some(model) = object.get("wind_shear_model") {
1847        validate_string_enum(
1848            model,
1849            "$.effects.wind_shear_model",
1850            &[
1851                "none",
1852                "logarithmic",
1853                "power_law",
1854                "ekman_spiral",
1855                // Alias, canonicalized to `ekman_spiral` in the resolved echo.
1856                "ekman",
1857            ],
1858        )?;
1859    }
1860
1861    if object.get("magnus").and_then(Value::as_bool) == Some(true)
1862        && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1863    {
1864        return Err(protocol_error(
1865            SolveErrorCodeV1::ConflictingFields,
1866            "magnus and enhanced_spin_drift cannot both be enabled",
1867            "$.effects",
1868        ));
1869    }
1870
1871    Ok(())
1872}
1873
1874fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1875    let path = "$.sampling";
1876    let object = require_object(value, path)?;
1877    validate_members(object, path, &["interval_m"], &[])?;
1878    validate_optional_number(object, path, "interval_m")
1879}
1880
1881fn require_object<'a>(
1882    value: &'a Value,
1883    path: &str,
1884) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1885    value
1886        .as_object()
1887        .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1888}
1889
1890fn required_value<'a>(
1891    object: &'a Map<String, Value>,
1892    field: &str,
1893    parent_path: &str,
1894) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1895    object.get(field).ok_or_else(|| {
1896        protocol_error(
1897            SolveErrorCodeV1::MissingField,
1898            format!("missing required field `{field}`"),
1899            child_path(parent_path, field),
1900        )
1901    })
1902}
1903
1904fn validate_members(
1905    object: &Map<String, Value>,
1906    path: &str,
1907    allowed: &[&str],
1908    required: &[&str],
1909) -> Result<(), SolveErrorEnvelopeV1> {
1910    if let Some(field) = object
1911        .keys()
1912        .find(|field| !allowed.contains(&field.as_str()))
1913    {
1914        return Err(protocol_error(
1915            SolveErrorCodeV1::UnknownField,
1916            format!("unknown field `{field}`"),
1917            child_path(path, field),
1918        ));
1919    }
1920
1921    if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1922        return Err(protocol_error(
1923            SolveErrorCodeV1::MissingField,
1924            format!("missing required field `{field}`"),
1925            child_path(path, field),
1926        ));
1927    }
1928    Ok(())
1929}
1930
1931fn validate_string_enum(
1932    value: &Value,
1933    path: &str,
1934    allowed: &[&str],
1935) -> Result<(), SolveErrorEnvelopeV1> {
1936    let Some(value) = value.as_str() else {
1937        return Err(protocol_error(
1938            SolveErrorCodeV1::InvalidValue,
1939            "expected a string enum value",
1940            path,
1941        ));
1942    };
1943    if !allowed.contains(&value) {
1944        return Err(protocol_error(
1945            SolveErrorCodeV1::InvalidValue,
1946            format!(
1947                "invalid value `{value}`; expected one of {}",
1948                allowed.join(", ")
1949            ),
1950            path,
1951        ));
1952    }
1953    Ok(())
1954}
1955
1956fn validate_required_numbers(
1957    object: &Map<String, Value>,
1958    parent_path: &str,
1959    fields: &[&str],
1960) -> Result<(), SolveErrorEnvelopeV1> {
1961    for field in fields {
1962        let value = required_value(object, field, parent_path)?;
1963        validate_number(value, &child_path(parent_path, field))?;
1964    }
1965    Ok(())
1966}
1967
1968fn validate_optional_numbers(
1969    object: &Map<String, Value>,
1970    parent_path: &str,
1971    fields: &[&str],
1972) -> Result<(), SolveErrorEnvelopeV1> {
1973    for field in fields {
1974        validate_optional_number(object, parent_path, field)?;
1975    }
1976    Ok(())
1977}
1978
1979fn validate_optional_number(
1980    object: &Map<String, Value>,
1981    parent_path: &str,
1982    field: &str,
1983) -> Result<(), SolveErrorEnvelopeV1> {
1984    if let Some(value) = object.get(field) {
1985        validate_number(value, &child_path(parent_path, field))?;
1986    }
1987    Ok(())
1988}
1989
1990fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1991    if value.is_number() {
1992        Ok(())
1993    } else {
1994        Err(protocol_error(
1995            SolveErrorCodeV1::InvalidValue,
1996            "expected a number",
1997            path,
1998        ))
1999    }
2000}
2001
2002fn validate_optional_booleans(
2003    object: &Map<String, Value>,
2004    parent_path: &str,
2005    fields: &[&str],
2006) -> Result<(), SolveErrorEnvelopeV1> {
2007    for field in fields {
2008        if let Some(value) = object.get(*field) {
2009            if !value.is_boolean() {
2010                return Err(protocol_error(
2011                    SolveErrorCodeV1::InvalidValue,
2012                    "expected a boolean",
2013                    child_path(parent_path, field),
2014                ));
2015            }
2016        }
2017    }
2018    Ok(())
2019}
2020
2021fn child_path(parent: &str, field: &str) -> String {
2022    format!("{parent}.{field}")
2023}
2024
2025/// MBA-1413: physical bounds on the projectile fields.
2026#[cfg(test)]
2027mod request_range_tests {
2028    use super::*;
2029
2030    /// A complete, ordinary request. Every range test below starts from this and perturbs one
2031    /// field, so a bound that accidentally rejects real data fails loudly here first.
2032    fn valid_request_json() -> String {
2033        r#"{"schema_version":1,
2034            "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2035                          "drag_model":"G7","ballistic_coefficient":0.243},
2036            "rifle":{"muzzle_velocity_mps":823.0},
2037            "shot":{"max_range_m":1000.0},
2038            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2039            .to_string()
2040    }
2041
2042    fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2043        decode_solve_request_v1(json).expect_err("request should have been rejected")
2044    }
2045
2046    #[test]
2047    fn an_ordinary_request_still_decodes() {
2048        decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2049    }
2050
2051    /// The exact input cargo-fuzz found (MBA-1413). It declared a projectile mass of about
2052    /// 1.1e-66 kg — roughly 10^40 times lighter than a proton — and the engine accepted and
2053    /// solved it. The visible symptom was that the request did not survive a JSON round trip,
2054    /// because serde_json's float parser is a bit off re-reading its own output at that
2055    /// magnitude; the actual defect was accepting the number at all.
2056    #[test]
2057    fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2058        let reproducer = r#"{"schema_version":1,
2059            "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2060                          "drag_model":"G7","ballistic_coefficient":1.2e2},
2061            "rifle":{"muzzle_velocity_mps":823.0},
2062            "shot":{"max_range_m":100.0},
2063            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2064
2065        let envelope = decode_err(reproducer);
2066        assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2067        assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2068    }
2069
2070    /// An error envelope must itself round-trip, since that is the invariant the fuzz target
2071    /// asserts on the rejection branch.
2072    #[test]
2073    fn the_rejection_envelope_round_trips() {
2074        let envelope = decode_err(
2075            r#"{"schema_version":1,
2076                "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2077                              "drag_model":"G7","ballistic_coefficient":0.243},
2078                "rifle":{"muzzle_velocity_mps":823.0},
2079                "shot":{"max_range_m":100.0},
2080                "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2081        );
2082        let encoded = serde_json::to_string(&envelope).expect("serialize");
2083        let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2084        assert_eq!(decoded, envelope);
2085    }
2086
2087    #[test]
2088    fn each_bounded_field_reports_its_own_path() {
2089        for (field, bad_value, path) in [
2090            ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2091            ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2092            (
2093                "ballistic_coefficient",
2094                "1.0e6",
2095                "$.projectile.ballistic_coefficient",
2096            ),
2097            ("length_m", "1.0e-9", "$.projectile.length_m"),
2098        ] {
2099            let json = valid_request_json().replace(
2100                &format!("\"{field}\":{}", default_for(field)),
2101                &format!("\"{field}\":{bad_value}"),
2102            );
2103            let envelope = decode_err(&json);
2104            assert_eq!(
2105                envelope.error.path(),
2106                Some(path),
2107                "wrong path for {field}"
2108            );
2109            assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2110        }
2111    }
2112
2113    fn default_for(field: &str) -> &'static str {
2114        match field {
2115            "mass_kg" => "0.01134",
2116            "diameter_m" => "0.00782",
2117            "ballistic_coefficient" => "0.243",
2118            "length_m" => "0.031",
2119            other => panic!("no default recorded for {other}"),
2120        }
2121    }
2122
2123    /// Muzzle velocity is deliberately unbounded: the MCP server documents and tests a split
2124    /// where a structurally valid request the engine cannot solve returns a tool error rather
2125    /// than a protocol error, using an absurd muzzle velocity as its example.
2126    #[test]
2127    fn muzzle_velocity_is_deliberately_left_unbounded() {
2128        let json = valid_request_json().replace("823.0", "1.0e308");
2129        decode_solve_request_v1(&json)
2130            .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2131    }
2132}