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