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/// Maximum number of trajectory observations in one solve-json v1 success response.
19///
20/// Service implementations must reject a response above this limit with
21/// [`SolveSuccessV1::validate_for_serialization`] instead of truncating it.
22pub const MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
23
24/// Deserialize a request member that may be omitted but may not be JSON `null`.
25///
26/// Serde supplies [`Option::None`] from the field's `default` only when the member is absent.
27/// When the member is present, deserialize `T` directly so `null` is rejected for scalar,
28/// enum, object, and array values alike.
29fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
30where
31    D: Deserializer<'de>,
32    T: Deserialize<'de>,
33{
34    T::deserialize(deserializer).map(Some)
35}
36
37/// An invariant solve-json v1 schema discriminator.
38///
39/// This unit type has no invalid public state: it always serializes as the JSON integer `1` and
40/// deserializes only from that integer.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
42pub struct SchemaVersionV1;
43
44impl SchemaVersionV1 {
45    /// Return the integer represented on the wire.
46    pub const fn get(self) -> u32 {
47        SOLVE_JSON_SCHEMA_VERSION_V1
48    }
49}
50
51impl Serialize for SchemaVersionV1 {
52    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
53    where
54        S: Serializer,
55    {
56        serializer.serialize_u32(SOLVE_JSON_SCHEMA_VERSION_V1)
57    }
58}
59
60impl<'de> Deserialize<'de> for SchemaVersionV1 {
61    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62    where
63        D: Deserializer<'de>,
64    {
65        struct SchemaVersionVisitor;
66
67        impl<'de> Visitor<'de> for SchemaVersionVisitor {
68            type Value = SchemaVersionV1;
69
70            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71                formatter.write_str("the integer 1")
72            }
73
74            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
75            where
76                E: de::Error,
77            {
78                if value == u64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
79                    Ok(SchemaVersionV1)
80                } else {
81                    Err(E::custom(format!(
82                        "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
83                    )))
84                }
85            }
86
87            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
88            where
89                E: de::Error,
90            {
91                if value == i64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
92                    Ok(SchemaVersionV1)
93                } else {
94                    Err(E::custom(format!(
95                        "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
96                    )))
97                }
98            }
99        }
100
101        deserializer.deserialize_any(SchemaVersionVisitor)
102    }
103}
104
105/// A complete v1 trajectory-solve request.
106///
107/// All dimensional values use SI units named in the field. Every section is mandatory, even
108/// when all fields in a section have defaults; this keeps the top-level request shape explicit.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct SolveRequestV1 {
112    /// Must be exactly [`SOLVE_JSON_SCHEMA_VERSION_V1`].
113    pub schema_version: SchemaVersionV1,
114    pub projectile: ProjectileV1,
115    pub rifle: RifleV1,
116    pub shot: ShotV1,
117    pub atmosphere: AtmosphereV1,
118    pub wind: WindV1,
119    pub solver: SolverV1,
120    pub effects: EffectsV1,
121    pub sampling: SamplingV1,
122}
123
124/// Projectile inputs supported by solve-json v1.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ProjectileV1 {
128    pub mass_kg: f64,
129    pub diameter_m: f64,
130    #[serde(
131        default,
132        skip_serializing_if = "Option::is_none",
133        deserialize_with = "deserialize_present"
134    )]
135    pub length_m: Option<f64>,
136    pub drag_model: DragModelV1,
137    pub ballistic_coefficient: f64,
138}
139
140/// Built-in reference-projectile drag models supported by solve-json v1.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142pub enum DragModelV1 {
143    #[serde(rename = "G1")]
144    G1,
145    #[serde(rename = "G6")]
146    G6,
147    #[serde(rename = "G7")]
148    G7,
149    #[serde(rename = "G8")]
150    G8,
151}
152
153/// Rifle and sight geometry.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct RifleV1 {
157    pub muzzle_velocity_mps: f64,
158    #[serde(
159        default,
160        skip_serializing_if = "Option::is_none",
161        deserialize_with = "deserialize_present"
162    )]
163    pub sight_height_m: Option<f64>,
164    #[serde(
165        default,
166        skip_serializing_if = "Option::is_none",
167        deserialize_with = "deserialize_present"
168    )]
169    pub muzzle_height_m: Option<f64>,
170    #[serde(
171        default,
172        skip_serializing_if = "Option::is_none",
173        deserialize_with = "deserialize_present"
174    )]
175    pub twist_rate_m_per_turn: Option<f64>,
176    #[serde(
177        default,
178        skip_serializing_if = "Option::is_none",
179        deserialize_with = "deserialize_present"
180    )]
181    pub twist_direction: Option<TwistDirectionV1>,
182}
183
184/// Direction of rifling twist as viewed from the breech toward the muzzle.
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum TwistDirectionV1 {
188    Left,
189    #[default]
190    Right,
191}
192
193/// Shot geometry and termination range.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct ShotV1 {
197    pub max_range_m: f64,
198    #[serde(
199        default,
200        skip_serializing_if = "Option::is_none",
201        deserialize_with = "deserialize_present"
202    )]
203    pub zero_distance_m: Option<f64>,
204    #[serde(
205        default,
206        skip_serializing_if = "Option::is_none",
207        deserialize_with = "deserialize_present"
208    )]
209    pub muzzle_angle_rad: Option<f64>,
210    #[serde(
211        default,
212        skip_serializing_if = "Option::is_none",
213        deserialize_with = "deserialize_present"
214    )]
215    pub aim_azimuth_rad: Option<f64>,
216    #[serde(
217        default,
218        skip_serializing_if = "Option::is_none",
219        deserialize_with = "deserialize_present"
220    )]
221    pub shot_azimuth_rad: Option<f64>,
222    #[serde(
223        default,
224        skip_serializing_if = "Option::is_none",
225        deserialize_with = "deserialize_present"
226    )]
227    pub shooting_angle_rad: Option<f64>,
228    #[serde(
229        default,
230        skip_serializing_if = "Option::is_none",
231        deserialize_with = "deserialize_present"
232    )]
233    pub cant_angle_rad: Option<f64>,
234    #[serde(
235        default,
236        skip_serializing_if = "Option::is_none",
237        deserialize_with = "deserialize_present"
238    )]
239    pub target_height_m: Option<f64>,
240    #[serde(
241        default,
242        skip_serializing_if = "Option::is_none",
243        deserialize_with = "deserialize_present"
244    )]
245    pub ground_threshold_m: Option<f64>,
246}
247
248/// Atmospheric station conditions.
249#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct AtmosphereV1 {
252    #[serde(
253        default,
254        skip_serializing_if = "Option::is_none",
255        deserialize_with = "deserialize_present"
256    )]
257    pub altitude_m: Option<f64>,
258    /// Authoritative station temperature, or `None` to resolve ICAO standard temperature at
259    /// `altitude_m`.
260    #[serde(
261        default,
262        skip_serializing_if = "Option::is_none",
263        deserialize_with = "deserialize_present"
264    )]
265    pub temperature_k: Option<f64>,
266    /// Authoritative station pressure, or `None` to resolve ICAO standard pressure at
267    /// `altitude_m`. Interpreted per `pressure_reference` (MBA-1397): `absolute` (the
268    /// default) means this value already IS station pressure; `qnh` means it is a
269    /// sea-level-corrected altimeter setting that must be reduced to station pressure at
270    /// `altitude_m` before use.
271    #[serde(
272        default,
273        skip_serializing_if = "Option::is_none",
274        deserialize_with = "deserialize_present"
275    )]
276    pub pressure_pa: Option<f64>,
277    /// Whether `pressure_pa` is absolute station pressure or a sea-level-corrected altimeter
278    /// setting (QNH, MBA-1397). `None` (the omitted-field default, and every request from
279    /// before this field existed) means [`PressureReferenceV1::Absolute`] — byte-identical to
280    /// pre-MBA-1397 behavior. Has no effect when `pressure_pa` is omitted: an omitted pressure
281    /// resolves to the ICAO standard station pressure either way.
282    #[serde(
283        default,
284        skip_serializing_if = "Option::is_none",
285        deserialize_with = "deserialize_present"
286    )]
287    pub pressure_reference: Option<PressureReferenceV1>,
288    #[serde(
289        default,
290        skip_serializing_if = "Option::is_none",
291        deserialize_with = "deserialize_present"
292    )]
293    pub relative_humidity: Option<f64>,
294    #[serde(
295        default,
296        skip_serializing_if = "Option::is_none",
297        deserialize_with = "deserialize_present"
298    )]
299    pub latitude_rad: Option<f64>,
300}
301
302/// Whether an `AtmosphereV1.pressure_pa` value is absolute station pressure or a sea-level-
303/// corrected altimeter setting (QNH) that must be reduced to station pressure before use
304/// (MBA-1397). Mirrors [`crate::atmosphere::PressureReferenceMode`].
305#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307pub enum PressureReferenceV1 {
308    #[default]
309    Absolute,
310    Qnh,
311}
312
313/// Constant or downrange-segmented wind.
314///
315/// Omit all fields for still air. A constant wind supplies both `speed_mps` and
316/// `direction_from_rad`. Segmented wind supplies `segments` and no constant-wind fields.
317#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct WindV1 {
320    #[serde(
321        default,
322        skip_serializing_if = "Option::is_none",
323        deserialize_with = "deserialize_present"
324    )]
325    pub speed_mps: Option<f64>,
326    #[serde(
327        default,
328        skip_serializing_if = "Option::is_none",
329        deserialize_with = "deserialize_present"
330    )]
331    pub direction_from_rad: Option<f64>,
332    #[serde(
333        default,
334        skip_serializing_if = "Option::is_none",
335        deserialize_with = "deserialize_present"
336    )]
337    pub vertical_speed_mps: Option<f64>,
338    #[serde(
339        default,
340        skip_serializing_if = "Option::is_none",
341        deserialize_with = "deserialize_present"
342    )]
343    pub segments: Option<Vec<WindSegmentV1>>,
344}
345
346/// One wind segment, active through `until_distance_m`.
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct WindSegmentV1 {
350    pub until_distance_m: f64,
351    pub speed_mps: f64,
352    pub direction_from_rad: f64,
353    #[serde(
354        default,
355        skip_serializing_if = "Option::is_none",
356        deserialize_with = "deserialize_present"
357    )]
358    pub vertical_speed_mps: Option<f64>,
359}
360
361/// Numerical integration configuration.
362#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct SolverV1 {
365    #[serde(
366        default,
367        skip_serializing_if = "Option::is_none",
368        deserialize_with = "deserialize_present"
369    )]
370    pub method: Option<SolverMethodV1>,
371    /// Fixed step for RK4 and Euler. RK45 owns its adaptive step size.
372    #[serde(
373        default,
374        skip_serializing_if = "Option::is_none",
375        deserialize_with = "deserialize_present"
376    )]
377    pub time_step_s: Option<f64>,
378}
379
380/// Integration algorithms exposed by solve-json v1.
381#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum SolverMethodV1 {
384    Euler,
385    Rk4,
386    #[default]
387    Rk45,
388}
389
390/// Optional physical effects supported by solve-json v1.
391#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct EffectsV1 {
394    #[serde(
395        default,
396        skip_serializing_if = "Option::is_none",
397        deserialize_with = "deserialize_present"
398    )]
399    pub magnus: Option<bool>,
400    #[serde(
401        default,
402        skip_serializing_if = "Option::is_none",
403        deserialize_with = "deserialize_present"
404    )]
405    pub coriolis: Option<bool>,
406    #[serde(
407        default,
408        skip_serializing_if = "Option::is_none",
409        deserialize_with = "deserialize_present"
410    )]
411    pub enhanced_spin_drift: Option<bool>,
412}
413
414/// Regular downrange output sampling configuration.
415#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
416#[serde(deny_unknown_fields)]
417pub struct SamplingV1 {
418    #[serde(
419        default,
420        skip_serializing_if = "Option::is_none",
421        deserialize_with = "deserialize_present"
422    )]
423    pub interval_m: Option<f64>,
424}
425
426/// A solve request after the service has applied every documented v1 default.
427///
428/// This is intentionally distinct from [`SolveRequestV1`]. Request DTOs retain whether a
429/// defaulted field was omitted, while resolved DTOs require a concrete value for every default
430/// that affected the solve. Semantically optional inputs remain optional.
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432#[serde(deny_unknown_fields)]
433pub struct ResolvedSolveRequestV1 {
434    pub schema_version: SchemaVersionV1,
435    pub projectile: ResolvedProjectileV1,
436    pub rifle: ResolvedRifleV1,
437    pub shot: ResolvedShotV1,
438    pub atmosphere: ResolvedAtmosphereV1,
439    pub wind: ResolvedWindV1,
440    pub solver: ResolvedSolverV1,
441    pub effects: ResolvedEffectsV1,
442    pub sampling: ResolvedSamplingV1,
443}
444
445/// Resolved projectile inputs.
446#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447#[serde(deny_unknown_fields)]
448pub struct ResolvedProjectileV1 {
449    pub mass_kg: f64,
450    pub diameter_m: f64,
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub length_m: Option<f64>,
453    pub drag_model: DragModelV1,
454    pub ballistic_coefficient: f64,
455}
456
457/// Resolved rifle and sight geometry.
458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460pub struct ResolvedRifleV1 {
461    pub muzzle_velocity_mps: f64,
462    pub sight_height_m: f64,
463    pub muzzle_height_m: f64,
464    pub twist_rate_m_per_turn: f64,
465    pub twist_direction: TwistDirectionV1,
466}
467
468/// Resolved shot geometry.
469///
470/// `zero_distance_m` records caller zeroing intent, while `muzzle_angle_rad` is always the
471/// effective angle used by the engine. A zero-distance solve therefore populates both fields.
472#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
473#[serde(deny_unknown_fields)]
474pub struct ResolvedShotV1 {
475    pub max_range_m: f64,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub zero_distance_m: Option<f64>,
478    pub muzzle_angle_rad: f64,
479    pub aim_azimuth_rad: f64,
480    pub shot_azimuth_rad: f64,
481    pub shooting_angle_rad: f64,
482    pub cant_angle_rad: f64,
483    pub target_height_m: f64,
484    pub ground_threshold_m: f64,
485}
486
487/// Resolved station conditions.
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
489#[serde(deny_unknown_fields)]
490pub struct ResolvedAtmosphereV1 {
491    pub altitude_m: f64,
492    pub temperature_k: f64,
493    pub pressure_pa: f64,
494    pub relative_humidity: f64,
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub latitude_rad: Option<f64>,
497}
498
499/// Resolved constant or segmented wind.
500///
501/// The untagged representation retains the request's object shape while making the selected wind
502/// variant explicit in Rust. Still air is a constant wind with three zero values.
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
504#[serde(untagged)]
505pub enum ResolvedWindV1 {
506    Constant(ResolvedConstantWindV1),
507    Segmented(ResolvedSegmentedWindV1),
508}
509
510/// Resolved still-air or constant-wind values.
511#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512#[serde(deny_unknown_fields)]
513pub struct ResolvedConstantWindV1 {
514    pub speed_mps: f64,
515    pub direction_from_rad: f64,
516    pub vertical_speed_mps: f64,
517}
518
519/// Resolved downrange wind segments.
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521#[serde(deny_unknown_fields)]
522pub struct ResolvedSegmentedWindV1 {
523    pub segments: Vec<ResolvedWindSegmentV1>,
524}
525
526/// One resolved wind segment.
527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ResolvedWindSegmentV1 {
530    pub until_distance_m: f64,
531    pub speed_mps: f64,
532    pub direction_from_rad: f64,
533    pub vertical_speed_mps: f64,
534}
535
536/// Resolved numerical integration configuration.
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct ResolvedSolverV1 {
540    pub method: SolverMethodV1,
541    pub time_step_s: f64,
542}
543
544/// Resolved physical-effect switches.
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(deny_unknown_fields)]
547pub struct ResolvedEffectsV1 {
548    pub magnus: bool,
549    pub coriolis: bool,
550    pub enhanced_spin_drift: bool,
551}
552
553/// Resolved result-sampling configuration.
554#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
555#[serde(deny_unknown_fields)]
556pub struct ResolvedSamplingV1 {
557    pub interval_m: f64,
558}
559
560/// A successful solve-json v1 response.
561#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct SolveSuccessV1 {
564    pub schema_version: SchemaVersionV1,
565    pub engine_version: String,
566    pub status: SuccessStatusV1,
567    pub resolved_request: ResolvedSolveRequestV1,
568    #[serde(default)]
569    pub assumptions: Vec<SolveNoticeV1>,
570    #[serde(default)]
571    pub warnings: Vec<SolveNoticeV1>,
572    pub summary: SolveSummaryV1,
573    #[serde(default, serialize_with = "serialize_solve_samples_v1")]
574    pub samples: Vec<TrajectorySampleV1>,
575}
576
577fn serialize_solve_samples_v1<S>(
578    samples: &[TrajectorySampleV1],
579    serializer: S,
580) -> Result<S::Ok, S::Error>
581where
582    S: Serializer,
583{
584    if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
585        return Err(serde::ser::Error::custom(format_args!(
586            "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
587            samples.len()
588        )));
589    }
590    samples.serialize(serializer)
591}
592
593impl SolveSuccessV1 {
594    /// Validate service-level response limits immediately before serialization.
595    ///
596    /// The exact limit is accepted. A response with more samples returns a structured
597    /// [`SolveErrorCodeV1::ResourceLimit`] error at the sampling interval that requested the
598    /// oversized result. The response is never silently truncated.
599    pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
600        if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
601            return Ok(());
602        }
603
604        Err(SolveErrorEnvelopeV1::new(
605            SolveErrorV1::new(
606                SolveErrorCodeV1::ResourceLimit,
607                format!(
608                    "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
609                    self.samples.len()
610                ),
611            )
612            .at_path("$.sampling.interval_m"),
613        ))
614    }
615}
616
617/// The success discriminator serialized in a response envelope.
618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
619#[serde(rename_all = "snake_case")]
620pub enum SuccessStatusV1 {
621    Ok,
622}
623
624/// A machine-readable assumption or warning.
625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626#[serde(deny_unknown_fields)]
627pub struct SolveNoticeV1 {
628    pub code: String,
629    pub message: String,
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub path: Option<String>,
632}
633
634/// Aggregate observations for a completed solve.
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636#[serde(deny_unknown_fields)]
637pub struct SolveSummaryV1 {
638    pub actual_range_m: f64,
639    /// Greatest world-vertical projectile height above the request's local ground/reference
640    /// datum. This is not shot-frame Y and is not height above the line of sight.
641    pub maximum_height_m: f64,
642    pub time_of_flight_s: f64,
643    pub terminal_speed_mps: f64,
644    pub terminal_energy_j: f64,
645    /// Muzzle gyroscopic stability factor Sg evaluated with the resolved projectile, muzzle
646    /// velocity, twist, and station atmosphere; absent only when it cannot be calculated.
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub stability_factor: Option<f64>,
649    /// Signed gyroscopic spin-drift contribution at the terminal sample, positive to the
650    /// shooter's right. This excludes wind drift and is absent when the effect is disabled or
651    /// cannot be calculated.
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub spin_drift_m: Option<f64>,
654    pub termination: TerminationReasonV1,
655}
656
657/// Why the trajectory ended.
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(rename_all = "snake_case")]
660pub enum TerminationReasonV1 {
661    MaxRange,
662    GroundThreshold,
663    TimeLimit,
664    VelocityFloor,
665}
666
667/// One regularly sampled trajectory observation.
668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct TrajectorySampleV1 {
671    pub distance_m: f64,
672    pub time_s: f64,
673    pub speed_mps: f64,
674    pub energy_j: f64,
675    /// Positive means below the line of sight.
676    pub drop_m: f64,
677    /// Positive means right of the line of sight from the shooter's perspective.
678    pub windage_m: f64,
679    pub mach: f64,
680    #[serde(default)]
681    pub flags: Vec<SampleFlagV1>,
682}
683
684/// Stable annotations for trajectory samples.
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "snake_case")]
687pub enum SampleFlagV1 {
688    Transonic,
689    Subsonic,
690    Terminal,
691    GroundThreshold,
692}
693
694/// A failed solve-json v1 response.
695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(deny_unknown_fields)]
697pub struct SolveErrorEnvelopeV1 {
698    pub schema_version: SchemaVersionV1,
699    pub status: ErrorStatusV1,
700    pub error: SolveErrorV1,
701}
702
703impl SolveErrorEnvelopeV1 {
704    /// Wrap a protocol error in a v1 envelope.
705    pub fn new(error: SolveErrorV1) -> Self {
706        Self {
707            schema_version: SchemaVersionV1,
708            status: ErrorStatusV1::Error,
709            error,
710        }
711    }
712}
713
714/// The error discriminator serialized in a response envelope.
715#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
716#[serde(rename_all = "snake_case")]
717pub enum ErrorStatusV1 {
718    Error,
719}
720
721/// A stable, machine-readable protocol error.
722///
723/// Location state is private so callers cannot construct a path together with a source location,
724/// a partial line/column pair, or a zero source coordinate. The JSON representation retains the
725/// flat `path`, `line`, and `column` fields.
726#[derive(Debug, Clone, PartialEq, Eq)]
727pub struct SolveErrorV1 {
728    pub code: SolveErrorCodeV1,
729    pub message: String,
730    location: SolveErrorLocationV1,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
734enum SolveErrorLocationV1 {
735    None,
736    Path(String),
737    Source {
738        line: NonZeroUsize,
739        column: NonZeroUsize,
740    },
741}
742
743/// Why a source location could not be attached to a protocol error.
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745pub enum SolveErrorLocationErrorV1 {
746    ZeroLine,
747    ZeroColumn,
748}
749
750impl fmt::Display for SolveErrorLocationErrorV1 {
751    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
752        match self {
753            Self::ZeroLine => formatter.write_str("error line must be one-based"),
754            Self::ZeroColumn => formatter.write_str("error column must be one-based"),
755        }
756    }
757}
758
759impl std::error::Error for SolveErrorLocationErrorV1 {}
760
761impl SolveErrorV1 {
762    /// Construct an error without input-location information.
763    pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
764        Self {
765            code,
766            message: message.into(),
767            location: SolveErrorLocationV1::None,
768        }
769    }
770
771    /// Attach a JSONPath-like input location, replacing any source location.
772    pub fn at_path(mut self, path: impl Into<String>) -> Self {
773        self.location = SolveErrorLocationV1::Path(path.into());
774        self
775    }
776
777    /// Attach a one-based malformed-JSON source location, replacing any JSON path.
778    pub fn at_location(
779        mut self,
780        line: usize,
781        column: usize,
782    ) -> Result<Self, SolveErrorLocationErrorV1> {
783        let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
784        let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
785        self.location = SolveErrorLocationV1::Source { line, column };
786        Ok(self)
787    }
788
789    /// Attach a parser location while keeping the public wire coordinates one-based.
790    ///
791    /// `serde_json` reports column zero for some end-of-file errors. Normalize either zero
792    /// coordinate to one instead of allowing malformed input to trigger a panic.
793    fn at_parser_location(mut self, line: usize, column: usize) -> Self {
794        self.location = SolveErrorLocationV1::Source {
795            line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
796            column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
797        };
798        self
799    }
800
801    /// Return the JSONPath-like input location, when present.
802    pub fn path(&self) -> Option<&str> {
803        match &self.location {
804            SolveErrorLocationV1::Path(path) => Some(path),
805            SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
806        }
807    }
808
809    /// Return the one-based malformed-JSON source line, when present.
810    pub fn line(&self) -> Option<usize> {
811        match &self.location {
812            SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
813            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
814        }
815    }
816
817    /// Return the one-based malformed-JSON source column, when present.
818    pub fn column(&self) -> Option<usize> {
819        match &self.location {
820            SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
821            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
822        }
823    }
824}
825
826impl Serialize for SolveErrorV1 {
827    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
828    where
829        S: Serializer,
830    {
831        let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
832        state.serialize_field("code", &self.code)?;
833        state.serialize_field("message", &self.message)?;
834        state.serialize_field("path", &self.path())?;
835        state.serialize_field("line", &self.line())?;
836        state.serialize_field("column", &self.column())?;
837        state.end()
838    }
839}
840
841impl<'de> Deserialize<'de> for SolveErrorV1 {
842    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
843    where
844        D: Deserializer<'de>,
845    {
846        #[derive(Deserialize)]
847        #[serde(deny_unknown_fields)]
848        struct SolveErrorWireV1 {
849            code: SolveErrorCodeV1,
850            message: String,
851            path: Option<String>,
852            line: Option<usize>,
853            column: Option<usize>,
854        }
855
856        let wire = SolveErrorWireV1::deserialize(deserializer)?;
857        let location = match (wire.path, wire.line, wire.column) {
858            (None, None, None) => SolveErrorLocationV1::None,
859            (Some(path), None, None) => SolveErrorLocationV1::Path(path),
860            (None, Some(line), Some(column)) => {
861                let line = NonZeroUsize::new(line)
862                    .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
863                let column = NonZeroUsize::new(column)
864                    .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
865                SolveErrorLocationV1::Source { line, column }
866            }
867            _ => {
868                return Err(de::Error::custom(
869                    "error location must contain either path or both line and column",
870                ));
871            }
872        };
873
874        Ok(Self {
875            code: wire.code,
876            message: wire.message,
877            location,
878        })
879    }
880}
881
882/// Stable solve-json v1 error categories.
883#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
884#[serde(rename_all = "snake_case")]
885pub enum SolveErrorCodeV1 {
886    InvalidJson,
887    UnsupportedSchemaVersion,
888    UnknownField,
889    MissingField,
890    InvalidValue,
891    ConflictingFields,
892    ResourceLimit,
893    SolveFailed,
894    IoError,
895    InternalError,
896}
897
898/// Decode and structurally validate one solve-json v1 request.
899///
900/// In addition to Serde's `deny_unknown_fields` enforcement, this entry point attaches exact
901/// JSONPath-like locations to unknown fields, missing required fields, invalid enums, and an
902/// unsupported schema version. Transport implementations should use this function rather than
903/// deserializing [`SolveRequestV1`] directly when they need a protocol error envelope.
904pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
905    let value: Value = serde_json::from_str(input).map_err(|error| {
906        let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
907            .at_parser_location(error.line(), error.column());
908        envelope(error)
909    })?;
910
911    validate_request_shape(&value)?;
912
913    let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
914        envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
915    })?;
916
917    validate_request_ranges(&request)?;
918
919    Ok(request)
920}
921
922/// Physically meaningful bounds for solve-json v1's numeric inputs (MBA-1413).
923///
924/// Deliberately enormous — the point is to exclude values that are not projectiles at all, not
925/// to police calibers. Every bound below admits everything from an airgun pellet to naval
926/// artillery, so a request rejected here was never going to produce a meaningful answer.
927///
928/// Found by fuzzing, which reached a request declaring a `mass_kg` of 1.1e-66 — about
929/// 10^40 times lighter than a proton. The engine accepted it and solved. The visible symptom was
930/// narrower: at that magnitude `serde_json`'s float parser is a bit off re-reading its own
931/// output, so the request did not survive a JSON round trip and broke the protocol's stability
932/// invariant. Tightening the float handling would have addressed the symptom; the actual defect
933/// is that a number no projectile could have was accepted as a projectile.
934mod limits {
935    /// 1 mg to 100 kg. A .17 cal pellet is ~500 mg; a 16-inch naval shell is ~1200 kg, so the
936    /// upper bound is the one a caller might conceivably reach — it is set past small artillery
937    /// on purpose and can be raised without ceremony.
938    pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
939    /// 0.1 mm to 1 m.
940    pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
941    /// 0.1 mm to 10 m. Only checked when supplied.
942    pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
943    /// Ballistic coefficient, lb/in². Real values run ~0.1–1.5; the bound is far past both ends.
944    pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
945
946    // Deliberately NOT bounded here: muzzle_velocity_mps and max_range_m. The MCP server
947    // documents — and tests — a split where a structurally valid request the engine cannot
948    // solve returns a tool error rather than a protocol error, and an absurd muzzle velocity is
949    // its worked example of that case. Bounding those two fields here would reclassify that
950    // example as invalid params and change a contract MCP clients may rely on, which is a
951    // separate decision from rejecting values that cannot describe a projectile.
952}
953
954fn require_range(
955    value: f64,
956    (min, max): (f64, f64),
957    path: &str,
958) -> Result<(), SolveErrorEnvelopeV1> {
959    if !value.is_finite() {
960        return Err(protocol_error(
961            SolveErrorCodeV1::InvalidValue,
962            format!("{path} must be a finite number"),
963            path,
964        ));
965    }
966    if value < min || value > max {
967        return Err(protocol_error(
968            SolveErrorCodeV1::InvalidValue,
969            format!("{path} must be between {min} and {max}, got {value}"),
970            path,
971        ));
972    }
973    Ok(())
974}
975
976/// Reject requests whose numbers cannot describe a projectile, before any solve runs.
977///
978/// Runs after deserialization so the fields are typed and each error can carry the exact
979/// JSONPath the caller used, matching how the shape errors above report.
980fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
981    require_range(
982        request.projectile.mass_kg,
983        limits::MASS_KG,
984        "$.projectile.mass_kg",
985    )?;
986    require_range(
987        request.projectile.diameter_m,
988        limits::DIAMETER_M,
989        "$.projectile.diameter_m",
990    )?;
991    require_range(
992        request.projectile.ballistic_coefficient,
993        limits::BALLISTIC_COEFFICIENT,
994        "$.projectile.ballistic_coefficient",
995    )?;
996    if let Some(length_m) = request.projectile.length_m {
997        require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
998    }
999    Ok(())
1000}
1001
1002fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1003    SolveErrorEnvelopeV1::new(error)
1004}
1005
1006fn protocol_error(
1007    code: SolveErrorCodeV1,
1008    message: impl Into<String>,
1009    path: impl Into<String>,
1010) -> SolveErrorEnvelopeV1 {
1011    envelope(SolveErrorV1::new(code, message).at_path(path))
1012}
1013
1014fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1015    let root = require_object(value, "$")?;
1016
1017    // Dispatch on the version before applying any v1-only shape rules. A future-version
1018    // request may legitimately contain fields unknown to v1 and must still receive the stable
1019    // unsupported-version error rather than an order-dependent unknown-field error.
1020    validate_schema_version(root)?;
1021    validate_members(
1022        root,
1023        "$",
1024        &[
1025            "schema_version",
1026            "projectile",
1027            "rifle",
1028            "shot",
1029            "atmosphere",
1030            "wind",
1031            "solver",
1032            "effects",
1033            "sampling",
1034        ],
1035        &[
1036            "schema_version",
1037            "projectile",
1038            "rifle",
1039            "shot",
1040            "atmosphere",
1041            "wind",
1042            "solver",
1043            "effects",
1044            "sampling",
1045        ],
1046    )?;
1047
1048    validate_projectile(required_value(root, "projectile", "$")?)?;
1049    validate_rifle(required_value(root, "rifle", "$")?)?;
1050    validate_shot(required_value(root, "shot", "$")?)?;
1051    validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1052    validate_wind(required_value(root, "wind", "$")?)?;
1053    validate_solver(required_value(root, "solver", "$")?)?;
1054    validate_effects(required_value(root, "effects", "$")?)?;
1055    validate_sampling(required_value(root, "sampling", "$")?)?;
1056    Ok(())
1057}
1058
1059fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1060    let value = required_value(root, "schema_version", "$")?;
1061    let version = if let Some(version) = value.as_i64() {
1062        i128::from(version)
1063    } else if let Some(version) = value.as_u64() {
1064        i128::from(version)
1065    } else {
1066        return Err(protocol_error(
1067            SolveErrorCodeV1::InvalidValue,
1068            "schema_version must be the integer 1",
1069            "$.schema_version",
1070        ));
1071    };
1072    if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1073        return Err(protocol_error(
1074            SolveErrorCodeV1::UnsupportedSchemaVersion,
1075            format!(
1076                "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1077            ),
1078            "$.schema_version",
1079        ));
1080    }
1081    Ok(())
1082}
1083
1084fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1085    let path = "$.projectile";
1086    let object = require_object(value, path)?;
1087    validate_members(
1088        object,
1089        path,
1090        &[
1091            "mass_kg",
1092            "diameter_m",
1093            "length_m",
1094            "drag_model",
1095            "ballistic_coefficient",
1096        ],
1097        &[
1098            "mass_kg",
1099            "diameter_m",
1100            "drag_model",
1101            "ballistic_coefficient",
1102        ],
1103    )?;
1104    validate_required_numbers(
1105        object,
1106        path,
1107        &["mass_kg", "diameter_m", "ballistic_coefficient"],
1108    )?;
1109    validate_optional_number(object, path, "length_m")?;
1110    validate_string_enum(
1111        required_value(object, "drag_model", path)?,
1112        "$.projectile.drag_model",
1113        &["G1", "G6", "G7", "G8"],
1114    )
1115}
1116
1117fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1118    let path = "$.rifle";
1119    let object = require_object(value, path)?;
1120    validate_members(
1121        object,
1122        path,
1123        &[
1124            "muzzle_velocity_mps",
1125            "sight_height_m",
1126            "muzzle_height_m",
1127            "twist_rate_m_per_turn",
1128            "twist_direction",
1129        ],
1130        &["muzzle_velocity_mps"],
1131    )?;
1132    validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1133    validate_optional_numbers(
1134        object,
1135        path,
1136        &["sight_height_m", "muzzle_height_m", "twist_rate_m_per_turn"],
1137    )?;
1138    if let Some(direction) = object.get("twist_direction") {
1139        validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1140    }
1141    Ok(())
1142}
1143
1144fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1145    let path = "$.shot";
1146    let object = require_object(value, path)?;
1147    validate_members(
1148        object,
1149        path,
1150        &[
1151            "max_range_m",
1152            "zero_distance_m",
1153            "muzzle_angle_rad",
1154            "aim_azimuth_rad",
1155            "shot_azimuth_rad",
1156            "shooting_angle_rad",
1157            "cant_angle_rad",
1158            "target_height_m",
1159            "ground_threshold_m",
1160        ],
1161        &["max_range_m"],
1162    )?;
1163    validate_required_numbers(object, path, &["max_range_m"])?;
1164    validate_optional_number(object, path, "zero_distance_m")?;
1165    validate_optional_number(object, path, "muzzle_angle_rad")?;
1166    validate_optional_numbers(
1167        object,
1168        path,
1169        &[
1170            "aim_azimuth_rad",
1171            "shot_azimuth_rad",
1172            "shooting_angle_rad",
1173            "cant_angle_rad",
1174            "target_height_m",
1175            "ground_threshold_m",
1176        ],
1177    )
1178}
1179
1180fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1181    let path = "$.atmosphere";
1182    let object = require_object(value, path)?;
1183    validate_members(
1184        object,
1185        path,
1186        &[
1187            "altitude_m",
1188            "temperature_k",
1189            "pressure_pa",
1190            "relative_humidity",
1191            "latitude_rad",
1192        ],
1193        &[],
1194    )?;
1195    validate_optional_numbers(
1196        object,
1197        path,
1198        &[
1199            "altitude_m",
1200            "temperature_k",
1201            "pressure_pa",
1202            "relative_humidity",
1203            "latitude_rad",
1204        ],
1205    )
1206}
1207
1208fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1209    let path = "$.wind";
1210    let object = require_object(value, path)?;
1211    validate_members(
1212        object,
1213        path,
1214        &[
1215            "speed_mps",
1216            "direction_from_rad",
1217            "vertical_speed_mps",
1218            "segments",
1219        ],
1220        &[],
1221    )?;
1222    for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1223        validate_optional_number(object, path, field)?;
1224    }
1225
1226    if let Some(segments) = object.get("segments") {
1227        let Some(segments) = segments.as_array() else {
1228            return Err(protocol_error(
1229                SolveErrorCodeV1::InvalidValue,
1230                "segments must be an array",
1231                "$.wind.segments",
1232            ));
1233        };
1234        for (index, segment) in segments.iter().enumerate() {
1235            let segment_path = format!("$.wind.segments[{index}]");
1236            let segment = require_object(segment, &segment_path)?;
1237            validate_members(
1238                segment,
1239                &segment_path,
1240                &[
1241                    "until_distance_m",
1242                    "speed_mps",
1243                    "direction_from_rad",
1244                    "vertical_speed_mps",
1245                ],
1246                &["until_distance_m", "speed_mps", "direction_from_rad"],
1247            )?;
1248            validate_required_numbers(
1249                segment,
1250                &segment_path,
1251                &["until_distance_m", "speed_mps", "direction_from_rad"],
1252            )?;
1253            validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1254        }
1255    }
1256    Ok(())
1257}
1258
1259fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1260    let path = "$.solver";
1261    let object = require_object(value, path)?;
1262    validate_members(object, path, &["method", "time_step_s"], &[])?;
1263    validate_optional_number(object, path, "time_step_s")?;
1264    if let Some(method) = object.get("method") {
1265        validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1266    }
1267    Ok(())
1268}
1269
1270fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1271    let path = "$.effects";
1272    let object = require_object(value, path)?;
1273    validate_members(
1274        object,
1275        path,
1276        &["magnus", "coriolis", "enhanced_spin_drift"],
1277        &[],
1278    )?;
1279    validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1280
1281    if object.get("magnus").and_then(Value::as_bool) == Some(true)
1282        && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1283    {
1284        return Err(protocol_error(
1285            SolveErrorCodeV1::ConflictingFields,
1286            "magnus and enhanced_spin_drift cannot both be enabled",
1287            "$.effects",
1288        ));
1289    }
1290
1291    Ok(())
1292}
1293
1294fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1295    let path = "$.sampling";
1296    let object = require_object(value, path)?;
1297    validate_members(object, path, &["interval_m"], &[])?;
1298    validate_optional_number(object, path, "interval_m")
1299}
1300
1301fn require_object<'a>(
1302    value: &'a Value,
1303    path: &str,
1304) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1305    value
1306        .as_object()
1307        .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1308}
1309
1310fn required_value<'a>(
1311    object: &'a Map<String, Value>,
1312    field: &str,
1313    parent_path: &str,
1314) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1315    object.get(field).ok_or_else(|| {
1316        protocol_error(
1317            SolveErrorCodeV1::MissingField,
1318            format!("missing required field `{field}`"),
1319            child_path(parent_path, field),
1320        )
1321    })
1322}
1323
1324fn validate_members(
1325    object: &Map<String, Value>,
1326    path: &str,
1327    allowed: &[&str],
1328    required: &[&str],
1329) -> Result<(), SolveErrorEnvelopeV1> {
1330    if let Some(field) = object
1331        .keys()
1332        .find(|field| !allowed.contains(&field.as_str()))
1333    {
1334        return Err(protocol_error(
1335            SolveErrorCodeV1::UnknownField,
1336            format!("unknown field `{field}`"),
1337            child_path(path, field),
1338        ));
1339    }
1340
1341    if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1342        return Err(protocol_error(
1343            SolveErrorCodeV1::MissingField,
1344            format!("missing required field `{field}`"),
1345            child_path(path, field),
1346        ));
1347    }
1348    Ok(())
1349}
1350
1351fn validate_string_enum(
1352    value: &Value,
1353    path: &str,
1354    allowed: &[&str],
1355) -> Result<(), SolveErrorEnvelopeV1> {
1356    let Some(value) = value.as_str() else {
1357        return Err(protocol_error(
1358            SolveErrorCodeV1::InvalidValue,
1359            "expected a string enum value",
1360            path,
1361        ));
1362    };
1363    if !allowed.contains(&value) {
1364        return Err(protocol_error(
1365            SolveErrorCodeV1::InvalidValue,
1366            format!(
1367                "invalid value `{value}`; expected one of {}",
1368                allowed.join(", ")
1369            ),
1370            path,
1371        ));
1372    }
1373    Ok(())
1374}
1375
1376fn validate_required_numbers(
1377    object: &Map<String, Value>,
1378    parent_path: &str,
1379    fields: &[&str],
1380) -> Result<(), SolveErrorEnvelopeV1> {
1381    for field in fields {
1382        let value = required_value(object, field, parent_path)?;
1383        validate_number(value, &child_path(parent_path, field))?;
1384    }
1385    Ok(())
1386}
1387
1388fn validate_optional_numbers(
1389    object: &Map<String, Value>,
1390    parent_path: &str,
1391    fields: &[&str],
1392) -> Result<(), SolveErrorEnvelopeV1> {
1393    for field in fields {
1394        validate_optional_number(object, parent_path, field)?;
1395    }
1396    Ok(())
1397}
1398
1399fn validate_optional_number(
1400    object: &Map<String, Value>,
1401    parent_path: &str,
1402    field: &str,
1403) -> Result<(), SolveErrorEnvelopeV1> {
1404    if let Some(value) = object.get(field) {
1405        validate_number(value, &child_path(parent_path, field))?;
1406    }
1407    Ok(())
1408}
1409
1410fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1411    if value.is_number() {
1412        Ok(())
1413    } else {
1414        Err(protocol_error(
1415            SolveErrorCodeV1::InvalidValue,
1416            "expected a number",
1417            path,
1418        ))
1419    }
1420}
1421
1422fn validate_optional_booleans(
1423    object: &Map<String, Value>,
1424    parent_path: &str,
1425    fields: &[&str],
1426) -> Result<(), SolveErrorEnvelopeV1> {
1427    for field in fields {
1428        if let Some(value) = object.get(*field) {
1429            if !value.is_boolean() {
1430                return Err(protocol_error(
1431                    SolveErrorCodeV1::InvalidValue,
1432                    "expected a boolean",
1433                    child_path(parent_path, field),
1434                ));
1435            }
1436        }
1437    }
1438    Ok(())
1439}
1440
1441fn child_path(parent: &str, field: &str) -> String {
1442    format!("{parent}.{field}")
1443}
1444
1445/// MBA-1413: physical bounds on the projectile fields.
1446#[cfg(test)]
1447mod request_range_tests {
1448    use super::*;
1449
1450    /// A complete, ordinary request. Every range test below starts from this and perturbs one
1451    /// field, so a bound that accidentally rejects real data fails loudly here first.
1452    fn valid_request_json() -> String {
1453        r#"{"schema_version":1,
1454            "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1455                          "drag_model":"G7","ballistic_coefficient":0.243},
1456            "rifle":{"muzzle_velocity_mps":823.0},
1457            "shot":{"max_range_m":1000.0},
1458            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1459            .to_string()
1460    }
1461
1462    fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1463        decode_solve_request_v1(json).expect_err("request should have been rejected")
1464    }
1465
1466    #[test]
1467    fn an_ordinary_request_still_decodes() {
1468        decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1469    }
1470
1471    /// The exact input cargo-fuzz found (MBA-1413). It declared a projectile mass of about
1472    /// 1.1e-66 kg — roughly 10^40 times lighter than a proton — and the engine accepted and
1473    /// solved it. The visible symptom was that the request did not survive a JSON round trip,
1474    /// because serde_json's float parser is a bit off re-reading its own output at that
1475    /// magnitude; the actual defect was accepting the number at all.
1476    #[test]
1477    fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1478        let reproducer = r#"{"schema_version":1,
1479            "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1480                          "drag_model":"G7","ballistic_coefficient":1.2e2},
1481            "rifle":{"muzzle_velocity_mps":823.0},
1482            "shot":{"max_range_m":100.0},
1483            "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1484
1485        let envelope = decode_err(reproducer);
1486        assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1487        assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1488    }
1489
1490    /// An error envelope must itself round-trip, since that is the invariant the fuzz target
1491    /// asserts on the rejection branch.
1492    #[test]
1493    fn the_rejection_envelope_round_trips() {
1494        let envelope = decode_err(
1495            r#"{"schema_version":1,
1496                "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1497                              "drag_model":"G7","ballistic_coefficient":0.243},
1498                "rifle":{"muzzle_velocity_mps":823.0},
1499                "shot":{"max_range_m":100.0},
1500                "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1501        );
1502        let encoded = serde_json::to_string(&envelope).expect("serialize");
1503        let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1504        assert_eq!(decoded, envelope);
1505    }
1506
1507    #[test]
1508    fn each_bounded_field_reports_its_own_path() {
1509        for (field, bad_value, path) in [
1510            ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1511            ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1512            (
1513                "ballistic_coefficient",
1514                "1.0e6",
1515                "$.projectile.ballistic_coefficient",
1516            ),
1517            ("length_m", "1.0e-9", "$.projectile.length_m"),
1518        ] {
1519            let json = valid_request_json().replace(
1520                &format!("\"{field}\":{}", default_for(field)),
1521                &format!("\"{field}\":{bad_value}"),
1522            );
1523            let envelope = decode_err(&json);
1524            assert_eq!(
1525                envelope.error.path(),
1526                Some(path),
1527                "wrong path for {field}"
1528            );
1529            assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1530        }
1531    }
1532
1533    fn default_for(field: &str) -> &'static str {
1534        match field {
1535            "mass_kg" => "0.01134",
1536            "diameter_m" => "0.00782",
1537            "ballistic_coefficient" => "0.243",
1538            "length_m" => "0.031",
1539            other => panic!("no default recorded for {other}"),
1540        }
1541    }
1542
1543    /// Muzzle velocity is deliberately unbounded: the MCP server documents and tests a split
1544    /// where a structurally valid request the engine cannot solve returns a tool error rather
1545    /// than a protocol error, using an absurd muzzle velocity as its example.
1546    #[test]
1547    fn muzzle_velocity_is_deliberately_left_unbounded() {
1548        let json = valid_request_json().replace("823.0", "1.0e308");
1549        decode_solve_request_v1(&json)
1550            .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1551    }
1552}