diff --git a/CHANGELOG.md b/CHANGELOG.md index d5dadc5..70afc23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to semantic versioning once public releases begin. ### Added +- Contract-version discovery command (Ticket 105). A new read-only `bvlos-sim + schema-versions` command (alias `contracts`) prints the resolved `tool_version` + plus every supported output-envelope and input-schema version as canonical JSON + and exits `0` without loading any mission, vehicle, or asset file. A backend can + call it at startup to pin and check contract compatibility instead of running a + full job to read versions off an envelope. Every printed version is sourced from + the same module constant the envelopes emit (a drift test asserts this), so the + map cannot silently diverge from a real run. `--version` is unchanged. - Atomic output writes and clean cancellation (Ticket 104). Every `--output` write and every on-disk artifact writer (flight trace, phase segments, validation report, calibration profile, SITL artifacts) now writes to a sibling diff --git a/adapters/cli.py b/adapters/cli.py index 51ca919..53cda55 100644 --- a/adapters/cli.py +++ b/adapters/cli.py @@ -167,6 +167,7 @@ def _register_commands() -> None: from adapters.commands.propagate import propagate from adapters.commands.sample import sample from adapters.commands.scenario import scenario + from adapters.commands.schema_versions import schema_versions from adapters.commands.sitl import sitl from adapters.commands.size_battery import size_battery from adapters.commands.sora import sora @@ -186,6 +187,8 @@ def _register_commands() -> None: app.command()(validate) app.command()(calibrate) app.command()(bump) + app.command("schema-versions")(schema_versions) + app.command("contracts")(schema_versions) _register_commands() diff --git a/adapters/commands/schema_versions.py b/adapters/commands/schema_versions.py new file mode 100644 index 0000000..110b3e0 --- /dev/null +++ b/adapters/commands/schema_versions.py @@ -0,0 +1,107 @@ +"""Read-only contract-version discovery command. + +Prints the supported input and output contract versions plus the tool version as +canonical JSON, then exits 0 without loading any mission or vehicle file. A +backend can call this at startup to pin and check contract compatibility instead +of inferring versions from a full run's envelope. +""" + +from typing import get_args + +import typer + +import adapters.cli as cli +from adapters.battery_sizing_envelope import BATTERY_SIZING_REPORT_SCHEMA_VERSION +from adapters.canonical_json import render_canonical_json +from adapters.envelope import ( + GEOFENCE_SCHEMA_VERSION, + LANDING_ZONE_SCHEMA_VERSION, + MISSION_SCHEMA_VERSION, + POPULATION_SCHEMA_VERSION, + RESULT_ENVELOPE_SCHEMA_VERSION, + TERRAIN_SCHEMA_VERSION, + VEHICLE_SCHEMA_VERSION, + WIND_GRID_SCHEMA_VERSION, +) +from adapters.scenario_envelope import ( + SCENARIO_INPUT_SCHEMA_VERSION, + SCENARIO_REPORT_SCHEMA_VERSION, +) +from adapters.sitl.evidence import SITL_EVIDENCE_SCHEMA_VERSION +from adapters.stochastic_envelope import ( + STOCHASTIC_ENVELOPE_SCHEMA_VERSION, + STOCHASTIC_INPUT_SCHEMA_VERSION, +) +from adapters.sora_envelope import SORA_ENVELOPE_SCHEMA_VERSION +from adapters.uncertainty_envelope import ( + UNCERTAINTY_INPUT_SCHEMA_VERSION, + UNCERTAINTY_REPORT_SCHEMA_VERSION, +) +from adapters.version import tool_version +from schemas.batch import BatchManifest +from schemas.calibration import CALIBRATION_PROFILE_SCHEMA_VERSION +from schemas.flight_log import FLIGHT_TRACE_SCHEMA_VERSION +from schemas.phase_segment import PHASE_SEGMENT_SCHEMA_VERSION +from schemas.sitl_comparison import SITL_COMPARISON_SCHEMA_VERSION +from schemas.sora import SORA_ASSESSMENT_SCHEMA_VERSION +from schemas.validation import VALIDATION_REPORT_SCHEMA_VERSION + +# The batch manifest carries its version as a Literal field rather than a named +# constant; source it from the field annotation so the discovery map cannot drift +# from what the loader actually accepts, without re-stating the string here. +BATCH_INPUT_SCHEMA_VERSION = get_args( + BatchManifest.model_fields["format_version"].annotation +)[0] + + +def _output_envelope_versions() -> dict[str, str]: + return { + "estimator": RESULT_ENVELOPE_SCHEMA_VERSION, + "scenario_report": SCENARIO_REPORT_SCHEMA_VERSION, + "uncertainty_report": UNCERTAINTY_REPORT_SCHEMA_VERSION, + "stochastic_envelope": STOCHASTIC_ENVELOPE_SCHEMA_VERSION, + "sora_envelope": SORA_ENVELOPE_SCHEMA_VERSION, + "battery_sizing_report": BATTERY_SIZING_REPORT_SCHEMA_VERSION, + "sitl_evidence": SITL_EVIDENCE_SCHEMA_VERSION, + "sitl_comparison": SITL_COMPARISON_SCHEMA_VERSION, + "validation_report": VALIDATION_REPORT_SCHEMA_VERSION, + "calibration_profile": CALIBRATION_PROFILE_SCHEMA_VERSION, + "flight_trace": FLIGHT_TRACE_SCHEMA_VERSION, + "phase_segments": PHASE_SEGMENT_SCHEMA_VERSION, + "sora_assessment": SORA_ASSESSMENT_SCHEMA_VERSION, + } + + +def _input_schema_versions() -> dict[str, str]: + return { + "mission": MISSION_SCHEMA_VERSION, + "vehicle": VEHICLE_SCHEMA_VERSION, + "geofences": GEOFENCE_SCHEMA_VERSION, + "landing_zones": LANDING_ZONE_SCHEMA_VERSION, + "terrain": TERRAIN_SCHEMA_VERSION, + "population": POPULATION_SCHEMA_VERSION, + "wind_grid": WIND_GRID_SCHEMA_VERSION, + "scenario": SCENARIO_INPUT_SCHEMA_VERSION, + "uncertainty": UNCERTAINTY_INPUT_SCHEMA_VERSION, + "stochastic": STOCHASTIC_INPUT_SCHEMA_VERSION, + "batch": BATCH_INPUT_SCHEMA_VERSION, + } + + +def _contract_versions() -> dict[str, object]: + return { + "tool_version": tool_version(), + "output_envelopes": _output_envelope_versions(), + "input_schemas": _input_schema_versions(), + } + + +def schema_versions() -> None: + """Print supported contract versions as canonical JSON and exit 0. + + Read-only discovery: loads no mission, vehicle, or asset file. The printed + versions are sourced from the same module constants the envelopes emit, so the + map cannot drift from what a real run would produce. + """ + typer.echo(render_canonical_json(_contract_versions()), nl=False) + raise typer.Exit(code=int(cli.CliExitCode.SUCCESS)) diff --git a/docs/CLI_EXIT_CODES.md b/docs/CLI_EXIT_CODES.md index ef409c5..eb18e8c 100644 --- a/docs/CLI_EXIT_CODES.md +++ b/docs/CLI_EXIT_CODES.md @@ -59,6 +59,7 @@ Python's default `KeyboardInterrupt` behaviour. | `convert` | ✓ | | ✓ | | ✓ | A missing/blank `--vehicle-profile` and parse errors are `11`. | | `sitl` | ✓ | | ✓ | | ✓ | Adapter and asset-load errors are `11`. | | `bump` | ✓ | | ✓ | | | Developer-only release tool. `11` on drift or a missing version part. | +| `schema-versions` | ✓ | | | | | Read-only contract discovery (alias `contracts`). Loads no input; always `0`. | ## Divergences to branch on carefully diff --git a/docs/USAGE.md b/docs/USAGE.md index afbe61b..594f5e5 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -23,7 +23,7 @@ uv run bvlos-sim --help ## CLI Commands -bvlos-sim exposes fourteen commands: +bvlos-sim exposes fifteen commands: - `estimate`: run deterministic mission estimation and static feasibility checks - `size-battery`: compute the minimum battery capacity needed for feasibility @@ -38,6 +38,7 @@ bvlos-sim exposes fourteen commands: - `sora`: run the SORA pre-assessment (Ground Risk, Air Risk, and SAIL) - `validate`: compare a predicted mission estimate against an observed flight trace - `calibrate`: fit a calibration profile from a base vehicle and observed flight traces +- `schema-versions` (alias `contracts`): print supported input/output contract versions as JSON - `bump`: bump the project version and roll the changelog (release tooling) | Command | Exit 0 | Exit 10 | Exit 11 | Exit 12 | Exit 13 | @@ -55,6 +56,7 @@ bvlos-sim exposes fourteen commands: | sora | success | - | invalid input | - | internal error | | validate | success | - | invalid input | - | internal error | | calibrate | success | - | invalid input | - | internal error | +| schema-versions | success | - | - | - | - | | bump | success / consistent | - | invalid input / drift | - | internal error | [`CLI_EXIT_CODES.md`](CLI_EXIT_CODES.md) is the authoritative per-command @@ -996,6 +998,60 @@ uv run bvlos-sim validate mission.yaml vehicle.yaml trace.json --calibration cal See `examples/calibration/` for a full ingestion → segmentation → fitting → apply walkthrough. +## Contract Discovery (`schema-versions`) + +`schema-versions` (alias `contracts`) prints the supported input and output +contract versions plus the resolved `tool_version` as canonical JSON, then exits +`0` without loading any mission, vehicle, or asset file. A backend can call it at +startup to pin and check contract compatibility instead of running a full job to +read the versions off an envelope. + +```bash +uv run bvlos-sim schema-versions +# alias: +uv run bvlos-sim contracts +``` + +Sample output (versions sourced from the same constants the envelopes emit, so +they cannot drift from a real run): + +```json +{ + "input_schemas": { + "batch": "batch.v1", + "geofences": "geofence-geojson.v1", + "landing_zones": "landing-zone-geojson.v1", + "mission": "mission.v6", + "population": "population-grid.v1", + "scenario": "scenario.v1", + "stochastic": "stochastic.v1", + "terrain": "terrain-grid.v1", + "uncertainty": "uncertainty.v1", + "vehicle": "vehicle.v4", + "wind_grid": "wind-grid.v1" + }, + "output_envelopes": { + "battery_sizing_report": "battery-sizing-report.v1", + "calibration_profile": "calibration-profile.v1", + "estimator": "estimator-envelope.v7", + "flight_trace": "flight-trace.v1", + "phase_segments": "phase-segments.v1", + "scenario_report": "scenario-report.v2", + "sitl_comparison": "sitl-comparison.v1", + "sitl_evidence": "sitl-evidence.v1", + "sora_assessment": "sora-assessment.v1", + "sora_envelope": "sora-envelope.v1", + "stochastic_envelope": "stochastic-envelope.v1", + "uncertainty_report": "uncertainty-report.v1", + "validation_report": "validation-report.v1" + }, + "tool_version": "0.32.0" +} +``` + +The command is read-only and always exits `0`; `--version` is unchanged and +still prints the plain `bvlos-sim ` line. + ## Releasing (`bump`) Cut a release in one reviewed step. `bump` bumps the version and rolls the diff --git a/docs/tickets/105-contract-version-discovery-command.md b/docs/tickets/105-contract-version-discovery-command.md index 1d43545..d738d94 100644 --- a/docs/tickets/105-contract-version-discovery-command.md +++ b/docs/tickets/105-contract-version-discovery-command.md @@ -2,7 +2,7 @@ ## Status -Planned. +Implemented. ## Goal @@ -59,3 +59,71 @@ version discovery to a successful run. needs its own golden-fixture and version review: `_input_schema_versions()` omits `obstacles` (`adapters/envelope.py:504`), and the battery-sizing envelope carries no input schema-version field. Track them separately if pursued. + +## Implementation + +### New files + +| File | Purpose | +|------|---------| +| `adapters/commands/schema_versions.py` | The `schema_versions` command: builds the `tool_version` / `output_envelopes` / `input_schemas` map from imported constants and prints it as canonical JSON. | +| `tests/test_schema_versions.py` | Exit-code, JSON-shape, drift-guard, alias-equivalence, no-file-argument, determinism, and `--version`-unchanged tests. | + +### Command and alias + +`schema-versions` is registered in `adapters/cli.py:_register_commands()` with +both its canonical name and the `contracts` alias, mirroring how `size-battery` +is registered with an explicit name: + +```python +app.command("schema-versions")(schema_versions) +app.command("contracts")(schema_versions) +``` + +The command takes no arguments or options. It loads nothing, calls +`tool_version()`, renders the version map with `render_canonical_json` (sorted +keys, stable float precision, trailing newline), and exits `0`. `--version` is +untouched and still prints the plain `bvlos-sim ` line. + +### Constants sourced (no string is restated) + +Every printed version is imported from the module that owns it, so the map cannot +drift from what a real run emits; `tests/test_schema_versions.py` asserts the +printed value equals each imported constant. + +- Output envelopes: `RESULT_ENVELOPE_SCHEMA_VERSION`, + `SCENARIO_REPORT_SCHEMA_VERSION`, `UNCERTAINTY_REPORT_SCHEMA_VERSION`, + `STOCHASTIC_ENVELOPE_SCHEMA_VERSION`, `SORA_ENVELOPE_SCHEMA_VERSION`, + `BATTERY_SIZING_REPORT_SCHEMA_VERSION`, `SITL_EVIDENCE_SCHEMA_VERSION`, + `SITL_COMPARISON_SCHEMA_VERSION`. +- Input schemas: `MISSION_SCHEMA_VERSION`, `VEHICLE_SCHEMA_VERSION`, + `GEOFENCE_SCHEMA_VERSION`, `LANDING_ZONE_SCHEMA_VERSION`, + `TERRAIN_SCHEMA_VERSION`, `POPULATION_SCHEMA_VERSION`, `WIND_GRID_SCHEMA_VERSION`, + `SCENARIO_INPUT_SCHEMA_VERSION`, `UNCERTAINTY_INPUT_SCHEMA_VERSION`, + `STOCHASTIC_INPUT_SCHEMA_VERSION`, and `batch.v1`. +- `batch.v1` has no named module constant — it is a `Literal` field on + `schemas.batch.BatchManifest`. Rather than restate the string, the command (and + the test) extract it from the field annotation with + `typing.get_args(BatchManifest.model_fields["format_version"].annotation)[0]`, + so the discovery map stays bound to what the loader actually accepts. + +### Beyond the spec's eight output contracts + +The spec listed eight core output/envelope contracts. Five more report/artifact +contracts have been added to the repo since this ticket was written; they are +included for completeness, each sourced from its own constant: +`VALIDATION_REPORT_SCHEMA_VERSION` (`validation-report.v1`), +`CALIBRATION_PROFILE_SCHEMA_VERSION` (`calibration-profile.v1`), +`FLIGHT_TRACE_SCHEMA_VERSION` (`flight-trace.v1`), +`PHASE_SEGMENT_SCHEMA_VERSION` (`phase-segments.v1`), and +`SORA_ASSESSMENT_SCHEMA_VERSION` (`sora-assessment.v1`). Including them does not +complicate the drift test (each is just another `imported == printed` assertion), +and it makes the discovery output a complete picture of the published contracts. + +### Deliberately left out + +The two data-completeness nits in the Notes above — the `obstacles` input-schema +version omitted from the live envelope's `_input_schema_versions()`, and the +missing battery-sizing input schema-version field — are out of scope here. Each +would change a published envelope and needs its own golden-fixture and version +review; the discovery command reports only what the envelopes emit today. diff --git a/docs/tickets/README.md b/docs/tickets/README.md index 3cb416c..8933940 100644 --- a/docs/tickets/README.md +++ b/docs/tickets/README.md @@ -1,6 +1,6 @@ # Ticket Backlog -**60 implemented · 18 planned · 1237 tests passing** +**61 implemented · 17 planned · 1245 tests passing** This directory tracks every capability from idea to implementation. Completed tickets are kept as historical records. Open tickets describe what to build @@ -53,7 +53,6 @@ worker depends on. | # | Ticket | What it adds | |---|---|---| -| 105 | [Contract-version discovery command](./105-contract-version-discovery-command.md) | `schema-versions` command printing supported input/output contract versions without running a job | | 106 | [Machine-readable run progress](./106-machine-readable-run-progress.md) | JSONL progress for `propagate`/`sample`/`batch` so a non-TTY worker can show live progress (extends 067) | | 107 | [Machine-readable preflight report](./107-machine-readable-preflight-report.md) | JSON `--validate-only` envelope plus GeoJSON asset preflight across run types (composes with 089) | @@ -203,3 +202,4 @@ New capabilities should work *with* existing pieces, not alongside them in isola 57. [098](./098-version-bump-and-release-tooling.md) Version bump and release tooling 58. [083](./083-calibration-profile-data-and-fitting.md) Calibration profile data and fitting 59. [104](./104-atomic-output-writes-and-cancellation.md) Atomic output writes and clean cancellation +60. [105](./105-contract-version-discovery-command.md) Contract-version discovery command diff --git a/tests/test_schema_versions.py b/tests/test_schema_versions.py new file mode 100644 index 0000000..e30bae9 --- /dev/null +++ b/tests/test_schema_versions.py @@ -0,0 +1,121 @@ +"""Tests for the read-only contract-version discovery command.""" + +import json +from typing import get_args + +from typer.testing import CliRunner + +from adapters.battery_sizing_envelope import BATTERY_SIZING_REPORT_SCHEMA_VERSION +from adapters.cli import CliExitCode, app +from adapters.envelope import ( + GEOFENCE_SCHEMA_VERSION, + LANDING_ZONE_SCHEMA_VERSION, + MISSION_SCHEMA_VERSION, + POPULATION_SCHEMA_VERSION, + RESULT_ENVELOPE_SCHEMA_VERSION, + TERRAIN_SCHEMA_VERSION, + VEHICLE_SCHEMA_VERSION, + WIND_GRID_SCHEMA_VERSION, +) +from adapters.scenario_envelope import ( + SCENARIO_INPUT_SCHEMA_VERSION, + SCENARIO_REPORT_SCHEMA_VERSION, +) +from adapters.sitl.evidence import SITL_EVIDENCE_SCHEMA_VERSION +from adapters.stochastic_envelope import ( + STOCHASTIC_ENVELOPE_SCHEMA_VERSION, + STOCHASTIC_INPUT_SCHEMA_VERSION, +) +from adapters.sora_envelope import SORA_ENVELOPE_SCHEMA_VERSION +from adapters.uncertainty_envelope import ( + UNCERTAINTY_INPUT_SCHEMA_VERSION, + UNCERTAINTY_REPORT_SCHEMA_VERSION, +) +from adapters.version import tool_version +from schemas.batch import BatchManifest +from schemas.calibration import CALIBRATION_PROFILE_SCHEMA_VERSION +from schemas.flight_log import FLIGHT_TRACE_SCHEMA_VERSION +from schemas.phase_segment import PHASE_SEGMENT_SCHEMA_VERSION +from schemas.sitl_comparison import SITL_COMPARISON_SCHEMA_VERSION +from schemas.sora import SORA_ASSESSMENT_SCHEMA_VERSION +from schemas.validation import VALIDATION_REPORT_SCHEMA_VERSION + +runner = CliRunner() + + +def _invoke(command: str): + return runner.invoke(app, [command]) + + +def test_schema_versions_exits_zero_and_emits_json() -> None: + result = _invoke("schema-versions") + assert result.exit_code == int(CliExitCode.SUCCESS) + payload = json.loads(result.stdout) + assert set(payload) == {"tool_version", "output_envelopes", "input_schemas"} + + +def test_schema_versions_needs_no_file_argument() -> None: + # The bare command succeeds with no mission/vehicle/asset path. + result = _invoke("schema-versions") + assert result.exit_code == int(CliExitCode.SUCCESS) + + +def test_tool_version_matches_resolver() -> None: + payload = json.loads(_invoke("schema-versions").stdout) + assert payload["tool_version"] == tool_version() + + +def test_output_envelope_versions_match_constants() -> None: + envelopes = json.loads(_invoke("schema-versions").stdout)["output_envelopes"] + assert envelopes == { + "estimator": RESULT_ENVELOPE_SCHEMA_VERSION, + "scenario_report": SCENARIO_REPORT_SCHEMA_VERSION, + "uncertainty_report": UNCERTAINTY_REPORT_SCHEMA_VERSION, + "stochastic_envelope": STOCHASTIC_ENVELOPE_SCHEMA_VERSION, + "sora_envelope": SORA_ENVELOPE_SCHEMA_VERSION, + "battery_sizing_report": BATTERY_SIZING_REPORT_SCHEMA_VERSION, + "sitl_evidence": SITL_EVIDENCE_SCHEMA_VERSION, + "sitl_comparison": SITL_COMPARISON_SCHEMA_VERSION, + "validation_report": VALIDATION_REPORT_SCHEMA_VERSION, + "calibration_profile": CALIBRATION_PROFILE_SCHEMA_VERSION, + "flight_trace": FLIGHT_TRACE_SCHEMA_VERSION, + "phase_segments": PHASE_SEGMENT_SCHEMA_VERSION, + "sora_assessment": SORA_ASSESSMENT_SCHEMA_VERSION, + } + + +def test_input_schema_versions_match_constants() -> None: + inputs = json.loads(_invoke("schema-versions").stdout)["input_schemas"] + batch_version = get_args(BatchManifest.model_fields["format_version"].annotation)[0] + assert inputs == { + "mission": MISSION_SCHEMA_VERSION, + "vehicle": VEHICLE_SCHEMA_VERSION, + "geofences": GEOFENCE_SCHEMA_VERSION, + "landing_zones": LANDING_ZONE_SCHEMA_VERSION, + "terrain": TERRAIN_SCHEMA_VERSION, + "population": POPULATION_SCHEMA_VERSION, + "wind_grid": WIND_GRID_SCHEMA_VERSION, + "scenario": SCENARIO_INPUT_SCHEMA_VERSION, + "uncertainty": UNCERTAINTY_INPUT_SCHEMA_VERSION, + "stochastic": STOCHASTIC_INPUT_SCHEMA_VERSION, + "batch": batch_version, + } + + +def test_contracts_alias_matches_schema_versions() -> None: + primary = _invoke("schema-versions") + alias = _invoke("contracts") + assert alias.exit_code == int(CliExitCode.SUCCESS) + assert alias.stdout == primary.stdout + + +def test_output_is_deterministic() -> None: + first = _invoke("schema-versions") + second = _invoke("schema-versions") + assert first.stdout == second.stdout + + +def test_version_flag_unchanged() -> None: + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert result.stdout.strip() == f"bvlos-sim {tool_version()}"