ballistics-engine 0.25.0

High-performance ballistics trajectory engine with professional physics
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# Solve JSON v1 contract

Solve JSON is the binding-neutral request and result contract for one deterministic trajectory
solve. It is separate from `BallisticInputs`, command-line profile JSON, FFI structs, and any
language binding. Those implementation APIs may change without changing this contract.

The Rust DTOs and the envelope-producing decoder are public in
`ballistics_engine::solve_json`. The transport-free Rust service is
`ballistics_engine::solve_v1`; it resolves defaults, validates physical and cross-field rules,
runs the engine, and constructs either a success value or a structured error envelope. Input and
output transport remain outside this contract.

## Process transport

The additive CLI transport reads one v1 request from standard input and writes one compact JSON
envelope followed by a newline to standard output:

```text
ballistics solve-json < request.json > response.json
```

Input is limited to 1 MiB (1,048,576 bytes); the exact limit is accepted. The command does not
read profiles, files, or the network. All dimensional fields remain explicit SI even when the
global `--units` option is supplied. Once command-line parsing has selected `solve-json`, stdout is
reserved exclusively for the protocol envelope; handled failures use the same v1 error shape as
the library service.

| Exit status | Meaning |
| --- | --- |
| `0` | Success envelope. |
| `1` | Standard-I/O or internal failure. |
| `2` | Malformed JSON, schema, shape, or semantic validation failure. |
| `3` | Resource limit or engine solve failure. |

Malformed JSON includes one-based `line` and `column` coordinates. A contained panic becomes a
generic `internal_error`; panic payloads and backtraces are never placed in the JSON envelope.
Failures before successful command selection (for example, an unknown command-line option) remain
ordinary command-line errors rather than protocol responses.

## Versioning and compatibility

Every request and envelope contains the integer:

```json
"schema_version": 1
```

Version 1 accepts only the value `1`; any other integer returns
`unsupported_schema_version`. Version dispatch happens before v1 field validation, so a v2
document containing fields unknown to v1 still receives `unsupported_schema_version`, not
`unknown_field`. The public Rust `SchemaVersionV1` invariant type always serializes as integer `1`
and deserializes only that integer.

All objects reject unknown fields. A producer must therefore emit only fields documented for v1,
and a consumer must not silently reinterpret a v1 field. Removing a field, changing a unit or
sign, renaming an enum value, or changing a field's meaning requires a new schema version.

The complete v1 wire surface is immutable: field names, JSON types, requiredness, omission and
default behavior, units, sign/frame conventions, enum values, error and notice codes, and request
and response object shapes cannot change under `schema_version: 1`. Because the response DTOs also
reject unknown fields, even an additive wire-field change requires a v2 schema rather than a
silent v1 extension.

Object member order, indentation, insignificant whitespace, human-readable diagnostic messages,
the engine version string, and the exact decimal spelling of floating-point values are not wire
contracts. Checked regression fixtures use
`abs(actual - expected) <= 1e-10 + 1e-9 * max(abs(actual), abs(expected))` to detect accidental
numeric drift. An intentional physics correction may update those expected values with its
regression evidence without requiring v2, provided the documented field meaning is unchanged.

Representative request, success, error, resource-limit, and early-termination documents live in
[`tests/fixtures/solve_json_v1`](../tests/fixtures/solve_json_v1/). Tests compare parsed object
shapes and tolerant numeric values rather than serialized bytes. A minimal Lattice process
consumer is checked in as
[`examples/solve_json_v1_lattice.lat`](../examples/solve_json_v1_lattice.lat); it intentionally
uses only a fixed trusted fixture until Lattice provides its shell-free argv/stdin process API.

Fields documented with defaults may be omitted inside their section. The request DTO preserves
that omission: serializing a decoded request does not insert defaults, and an explicitly supplied
value remains present even when it equals the documented default. This lets the solve service
distinguish caller intent while it performs one centralized resolution step. The eight top-level
request sections themselves are required so the shape remains explicit.

