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