Skip to main content

ballistics_engine/
solve_json.rs

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