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    serde_json::from_value(value).map_err(|error| {
914        envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
915    })
916}
917
918fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
919    SolveErrorEnvelopeV1::new(error)
920}
921
922fn protocol_error(
923    code: SolveErrorCodeV1,
924    message: impl Into<String>,
925    path: impl Into<String>,
926) -> SolveErrorEnvelopeV1 {
927    envelope(SolveErrorV1::new(code, message).at_path(path))
928}
929
930fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
931    let root = require_object(value, "$")?;
932
933    // Dispatch on the version before applying any v1-only shape rules. A future-version
934    // request may legitimately contain fields unknown to v1 and must still receive the stable
935    // unsupported-version error rather than an order-dependent unknown-field error.
936    validate_schema_version(root)?;
937    validate_members(
938        root,
939        "$",
940        &[
941            "schema_version",
942            "projectile",
943            "rifle",
944            "shot",
945            "atmosphere",
946            "wind",
947            "solver",
948            "effects",
949            "sampling",
950        ],
951        &[
952            "schema_version",
953            "projectile",
954            "rifle",
955            "shot",
956            "atmosphere",
957            "wind",
958            "solver",
959            "effects",
960            "sampling",
961        ],
962    )?;
963
964    validate_projectile(required_value(root, "projectile", "$")?)?;
965    validate_rifle(required_value(root, "rifle", "$")?)?;
966    validate_shot(required_value(root, "shot", "$")?)?;
967    validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
968    validate_wind(required_value(root, "wind", "$")?)?;
969    validate_solver(required_value(root, "solver", "$")?)?;
970    validate_effects(required_value(root, "effects", "$")?)?;
971    validate_sampling(required_value(root, "sampling", "$")?)?;
972    Ok(())
973}
974
975fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
976    let value = required_value(root, "schema_version", "$")?;
977    let version = if let Some(version) = value.as_i64() {
978        i128::from(version)
979    } else if let Some(version) = value.as_u64() {
980        i128::from(version)
981    } else {
982        return Err(protocol_error(
983            SolveErrorCodeV1::InvalidValue,
984            "schema_version must be the integer 1",
985            "$.schema_version",
986        ));
987    };
988    if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
989        return Err(protocol_error(
990            SolveErrorCodeV1::UnsupportedSchemaVersion,
991            format!(
992                "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
993            ),
994            "$.schema_version",
995        ));
996    }
997    Ok(())
998}
999
1000fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1001    let path = "$.projectile";
1002    let object = require_object(value, path)?;
1003    validate_members(
1004        object,
1005        path,
1006        &[
1007            "mass_kg",
1008            "diameter_m",
1009            "length_m",
1010            "drag_model",
1011            "ballistic_coefficient",
1012        ],
1013        &[
1014            "mass_kg",
1015            "diameter_m",
1016            "drag_model",
1017            "ballistic_coefficient",
1018        ],
1019    )?;
1020    validate_required_numbers(
1021        object,
1022        path,
1023        &["mass_kg", "diameter_m", "ballistic_coefficient"],
1024    )?;
1025    validate_optional_number(object, path, "length_m")?;
1026    validate_string_enum(
1027        required_value(object, "drag_model", path)?,
1028        "$.projectile.drag_model",
1029        &["G1", "G6", "G7", "G8"],
1030    )
1031}
1032
1033fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1034    let path = "$.rifle";
1035    let object = require_object(value, path)?;
1036    validate_members(
1037        object,
1038        path,
1039        &[
1040            "muzzle_velocity_mps",
1041            "sight_height_m",
1042            "muzzle_height_m",
1043            "twist_rate_m_per_turn",
1044            "twist_direction",
1045        ],
1046        &["muzzle_velocity_mps"],
1047    )?;
1048    validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1049    validate_optional_numbers(
1050        object,
1051        path,
1052        &["sight_height_m", "muzzle_height_m", "twist_rate_m_per_turn"],
1053    )?;
1054    if let Some(direction) = object.get("twist_direction") {
1055        validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1056    }
1057    Ok(())
1058}
1059
1060fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1061    let path = "$.shot";
1062    let object = require_object(value, path)?;
1063    validate_members(
1064        object,
1065        path,
1066        &[
1067            "max_range_m",
1068            "zero_distance_m",
1069            "muzzle_angle_rad",
1070            "aim_azimuth_rad",
1071            "shot_azimuth_rad",
1072            "shooting_angle_rad",
1073            "cant_angle_rad",
1074            "target_height_m",
1075            "ground_threshold_m",
1076        ],
1077        &["max_range_m"],
1078    )?;
1079    validate_required_numbers(object, path, &["max_range_m"])?;
1080    validate_optional_number(object, path, "zero_distance_m")?;
1081    validate_optional_number(object, path, "muzzle_angle_rad")?;
1082    validate_optional_numbers(
1083        object,
1084        path,
1085        &[
1086            "aim_azimuth_rad",
1087            "shot_azimuth_rad",
1088            "shooting_angle_rad",
1089            "cant_angle_rad",
1090            "target_height_m",
1091            "ground_threshold_m",
1092        ],
1093    )
1094}
1095
1096fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1097    let path = "$.atmosphere";
1098    let object = require_object(value, path)?;
1099    validate_members(
1100        object,
1101        path,
1102        &[
1103            "altitude_m",
1104            "temperature_k",
1105            "pressure_pa",
1106            "relative_humidity",
1107            "latitude_rad",
1108        ],
1109        &[],
1110    )?;
1111    validate_optional_numbers(
1112        object,
1113        path,
1114        &[
1115            "altitude_m",
1116            "temperature_k",
1117            "pressure_pa",
1118            "relative_humidity",
1119            "latitude_rad",
1120        ],
1121    )
1122}
1123
1124fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1125    let path = "$.wind";
1126    let object = require_object(value, path)?;
1127    validate_members(
1128        object,
1129        path,
1130        &[
1131            "speed_mps",
1132            "direction_from_rad",
1133            "vertical_speed_mps",
1134            "segments",
1135        ],
1136        &[],
1137    )?;
1138    for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1139        validate_optional_number(object, path, field)?;
1140    }
1141
1142    if let Some(segments) = object.get("segments") {
1143        let Some(segments) = segments.as_array() else {
1144            return Err(protocol_error(
1145                SolveErrorCodeV1::InvalidValue,
1146                "segments must be an array",
1147                "$.wind.segments",
1148            ));
1149        };
1150        for (index, segment) in segments.iter().enumerate() {
1151            let segment_path = format!("$.wind.segments[{index}]");
1152            let segment = require_object(segment, &segment_path)?;
1153            validate_members(
1154                segment,
1155                &segment_path,
1156                &[
1157                    "until_distance_m",
1158                    "speed_mps",
1159                    "direction_from_rad",
1160                    "vertical_speed_mps",
1161                ],
1162                &["until_distance_m", "speed_mps", "direction_from_rad"],
1163            )?;
1164            validate_required_numbers(
1165                segment,
1166                &segment_path,
1167                &["until_distance_m", "speed_mps", "direction_from_rad"],
1168            )?;
1169            validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1170        }
1171    }
1172    Ok(())
1173}
1174
1175fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1176    let path = "$.solver";
1177    let object = require_object(value, path)?;
1178    validate_members(object, path, &["method", "time_step_s"], &[])?;
1179    validate_optional_number(object, path, "time_step_s")?;
1180    if let Some(method) = object.get("method") {
1181        validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1182    }
1183    Ok(())
1184}
1185
1186fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1187    let path = "$.effects";
1188    let object = require_object(value, path)?;
1189    validate_members(
1190        object,
1191        path,
1192        &["magnus", "coriolis", "enhanced_spin_drift"],
1193        &[],
1194    )?;
1195    validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1196
1197    if object.get("magnus").and_then(Value::as_bool) == Some(true)
1198        && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1199    {
1200        return Err(protocol_error(
1201            SolveErrorCodeV1::ConflictingFields,
1202            "magnus and enhanced_spin_drift cannot both be enabled",
1203            "$.effects",
1204        ));
1205    }
1206
1207    Ok(())
1208}
1209
1210fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1211    let path = "$.sampling";
1212    let object = require_object(value, path)?;
1213    validate_members(object, path, &["interval_m"], &[])?;
1214    validate_optional_number(object, path, "interval_m")
1215}
1216
1217fn require_object<'a>(
1218    value: &'a Value,
1219    path: &str,
1220) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1221    value
1222        .as_object()
1223        .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1224}
1225
1226fn required_value<'a>(
1227    object: &'a Map<String, Value>,
1228    field: &str,
1229    parent_path: &str,
1230) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1231    object.get(field).ok_or_else(|| {
1232        protocol_error(
1233            SolveErrorCodeV1::MissingField,
1234            format!("missing required field `{field}`"),
1235            child_path(parent_path, field),
1236        )
1237    })
1238}
1239
1240fn validate_members(
1241    object: &Map<String, Value>,
1242    path: &str,
1243    allowed: &[&str],
1244    required: &[&str],
1245) -> Result<(), SolveErrorEnvelopeV1> {
1246    if let Some(field) = object
1247        .keys()
1248        .find(|field| !allowed.contains(&field.as_str()))
1249    {
1250        return Err(protocol_error(
1251            SolveErrorCodeV1::UnknownField,
1252            format!("unknown field `{field}`"),
1253            child_path(path, field),
1254        ));
1255    }
1256
1257    if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1258        return Err(protocol_error(
1259            SolveErrorCodeV1::MissingField,
1260            format!("missing required field `{field}`"),
1261            child_path(path, field),
1262        ));
1263    }
1264    Ok(())
1265}
1266
1267fn validate_string_enum(
1268    value: &Value,
1269    path: &str,
1270    allowed: &[&str],
1271) -> Result<(), SolveErrorEnvelopeV1> {
1272    let Some(value) = value.as_str() else {
1273        return Err(protocol_error(
1274            SolveErrorCodeV1::InvalidValue,
1275            "expected a string enum value",
1276            path,
1277        ));
1278    };
1279    if !allowed.contains(&value) {
1280        return Err(protocol_error(
1281            SolveErrorCodeV1::InvalidValue,
1282            format!(
1283                "invalid value `{value}`; expected one of {}",
1284                allowed.join(", ")
1285            ),
1286            path,
1287        ));
1288    }
1289    Ok(())
1290}
1291
1292fn validate_required_numbers(
1293    object: &Map<String, Value>,
1294    parent_path: &str,
1295    fields: &[&str],
1296) -> Result<(), SolveErrorEnvelopeV1> {
1297    for field in fields {
1298        let value = required_value(object, field, parent_path)?;
1299        validate_number(value, &child_path(parent_path, field))?;
1300    }
1301    Ok(())
1302}
1303
1304fn validate_optional_numbers(
1305    object: &Map<String, Value>,
1306    parent_path: &str,
1307    fields: &[&str],
1308) -> Result<(), SolveErrorEnvelopeV1> {
1309    for field in fields {
1310        validate_optional_number(object, parent_path, field)?;
1311    }
1312    Ok(())
1313}
1314
1315fn validate_optional_number(
1316    object: &Map<String, Value>,
1317    parent_path: &str,
1318    field: &str,
1319) -> Result<(), SolveErrorEnvelopeV1> {
1320    if let Some(value) = object.get(field) {
1321        validate_number(value, &child_path(parent_path, field))?;
1322    }
1323    Ok(())
1324}
1325
1326fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1327    if value.is_number() {
1328        Ok(())
1329    } else {
1330        Err(protocol_error(
1331            SolveErrorCodeV1::InvalidValue,
1332            "expected a number",
1333            path,
1334        ))
1335    }
1336}
1337
1338fn validate_optional_booleans(
1339    object: &Map<String, Value>,
1340    parent_path: &str,
1341    fields: &[&str],
1342) -> Result<(), SolveErrorEnvelopeV1> {
1343    for field in fields {
1344        if let Some(value) = object.get(*field) {
1345            if !value.is_boolean() {
1346                return Err(protocol_error(
1347                    SolveErrorCodeV1::InvalidValue,
1348                    "expected a boolean",
1349                    child_path(parent_path, field),
1350                ));
1351            }
1352        }
1353    }
1354    Ok(())
1355}
1356
1357fn child_path(parent: &str, field: &str) -> String {
1358    format!("{parent}.{field}")
1359}