Omission means the member is not present. Explicit JSON `null` is invalid for every request field,
including fields that may be omitted; the envelope-producing decoder reports `invalid_value` at
that field's path rather than treating null as omission.

Success responses use a distinct `ResolvedSolveRequestV1` representation for
`resolved_request`. Every documented default is a required, concrete value there. Values that are
semantically inapplicable may remain absent: projectile length and latitude, for example, do not
acquire invented values. In particular, a successful response always materializes
`temperature_k`, `pressure_pa`, and the effective `shot.muzzle_angle_rad`, even when their input
members were omitted.

## Units and conventions

All dimensional input and result fields use SI units, made explicit by their suffix:

- `_kg` is kilograms, `_m` is metres, `_mps` is metres per second, and `_j` is joules.
- `_s` is seconds, `_pa` is pascals, `_k` is kelvin, and `_rad` is radians.
- `relative_humidity` is a dimensionless fraction from `0` through `1`.
- Wind angles are wind-from directions relative to the shot axis: `0` is a headwind and positive
  `pi / 2` is wind from the shooter's right.
- `drop_m` is positive below the line of sight.
- `windage_m` is positive to the shooter's right.
- `twist_direction` is viewed from the breech toward the muzzle.

Numbers must be finite. Physical ranges and service-level cross-field constraints are checked by
the solve service and reported as `invalid_value` or `conflicting_fields`; parsing a DTO does not
itself run a trajectory. The request decoder does enforce structural conflicts explicitly defined
by this contract, including the mutually exclusive effects below.

## Request

A representative request is:

```json
{
  "schema_version": 1,
  "projectile": {
    "mass_kg": 0.01134,
    "diameter_m": 0.00671,
    "length_m": 0.031,
    "drag_model": "G7",
    "ballistic_coefficient": 0.243
  },
  "rifle": {
    "muzzle_velocity_mps": 823.0,
    "sight_height_m": 0.0381,
    "muzzle_height_m": 0.0,
    "twist_rate_m_per_turn": 0.2032,
    "twist_direction": "right"
  },
  "shot": {
    "max_range_m": 1000.0,
    "zero_distance_m": 100.0,
    "aim_azimuth_rad": 0.0,
    "shot_azimuth_rad": 0.0,
    "shooting_angle_rad": 0.0,
    "cant_angle_rad": 0.0,
    "target_height_m": 0.0,
    "ground_threshold_m": -100.0
  },
  "atmosphere": {
    "altitude_m": 250.0,
    "temperature_k": 288.15,
    "pressure_pa": 101325.0,
    "relative_humidity": 0.5,
    "latitude_rad": 0.7853981633974483
  },
  "wind": {
    "speed_mps": 4.4704,
    "direction_from_rad": 1.5707963267948966,
    "vertical_speed_mps": 0.0
  },
  "solver": {
    "method": "rk45",
    "time_step_s": 0.001
  },
  "effects": {
    "magnus": false,
    "coriolis": true,
    "enhanced_spin_drift": true
  },
  "sampling": {
    "interval_m": 10.0
  }
}
```

### `projectile`

| Field | Required | Meaning |
| --- | --- | --- |
| `mass_kg` | yes | Projectile mass. |
| `diameter_m` | yes | Projectile diameter. |
| `length_m` | no | Projectile length; required by effects that need geometry. |
| `drag_model` | yes | One of `G1`, `G6`, `G7`, or `G8`. |
| `ballistic_coefficient` | yes | BC for the selected reference drag model. |

Only reference drag models backed by distinct tables in the engine are part of v1. The engine's
legacy `G2`, `G5`, `GI`, and `GS` enum values currently fall back to the G1 table, so v1 rejects
them rather than silently claiming a distinct model.

### `rifle`

| Field | Required | Default | Meaning |
| --- | --- | --- | --- |
| `muzzle_velocity_mps` | yes || Projectile speed at the muzzle. |
| `sight_height_m` | no | `0.05` | Sight height above the bore. |
| `muzzle_height_m` | no | `0` | Bore height above the ground reference. |
| `twist_rate_m_per_turn` | no | `0.3048` | Rifling travel per full turn. |
| `twist_direction` | no | `right` | `left` or `right`. |

