ballistics_engine/
solve_json.rs

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