# Ballistics Engine Validation — User Guide

How to run, read, and extend the ballistics engine validation suite. This guide is
operational only; it does not document engine internals.

The suite checks two things, treating the engine as a black box:

1. **Physics accuracy** — does the engine's pure-physics trajectory agree with trusted
   references (independent solvers, and where available confirmed field-fired data)?
2. **DOPE-correction accuracy** — given recorded come-ups, does the engine predict a
   held-out point more accurately than pure physics alone?

---

## Quick start

Open a terminal and run the validation from either repo (the project prefix differs —
see [Running the validation](#running-the-validation)):

```sh
cd scopedope-android   &&  ./gradlew :shared:ballistics:jvmTest
# or, from the standalone shared repo:
cd scopedope-shared    &&  ./gradlew :ballistics:jvmTest
```

One run validates the whole suite — calibrated reference cases **and** the imported
real-world fired-DOPE cases, each scored against the band appropriate to it. A passing run
prints `BUILD SUCCESSFUL` and writes a timestamped report to
`ballistics/reports/ballistics-validation-<timestamp>.md`. No setup beyond a supported
JDK — see [Prerequisites](#prerequisites).

---

## Where things live

| Path | What |
|---|---|
| `ballistics/src/commonMain/…` | The engine under test (KMP `:ballistics` module) |
| `ballistics/src/jvmTest/…` | The validation test + cross-validation runner + report writer |
| `ballistics-test-data/test_cases/` | All validation cases — calibrated reference + imported real-world fired DOPE (prefixed `real_`) |
| `ballistics-test-data/tools/` | Helper scripts (reference regeneration, real-data import) |
| `ballistics/reports/…` | Timestamped Markdown validation reports (persistent — survive `gradle clean`) |
| `ballistics/build/…` | Gradle's JUnit XML + auto-generated HTML test reports (git-ignored, ephemeral) |

`scopedope-shared` is consumed by `scopedope-android` as the `shared/` git submodule, so
the same files also appear under `scopedope-android/shared/…`.

---

## Prerequisites

A supported JDK (the build runs on **JDK 21**; the system default may be newer than the
build tooling supports). The Gradle JVM is pinned in `gradle.properties`, so no manual
`JAVA_HOME` is needed when running from this repo.

---

## Running the validation

A single task runs the whole suite. **The task path depends on which repo you run from:**

| Run from | Command |
|---|---|
| `scopedope-shared` | `./gradlew :ballistics:jvmTest` |
| `scopedope-android` | `./gradlew :shared:ballistics:jvmTest` |

It runs every case in `test_cases/` — calibrated and real-world — and **fails the build**
if the primary verdict is `FAIL`. Each case is scored against the band appropriate to it
(see [Verdict bands](#verdict-bands)), so noisy real-world data is held to field-realistic
tolerances rather than the strict reference band.

The task **always re-runs** (it is a validation/report task, not a cached unit test), so
each invocation executes the suite and writes a fresh timestamped report — you do not need
`--rerun-tasks`.

---

## The dataset

All cases live in `test_cases/` and run in one suite. They come in two kinds, distinguished
by the `realworld` flag in each case (see schema below):

| | Calibrated reference | Real-world (`realworld: true`) |
|---|---|---|
| Source | Curated reference trajectories + a little confirmed-fired data | Verified user-attributed field DOPE, imported from the separate real-data dataset |
| File prefix | caliber (e.g. `308_…`) | `real_` |
| Tolerance band | Strict (solver/click floor) | Wide (scope-click reality) |
| Authoring | By hand | Generated — re-run the importer, do not hand-edit |

Both kinds gate the build, each against its own band.

---

## Test-case JSON schema

Each case is one JSON file. Required fields unless noted:

```json
{
  "id": "308_fed175_smk",
  "caliber": ".308 Winchester",
  "load_name": "Federal 175gr Gold Medal Match SMK",
  "bc_model": "G7",                 // "G1" or "G7"
  "bc": 0.243,
  "bc_source": "…",                 // optional, free text
  "mv_fps": 2613,
  "mv_source": "…",                 // optional, free text
  "bullet_weight_gr": 175.0,
  "zero_yards": 100,
  "sight_height_in": 1.5,
  "twist_rate_in": 10.0,            // inches per turn; 0 if unknown
  "conditions": {
    "altitude_ft": 0,
    "temp_f": 59.0,
    "pressure_hpa": 0,              // 0 = derive from altitude
    "humidity_pct": 0,
    "confirmed_fields": []          // which of the 4 fields are actually known
  },
  "dope_points": [
    { "yards": 200, "mil": 0.62, "moa": 2.13, "source": "…", "fired": false }
  ],
  "tier": "Sub-3",                  // "1" | "2" | "3" | "Sub-3"
  "realworld": false,               // true = real field DOPE; use the wider field band
  "data_notes": "…",
  "source_urls": []                 // optional
}
```

Notes:
- **`mil`** is the elevation come-up (positive = dial up). `moa` is the same value in MOA.
- **`fired: true`** marks a point that came from a physical impact (not a calculator).
- **`tier`** reflects data quality, not engine quality:
  - `1` / `2` / `3` — has confirmed fired impacts, with decreasing environmental completeness.
  - `Sub-3` — no confirmed fired impacts (reference comparison only).
- `confirmed_fields` lists which of `altitude_ft`, `temp_f`, `pressure_hpa`, `humidity_pct`
  are actually known versus assumed.

---

## How cases are scored

For each case the runner performs **exhaustive C(N,k) cross-validation**: it repeatedly
hides subsets of the recorded points, predicts them from the rest, and measures the error.

- **Primary verdict — `k=1` interior leave-one-out:** hide one point that the remaining
  points bracket, and predict it. This is the realistic interpolation scenario and is what
  gates the build.
- **Stress-test verdict — all `k`:** the worst case across every subset size, down to the
  minimum training set. Larger errors here are expected and document graceful degradation,
  not regressions.

Cases with fewer than 4 usable points are reported but skipped for cross-validation.

### Verdict bands

Each prediction error is labeled `OK` / `INVESTIGATE` / `FAIL`. Transonic-zone points get
2× the tolerance.

The band a case is scored against is chosen **per case**:

| Case kind | OK | INVESTIGATE | FAIL |
|---|---|---|---|
| Calibrated reference (`tier` ≠ `3`) | < 0.025 mil | 0.025–0.05 mil | ≥ 0.05 mil |
| Calibrated confirmed-fired (`tier` = `3`) | < 0.05 mil | 0.05–0.10 mil | ≥ 0.10 mil |
| Real-world (`realworld: true`) | < 0.10 mil | 0.10–0.20 mil | ≥ 0.20 mil |

The real-world band is intentionally wider: field DOPE is recorded to scope-click resolution
(0.1 mil = one click) and is sparser, so it cannot meet the reference bands.

---

## Reading the report

After a run, open the timestamped Markdown report:

- `ballistics/reports/ballistics-validation-<timestamp>.md`

It contains an executive summary (case counts, max deltas, primary/stress verdicts), a tier
table, and a per-case breakdown of interior vs. extrapolated predictions with verdicts.
JUnit XML for CI is under `ballistics/build/test-results/`.

> Sparse cases (few points spread over wide range gaps) can show large interior errors purely
> from interpolating across the gaps — that is a data-density limitation, not an engine fault.
> Real-world cases that cannot be cross-validated within the field band are excluded by the
> importer (see below) rather than allowed to fail the gate.

---

## Importing real-world DOPE

`tools/import_real_dope.py` converts verified field DOPE from the separate real-data dataset
into `test_cases/` as `real_*.json` (each stamped `realworld: true`). It is idempotent and
dry-runs by default:

```sh
cd ballistics-test-data/tools
python3 import_real_dope.py            # dry run — prints the cases it would generate
python3 import_real_dope.py --write    # write real_*.json into ../test_cases/
python3 import_real_dope.py --db /path/to/dataset.db   # custom dataset location
```

Sessions too sparse/rounded to cross-validate within the field band are skipped via the
tool's `EXCLUDE_SESSIONS` set, so the merged gating suite stays clean.

It selects cross-validation-ready sessions (verified provenance, enough points, a recorded
zero and muzzle velocity), normalizes units and sign conventions, classifies the tier from
environmental completeness, and writes one file per session. Re-run it whenever the source
dataset changes. (The source dataset itself lives outside this repo and is not required to
run the validation tasks.)

---

## Adding a case by hand

1. Create a JSON file in `test_cases/` following the schema above.
2. Provide at least 4 usable points (beyond the zero) for cross-validation.
3. Set `tier` honestly and list any known environment in `confirmed_fields`.
4. Re-run the calibrated suite and review the report.

---

## Troubleshooting

| Symptom | Fix |
|---|---|
| Gradle fails immediately with a Java version string (e.g. `25.0.x`) | The pinned JDK in `gradle.properties` is missing/invalid — fix the path or set `JAVA_HOME` to a supported JDK. |
| `WARNING: …restricted method…` / `openjdk version 25` lines, then it seems to do nothing | Harmless. The `gradlew` launcher is on your shell's newer JDK (the warnings), but the build runs on the pinned JDK. The suite still executes and prints `Loaded N test cases` + verdicts; to silence the warnings, `export JAVA_HOME` to the pinned JDK before running. |
| `project 'ballistics' not found` | Wrong task path for the repo you are in — use `:ballistics:…` from `scopedope-shared`, `:shared:ballistics:…` from `scopedope-android`. |
| `No test cases found` | The data directory is empty or the wrong path is wired — confirm `test_cases/` is populated. |
| New cases not picked up in `scopedope-android` | The `shared/` submodule is pinned — commit in `scopedope-shared` and update the submodule pointer. |