### `shot`

| Field | Required | Default | Meaning |
| --- | --- | --- | --- |
| `max_range_m` | yes || Requested downrange termination distance. |
| `zero_distance_m` | no | absent | Solve the muzzle elevation for this zero distance. |
| `muzzle_angle_rad` | no | absent | Supply muzzle elevation directly. |
| `aim_azimuth_rad` | no | `0` | Small horizontal aim offset in the sight frame. |
| `shot_azimuth_rad` | no | `0` | Compass bearing used for Earth-rotation effects; `0` is north. |
| `shooting_angle_rad` | no | `0` | Uphill/downhill line-of-sight angle. |
| `cant_angle_rad` | no | `0` | Clockwise rifle cant is positive from the shooter's view. |
| `target_height_m` | no | `0` | Target height above the ground reference for zeroing. |
| `ground_threshold_m` | no | `-100` | Stop after the projectile falls below this height. |

`zero_distance_m` and `muzzle_angle_rad` conflict in an input request. A request supplies at most
one. When neither is present, the service uses a zero muzzle angle and records that assumption in
the response. In `resolved_request`, `muzzle_angle_rad` is always the effective angle used by the
engine. If the caller requested a zero distance, the resolved shot contains both the original
`zero_distance_m` intent and the muzzle angle calculated for it.

The zero search uses the request's resolved projectile, atmosphere, wind (including downrange
segments), effects, and integration method. It follows the engine's level-rifle convention by
solving with zero cant; the requested `cant_angle_rad` is applied only to the subsequent trajectory.
`target_height_m` remains an absolute world-vertical height above the local ground datum, as named
above; inclined zeroing projects the shot-frame trajectory back into that world frame.

### `atmosphere`

| Field | Required | Default | Meaning |
| --- | --- | --- | --- |
| `altitude_m` | no | `0` | Station altitude. |
| `temperature_k` | no | ICAO at `altitude_m` | Authoritative station temperature when present. |
| `pressure_pa` | no | ICAO at `altitude_m` | Authoritative station pressure when present. |
| `relative_humidity` | no | `0.5` | Relative-humidity fraction. |
| `latitude_rad` | no | absent | Geodetic latitude; needed when Coriolis is enabled. |

An empty atmosphere object selects ICAO standard conditions at the resolved altitude (sea level
when `altitude_m` is also omitted). Enabling Coriolis without a latitude is an `invalid_value`
error rather than a silently chosen latitude.

Explicit temperature and pressure values are authoritative station conditions, including values
equal to `288.15 K` and `101325 Pa` at nonzero altitude. The solve service must preserve that
explicit intent and must not apply the legacy CLI rule that treats those exact values as omitted
standard-atmosphere sentinels. MBA-1302 must bypass that CLI sentinel inference: omitted fields
select ICAO-at-altitude resolution, while present fields are passed as authoritative values. The
resolved values are recorded as explicit numbers in a successful response's
`resolved_request`; the resolved altitude and relative humidity are concrete there as well.

### `wind`

An empty object means still air. Constant wind uses:

- `speed_mps` and `direction_from_rad`, which must be supplied together;
- optional `vertical_speed_mps`, defaulting to zero when constant wind is selected.

Downrange wind uses `segments` instead:

```json
{
  "segments": [
    {
      "until_distance_m": 300.0,
      "speed_mps": 2.0,
      "direction_from_rad": 0.0,
      "vertical_speed_mps": 0.0
    },
    {
      "until_distance_m": 1000.0,
      "speed_mps": 5.0,
      "direction_from_rad": 1.5707963267948966
    }
  ]
}
```

Segment boundaries must increase strictly. `segments` conflicts with all three constant-wind
fields. A partial constant wind or overlapping segment boundaries are reported as
`conflicting_fields` or `invalid_value` by the service. Segments may end before the requested
range; the engine uses still air beyond the final boundary and the response includes a
`partial_wind_coverage` warning. Coverage is checked through the farther of `max_range_m` and an
applicable `zero_distance_m`, because the zero trial uses the same segmented wind.

