Skip to main content

ballistics_engine/
solve_json.rs

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