From 96b2f878fcbe0fe5d07f10439ced87b1017abc5a Mon Sep 17 00:00:00 2001 From: Monotox Date: Mon, 1 Jun 2026 07:17:41 +0200 Subject: [PATCH] feat: add contract-version discovery command Add a read-only `schema-versions` command (alias `contracts`) that prints the resolved tool_version plus every supported output-envelope and input-schema 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 versions off an envelope. Every printed version is sourced from the same module constant the envelopes emit; a drift test asserts the printed map matches those constants so the two cannot silently diverge. The batch input version, which has no named constant, is read from the BatchManifest Literal field annotation. No new schema or envelope version is introduced and golden fixtures are unchanged. --- CHANGELOG.md | 8 ++ adapters/cli.py | 3 + adapters/commands/schema_versions.py | 107 ++++++++++++++++ docs/CLI_EXIT_CODES.md | 1 + docs/USAGE.md | 58 ++++++++- .../105-contract-version-discovery-command.md | 70 +++++++++- docs/tickets/README.md | 4 +- tests/test_schema_versions.py | 121 ++++++++++++++++++ 8 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 adapters/commands/schema_versions.py create mode 100644 tests/test_schema_versions.py 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()}"