Input presence is preserved for wind too: an omitted `segments` member is distinct from an
explicit array, and an omitted segment `vertical_speed_mps` remains absent until resolution.
Resolved wind is exactly one of two object shapes: a constant object with concrete `speed_mps`,
`direction_from_rad`, and `vertical_speed_mps`, or a segmented object whose segments each have a
concrete vertical speed. Still air resolves to the constant shape with all three values set to
zero.

### `solver`, `effects`, and `sampling`

| Section and field | Default | Meaning |
| --- | --- | --- |
| `solver.method` | `rk45` | `rk45`, `rk4`, or `euler`. |
| `solver.time_step_s` | `0.001` | Fixed step for RK4 and Euler; accepted but ignored by adaptive RK45. |
| `effects.magnus` | `false` | Enable the engine's Magnus-force model. |
| `effects.coriolis` | `false` | Enable Earth-rotation deflection. |
| `effects.enhanced_spin_drift` | `false` | Enable enhanced spin-drift modeling. |
| `sampling.interval_m` | `10` | Regular downrange result interval. |

Supplying `solver.time_step_s` with `rk45` is valid, but RK45 owns its adaptive step size. The
resolved request retains the supplied value and the response includes an
`rk45_time_step_ignored` warning.

A v1 success response contains at most 10,000 trajectory samples. Exactly 10,000 is valid;
10,001 is not. Sampling is evaluated against the trajectory's actual reached range, so an early
ground or time termination can keep a fine-grid response within the limit. If the completed
trajectory would produce more than 10,000 observations, the service fails with `resource_limit` at
`$.sampling.interval_m` before allocating or serializing the response. The service must not
truncate or thin the requested sample sequence to fit the limit.

Effects remain opt-in. The service may require projectile length, twist data, latitude, or other
documented prerequisites when a corresponding effect is enabled.

Magnus and enhanced spin drift are experimental engine models. Enabling either produces an
`experimental_effect` warning at the corresponding request path.

`effects.magnus` and `effects.enhanced_spin_drift` cannot both be true in v1. The engine's legacy
solver silently suppresses Magnus in that combination; the request decoder instead reports
`conflicting_fields` so the resolved request never misstates which physics ran.

## Success envelope

`status` is the literal `"ok"`. `engine_version` identifies the engine implementation that
produced the result. `resolved_request` is a `ResolvedSolveRequestV1`, not a replay of the
presence-aware input DTO: all rifle, shot, atmosphere, wind, solver, effects, and sampling
defaults are materialized so the calculation is reproducible. `assumptions` and `warnings` are
arrays of objects with a stable `code`, a human-readable `message`, and an optional request
`path`.

The service emits notices in deterministic request-field order. Stable v1 assumption codes are
`default_applied` for literal defaults, `icao_standard_temperature` and
`icao_standard_pressure` for omitted station values resolved from the requested altitude, and
`estimated_projectile_length` when the engine needs inferred projectile geometry. Stable v1
warning codes are `partial_wind_coverage`, `experimental_effect`, and
`rk45_time_step_ignored`. Messages are descriptive text rather than a compatibility surface.

```json
{
  "schema_version": 1,
  "engine_version": "0.24.1",
  "status": "ok",
  "resolved_request": { "...": "the complete v1 request" },
  "assumptions": [],
  "warnings": [],
  "summary": {
    "actual_range_m": 1000.0,
    "maximum_height_m": 3.2,
    "time_of_flight_s": 1.6,
    "terminal_speed_mps": 360.0,
    "terminal_energy_j": 734.8,
    "stability_factor": 1.5,
    "spin_drift_m": 0.12,
    "termination": "max_range"
  },
  "samples": [
    {
      "distance_m": 1000.0,
      "time_s": 1.6,
      "speed_mps": 360.0,
      "energy_j": 734.8,
      "drop_m": 8.1,
      "windage_m": 0.43,
      "mach": 1.06,
      "flags": ["transonic", "terminal"]
    }
  ]
}
```

