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