Summary fields have fixed evaluation frames:

- `maximum_height_m` is the greatest world-vertical projectile height above the same local
  ground/reference datum used by `rifle.muzzle_height_m`. It is not the inclined shot frame's Y
  coordinate and is not height above the line of sight.
- `stability_factor` is the dimensionless muzzle gyroscopic stability factor Sg, evaluated after
  resolving projectile geometry, muzzle velocity, twist, and the station atmosphere. It is absent
  only when the service cannot calculate Sg from the resolved inputs.
- `spin_drift_m` is the signed gyroscopic spin-drift contribution at the terminal sample, positive
  to the shooter's right. It excludes wind drift and is absent when enhanced spin drift is disabled
  or cannot be calculated.

`summary.termination` is one of `max_range`, `ground_threshold`, `time_limit`, or
`velocity_floor`. The solve service must populate it from explicit termination metadata returned
by the engine. It must not infer a reason heuristically from the last distance, height, or speed.

Regular interval sampling never drops the terminal observation. `samples` includes the terminal
sample exactly once even when its distance is not an interval boundary, and that sample carries
the `terminal` flag. Sample flags are `transonic`, `subsonic`, `terminal`, and
`ground_threshold`. `transonic` denotes the inclusive Mach 0.8–1.2 band and `subsonic` denotes
Mach below 1.0, so both flags intentionally appear from Mach 0.8 through values just below 1.0.

Rust service implementations must call `SolveSuccessV1::validate_for_serialization` immediately
before encoding a success envelope. The corresponding public ceiling is
`MAX_SOLVE_JSON_SAMPLES_V1` (`10_000`). This explicit check lets the service return the structured
`resource_limit` error envelope. The DTO serializer also rejects an oversized `samples` array as a
fail-closed backstop, but that serializer error is intentionally not a substitute for the protocol
error envelope.

## Error envelope

`status` is the literal `"error"`. Structurally valid JSON uses `path` and null line/column fields;
malformed JSON uses one-based `line` and `column` and a null path. These forms are mutually
exclusive: a path cannot appear with a source location, line and column must appear together, and
neither source coordinate may be zero. Errors unrelated to a particular input location use null
for all three fields. Because `serde_json` reports column zero for some end-of-file errors, the
decoder normalizes that parser-only value to column one before creating the envelope.

```json
{
  "schema_version": 1,
  "status": "error",
  "error": {
    "code": "unknown_field",
    "message": "unknown field `balistic_coefficient`",
    "path": "$.projectile.balistic_coefficient",
    "line": null,
    "column": null
  }
}
```

The v1 error codes are:

| Code | Meaning |
| --- | --- |
| `invalid_json` | The input is not a JSON document; line and column identify the parser error. |
| `unsupported_schema_version` | `schema_version` is not `1`. |
| `unknown_field` | An object contains a field not defined by v1. |
| `missing_field` | A required field or top-level section is absent. |
| `invalid_value` | A value has the wrong type, range, enum, or physical validity. |
| `conflicting_fields` | Individually valid fields cannot be used together. |
| `resource_limit` | The request or requested result exceeds a documented service limit. |
| `solve_failed` | The engine could not complete a valid trajectory solve. |
| `io_error` | The transport could not read input or write output. |
| `internal_error` | An unexpected implementation failure was contained. |

Human-readable messages are diagnostic and are not a compatibility surface. Consumers branch on
`code` and may use `path`, `line`, and `column` to highlight the input.

`solve_v1` is deterministic and transport-free: it performs no filesystem or network access,
does not load profiles, and does not write to stdout or stderr.

## Deliberate v1 exclusions

V1 does not expose custom drag files or tables, velocity/Mach-dependent BC schedules, powder
temperature curves, atmosphere zones, cluster-BC degradation, wind shear, pitch damping,
precession/nutation, or angular diagnostics. It also does not expose arbitrary filesystem or
network access. Those features require a later schema or a separately versioned extension after
their input semantics and result provenance are stable.