From 3acc7f33dd6fbf97586a00aabdb7234eb7cb04e2 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Mon, 11 May 2026 02:03:36 +0300 Subject: [PATCH] [AZ-270] [AZ-272] [AZ-279] [AZ-281] [AZ-283] Compose root + FDR schema + 3 Layer-1 helpers AZ-270: composition root with strategy registry, tier-gated lookup, topo-order construction, all-or-nothing teardown, StrategyNotLinkedError payload. AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for unknown payload + top-level fields and canonical overrun-record shape. AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with WgsConversionError for shape/range/zoom guards. AZ-281: strict EngineFilenameSchema build/parse/matches_host with anchored regex + enum validation; round-trip identity by construction. AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with zero-norm safety and descriptor_metric() source-of-truth. pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and EngineCacheKey + HostCapabilities land in _types/ to back the helper contracts. 203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR deferrals + dep amendment + minor docstring polish. Co-authored-by: Cursor --- .../{todo => done}/AZ-270_compose_root.md | 0 .../AZ-272_fdr_record_schema.md | 0 .../{todo => done}/AZ-279_wgs_converter.md | 0 .../AZ-281_engine_filename_schema.md | 0 .../AZ-283_descriptor_normaliser.md | 0 .../batch_03_cycle1_report.md | 133 +++++++ .../reviews/batch_03_review.md | 331 ++++++++++++++++ _docs/_autodev_state.md | 2 +- pyproject.toml | 4 + src/gps_denied_onboard/_types/geo.py | 38 ++ src/gps_denied_onboard/_types/manifests.py | 31 ++ src/gps_denied_onboard/fdr_client/__init__.py | 25 +- src/gps_denied_onboard/fdr_client/records.py | 227 ++++++++++- src/gps_denied_onboard/helpers/__init__.py | 28 ++ .../helpers/descriptor_normaliser.py | 97 ++++- .../helpers/engine_filename_schema.py | 135 ++++++- .../helpers/wgs_converter.py | 180 ++++++++- src/gps_denied_onboard/runtime_root.py | 365 ++++++++++++++++-- tests/unit/test_az270_compose_root.py | 270 +++++++++++++ tests/unit/test_az272_fdr_record_schema.py | 256 ++++++++++++ tests/unit/test_az279_wgs_converter.py | 122 ++++++ .../unit/test_az281_engine_filename_schema.py | 100 +++++ .../unit/test_az283_descriptor_normaliser.py | 122 ++++++ tests/unit/test_runtime_root_env_gate.py | 12 +- 24 files changed, 2381 insertions(+), 97 deletions(-) rename _docs/02_tasks/{todo => done}/AZ-270_compose_root.md (100%) rename _docs/02_tasks/{todo => done}/AZ-272_fdr_record_schema.md (100%) rename _docs/02_tasks/{todo => done}/AZ-279_wgs_converter.md (100%) rename _docs/02_tasks/{todo => done}/AZ-281_engine_filename_schema.md (100%) rename _docs/02_tasks/{todo => done}/AZ-283_descriptor_normaliser.md (100%) create mode 100644 _docs/03_implementation/batch_03_cycle1_report.md create mode 100644 _docs/03_implementation/reviews/batch_03_review.md create mode 100644 src/gps_denied_onboard/_types/geo.py create mode 100644 tests/unit/test_az270_compose_root.py create mode 100644 tests/unit/test_az272_fdr_record_schema.py create mode 100644 tests/unit/test_az279_wgs_converter.py create mode 100644 tests/unit/test_az281_engine_filename_schema.py create mode 100644 tests/unit/test_az283_descriptor_normaliser.py diff --git a/_docs/02_tasks/todo/AZ-270_compose_root.md b/_docs/02_tasks/done/AZ-270_compose_root.md similarity index 100% rename from _docs/02_tasks/todo/AZ-270_compose_root.md rename to _docs/02_tasks/done/AZ-270_compose_root.md diff --git a/_docs/02_tasks/todo/AZ-272_fdr_record_schema.md b/_docs/02_tasks/done/AZ-272_fdr_record_schema.md similarity index 100% rename from _docs/02_tasks/todo/AZ-272_fdr_record_schema.md rename to _docs/02_tasks/done/AZ-272_fdr_record_schema.md diff --git a/_docs/02_tasks/todo/AZ-279_wgs_converter.md b/_docs/02_tasks/done/AZ-279_wgs_converter.md similarity index 100% rename from _docs/02_tasks/todo/AZ-279_wgs_converter.md rename to _docs/02_tasks/done/AZ-279_wgs_converter.md diff --git a/_docs/02_tasks/todo/AZ-281_engine_filename_schema.md b/_docs/02_tasks/done/AZ-281_engine_filename_schema.md similarity index 100% rename from _docs/02_tasks/todo/AZ-281_engine_filename_schema.md rename to _docs/02_tasks/done/AZ-281_engine_filename_schema.md diff --git a/_docs/02_tasks/todo/AZ-283_descriptor_normaliser.md b/_docs/02_tasks/done/AZ-283_descriptor_normaliser.md similarity index 100% rename from _docs/02_tasks/todo/AZ-283_descriptor_normaliser.md rename to _docs/02_tasks/done/AZ-283_descriptor_normaliser.md diff --git a/_docs/03_implementation/batch_03_cycle1_report.md b/_docs/03_implementation/batch_03_cycle1_report.md new file mode 100644 index 0000000..4121851 --- /dev/null +++ b/_docs/03_implementation/batch_03_cycle1_report.md @@ -0,0 +1,133 @@ +# Batch Report — Cycle 1 · Batch 3 + +**Tasks shipped**: AZ-270, AZ-272, AZ-279, AZ-281, AZ-283 +**Date**: 2026-05-11 +**Branch**: dev +**Review verdict**: PASS_WITH_WARNINGS — see `reviews/batch_03_review.md` + +## Tasks + +| ID | Title | Owner Layer | Outcome | +|----|-------|-------------|---------| +| AZ-270 | Composition Root | cross-cutting / `runtime_root.py` | Implemented — strategy registry, tier gating, topological-order construction, all-or-nothing teardown, `StrategyNotLinkedError` payload | +| AZ-272 | FdrRecord Schema | cross-cutting / `fdr_client/records.py` | Implemented — `orjson`-backed `serialise`/`parse`, forward-compat for unknown payload + top-level fields, overrun-payload contract | +| AZ-279 | WgsConverter | Layer 1 / `helpers/wgs_converter.py` | Implemented — `pyproj`-backed ECEF/ENU with hand-rolled OSM slippy-map tile math + `WgsConversionError` for shape / range / zoom guards | +| AZ-281 | EngineFilenameSchema | Layer 1 / `helpers/engine_filename_schema.py` | Implemented — strict `build`/`parse`/`matches_host` with anchored regex + enum validation | +| AZ-283 | DescriptorNormaliser | Layer 1 / `helpers/descriptor_normaliser.py` | Implemented — dtype-preserving single + batch L2 with zero-norm safety + `descriptor_metric()` source of truth | + +## Files Changed + +``` +Modified: + pyproject.toml (+4 lines: pyproj, orjson pins) + src/gps_denied_onboard/runtime_root.py (composition root impl) + src/gps_denied_onboard/fdr_client/records.py (full schema + serialiser) + src/gps_denied_onboard/fdr_client/__init__.py (re-exports) + src/gps_denied_onboard/helpers/__init__.py (re-exports) + src/gps_denied_onboard/helpers/wgs_converter.py (pyproj-backed converter) + src/gps_denied_onboard/helpers/engine_filename_schema.py (build/parse/matches_host) + src/gps_denied_onboard/helpers/descriptor_normaliser.py (L2 + zero-safe + dtype-preserving) + src/gps_denied_onboard/_types/manifests.py (EngineCacheKey, HostCapabilities) + tests/unit/test_runtime_root_env_gate.py (updated to Config-typed compose_root) + +Added: + src/gps_denied_onboard/_types/geo.py (LatLonAlt, BoundingBox) + tests/unit/test_az270_compose_root.py + tests/unit/test_az272_fdr_record_schema.py + tests/unit/test_az279_wgs_converter.py + tests/unit/test_az281_engine_filename_schema.py + tests/unit/test_az283_descriptor_normaliser.py + _docs/03_implementation/reviews/batch_03_review.md +``` + +## Test Results + +``` +$ pytest tests/unit -q --timeout=30 +203 passed, 2 skipped in 2.60s +``` + +Skips are environment-gated (cmake configure, actionlint), both verified +in CI. + +### AC Coverage + +| Task | ACs declared | ACs covered locally | Tier-2 deferred | +|------|--------------|---------------------|-----------------| +| AZ-270 | 6 + 2 NFR | 6 ACs + NFR-reliability (all-or-nothing) | NFR-perf (compose_root ≤ 750 ms on Jetson) | +| AZ-272 | 6 + 2 NFR | 6 ACs + NFR-reliability + invariant inline-blob | NFR-perf (serialise p99 ≤ 20 µs, parse p99 ≤ 50 µs on Jetson) | +| AZ-279 | 9 + 2 NFR | 9 ACs + determinism | NFR-perf (≤ 200 µs each on Jetson) | +| AZ-281 | 11 + 2 NFR | 11 ACs | NFR-perf (≤ 50 µs each on Jetson) | +| AZ-283 | 12 + 2 NFR | 12 ACs | NFR-perf-vector (≤ 50 µs / D=512) + NFR-perf-batch (≤ 5 ms / N=1000, D=512) | + +Tier-2 perf budgets are owned by AZ-428..AZ-431; same deferral as +batch 2. The batch-3 modules expose stable APIs so those tasks plug in +without further code changes here. + +## Dependency Pins + +This batch amended `pyproject.toml` with two new runtime pins required +by AZ-272 and AZ-279 contracts: + +- `pyproj>=3.6,<4.0` — WGS84 geodesy backend per AZ-279 contract +- `orjson>=3.9,<4.0` — FDR wire format per AZ-272 contract + +Both names appear verbatim in the upstream contract documents and were +the named-backend constraint. AZ-263 left these unpinned; the pins are +added by the first batch that needs them — same pattern as batch 2. + +## Adjacent Hygiene + +This batch added four new DTOs to `_types/` because the AZ-279 / AZ-281 +contracts explicitly import them from `_types`: + +- `_types/geo.py` (new file): `LatLonAlt`, `BoundingBox` +- `_types/manifests.py`: `EngineCacheKey`, `HostCapabilities` + +AZ-263 left these out; this batch closes the gap. No other helper or +component depends on them yet, so the additions are forward-compatible. + +The `runtime_root.py` signature change (`compose_root()` → `compose_root(config)`) +forced an update to `tests/unit/test_runtime_root_env_gate.py` (now passes +`Config()` so the env-var fail-fast still fires). AC-8 of AZ-263 is +intact. + +## Architecture Compliance + +- `helpers/wgs_converter.py` imports only `numpy`, `pyproj`, `_types.geo`, + and stdlib. AC-9 AST scan verifies no `components.*` imports. +- `helpers/engine_filename_schema.py` imports only `re`, `_types.manifests`, + and stdlib. AC-11 AST scan verifies. +- `helpers/descriptor_normaliser.py` imports only `numpy` + stdlib. + AC-12 AST scan verifies. +- `fdr_client/records.py` imports only `orjson` + stdlib + the + `dataclasses` decorator. No upward imports. +- `runtime_root.py` imports from `gps_denied_onboard.config` only (the + composition root is permitted to consume the config loader). The AC-6 + AST scan over every file under `components/` confirms no + cross-component imports exist (the registry is the only sanctioned + cross-component reference path). + +No new circular imports. + +## Review Findings Summary + +Verdict: **PASS_WITH_WARNINGS**. Four Low-severity findings: + +1. Two engine-cache types coexist (`EngineCacheEntry` vs `EngineCacheKey`) + — recommend a docstring sentence on each. +2. NFR-perf microbenchmarks deferred to Tier-2 perf suite. +3. `pyproject.toml` dep amendment (pyproj + orjson) — by the consumer + batch as designed. +4. AC-6 architecture lint lives in tests; future `importlinter` upgrade + recommended once concrete components ship. + +Full details in `reviews/batch_03_review.md`. + +## Tracker Transitions + +- AZ-270: To Do → In Progress → (batch close) In Testing +- AZ-272: To Do → In Progress → (batch close) In Testing +- AZ-279: To Do → In Progress → (batch close) In Testing +- AZ-281: To Do → In Progress → (batch close) In Testing +- AZ-283: To Do → In Progress → (batch close) In Testing diff --git a/_docs/03_implementation/reviews/batch_03_review.md b/_docs/03_implementation/reviews/batch_03_review.md new file mode 100644 index 0000000..501f8b2 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_03_review.md @@ -0,0 +1,331 @@ +# Code Review Report + +**Batch**: 3 +**Tasks**: AZ-270 (Composition Root), AZ-272 (FdrRecord Schema), AZ-279 (WgsConverter), AZ-281 (EngineFilenameSchema), AZ-283 (DescriptorNormaliser) +**Date**: 2026-05-11 +**Verdict**: PASS_WITH_WARNINGS + +## Scope + +Batch 3 closes the E-CC-CONF epic (AZ-270 ships the real composition root +with strategy registry and tier gates) and kicks off E-CC-FDR-CLIENT +(AZ-272 ships the wire-format schema). Three Layer-1 helpers — WGS +converter, engine-filename schema, descriptor normaliser — land in the +same batch because each is a small, contract-frozen unit that dozens of +downstream component tasks gate on. + +## Phase 1: Context Loading + +Read: + +- `_docs/02_tasks/todo/AZ-270_compose_root.md` (6 ACs + 2 NFRs) +- `_docs/02_tasks/todo/AZ-272_fdr_record_schema.md` (6 ACs + 2 NFRs) +- `_docs/02_tasks/todo/AZ-279_wgs_converter.md` (9 ACs + 2 NFRs) +- `_docs/02_tasks/todo/AZ-281_engine_filename_schema.md` (11 ACs + 2 NFRs) +- `_docs/02_tasks/todo/AZ-283_descriptor_normaliser.md` (12 ACs + 2 NFRs) +- Five contracts under `_docs/02_document/contracts/` +- `_docs/02_document/module-layout.md` (`runtime_root.py` ownership, helper + layer envelope, Layer-1 import rules) + +Ownership envelopes resolved per `module-layout.md`: + +- AZ-270 owns `src/gps_denied_onboard/runtime_root.py` (replaces the + AZ-263 stub) + `tests/unit/test_az270_compose_root.py` + adjusts the + existing `tests/unit/test_runtime_root_env_gate.py` for the new + `compose_root(config)` signature +- AZ-272 owns `src/gps_denied_onboard/fdr_client/records.py` + + `src/gps_denied_onboard/fdr_client/__init__.py` re-exports + + `tests/unit/test_az272_fdr_record_schema.py` +- AZ-279 owns `src/gps_denied_onboard/helpers/wgs_converter.py` + + `tests/unit/test_az279_wgs_converter.py` +- AZ-281 owns `src/gps_denied_onboard/helpers/engine_filename_schema.py` + + `tests/unit/test_az281_engine_filename_schema.py` +- AZ-283 owns `src/gps_denied_onboard/helpers/descriptor_normaliser.py` + + `tests/unit/test_az283_descriptor_normaliser.py` + +Adjacent hygiene this batch (per `coderule.mdc` scope-discipline rule): + +- New DTOs in `src/gps_denied_onboard/_types/geo.py` (`LatLonAlt`, + `BoundingBox`) and added `EngineCacheKey` + `HostCapabilities` to + `src/gps_denied_onboard/_types/manifests.py`. The AZ-279 and AZ-281 + contracts explicitly import these from `_types`; AZ-263 left them + un-declared, so this batch closes the gap. +- `pyproject.toml` amended with two new pinned dependencies (`pyproj` + for AZ-279, `orjson` for AZ-272). Both names appear verbatim in the + upstream contract documents. + +## Phase 2: Spec Compliance + +### AZ-270 — Composition Root + +| AC | Verification | +|----|--------------| +| AC-1 Default deployment composes | `test_ac1_default_deployment_composes` builds a 3-component graph and asserts every slot is populated | +| AC-2 Strategy/build-flag mismatch rejected | `test_ac2_strategy_not_linked_raises_with_payload` asserts `StrategyNotLinkedError(strategy_name, component_slug, available_strategies)` payload matches | +| AC-3 Operator-side excludes airborne | `test_ac3_operator_excludes_airborne_only` registers a strategy with `tier="airborne"`, asserts the same strategy in `compose_operator` raises | +| AC-4 Reachability proof | `test_ac4_runtime_root_smoke_exit_zero` runs `compose_root(Config())` with no component blocks and asserts a fully-formed `RuntimeRoot` | +| AC-5 Construction order respects deps | `test_ac5_construction_order_respects_dependencies` registers in reverse order, asserts the topo pass orders dependents after dependencies | +| AC-6 Single import point enforced | `test_ac6_only_compose_root_imports_concrete_strategies` AST-walks every file under `components/` and asserts no cross-component imports | +| NFR-reliability all-or-nothing | `test_nfr_reliability_partial_construction_closed_on_failure` injects a failing factory, asserts every prior `_Closable` had `close()` called | + +The implementation provides a `_Registration` dataclass + global +`_STRATEGY_REGISTRY` keyed by `(component_slug, strategy_name)`, a +`register_strategy()` entrypoint (the only sanctioned write-path), a +`clear_strategy_registry()` helper for test isolation, a Kahn-style +topo-sort over the `depends_on` graph, and a `_close_partial_instances` +best-effort teardown hook. + +### AZ-272 — FdrRecord Schema + +| AC | Verification | +|----|--------------| +| AC-1 Every kind round-trips | `test_ac1_roundtrip_every_known_kind` parametrised over all 10 v1.0.0 kinds with kind-specific payload fixtures | +| AC-2 Forward-compat payload | `test_ac2_forward_compatible_unknown_payload_field_preserved` (and `_ac2b_unknown_top_level_field_preserved` for the top-level bucket) | +| AC-3 Unknown future kind opaque | `test_ac3_unknown_future_kind_returned_opaquely` | +| AC-4 Missing/non-int schema_version | `test_ac4_missing_schema_version_raises` + `test_ac4_non_integer_schema_version_raises` | +| AC-5 Overrun shape | `test_ac5_overrun_missing_dropped_count_rejected_on_parse` + `test_ac5_overrun_zero_dropped_count_rejected_on_serialise` | +| AC-6 Producer ID required | `test_ac6_empty_producer_id_rejected_on_serialise` | +| Invariant inline-blob cap | `test_nfr_oversized_inline_blob_rejected` | +| Pure determinism | `test_nfr_serialise_is_pure_byte_identical` | + +Tier-2 perf NFR (`serialise` p99 ≤ 20 µs; `parse` p99 ≤ 50 µs) is +deferred to the Tier-2 perf suite — same pattern as batch-2 NFR-perf. + +### AZ-279 — WgsConverter + +| AC | Verification | +|----|--------------| +| AC-1 ECEF round-trip | `test_ac1_ecef_roundtrip` over 5 globally-distributed samples within `atol=1e-9` deg + `1e-6` m | +| AC-2 ENU 10 km round-trip | `test_ac2_enu_roundtrip_within_10_km` asserts horizontal residual < 1 m, vertical < 1 cm | +| AC-3 Slippy-map z18 round-trip | `test_ac3_slippy_map_tile_roundtrip_z18_contains_input` pinned to `(153295, 88392)` per OSM convention | +| AC-4 Lat range guard | `test_ac4_web_mercator_latitude_range_guard` | +| AC-5 Zoom range guard | `test_ac5_zoom_range_guard` | +| AC-6 Tile-xy range guard | `test_ac6_tile_xy_range_guard` | +| AC-7 ECEF shape contract | `test_ac7_ecef_shape_contract` | +| AC-8 Determinism | `test_ac8_determinism_byte_equal_outputs` (`tobytes()` equality) | +| AC-9 No upward imports | `test_ac9_no_upward_imports_to_components` (AST scan) | + +Web-Mercator max-lat constant is `arctan(sinh(pi))` ≈ 85.0511287798066° +(matches the OSM-documented constant). `_enu_to_ecef_rotation` +implements the canonical local-tangent-plane basis at +`(lat, lon)`; ENU sign convention is `(east, north, up)`. + +### AZ-281 — EngineFilenameSchema + +| AC | Verification | +|----|--------------| +| AC-1 Reference example | `test_ac1_reference_example_builds_exact_string` | +| AC-2 Round-trip identity | `test_ac2_roundtrip_identity_over_10_random_tuples` (seeded `random.Random(2026)`) | +| AC-3 Host-match exact | `test_ac3_matches_host_exact_match` | +| AC-4 Host-mismatch no exception | `test_ac4_matches_host_tuple_mismatch_returns_false` (sm + trt variants) | +| AC-5 Precision enum strictness | `test_ac5_precision_enum_strictness` | +| AC-6 Model char set | `test_ac6_model_name_character_set_rejection` | +| AC-7 Reserved separator | `test_ac7_reserved_separator_collision_rejected` | +| AC-8 Version format | `test_ac8_three_segment_version_rejected` | +| AC-9 Parse malformed | `test_ac9_parse_rejects_malformed_filename` | +| AC-10 `.engine` suffix | `test_ac10_parse_requires_engine_suffix` | +| AC-11 No upward imports | `test_ac11_no_upward_imports_to_components` (AST scan) | + +The implementation backs everything on a single anchored regex +`_FILENAME_RE` plus explicit `_validate_*` helpers for the producer +path. Round-trip identity holds by construction because `parse` extracts +the same five fields the regex consumed. + +### AZ-283 — DescriptorNormaliser + +| AC | Verification | +|----|--------------| +| AC-1 Unit-vector example | `test_ac1_unit_vector_example` | +| AC-2 Batch normalisation | `test_ac2_batch_normalisation` | +| AC-3 fp16 dtype preserved | `test_ac3_fp16_dtype_preservation` | +| AC-4 fp32 dtype preserved | `test_ac4_fp32_dtype_preservation` | +| AC-5 Zero-vector safe | `test_ac5_zero_vector_handling` + `_ac5b_zero_row_in_batch_remains_zero` | +| AC-6 fp32 idempotence | `test_ac6_idempotence_fp32` (`tobytes()` equality) | +| AC-7 fp16 idempotence | `test_ac7_idempotence_fp16_within_half_precision_tol` | +| AC-8 No in-place mutation | `test_ac8_no_in_place_mutation` | +| AC-9 Metric source-of-truth | `test_ac9_metric_is_inner_product_exact_string` | +| AC-10 fp64 rejected | `test_ac10_float64_dtype_rejected` | +| AC-11 Shape contract | `test_ac11_shape_contract_single_rejects_2d` + `_batch_rejects_1d` | +| AC-12 No upward imports | `test_ac12_no_upward_imports_to_components` (AST scan) | + +The implementation routes through `float32` internally for norm +stability, then casts back to the caller dtype (no silent up-cast). The +batch path uses `np.where(norms == 0.0, 1.0, norms)` to avoid +division-by-zero without branching per row. + +No Spec-Gap findings. + +## Phase 3: Code Quality + +- **SRP** — each module owns exactly one concern. `runtime_root.py` is + marginally larger (registry + topo + compose + entrypoint) but every + internal function has a sharp name; nothing leaks responsibility into + generic "candidate"/"data" helpers. +- **Error handling** — every public surface raises one typed exception: + `StrategyNotLinkedError`, `FdrSchemaError`, `WgsConversionError`, + `EngineFilenameSchemaError`, `DescriptorNormaliserError`. Library + errors (`orjson.JSONDecodeError`, `pyproj.ProjError`-tier exceptions) + are wrapped at the public boundary. +- **Naming** — public symbols match the contract files verbatim. + `RuntimeRoot.construction_order` is an additive field the contract + permits but does not mandate; it's the observable used by AC-5 tests. +- **Complexity** — no function exceeds 50 lines; the busiest function is + `_compose` at ~30 lines. +- **DRY** — shared helpers (`_enu_to_ecef_rotation`, + `_validate_envelope_outgoing`, `_validate_overrun_payload`) are + module-private; no duplication across helpers. +- **Test quality** — every AC has a directly-mapped test that asserts + the contractually-named behaviour. AST-based import scans for the + "no upward imports" invariant rather than string matches. +- **Dead code** — the previous AZ-263 `compose_root` stub returned a + hollow `RuntimeRoot(binary, profile)`; replaced verbatim. The previous + `runtime_root.py` had a `dataclass` import that is no longer needed at + module level — confirmed it is still used (the new file imports it + via `dataclass` for `RuntimeRoot`/`OperatorRoot`). + +## Phase 4: Security Quick-Scan + +- **No SQL string interpolation** anywhere in the batch. +- **No `shell=True` / `eval` / `exec`**. +- **No hardcoded secrets** — operator/airborne env-var lists are + identifiers only. +- **No insecure deserialization** — `orjson.loads` is the production + decoder; type-validated before any field is used. YAML loading is in + the AZ-269 path (out of scope here) and uses `yaml.safe_load`. +- **`_FILENAME_RE` is anchored** with `^...$`; no ReDoS surface (the + pattern is linear-time on input length). +- **`atomic_write` is delegated** to the AZ-280 helper (`Sha256Sidecar`); + this batch does not introduce new disk-write code paths. + +No security findings. + +## Phase 5: Performance Scan + +- `serialise` / `parse` go through `orjson` (C-extension); a single + allocation per record. No O(n²) loops. +- `WgsConverter._ECEF_FROM_LLA` and `_LLA_FROM_ECEF` are module-level + cached `pyproj.Transformer` instances; no per-call transformer setup + cost. +- `DescriptorNormaliser.l2_normalise_batch` uses NumPy vectorised + `np.linalg.norm(..., axis=1, keepdims=True)`; no Python-level row + iteration. +- `_topo_order` is Kahn-style DFS at O(V+E) in the strategy graph + (sub-msec for any realistic component count). + +NFR microbenchmarks (AZ-272 serialise / parse latency; AZ-279 per-helper +latency; AZ-283 per-vector + batch latency) need Tier-2 hardware; same +deferral pattern as batch 2. + +## Phase 6: Cross-Task Consistency + +- `fdr_client.records` imports `orjson` only; no upward imports. +- `helpers/wgs_converter.py` imports `pyproj`, `numpy`, `_types.geo` + only; no component imports. +- `helpers/engine_filename_schema.py` imports `re` and `_types.manifests` + only. +- `helpers/descriptor_normaliser.py` imports `numpy` only. +- `runtime_root.py` imports `config` (allowed — it's the consumer) and + no `components.*` modules (because no concrete components exist yet; + AC-6 enforces this going forward). +- The `_close_partial_instances` cleanup hook uses `getattr(inst, + "close", None)` so it does not require components to implement a + particular interface — they opt in by exposing `.close()`. + +No cross-task consistency findings. + +## Phase 7: Architecture Compliance + +Per `module-layout.md`: + +- `helpers/*` (Layer 1): allowed imports are `_types`, stdlib, and named + external deps (pyproj, numpy, orjson). PASS for AZ-279 / AZ-281 / + AZ-283. +- `runtime_root.py` (composition root): allowed to import concrete + strategies from `components.*` — but none exist yet, so this + permission is unused. +- `fdr_client/records.py` (Layer 0 / cross-cutting): allowed imports are + stdlib + `orjson`. PASS. +- The strategy registry is global state inside `runtime_root.py`. This + is intentional and matches the ADR-009 "interface-first DI" + prescription — the registry is filled by bootstrap modules, then + consumed by `compose_*`. Tests reset it via the + `clear_strategy_registry()` fixture. + +No new circular imports. The import graph +`runtime_root → config → (stdlib + pyyaml)` and +`fdr_client.records → (stdlib + orjson)` are both acyclic. + +No Architecture findings. + +## Findings + +| # | Severity | Category | File:Line | Title | +|---|----------|----------|-----------|-------| +| 1 | Low | Maintainability | _types/manifests.py | Two engine-cache types coexist (`EngineCacheEntry` and `EngineCacheKey`) | +| 2 | Low | Performance | tests/unit/test_az272*.py + test_az279*.py + test_az283*.py | NFR-perf microbenchmarks deferred to Tier-2 | +| 3 | Low | Scope | pyproject.toml | Batch 3 added `pyproj` + `orjson` deps that AZ-263 had not pinned | +| 4 | Low | Architecture | src/gps_denied_onboard/runtime_root.py + tests | AC-6 architecture lint relies on a tests-only AST scan | + +### Finding Details + +**F1: Two engine-cache types coexist** (Low / Maintainability) + +- Location: `src/gps_denied_onboard/_types/manifests.py` +- Description: `EngineCacheEntry` (AZ-263 stub, carries `engine_path`, + `content_hash`, `int8_calibration_path`) and `EngineCacheKey` (AZ-281, + the five-tuple filename key) sit side by side. They serve different + purposes — a `Key` is the parsed filename tuple, an `Entry` is the + cache row that also tracks a content hash. C10's Manifest will need + both. Worth a follow-up doc note in `_types/manifests.py` so the next + reader doesn't think one supersedes the other. +- Suggestion: capture a short docstring sentence on each, calling out + the other. +- Task: AZ-281 + +**F2: NFR-perf microbenchmarks deferred** (Low / Performance) + +- Location: AZ-272 / AZ-279 / AZ-283 perf NFRs +- Description: Same pattern as batch 2. Tier-2 hardware-pinned budgets + (serialise/parse latency, per-helper p99, batch p99) cannot be + validated locally; AZ-428..AZ-431 own the Tier-2 perf suite. +- Suggestion: add corresponding `tests/perf/` files when AZ-428..AZ-431 + lands. +- Task: AZ-272, AZ-279, AZ-283 + +**F3: pyproject.toml dep amendment** (Low / Scope) + +- Location: `pyproject.toml::dependencies` +- Description: Batch 3 added `pyproj>=3.6,<4.0` and `orjson>=3.9,<4.0`. + Both are named-backend deps the upstream contract documents call out. + Same justification as batch 2's `gtsam` / `atomicwrites` amendment. +- Suggestion: none — recorded so the AZ-263 implementation report and + the Product-Implementation-Completeness audit reflect the batch-3 dep + addition. +- Task: AZ-272, AZ-279 + +**F4: AC-6 architecture lint lives in tests** (Low / Architecture) + +- Location: `tests/unit/test_az270_compose_root.py::test_ac6_only_compose_root_imports_concrete_strategies` +- Description: The "only compose_root imports concrete strategies" + invariant is enforced by an AST scan inside the unit suite. A future + CI lane that runs `importlinter` would catch the same thing earlier. + For v1.0.0 the unit-test gate is sufficient (and necessary — without + any concrete strategies yet, there's no need for a separate tool). +- Suggestion: when the first concrete component strategy is wired in, + consider adding an `importlinter.cfg` so the check runs at lint time + too. +- Task: AZ-270 + +## Verdict + +**PASS_WITH_WARNINGS**. Four Low-severity findings, all informational +follow-ups (per-NFR deferrals, dep amendment, docstring polish, and a +future lint-tier upgrade). Per the Auto-Fix Gate matrix, Low findings +continue to commit without escalation. + +## Test Run Summary + +- **Local**: 203 passed, 2 skipped (cmake configure, actionlint — both + CI-gated). Includes 64 new batch-3 tests. +- **Coverage**: every AC across all five tasks has at least one + corresponding test. NFR-perf budgets are deferred to Tier-2. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 102fd55..7a37fdb 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -8,7 +8,7 @@ status: in_progress sub_step: phase: 14 name: loop-next-batch - detail: "batch 2 of N committed" + detail: "batch 3 of N committed" retry_count: 0 cycle: 1 tracker: jira diff --git a/pyproject.toml b/pyproject.toml index 5ba75a8..19d5e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ dependencies = [ "gtsam>=4.2,<5.0", # Atomic-rename backend for helpers.sha256_sidecar (D-C10-3, AZ-280). "atomicwrites>=1.4,<2.0", + # WGS84 geodesy backend for helpers.wgs_converter (AZ-264 / AZ-279). + "pyproj>=3.6,<4.0", + # FDR wire format for fdr_client.records (E-CC-FDR-CLIENT / AZ-272). + "orjson>=3.9,<4.0", ] [project.optional-dependencies] diff --git a/src/gps_denied_onboard/_types/geo.py b/src/gps_denied_onboard/_types/geo.py new file mode 100644 index 0000000..f9d44cb --- /dev/null +++ b/src/gps_denied_onboard/_types/geo.py @@ -0,0 +1,38 @@ +"""Geographic DTOs shared by every component that crosses the WGS / ENU / tile-pixel +boundary. + +Consumed by `helpers.wgs_converter` (AZ-279), C4 / C5 / C6 / C8 / C10 / C11 / C12. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LatLonAlt: + """A WGS84 geographic position. ``alt_m`` is height above the WGS84 ellipsoid.""" + + lat_deg: float + lon_deg: float + alt_m: float + + +@dataclass(frozen=True) +class BoundingBox: + """An axis-aligned lat/lon bounding box. + + The slippy-map tile bounds returned by ``WgsConverter.tile_xy_to_latlon_bounds`` + use this shape. Latitude axis grows northward; longitude axis grows eastward. + """ + + min_lat_deg: float + min_lon_deg: float + max_lat_deg: float + max_lon_deg: float + + def contains(self, lat_deg: float, lon_deg: float) -> bool: + return ( + self.min_lat_deg <= lat_deg <= self.max_lat_deg + and self.min_lon_deg <= lon_deg <= self.max_lon_deg + ) diff --git a/src/gps_denied_onboard/_types/manifests.py b/src/gps_denied_onboard/_types/manifests.py index 44e97aa..1aabec1 100644 --- a/src/gps_denied_onboard/_types/manifests.py +++ b/src/gps_denied_onboard/_types/manifests.py @@ -30,3 +30,34 @@ class EngineCacheEntry: precision: str content_hash: str int8_calibration_path: str | None = None + + +@dataclass(frozen=True) +class EngineCacheKey: + """Parsed tuple of a self-describing `.engine` filename (D-C10-7, AZ-281). + + Filename schema: ``{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine``. + The dotted-version strings (``jetpack``, ``trt``) are stored verbatim so the + round-trip ``parse(build(*args)) == EngineCacheKey(*args)`` invariant holds. + """ + + model_name: str + sm: int + jetpack: str + trt: str + precision: str + + +@dataclass(frozen=True) +class HostCapabilities: + """Host-side TensorRT capability tuple consulted by AZ-281's ``matches_host``. + + Captures the same five-tuple the engine filename encodes; ``matches_host`` is + true iff every element matches exactly. Precision and ``model_name`` are + excluded from the comparison: the predicate is host-vs-binary, not + engine-vs-engine. + """ + + sm: int + jetpack: str + trt: str diff --git a/src/gps_denied_onboard/fdr_client/__init__.py b/src/gps_denied_onboard/fdr_client/__init__.py index 0af5444..9590fd2 100644 --- a/src/gps_denied_onboard/fdr_client/__init__.py +++ b/src/gps_denied_onboard/fdr_client/__init__.py @@ -5,6 +5,27 @@ Producer-side API used by every component. Consumer-side writer lives in """ from gps_denied_onboard.fdr_client.client import FdrClient -from gps_denied_onboard.fdr_client.records import FdrRecord +from gps_denied_onboard.fdr_client.records import ( + CURRENT_SCHEMA_VERSION, + KNOWN_KINDS, + MAX_INLINE_BLOB_BYTES, + OVERRUN_KIND, + OVERRUN_PRODUCER_ID, + FdrRecord, + FdrSchemaError, + parse, + serialise, +) -__all__ = ["FdrClient", "FdrRecord"] +__all__ = [ + "CURRENT_SCHEMA_VERSION", + "KNOWN_KINDS", + "MAX_INLINE_BLOB_BYTES", + "OVERRUN_KIND", + "OVERRUN_PRODUCER_ID", + "FdrClient", + "FdrRecord", + "FdrSchemaError", + "parse", + "serialise", +] diff --git a/src/gps_denied_onboard/fdr_client/records.py b/src/gps_denied_onboard/fdr_client/records.py index c550b61..57f5644 100644 --- a/src/gps_denied_onboard/fdr_client/records.py +++ b/src/gps_denied_onboard/fdr_client/records.py @@ -1,25 +1,226 @@ -"""FDR record schema — STUB. +"""FDR record schema + versioned (de)serialiser (AZ-272 / E-CC-FDR-CLIENT). -Concrete schema (estimates / IMU / MAVLink / health / tile / thumbnail discriminated -record types) is owned by AZ-272. Bootstrap declares the umbrella DTO so every -producer can import it. +Public surface frozen by +`_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` v1.0.0. + +The library backing ``serialise`` / ``parse`` (``orjson``) is pinned in +``pyproject.toml`` and intentionally hidden from the public API — callers +trade ``FdrRecord <-> bytes`` only. """ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime -from typing import Any +from typing import Any, Final + +import orjson + +__all__ = [ + "CURRENT_SCHEMA_VERSION", + "KNOWN_KINDS", + "MAX_INLINE_BLOB_BYTES", + "OVERRUN_KIND", + "OVERRUN_PRODUCER_ID", + "FdrRecord", + "FdrSchemaError", + "parse", + "serialise", +] + +CURRENT_SCHEMA_VERSION: Final[int] = 1 + +OVERRUN_KIND: Final[str] = "overrun" +OVERRUN_PRODUCER_ID: Final[str] = "shared.fdr_client" + +# Per-kind allowed payload keys. The parser uses this to route unknown future +# fields into ``payload["extra"]`` (forward-compat AC-2). Unknown ``kind`` values +# bypass the table and are returned opaquely (AC-3). +KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = { + "log": frozenset({"level", "component", "frame_id", "kind", "msg", "kv", "exc"}), + "vio.tick": frozenset( + {"frame_id", "R", "t", "P", "last_anchor_age_ms", "mre_px", "imu_bias_norm"} + ), + "state.tick": frozenset({"frame_id", "fused_pose", "covariance_2x2", "estimator_label"}), + "tile_match": frozenset({"frame_id", "tile_id", "score", "match_count", "ransac_inliers"}), + "overrun": frozenset({"producer_id", "dropped_count"}), + "segment_rollover": frozenset({"old_segment", "new_segment", "total_bytes_after"}), + "failed_tile_thumbnail": frozenset({"frame_id", "tile_id", "jpeg_bytes_b64"}), + "mid_flight_tile_snapshot": frozenset({"snapshot_path", "captured_at"}), + "flight_header": frozenset({"flight_id", "started_at", "schema_version", "build_info"}), + "flight_footer": frozenset({"flight_id", "ended_at", "records_written", "records_dropped"}), +} + +KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys()) + +# Inline binary blob cap; bigger payloads must reference a sidecar path. +MAX_INLINE_BLOB_BYTES: Final[int] = 4 * 1024 + + +class FdrSchemaError(ValueError): + """Raised on schema-violation in ``serialise`` / ``parse`` (AZ-272). + + The ONLY exception type either function raises on schema-violation; orjson + / library-specific errors are wrapped at the public boundary. + """ @dataclass(frozen=True) class FdrRecord: - """A single FDR record (record-type discriminator + payload). + """Frozen FDR record envelope per contract v1.0.0.""" - The full discriminated-union of record types is defined by AZ-272. - """ - - record_type: str - timestamp: datetime - producer: str + schema_version: int + ts: str + producer_id: str + kind: str payload: dict[str, Any] = field(default_factory=dict) + extra: dict[str, Any] = field(default_factory=dict) + + +_ENVELOPE_REQUIRED: Final[tuple[str, ...]] = ( + "schema_version", + "ts", + "producer_id", + "kind", + "payload", +) + + +def _validate_envelope_outgoing(record: FdrRecord) -> None: + if not isinstance(record.schema_version, int) or isinstance(record.schema_version, bool): + raise FdrSchemaError( + f"FdrRecord.schema_version must be a non-bool integer; got {record.schema_version!r}" + ) + if record.schema_version < 1: + raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {record.schema_version}") + if not isinstance(record.ts, str) or not record.ts: + raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {record.ts!r}") + if not isinstance(record.producer_id, str) or not record.producer_id: + raise FdrSchemaError( + f"FdrRecord.producer_id must be a non-empty string; got {record.producer_id!r}" + ) + if not isinstance(record.kind, str) or not record.kind: + raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {record.kind!r}") + if not isinstance(record.payload, dict): + raise FdrSchemaError( + f"FdrRecord.payload must be a dict; got {type(record.payload).__name__}" + ) + if record.extra: + raise FdrSchemaError( + "FdrRecord.extra is populated only by the parser; producers must leave it empty" + ) + _validate_payload_size(record.payload) + if record.kind == OVERRUN_KIND: + _validate_overrun_payload(record.payload) + + +def _validate_payload_size(payload: dict[str, Any]) -> None: + """Reject any single binary blob >MAX_INLINE_BLOB_BYTES inside the payload.""" + for key, value in payload.items(): + if isinstance(value, (bytes, bytearray)) and len(value) > MAX_INLINE_BLOB_BYTES: + raise FdrSchemaError( + f"FdrRecord.payload[{key!r}] is {len(value)} bytes; " + f"max inline blob is {MAX_INLINE_BLOB_BYTES} bytes — use a sidecar path" + ) + + +def _validate_overrun_payload(payload: dict[str, Any]) -> None: + inner_producer_id = payload.get("producer_id") + if not isinstance(inner_producer_id, str) or not inner_producer_id: + raise FdrSchemaError( + "overrun record: payload.producer_id must be a non-empty string identifying the " + "originating producer" + ) + dropped_count = payload.get("dropped_count") + if not isinstance(dropped_count, int) or isinstance(dropped_count, bool): + raise FdrSchemaError( + "overrun record: payload.dropped_count must be a non-bool integer; " + f"got {dropped_count!r}" + ) + if dropped_count <= 0: + raise FdrSchemaError( + f"overrun record: payload.dropped_count must be > 0; got {dropped_count}" + ) + + +def serialise(record: FdrRecord) -> bytes: + """Encode ``record`` to wire bytes (single-line UTF-8 JSON, ``orjson``-backed).""" + _validate_envelope_outgoing(record) + envelope: dict[str, Any] = { + "schema_version": record.schema_version, + "ts": record.ts, + "producer_id": record.producer_id, + "kind": record.kind, + "payload": record.payload, + } + try: + return orjson.dumps(envelope) + except (TypeError, orjson.JSONEncodeError) as exc: + raise FdrSchemaError(f"failed to serialise FdrRecord: {exc}") from exc + + +def parse(buf: bytes) -> FdrRecord: + """Decode wire bytes to an ``FdrRecord``; forward-compatible per contract AC-2/3.""" + if not isinstance(buf, (bytes, bytearray)): + raise FdrSchemaError(f"parse expects bytes; got {type(buf).__name__}") + try: + decoded = orjson.loads(buf) + except orjson.JSONDecodeError as exc: + raise FdrSchemaError(f"failed to decode FdrRecord bytes: {exc}") from exc + + if not isinstance(decoded, dict): + raise FdrSchemaError( + f"FdrRecord wire payload must decode to a dict; got {type(decoded).__name__}" + ) + missing = [k for k in _ENVELOPE_REQUIRED if k not in decoded] + if missing: + raise FdrSchemaError(f"FdrRecord missing required field(s): {', '.join(missing)}") + + schema_version = decoded.pop("schema_version") + if not isinstance(schema_version, int) or isinstance(schema_version, bool): + raise FdrSchemaError( + f"FdrRecord.schema_version must be a non-bool integer; got {schema_version!r}" + ) + if schema_version < 1: + raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {schema_version}") + + ts = decoded.pop("ts") + if not isinstance(ts, str) or not ts: + raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {ts!r}") + producer_id = decoded.pop("producer_id") + if not isinstance(producer_id, str) or not producer_id: + raise FdrSchemaError( + f"FdrRecord.producer_id must be a non-empty string; got {producer_id!r}" + ) + kind = decoded.pop("kind") + if not isinstance(kind, str) or not kind: + raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {kind!r}") + payload = decoded.pop("payload") + if not isinstance(payload, dict): + raise FdrSchemaError(f"FdrRecord.payload must be a dict; got {type(payload).__name__}") + + # Anything left at the top level after popping required + payload is forward-compat extra. + extra = dict(decoded) + + # Forward-compat payload sweep: for known kinds, keys outside the v1.0.0 + # set are stashed in payload["extra"] so future v1.x producers can add new + # fields without breaking v1.0 readers. + known_keys = KNOWN_PAYLOAD_KEYS.get(kind) + if known_keys is not None: + unknown_keys = [k for k in payload.keys() if k not in known_keys and k != "extra"] + if unknown_keys: + extra_bucket = dict(payload.get("extra") or {}) + for key in unknown_keys: + extra_bucket[key] = payload.pop(key) + payload["extra"] = extra_bucket + + if kind == OVERRUN_KIND: + _validate_overrun_payload(payload) + + return FdrRecord( + schema_version=schema_version, + ts=ts, + producer_id=producer_id, + kind=kind, + payload=payload, + extra=extra, + ) diff --git a/src/gps_denied_onboard/helpers/__init__.py b/src/gps_denied_onboard/helpers/__init__.py index 3b74300..3dc9f06 100644 --- a/src/gps_denied_onboard/helpers/__init__.py +++ b/src/gps_denied_onboard/helpers/__init__.py @@ -5,6 +5,17 @@ ones that have landed so consumers can depend on a stable public surface without reaching into the helper modules directly. """ +from gps_denied_onboard.helpers.descriptor_normaliser import ( + ALLOWED_DTYPES, + DescriptorNormaliser, + DescriptorNormaliserError, +) +from gps_denied_onboard.helpers.engine_filename_schema import ( + ALLOWED_PRECISIONS, + ENGINE_SUFFIX, + EngineFilenameSchema, + EngineFilenameSchemaError, +) from gps_denied_onboard.helpers.se3_utils import ( SE3, Se3InvalidMatrixError, @@ -20,13 +31,30 @@ from gps_denied_onboard.helpers.sha256_sidecar import ( Sha256Sidecar, Sha256SidecarError, ) +from gps_denied_onboard.helpers.wgs_converter import ( + MAX_ZOOM, + WEB_MERCATOR_MAX_LAT_DEG, + WgsConversionError, + WgsConverter, +) __all__ = [ + "ALLOWED_DTYPES", + "ALLOWED_PRECISIONS", + "ENGINE_SUFFIX", + "MAX_ZOOM", "SE3", "SIDECAR_SUFFIX", + "WEB_MERCATOR_MAX_LAT_DEG", + "DescriptorNormaliser", + "DescriptorNormaliserError", + "EngineFilenameSchema", + "EngineFilenameSchemaError", "Se3InvalidMatrixError", "Sha256Sidecar", "Sha256SidecarError", + "WgsConversionError", + "WgsConverter", "adjoint", "exp_map", "is_valid_rotation", diff --git a/src/gps_denied_onboard/helpers/descriptor_normaliser.py b/src/gps_denied_onboard/helpers/descriptor_normaliser.py index 670b677..7fb2c37 100644 --- a/src/gps_denied_onboard/helpers/descriptor_normaliser.py +++ b/src/gps_denied_onboard/helpers/descriptor_normaliser.py @@ -1,14 +1,97 @@ -"""Descriptor-normalisation utility — STUB. +"""L2 descriptor normaliser aligning cosine similarity to FAISS inner-product (AZ-283). -Concrete impl owned by AZ-283. Contract: -`_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md`. +Public surface frozen by +``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0. + +Used on both the corpus side (C10 index build) and the query side (C2 runtime +lookup). The two sides MUST go through the same helper so the FAISS HNSW +search returns useful neighbours. """ from __future__ import annotations -from typing import Any +from typing import Final + +import numpy as np + +__all__ = [ + "ALLOWED_DTYPES", + "DescriptorNormaliser", + "DescriptorNormaliserError", +] + +# Allowed input dtypes; anything else is rejected to keep the FAISS index and +# query path on the same precision. +ALLOWED_DTYPES: Final[tuple[np.dtype, ...]] = ( + np.dtype(np.float16), + np.dtype(np.float32), +) + +_METRIC_VALUE: Final[str] = "inner_product" -def l2_normalise(descriptors: Any) -> Any: - """L2-normalise a (N, D) descriptor matrix in-place semantics.""" - raise NotImplementedError("descriptor_normaliser concrete impl is AZ-283") +class DescriptorNormaliserError(ValueError): + """Raised on shape / dtype violations (AZ-283).""" + + +def _validate_dtype(arr: np.ndarray, label: str) -> None: + if arr.dtype not in ALLOWED_DTYPES: + raise DescriptorNormaliserError( + f"{label}: dtype {arr.dtype} not in allowed set (float16, float32)" + ) + + +class DescriptorNormaliser: + """Stateless L2-normalisation helper; dtype-preserving; zero-norm safe.""" + + @staticmethod + def l2_normalise(descriptor: np.ndarray) -> np.ndarray: + if not isinstance(descriptor, np.ndarray): + raise DescriptorNormaliserError( + f"l2_normalise: expected np.ndarray; got {type(descriptor).__name__}" + ) + if descriptor.ndim != 1: + raise DescriptorNormaliserError( + f"l2_normalise: expected 1-D shape (D,); got shape {descriptor.shape}" + ) + if descriptor.shape[0] < 1: + raise DescriptorNormaliserError( + f"l2_normalise: dimension must be >= 1; got shape {descriptor.shape}" + ) + _validate_dtype(descriptor, "l2_normalise") + in_dtype = descriptor.dtype + # Compute norm in float32 to stabilise float16 inputs against overflow / + # underflow; cast back to the caller dtype so we never silently up-cast. + as_f32 = descriptor.astype(np.float32, copy=False) + norm = float(np.linalg.norm(as_f32)) + if norm == 0.0: + return np.zeros_like(descriptor) + normalised_f32 = as_f32 / norm + return normalised_f32.astype(in_dtype, copy=False) + + @staticmethod + def l2_normalise_batch(descriptors: np.ndarray) -> np.ndarray: + if not isinstance(descriptors, np.ndarray): + raise DescriptorNormaliserError( + f"l2_normalise_batch: expected np.ndarray; got {type(descriptors).__name__}" + ) + if descriptors.ndim != 2: + raise DescriptorNormaliserError( + f"l2_normalise_batch: expected 2-D shape (N, D); got shape {descriptors.shape}" + ) + if descriptors.shape[0] < 1 or descriptors.shape[1] < 1: + raise DescriptorNormaliserError( + f"l2_normalise_batch: N and D must be >= 1; got shape {descriptors.shape}" + ) + _validate_dtype(descriptors, "l2_normalise_batch") + in_dtype = descriptors.dtype + as_f32 = descriptors.astype(np.float32, copy=False) + norms = np.linalg.norm(as_f32, axis=1, keepdims=True) + # Avoid division-by-zero: leave zero rows as zero. + safe = np.where(norms == 0.0, 1.0, norms) + normalised_f32 = np.where(norms == 0.0, 0.0, as_f32 / safe) + return normalised_f32.astype(in_dtype, copy=False) + + @staticmethod + def descriptor_metric() -> str: + return _METRIC_VALUE diff --git a/src/gps_denied_onboard/helpers/engine_filename_schema.py b/src/gps_denied_onboard/helpers/engine_filename_schema.py index 4bf1661..701fc50 100644 --- a/src/gps_denied_onboard/helpers/engine_filename_schema.py +++ b/src/gps_denied_onboard/helpers/engine_filename_schema.py @@ -1,28 +1,127 @@ -"""TensorRT engine filename schema — STUB. +"""Self-describing `.engine` filename schema (AZ-281 / D-C10-7). -D-C10-7 self-describing engine names. Concrete impl owned by AZ-281. Contract: -`_docs/02_document/common-helpers/06_helper_engine_filename_schema.md`. +Public surface frozen by +``_docs/02_document/contracts/shared_helpers/engine_filename_schema.md`` v1.0.0. + +Filename format: ``{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine`` +where ``model`` is ``[a-z0-9_]`` (no ``__``), versions are dotted +``.``, and ``precision`` is one of ``fp16``, ``int8``, ``mixed``. """ from __future__ import annotations -from dataclasses import dataclass +import re +from typing import Final + +from gps_denied_onboard._types.manifests import EngineCacheKey, HostCapabilities + +__all__ = [ + "ALLOWED_PRECISIONS", + "ENGINE_SUFFIX", + "EngineFilenameSchema", + "EngineFilenameSchemaError", +] + +ENGINE_SUFFIX: Final[str] = ".engine" +ALLOWED_PRECISIONS: Final[frozenset[str]] = frozenset({"fp16", "int8", "mixed"}) + +_MODEL_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9_]+$") +_DOTTED_VERSION_RE: Final[re.Pattern[str]] = re.compile(r"^\d+\.\d+$") +_FILENAME_RE: Final[re.Pattern[str]] = re.compile( + r"^(?P[a-z0-9_]+)__sm(?P\d+)_jp(?P\d+\.\d+)_trt(?P\d+\.\d+)_" + r"(?Pfp16|int8|mixed)\.engine$" +) -@dataclass(frozen=True) -class EngineFilename: - """Parsed parts of a self-describing engine filename.""" +class EngineFilenameSchemaError(ValueError): + """Raised by ``build`` / ``parse`` on validation / format violations (AZ-281).""" - model_name: str - sm_arch: str - jetpack_version: str - tensorrt_version: str - precision: str - content_hash: str - def render(self) -> str: - raise NotImplementedError("engine_filename_schema concrete impl is AZ-281") +class EngineFilenameSchema: + """Stateless ``.engine`` filename builder / parser / host-match predicate.""" - @classmethod - def parse(cls, filename: str) -> EngineFilename: - raise NotImplementedError("engine_filename_schema concrete impl is AZ-281") + @staticmethod + def build(model_name: str, sm: int, jetpack: str, trt: str, precision: str) -> str: + _validate_model_name(model_name) + _validate_sm(sm) + _validate_version(jetpack, "jetpack") + _validate_version(trt, "trt") + _validate_precision(precision) + return f"{model_name}__sm{sm}_jp{jetpack}_trt{trt}_{precision}{ENGINE_SUFFIX}" + + @staticmethod + def parse(filename: str) -> EngineCacheKey: + if not isinstance(filename, str): + raise EngineFilenameSchemaError(f"parse expects str; got {type(filename).__name__}") + if not filename.endswith(ENGINE_SUFFIX): + raise EngineFilenameSchemaError( + f"parse: filename must end with {ENGINE_SUFFIX!r}; got {filename!r}" + ) + match = _FILENAME_RE.match(filename) + if not match: + raise EngineFilenameSchemaError( + f"parse: filename {filename!r} does not match the engine-schema format " + "'{model}__sm{SM}_jp{JP}_trt{TRT}_{precision}.engine'" + ) + model = match.group("model") + if "__" in model: + raise EngineFilenameSchemaError( + f"parse: model segment {model!r} contains reserved separator '__'" + ) + return EngineCacheKey( + model_name=model, + sm=int(match.group("sm")), + jetpack=match.group("jetpack"), + trt=match.group("trt"), + precision=match.group("precision"), + ) + + @staticmethod + def matches_host(filename: str, host_capabilities: HostCapabilities) -> bool: + key = EngineFilenameSchema.parse(filename) + return ( + key.sm == host_capabilities.sm + and key.jetpack == host_capabilities.jetpack + and key.trt == host_capabilities.trt + ) + + +def _validate_model_name(model_name: str) -> None: + if not isinstance(model_name, str): + raise EngineFilenameSchemaError(f"model_name must be str; got {type(model_name).__name__}") + if not model_name: + raise EngineFilenameSchemaError("model_name must be a non-empty string") + if "__" in model_name: + raise EngineFilenameSchemaError( + f"model_name {model_name!r} contains reserved separator '__'" + ) + if not _MODEL_RE.match(model_name): + raise EngineFilenameSchemaError( + f"model_name {model_name!r} must match [a-z0-9_]+ (lowercase, digits, underscores)" + ) + if len(model_name) > 64: + raise EngineFilenameSchemaError(f"model_name {model_name!r} exceeds 64-character limit") + + +def _validate_sm(sm: int) -> None: + if not isinstance(sm, int) or isinstance(sm, bool): + raise EngineFilenameSchemaError(f"sm must be a non-bool integer; got {sm!r}") + if sm <= 0: + raise EngineFilenameSchemaError(f"sm must be > 0; got {sm}") + + +def _validate_version(version: str, field_name: str) -> None: + if not isinstance(version, str): + raise EngineFilenameSchemaError(f"{field_name} must be str; got {type(version).__name__}") + if not _DOTTED_VERSION_RE.match(version): + raise EngineFilenameSchemaError( + f"{field_name} {version!r} must match dotted '.' format" + ) + + +def _validate_precision(precision: str) -> None: + if precision not in ALLOWED_PRECISIONS: + raise EngineFilenameSchemaError( + f"precision {precision!r} not in allowed enum " + f"{{{', '.join(sorted(ALLOWED_PRECISIONS))}}}" + ) diff --git a/src/gps_denied_onboard/helpers/wgs_converter.py b/src/gps_denied_onboard/helpers/wgs_converter.py index 9dfa2ee..840743c 100644 --- a/src/gps_denied_onboard/helpers/wgs_converter.py +++ b/src/gps_denied_onboard/helpers/wgs_converter.py @@ -1,26 +1,170 @@ -"""WGS84 ↔ local-tangent-plane converter — STUB. +"""WGS84 ↔ ECEF ↔ ENU ↔ slippy-map tile-xy conversions (AZ-279 / E-CC-HELPERS). -Concrete implementation is owned by AZ-279. Contract: -`_docs/02_document/common-helpers/04_helper_wgs_converter.md`. +Public surface frozen by +``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0. + +Backed by ``pyproj`` for the geodesy primitives. Slippy-map tile math is hand +rolled to match OSM's `{zoom}/{x}/{y}.jpg` convention exactly so the on-disk +layout produced by ``satellite-provider`` round-trips byte-equal. """ from __future__ import annotations +import math +from typing import Final -def wgs84_to_ltp( - lat_deg: float, - lon_deg: float, - alt_m: float, - ref_lat_deg: float, - ref_lon_deg: float, - ref_alt_m: float, -) -> tuple[float, float, float]: - """Convert a WGS-84 lat/lon/alt to local-tangent-plane east/north/up metres.""" - raise NotImplementedError("wgs_converter concrete impl is AZ-279") +import numpy as np +from pyproj import Transformer # type: ignore[import-not-found] + +from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt + +__all__ = ["MAX_ZOOM", "WEB_MERCATOR_MAX_LAT_DEG", "WgsConversionError", "WgsConverter"] -def ltp_to_wgs84( - e_m: float, n_m: float, u_m: float, ref_lat_deg: float, ref_lon_deg: float, ref_alt_m: float -) -> tuple[float, float, float]: - """Inverse of wgs84_to_ltp.""" - raise NotImplementedError("wgs_converter concrete impl is AZ-279") +WEB_MERCATOR_MAX_LAT_DEG: Final[float] = 85.0511287798066 +MAX_ZOOM: Final[int] = 22 + + +class WgsConversionError(ValueError): + """Raised on shape / range violations in any ``WgsConverter`` static method.""" + + +_ECEF_FROM_LLA: Final[Transformer] = Transformer.from_crs("EPSG:4326", "EPSG:4978", always_xy=True) +_LLA_FROM_ECEF: Final[Transformer] = Transformer.from_crs("EPSG:4978", "EPSG:4326", always_xy=True) + + +def _validate_finite_latlonalt(p: LatLonAlt, label: str) -> None: + if not (math.isfinite(p.lat_deg) and math.isfinite(p.lon_deg) and math.isfinite(p.alt_m)): + raise WgsConversionError(f"{label}: non-finite component in {p!r}") + if not (-90.0 <= p.lat_deg <= 90.0): + raise WgsConversionError(f"{label}: latitude {p.lat_deg} outside [-90, 90]") + if not (-180.0 <= p.lon_deg <= 180.0): + raise WgsConversionError(f"{label}: longitude {p.lon_deg} outside [-180, 180]") + + +def _enforce_ecef_shape(arr: np.ndarray, label: str) -> None: + if not isinstance(arr, np.ndarray): + raise WgsConversionError( + f"{label}: expected np.ndarray of shape (3,); got {type(arr).__name__}" + ) + if arr.shape != (3,): + raise WgsConversionError( + f"{label}: expected np.ndarray of shape (3,); got shape {arr.shape}" + ) + if not np.all(np.isfinite(arr)): + raise WgsConversionError(f"{label}: non-finite component in {arr!r}") + + +class WgsConverter: + """Stateless WGS84 / ECEF / ENU / slippy-map-tile converter. + + Every method is a pure function of its arguments; no module-level state + other than the cached ``pyproj`` transformer pair. + """ + + @staticmethod + def latlonalt_to_ecef(p: LatLonAlt) -> np.ndarray: + _validate_finite_latlonalt(p, "latlonalt_to_ecef") + x, y, z = _ECEF_FROM_LLA.transform(p.lon_deg, p.lat_deg, p.alt_m) + return np.array([x, y, z], dtype=np.float64) + + @staticmethod + def ecef_to_latlonalt(p_ecef: np.ndarray) -> LatLonAlt: + _enforce_ecef_shape(p_ecef, "ecef_to_latlonalt") + lon, lat, alt = _LLA_FROM_ECEF.transform( + float(p_ecef[0]), float(p_ecef[1]), float(p_ecef[2]) + ) + return LatLonAlt(lat_deg=float(lat), lon_deg=float(lon), alt_m=float(alt)) + + @staticmethod + def latlonalt_to_local_enu(origin: LatLonAlt, p: LatLonAlt) -> np.ndarray: + _validate_finite_latlonalt(origin, "latlonalt_to_local_enu/origin") + _validate_finite_latlonalt(p, "latlonalt_to_local_enu/p") + return _ecef_delta_to_enu(origin, WgsConverter.latlonalt_to_ecef(p)) + + @staticmethod + def local_enu_to_latlonalt(origin: LatLonAlt, p_enu: np.ndarray) -> LatLonAlt: + _validate_finite_latlonalt(origin, "local_enu_to_latlonalt/origin") + _enforce_ecef_shape(p_enu, "local_enu_to_latlonalt/p_enu") + origin_ecef = WgsConverter.latlonalt_to_ecef(origin) + rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg) + delta_ecef = rotation @ p_enu.astype(np.float64) + return WgsConverter.ecef_to_latlonalt(origin_ecef + delta_ecef) + + @staticmethod + def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]: + _validate_zoom(zoom) + if not (math.isfinite(lat) and math.isfinite(lon)): + raise WgsConversionError(f"latlon_to_tile_xy: non-finite input (lat={lat}, lon={lon})") + if abs(lat) > WEB_MERCATOR_MAX_LAT_DEG: + raise WgsConversionError( + f"latlon_to_tile_xy: latitude {lat} outside Web-Mercator range " + f"[-{WEB_MERCATOR_MAX_LAT_DEG}, {WEB_MERCATOR_MAX_LAT_DEG}]" + ) + if not (-180.0 <= lon <= 180.0): + raise WgsConversionError(f"latlon_to_tile_xy: longitude {lon} outside [-180, 180]") + n = 1 << zoom + lat_rad = math.radians(lat) + x = math.floor((lon + 180.0) / 360.0 * n) + y = math.floor( + (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n + ) + x = max(0, min(x, n - 1)) + y = max(0, min(y, n - 1)) + return x, y + + @staticmethod + def tile_xy_to_latlon_bounds(zoom: int, x: int, y: int) -> BoundingBox: + _validate_zoom(zoom) + n = 1 << zoom + if not (0 <= x < n and 0 <= y < n): + raise WgsConversionError( + f"tile_xy_to_latlon_bounds: tile (x={x}, y={y}) outside [0, {n}) at zoom {zoom}" + ) + return BoundingBox( + min_lat_deg=_tile_y_to_lat(y + 1, n), + min_lon_deg=_tile_x_to_lon(x, n), + max_lat_deg=_tile_y_to_lat(y, n), + max_lon_deg=_tile_x_to_lon(x + 1, n), + ) + + +def _validate_zoom(zoom: int) -> None: + if not isinstance(zoom, int) or isinstance(zoom, bool): + raise WgsConversionError(f"zoom must be a non-bool integer; got {zoom!r}") + if not (0 <= zoom <= MAX_ZOOM): + raise WgsConversionError(f"zoom {zoom} outside supported range [0, {MAX_ZOOM}]") + + +def _tile_x_to_lon(x: int, n: int) -> float: + return x / n * 360.0 - 180.0 + + +def _tile_y_to_lat(y: int, n: int) -> float: + t = math.pi * (1.0 - 2.0 * y / n) + return math.degrees(math.atan(math.sinh(t))) + + +def _enu_to_ecef_rotation(lat_deg: float, lon_deg: float) -> np.ndarray: + """Rotation matrix mapping local ENU vectors to ECEF deltas at ``(lat, lon)``.""" + lat = math.radians(lat_deg) + lon = math.radians(lon_deg) + sin_lat = math.sin(lat) + cos_lat = math.cos(lat) + sin_lon = math.sin(lon) + cos_lon = math.cos(lon) + return np.array( + [ + [-sin_lon, -sin_lat * cos_lon, cos_lat * cos_lon], + [cos_lon, -sin_lat * sin_lon, cos_lat * sin_lon], + [0.0, cos_lat, sin_lat], + ], + dtype=np.float64, + ) + + +def _ecef_delta_to_enu(origin: LatLonAlt, p_ecef: np.ndarray) -> np.ndarray: + origin_ecef = WgsConverter.latlonalt_to_ecef(origin) + delta = p_ecef - origin_ecef + rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg) + return rotation.T @ delta diff --git a/src/gps_denied_onboard/runtime_root.py b/src/gps_denied_onboard/runtime_root.py index b5e85a2..2598fa7 100644 --- a/src/gps_denied_onboard/runtime_root.py +++ b/src/gps_denied_onboard/runtime_root.py @@ -1,26 +1,82 @@ -"""Composition root (ADR-009 interface-first DI). +"""Composition root (AZ-270 / E-CC-CONF; ADR-009 interface-first DI). -The only place that may import concrete strategy implementations across -components. Per-binary `compose_*` entrypoints select the strategy graph for that -binary (airborne / research / operator-tooling / replay-cli) — gated by CMake -`BUILD_*` flags at compile time and validated again here at startup. +The single module allowed to import concrete strategy implementations across +components. Per-binary ``compose_*`` functions resolve the ``Config``-selected +strategies, validate them against the registry (ADR-002 gate #3), and assemble +the component graph in dependency order. -Bootstrap (AZ-263) ships the entrypoints as stubs that perform the required env-var -fail-fast (AC-8). Per-component wiring is added by each component's "wire-in" -implementation task. +Per-binary entrypoints: + +* :func:`compose_root` - airborne runtime +* :func:`compose_operator` - operator-side tooling (pre-flight, post-landing) +* :func:`compose_replay` - replay-cli runtime (extension owned by AZ-401) + +Public surface frozen by +``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0. """ from __future__ import annotations import os import sys -from collections.abc import Iterable -from dataclasses import dataclass +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any, Literal, get_args + +from gps_denied_onboard.config import Config, load_config + +__all__ = [ + "REQUIRED_ENV_VARS", + "ConfigurationError", + "OperatorRoot", + "RuntimeRoot", + "StrategyNotLinkedError", + "StrategyTier", + "clear_strategy_registry", + "compose_operator", + "compose_replay", + "compose_root", + "list_registered_strategies", + "main", + "register_strategy", +] + +StrategyTier = Literal["airborne", "operator", "shared"] +_ALL_TIERS: tuple[StrategyTier, ...] = get_args(StrategyTier) class ConfigurationError(RuntimeError): - """Raised when a required environment variable is missing or a strategy whose - CMake `BUILD_*` flag is OFF would otherwise be wired.""" + """Raised when a required environment variable is missing. + + AC-8 (Bootstrap / AZ-263). + """ + + +class StrategyNotLinkedError(RuntimeError): + """Raised when the config selects a strategy that is not linked into this binary. + + Carries the offending strategy name, the owning component slug, and the + actually-linked strategies for that component — gives the operator a + clear next step (build with the right flag, or pick a different strategy). + """ + + def __init__( + self, + strategy_name: str, + component_slug: str, + available_strategies: Iterable[str], + *, + reason: str = "not linked", + ) -> None: + self.strategy_name: str = strategy_name + self.component_slug: str = component_slug + self.available_strategies: list[str] = sorted(set(available_strategies)) + self.reason: str = reason + available_text = ", ".join(self.available_strategies) or "" + super().__init__( + f"strategy {strategy_name!r} requested for component {component_slug!r} is " + f"{reason}; available strategies: [{available_text}]" + ) REQUIRED_ENV_VARS: tuple[str, ...] = ( @@ -36,11 +92,89 @@ REQUIRED_ENV_VARS: tuple[str, ...] = ( ) -def _check_required_env(extra_required: Iterable[str] = ()) -> None: - """Fail fast with a clear pointer at the first missing required env var. +@dataclass(frozen=True) +class _Registration: + component_slug: str + strategy_name: str + factory: Callable[..., Any] + tier: StrategyTier + depends_on: tuple[str, ...] - AC-8 (Bootstrap / AZ-263). + +_STRATEGY_REGISTRY: dict[tuple[str, str], _Registration] = {} + + +def register_strategy( + component_slug: str, + strategy_name: str, + factory: Callable[..., Any], + *, + tier: StrategyTier = "shared", + depends_on: Iterable[str] = (), +) -> None: + """Register a concrete strategy implementation for ``component_slug``. + + The single allowed call site is the composition root or a binary-specific + bootstrap module (one module per ``BUILD_*`` flag combination). Calling + this from a component module is an architecture violation; AC-6 catches + that statically. """ + if tier not in _ALL_TIERS: + raise ValueError(f"tier must be one of {_ALL_TIERS}; got {tier!r}") + key = (component_slug, strategy_name) + existing = _STRATEGY_REGISTRY.get(key) + incoming = _Registration( + component_slug=component_slug, + strategy_name=strategy_name, + factory=factory, + tier=tier, + depends_on=tuple(depends_on), + ) + if existing is not None and existing != incoming: + raise StrategyNotLinkedError( + strategy_name=strategy_name, + component_slug=component_slug, + available_strategies=list_registered_strategies(component_slug), + reason="duplicate registration with conflicting attributes", + ) + _STRATEGY_REGISTRY[key] = incoming + + +def clear_strategy_registry() -> None: + """Reset the global registry; intended for unit-test isolation only.""" + _STRATEGY_REGISTRY.clear() + + +def list_registered_strategies(component_slug: str) -> list[str]: + """Return the strategy names registered for ``component_slug`` (sorted).""" + return sorted( + reg.strategy_name + for (slug, _name), reg in _STRATEGY_REGISTRY.items() + if slug == component_slug + ) + + +@dataclass(frozen=True) +class RuntimeRoot: + """Composed airborne runtime graph (every component slot populated).""" + + binary: str + profile: str + components: Mapping[str, Any] = field(default_factory=dict) + construction_order: tuple[str, ...] = () + + +@dataclass(frozen=True) +class OperatorRoot: + """Composed operator-side runtime graph (operator-tier components only).""" + + binary: str + profile: str + components: Mapping[str, Any] = field(default_factory=dict) + construction_order: tuple[str, ...] = () + + +def _check_required_env(extra_required: Iterable[str] = ()) -> None: missing = [name for name in (*REQUIRED_ENV_VARS, *extra_required) if name not in os.environ] if missing: raise ConfigurationError( @@ -50,36 +184,197 @@ def _check_required_env(extra_required: Iterable[str] = ()) -> None: ) -@dataclass(frozen=True) -class RuntimeRoot: - """Composed runtime graph (placeholder; per-component wiring is per-task).""" - - binary: str - profile: str +def _resolve_strategy( + component_slug: str, + strategy_name: str, + allowed_tiers: frozenset[StrategyTier], +) -> _Registration: + """Look up ``(component_slug, strategy_name)`` in the registry and gate by tier.""" + key = (component_slug, strategy_name) + registration = _STRATEGY_REGISTRY.get(key) + if registration is None: + raise StrategyNotLinkedError( + strategy_name=strategy_name, + component_slug=component_slug, + available_strategies=list_registered_strategies(component_slug), + ) + if registration.tier not in allowed_tiers: + raise StrategyNotLinkedError( + strategy_name=strategy_name, + component_slug=component_slug, + available_strategies=list_registered_strategies(component_slug), + reason=( + f"registered under tier={registration.tier!r}; " + f"not allowed in this binary (allowed tiers: {sorted(allowed_tiers)})" + ), + ) + return registration -def compose_root(yaml_config_path: str | None = None) -> RuntimeRoot: - """Compose the airborne runtime graph.""" - _check_required_env(extra_required=("MAVLINK_SIGNING_KEY",)) - return RuntimeRoot(binary="airborne", profile=os.environ["GPS_DENIED_FC_PROFILE"]) +def _topo_order( + target_slugs: Iterable[str], registrations: Mapping[str, _Registration] +) -> list[str]: + """Return ``target_slugs`` in a dependency-respecting order (Kahn's algorithm).""" + ordered: list[str] = [] + remaining = set(target_slugs) + visited: set[str] = set() + + def visit(slug: str, stack: tuple[str, ...]) -> None: + if slug in visited: + return + if slug in stack: + raise StrategyNotLinkedError( + strategy_name=registrations[slug].strategy_name, + component_slug=slug, + available_strategies=list_registered_strategies(slug), + reason=f"dependency cycle detected: {' -> '.join((*stack, slug))}", + ) + if slug not in registrations: + return + for dep in registrations[slug].depends_on: + visit(dep, (*stack, slug)) + visited.add(slug) + ordered.append(slug) + + for slug in sorted(remaining): + visit(slug, ()) + return [slug for slug in ordered if slug in remaining] -def compose_operator(yaml_config_path: str | None = None) -> RuntimeRoot: - """Compose the operator-tooling runtime graph (pre-flight + post-landing).""" - _check_required_env(extra_required=("SATELLITE_PROVIDER_URL",)) - return RuntimeRoot(binary="operator-tooling", profile=os.environ["GPS_DENIED_FC_PROFILE"]) +def _compose( + config: Config, + *, + binary: str, + allowed_tiers: frozenset[StrategyTier], + extra_required_env: Iterable[str], +) -> tuple[dict[str, Any], tuple[str, ...]]: + """Shared composition path used by ``compose_root`` / ``compose_operator``.""" + _check_required_env(extra_required=extra_required_env) + selections = _resolve_component_strategies(config, allowed_tiers) + resolved: dict[str, _Registration] = { + slug: _resolve_strategy(slug, strategy, allowed_tiers) + for slug, strategy in selections.items() + } + order = _topo_order(resolved.keys(), resolved) + constructed: dict[str, Any] = {} + for slug in order: + registration = resolved[slug] + try: + constructed[slug] = registration.factory(config, constructed) + except Exception: + # All-or-nothing: close anything already built before re-raising. + _close_partial_instances(constructed) + raise + _ = binary # documented but unused beyond labelling the returned root + return constructed, tuple(order) -def compose_replay(yaml_config_path: str | None = None) -> RuntimeRoot: - """Compose the replay-cli runtime graph. Concrete wiring owned by AZ-401.""" - _check_required_env() - return RuntimeRoot(binary="replay-cli", profile=os.environ["GPS_DENIED_FC_PROFILE"]) +def _close_partial_instances(instances: Mapping[str, Any]) -> None: + """Best-effort cleanup of partially-constructed components on failure. + + Calls ``.close()`` on each instance that exposes one; swallows individual + close failures so the original exception propagates to the caller. + """ + for inst in instances.values(): + close = getattr(inst, "close", None) + if callable(close): + try: + close() + except Exception: + continue + + +def _resolve_component_strategies( + config: Config, allowed_tiers: frozenset[StrategyTier] +) -> dict[str, str]: + """Translate ``config.components[slug]`` into ``{slug: strategy_name}``. + + Two recognised shapes for a per-component block: + + * a dataclass with a ``strategy`` field (preferred); + * a mapping with a ``"strategy"`` key (fallback for raw YAML). + + Blocks without a ``strategy`` field are skipped — they configure a + component whose strategy was hard-wired by the binary's bootstrap. + """ + _ = allowed_tiers # tier filtering happens in ``_resolve_strategy`` + selections: dict[str, str] = {} + for slug, block in (config.components or {}).items(): + strategy = _read_strategy_attr(block) + if strategy is None: + continue + if not isinstance(strategy, str) or not strategy: + raise StrategyNotLinkedError( + strategy_name=str(strategy), + component_slug=slug, + available_strategies=list_registered_strategies(slug), + reason="config.components[slug].strategy must be a non-empty string", + ) + selections[slug] = strategy + return selections + + +def _read_strategy_attr(block: Any) -> Any: + if hasattr(block, "strategy"): + return block.strategy + if isinstance(block, Mapping): + return block.get("strategy") + return None + + +def compose_root(config: Config) -> RuntimeRoot: + """Compose the airborne runtime graph (per contract v1.0.0).""" + components, order = _compose( + config, + binary="airborne", + allowed_tiers=frozenset({"airborne", "shared"}), + extra_required_env=("MAVLINK_SIGNING_KEY",), + ) + return RuntimeRoot( + binary="airborne", + profile=os.environ["GPS_DENIED_FC_PROFILE"], + components=components, + construction_order=order, + ) + + +def compose_operator(config: Config) -> OperatorRoot: + """Compose the operator-tooling runtime graph (per contract v1.0.0).""" + components, order = _compose( + config, + binary="operator-tooling", + allowed_tiers=frozenset({"operator", "shared"}), + extra_required_env=("SATELLITE_PROVIDER_URL",), + ) + return OperatorRoot( + binary="operator-tooling", + profile=os.environ["GPS_DENIED_FC_PROFILE"], + components=components, + construction_order=order, + ) + + +def compose_replay(config: Config) -> RuntimeRoot: + """Compose the replay-cli runtime graph. Concrete wiring is owned by AZ-401.""" + components, order = _compose( + config, + binary="replay-cli", + allowed_tiers=frozenset({"airborne", "shared"}), + extra_required_env=(), + ) + return RuntimeRoot( + binary="replay-cli", + profile=os.environ["GPS_DENIED_FC_PROFILE"], + components=components, + construction_order=order, + ) def main() -> int: # pragma: no cover — guarded entrypoint try: - compose_root() - except ConfigurationError as exc: + config = load_config(env=os.environ, paths=()) + compose_root(config) + except (ConfigurationError, StrategyNotLinkedError, RuntimeError) as exc: print(f"runtime_root: {exc}", file=sys.stderr) return 2 return 0 diff --git a/tests/unit/test_az270_compose_root.py b/tests/unit/test_az270_compose_root.py new file mode 100644 index 0000000..b28ab45 --- /dev/null +++ b/tests/unit/test_az270_compose_root.py @@ -0,0 +1,270 @@ +"""AZ-270 — Composition Root AC tests. + +Verifies the contract at ``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0. +""" + +from __future__ import annotations + +import ast +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from gps_denied_onboard.config import Config +from gps_denied_onboard.runtime_root import ( + RuntimeRoot, + StrategyNotLinkedError, + clear_strategy_registry, + compose_operator, + compose_root, + list_registered_strategies, + register_strategy, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +@dataclass(frozen=True) +class _C1Block: + strategy: str = "okvis2" + + +@dataclass(frozen=True) +class _C4Block: + strategy: str = "opencv_gtsam" + + +@dataclass(frozen=True) +class _C5Block: + strategy: str = "gtsam_isam2" + + +@dataclass(frozen=True) +class _C11Block: + strategy: str = "ardupilot_tile_manager" + + +@dataclass +class _OrderRecorder: + constructed: list[str] + + +@pytest.fixture(autouse=True) +def _isolated_registry() -> Iterator[None]: + """Reset the strategy registry around every test.""" + clear_strategy_registry() + yield + clear_strategy_registry() + + +@pytest.fixture +def _airborne_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name, value in ( + ("GPS_DENIED_FC_PROFILE", "ardupilot_plane"), + ("GPS_DENIED_TIER", "1"), + ("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"), + ("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"), + ("LOG_LEVEL", "INFO"), + ("LOG_SINK", "console"), + ("INFERENCE_BACKEND", "pytorch_fp16"), + ("FDR_PATH", "/var/lib/gps-denied/fdr"), + ("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"), + ("MAVLINK_SIGNING_KEY", "ZZZZZZZZ"), + ): + monkeypatch.setenv(name, value) + + +@pytest.fixture +def _operator_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name, value in ( + ("GPS_DENIED_FC_PROFILE", "ardupilot_plane"), + ("GPS_DENIED_TIER", "1"), + ("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"), + ("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"), + ("LOG_LEVEL", "INFO"), + ("LOG_SINK", "console"), + ("INFERENCE_BACKEND", "pytorch_fp16"), + ("FDR_PATH", "/var/lib/gps-denied/fdr"), + ("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"), + ("SATELLITE_PROVIDER_URL", "http://localhost:8080"), + ): + monkeypatch.setenv(name, value) + + +def _register_okvis2(recorder: _OrderRecorder) -> None: + def factory(config: Config, components: dict[str, object]) -> object: + recorder.constructed.append("c1_vio") + return ("c1_vio", "okvis2") + + register_strategy("c1_vio", "okvis2", factory, tier="airborne") + + +def _register_c4(recorder: _OrderRecorder) -> None: + def factory(config: Config, components: dict[str, object]) -> object: + recorder.constructed.append("c4_pose") + return ("c4_pose", "opencv_gtsam") + + register_strategy("c4_pose", "opencv_gtsam", factory, tier="airborne") + + +def _register_c5(recorder: _OrderRecorder) -> None: + def factory(config: Config, components: dict[str, object]) -> object: + assert "c1_vio" in components, "c5_state factory ran before c1_vio existed" + assert "c4_pose" in components, "c5_state factory ran before c4_pose existed" + recorder.constructed.append("c5_state") + return ("c5_state", "gtsam_isam2") + + register_strategy( + "c5_state", + "gtsam_isam2", + factory, + tier="airborne", + depends_on=("c1_vio", "c4_pose"), + ) + + +def test_ac1_default_deployment_composes(_airborne_env: None) -> None: + recorder = _OrderRecorder(constructed=[]) + _register_okvis2(recorder) + _register_c4(recorder) + _register_c5(recorder) + config = Config.with_blocks( + c1_vio=_C1Block(), + c4_pose=_C4Block(), + c5_state=_C5Block(), + ) + root = compose_root(config) + assert isinstance(root, RuntimeRoot) + assert root.binary == "airborne" + assert set(root.components.keys()) == {"c1_vio", "c4_pose", "c5_state"} + + +def test_ac2_strategy_not_linked_raises_with_payload(_airborne_env: None) -> None: + # Only okvis2 is registered; config asks for vins_mono. + recorder = _OrderRecorder(constructed=[]) + _register_okvis2(recorder) + config = Config.with_blocks(c1_vio=_C1Block(strategy="vins_mono")) + with pytest.raises(StrategyNotLinkedError) as info: + compose_root(config) + assert info.value.strategy_name == "vins_mono" + assert info.value.component_slug == "c1_vio" + assert info.value.available_strategies == ["okvis2"] + + +def test_ac3_operator_excludes_airborne_only(_operator_env: None) -> None: + # c1_vio is registered as airborne; an operator config that references it must fail. + recorder = _OrderRecorder(constructed=[]) + _register_okvis2(recorder) + config = Config.with_blocks(c1_vio=_C1Block()) + with pytest.raises(StrategyNotLinkedError) as info: + compose_operator(config) + assert info.value.component_slug == "c1_vio" + assert "airborne" in info.value.reason or "tier" in info.value.reason + + +def test_ac4_runtime_root_smoke_exit_zero(_airborne_env: None) -> None: + # A Config with no component blocks must compose cleanly (every required + # component is hard-wired by its bootstrap; no strategy to resolve). + config = Config() + root = compose_root(config) + assert isinstance(root, RuntimeRoot) + assert root.components == {} + + +def test_ac5_construction_order_respects_dependencies(_airborne_env: None) -> None: + recorder = _OrderRecorder(constructed=[]) + # Register in reverse order to make the topological pass non-trivial. + _register_c5(recorder) + _register_c4(recorder) + _register_okvis2(recorder) + config = Config.with_blocks( + c1_vio=_C1Block(), + c4_pose=_C4Block(), + c5_state=_C5Block(), + ) + root = compose_root(config) + # Dependencies must construct strictly before dependents. + assert recorder.constructed.index("c1_vio") < recorder.constructed.index("c5_state") + assert recorder.constructed.index("c4_pose") < recorder.constructed.index("c5_state") + assert root.construction_order[-1] == "c5_state" + + +def test_ac6_only_compose_root_imports_concrete_strategies() -> None: + """Architecture lint: no module under ``components.*`` imports another component's concrete strategy. + + We accept only: + * the composition root (``runtime_root.py``); + * per-component public re-exports inside the component's own subpackage + (e.g. ``components.c5_state`` importing ``components.c5_state.interface``). + Imports across components (e.g. ``components.c5_state`` importing + ``components.c1_vio.okvis2``) are violations. + """ + components_root = _REPO_ROOT / "src" / "gps_denied_onboard" / "components" + violations: list[str] = [] + for module_path in components_root.rglob("*.py"): + own_component = module_path.relative_to(components_root).parts[0] + try: + tree = ast.parse(module_path.read_text(encoding="utf-8")) + except SyntaxError: + continue + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("gps_denied_onboard.components."): + referenced = node.module.split(".")[2] + if referenced != own_component: + violations.append( + f"{module_path.relative_to(_REPO_ROOT)} imports {node.module}" + ) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("gps_denied_onboard.components."): + referenced = alias.name.split(".")[2] + if referenced != own_component: + violations.append( + f"{module_path.relative_to(_REPO_ROOT)} imports {alias.name}" + ) + assert not violations, ( + "components.* may not import other components — only the composition root may; " + f"violations: {violations}" + ) + + +def test_nfr_reliability_partial_construction_closed_on_failure(_airborne_env: None) -> None: + closed: list[str] = [] + + class _Closable: + def __init__(self, slug: str) -> None: + self.slug = slug + + def close(self) -> None: + closed.append(self.slug) + + def good_factory(config: Config, components: dict[str, object]) -> _Closable: + return _Closable("c1_vio") + + def failing_factory(config: Config, components: dict[str, object]) -> object: + raise RuntimeError("boom from c5_state factory") + + register_strategy("c1_vio", "okvis2", good_factory, tier="airborne") + register_strategy( + "c5_state", + "gtsam_isam2", + failing_factory, + tier="airborne", + depends_on=("c1_vio",), + ) + config = Config.with_blocks(c1_vio=_C1Block(), c5_state=_C5Block()) + with pytest.raises(RuntimeError, match=r"boom"): + compose_root(config) + assert closed == ["c1_vio"], "prior instances must be .close()d on mid-composition failure" + + +def test_list_registered_strategies_returns_sorted_names() -> None: + register_strategy("c1_vio", "okvis2", lambda c, m: None, tier="airborne") + register_strategy("c1_vio", "vins_mono", lambda c, m: None, tier="airborne") + register_strategy("c2_vpr", "netvlad", lambda c, m: None, tier="airborne") + assert list_registered_strategies("c1_vio") == ["okvis2", "vins_mono"] + assert list_registered_strategies("c2_vpr") == ["netvlad"] + assert list_registered_strategies("c99_unknown") == [] diff --git a/tests/unit/test_az272_fdr_record_schema.py b/tests/unit/test_az272_fdr_record_schema.py new file mode 100644 index 0000000..c15c435 --- /dev/null +++ b/tests/unit/test_az272_fdr_record_schema.py @@ -0,0 +1,256 @@ +"""AZ-272 — FdrRecord schema + versioned serialiser AC tests. + +Verifies the contract at ``_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md`` v1.0.0. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import orjson +import pytest + +from gps_denied_onboard.fdr_client import ( + CURRENT_SCHEMA_VERSION, + KNOWN_KINDS, + MAX_INLINE_BLOB_BYTES, + OVERRUN_KIND, + OVERRUN_PRODUCER_ID, + FdrRecord, + FdrSchemaError, + parse, + serialise, +) + +_TS = "2026-05-11T00:00:00.000000Z" + + +def _kind_payload(kind: str) -> dict[str, object]: + """Return a minimal valid payload for each v1.0.0 kind.""" + if kind == "log": + return { + "level": "INFO", + "component": "c2_vpr", + "frame_id": 42, + "kind": "vpr.warmup", + "msg": "loaded", + "kv": {"model": "salad"}, + } + if kind == "vio.tick": + return { + "frame_id": 1, + "R": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + "t": [0.0, 0.0, 0.0], + "P": [[1.0]], + "last_anchor_age_ms": 100, + } + if kind == "state.tick": + return { + "frame_id": 1, + "fused_pose": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "covariance_2x2": [[1.0, 0.0], [0.0, 1.0]], + "estimator_label": "primary", + } + if kind == "tile_match": + return { + "frame_id": 1, + "tile_id": "tile-001", + "score": 0.85, + "match_count": 124, + "ransac_inliers": 96, + } + if kind == "overrun": + return {"producer_id": "c1_vio", "dropped_count": 42} + if kind == "segment_rollover": + return { + "old_segment": "seg-0001", + "new_segment": "seg-0002", + "total_bytes_after": 1024 * 1024, + } + if kind == "failed_tile_thumbnail": + return {"frame_id": 1, "tile_id": "tile-002", "jpeg_bytes_b64": "AAAAAA=="} + if kind == "mid_flight_tile_snapshot": + return {"snapshot_path": "/var/lib/gps-denied/snap.dat", "captured_at": _TS} + if kind == "flight_header": + return { + "flight_id": "f-0001", + "started_at": _TS, + "schema_version": CURRENT_SCHEMA_VERSION, + "build_info": {"commit": "abc123"}, + } + if kind == "flight_footer": + return { + "flight_id": "f-0001", + "ended_at": _TS, + "records_written": 12345, + "records_dropped": 0, + } + raise AssertionError(f"unhandled kind in fixture: {kind!r}") + + +def _make_record(kind: str, **overrides: object) -> FdrRecord: + """Build a minimal valid record for ``kind`` so each AC test can mutate the field it cares about.""" + if kind == OVERRUN_KIND: + producer_id = overrides.pop("producer_id", OVERRUN_PRODUCER_ID) + else: + producer_id = overrides.pop("producer_id", "c2_vpr") + return FdrRecord( + schema_version=int(overrides.pop("schema_version", CURRENT_SCHEMA_VERSION)), + ts=str(overrides.pop("ts", _TS)), + producer_id=str(producer_id), + kind=str(overrides.pop("kind", kind)), + payload=dict(overrides.pop("payload", _kind_payload(kind))), # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize("kind", sorted(KNOWN_KINDS)) +def test_ac1_roundtrip_every_known_kind(kind: str) -> None: + # Arrange + record = _make_record(kind) + # Act + decoded = parse(serialise(record)) + # Assert + assert decoded == record + + +def test_ac2_forward_compatible_unknown_payload_field_preserved() -> None: + # Arrange — synthesise a future v1.1 record adding an unknown payload field. + payload = _kind_payload("log") | {"new_field": "x"} + wire = orjson.dumps( + { + "schema_version": 1, + "ts": _TS, + "producer_id": "c2_vpr", + "kind": "log", + "payload": payload, + } + ) + # Act + record = parse(wire) + # Assert + assert record.payload.get("extra", {}).get("new_field") == "x" + assert "new_field" not in record.payload + # Known fields still parse out cleanly. + assert record.payload["msg"] == "loaded" + + +def test_ac2b_forward_compatible_unknown_top_level_field_preserved() -> None: + wire = orjson.dumps( + { + "schema_version": 1, + "ts": _TS, + "producer_id": "c2_vpr", + "kind": "log", + "payload": _kind_payload("log"), + "trailing": "future-field", + } + ) + record = parse(wire) + assert record.extra == {"trailing": "future-field"} + + +def test_ac3_unknown_future_kind_returned_opaquely() -> None: + wire = orjson.dumps( + { + "schema_version": 1, + "ts": _TS, + "producer_id": "future.producer", + "kind": "future.kind", + "payload": {"foo": 1}, + } + ) + record = parse(wire) + assert record.kind == "future.kind" + assert record.payload == {"foo": 1} + assert record.extra == {} + + +def test_ac4_missing_schema_version_raises() -> None: + wire = orjson.dumps( + { + "ts": _TS, + "producer_id": "c2_vpr", + "kind": "log", + "payload": _kind_payload("log"), + } + ) + with pytest.raises(FdrSchemaError, match=r"schema_version"): + parse(wire) + + +def test_ac4_non_integer_schema_version_raises() -> None: + wire = orjson.dumps( + { + "schema_version": "1.0", + "ts": _TS, + "producer_id": "c2_vpr", + "kind": "log", + "payload": _kind_payload("log"), + } + ) + with pytest.raises(FdrSchemaError, match=r"schema_version"): + parse(wire) + + +def test_ac5_overrun_missing_dropped_count_rejected_on_parse() -> None: + wire = orjson.dumps( + { + "schema_version": 1, + "ts": _TS, + "producer_id": OVERRUN_PRODUCER_ID, + "kind": OVERRUN_KIND, + "payload": {"producer_id": "c1_vio"}, + } + ) + with pytest.raises(FdrSchemaError, match=r"dropped_count"): + parse(wire) + + +def test_ac5_overrun_zero_dropped_count_rejected_on_serialise() -> None: + record = _make_record(OVERRUN_KIND, payload={"producer_id": "c1_vio", "dropped_count": 0}) + with pytest.raises(FdrSchemaError, match=r"dropped_count"): + serialise(record) + + +def test_ac6_empty_producer_id_rejected_on_serialise() -> None: + record = _make_record("log", producer_id="") + with pytest.raises(FdrSchemaError, match=r"producer_id"): + serialise(record) + + +def test_nfr_oversized_inline_blob_rejected() -> None: + blob = b"\x00" * (MAX_INLINE_BLOB_BYTES + 1) + payload = _kind_payload("failed_tile_thumbnail") | {"jpeg_bytes": blob} + record = _make_record("failed_tile_thumbnail", payload=payload) + with pytest.raises(FdrSchemaError, match=r"sidecar path"): + serialise(record) + + +def test_nfr_serialise_is_pure_byte_identical() -> None: + record = _make_record("log") + first = serialise(record) + second = serialise(record) + assert first == second + + +def test_no_upward_imports_to_components() -> None: + """Layer-0/cross-cutting: ``fdr_client.records`` must not import any component.""" + module_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "gps_denied_onboard" + / "fdr_client" + / "records.py" + ) + tree = ast.parse(module_path.read_text(encoding="utf-8")) + bad: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("gps_denied_onboard.components"): + bad.append(node.module) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("gps_denied_onboard.components"): + bad.append(alias.name) + assert not bad, f"fdr_client.records must not import components.*; found: {bad}" diff --git a/tests/unit/test_az279_wgs_converter.py b/tests/unit/test_az279_wgs_converter.py new file mode 100644 index 0000000..28b3798 --- /dev/null +++ b/tests/unit/test_az279_wgs_converter.py @@ -0,0 +1,122 @@ +"""AZ-279 — WgsConverter helper AC tests. + +Verifies the contract at ``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0. +""" + +from __future__ import annotations + +import ast +import math +from pathlib import Path + +import numpy as np +import pytest + +from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt +from gps_denied_onboard.helpers import ( + MAX_ZOOM, + WEB_MERCATOR_MAX_LAT_DEG, + WgsConversionError, + WgsConverter, +) + + +def test_ac1_ecef_roundtrip() -> None: + # Arrange + samples = [ + LatLonAlt(50.0, 30.0, 100.0), + LatLonAlt(-33.9, 151.2, 25.0), + LatLonAlt(0.0, 0.0, 0.0), + LatLonAlt(80.0, -120.0, 1500.0), + LatLonAlt(-60.0, 179.999, -50.0), + ] + # Act / Assert + for p in samples: + ecef = WgsConverter.latlonalt_to_ecef(p) + back = WgsConverter.ecef_to_latlonalt(ecef) + assert math.isclose(back.lat_deg, p.lat_deg, abs_tol=1e-9), (back, p) + assert math.isclose(back.lon_deg, p.lon_deg, abs_tol=1e-9), (back, p) + assert math.isclose(back.alt_m, p.alt_m, abs_tol=1e-6), (back, p) + + +def test_ac2_enu_roundtrip_within_10_km() -> None: + origin = LatLonAlt(50.0, 30.0, 100.0) + # ~10 km away in NE direction + p = LatLonAlt(50.07, 30.10, 250.0) + enu = WgsConverter.latlonalt_to_local_enu(origin, p) + back = WgsConverter.local_enu_to_latlonalt(origin, enu) + horizontal_m = math.hypot( + (back.lat_deg - p.lat_deg) * 111_320.0, + (back.lon_deg - p.lon_deg) * 111_320.0 * math.cos(math.radians(p.lat_deg)), + ) + vertical_m = abs(back.alt_m - p.alt_m) + assert horizontal_m < 1.0, f"horizontal residual {horizontal_m} m > 1 m" + assert vertical_m < 0.01, f"vertical residual {vertical_m} m > 1 cm" + + +def test_ac3_slippy_map_tile_roundtrip_z18_contains_input() -> None: + zoom, lat, lon = 18, 50.45, 30.52 + x, y = WgsConverter.latlon_to_tile_xy(zoom, lat, lon) + bounds = WgsConverter.tile_xy_to_latlon_bounds(zoom, x, y) + assert isinstance(bounds, BoundingBox) + assert bounds.contains(lat, lon) + # OSM-pinned reference for (lat=50.45, lon=30.52, z=18); precomputed via + # the slippy-map formula and matching satellite-provider's on-disk layout. + assert (x, y) == (153295, 88392) + + +def test_ac4_web_mercator_latitude_range_guard() -> None: + with pytest.raises(WgsConversionError, match=r"Web-Mercator"): + WgsConverter.latlon_to_tile_xy(18, 95.0, 0.0) + + +def test_ac5_zoom_range_guard() -> None: + with pytest.raises(WgsConversionError, match=r"zoom"): + WgsConverter.latlon_to_tile_xy(MAX_ZOOM + 3, 50.0, 30.0) + with pytest.raises(WgsConversionError, match=r"zoom"): + WgsConverter.tile_xy_to_latlon_bounds(MAX_ZOOM + 3, 0, 0) + + +def test_ac6_tile_xy_range_guard() -> None: + with pytest.raises(WgsConversionError, match=r"tile"): + WgsConverter.tile_xy_to_latlon_bounds(18, 1 << 18, 0) + + +def test_ac7_ecef_shape_contract() -> None: + with pytest.raises(WgsConversionError, match=r"shape"): + WgsConverter.ecef_to_latlonalt(np.array([1.0, 2.0], dtype=np.float64)) + + +def test_ac8_determinism_byte_equal_outputs() -> None: + p = LatLonAlt(50.0, 30.0, 100.0) + first = WgsConverter.latlonalt_to_ecef(p) + second = WgsConverter.latlonalt_to_ecef(p) + assert first.tobytes() == second.tobytes() + + +def test_ac9_no_upward_imports_to_components() -> None: + module_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "gps_denied_onboard" + / "helpers" + / "wgs_converter.py" + ) + tree = ast.parse(module_path.read_text(encoding="utf-8")) + bad: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("gps_denied_onboard.components"): + bad.append(node.module) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("gps_denied_onboard.components"): + bad.append(alias.name) + assert not bad, f"wgs_converter must not import components.*; found: {bad}" + + +def test_invariant_web_mercator_max_lat_close_to_documented_value() -> None: + # Sanity bound: documented constant matches the Mercator-projection-valid + # latitude (arctan(sinh(pi))) within rounding. + expected = math.degrees(math.atan(math.sinh(math.pi))) + assert math.isclose(WEB_MERCATOR_MAX_LAT_DEG, expected, abs_tol=1e-9) diff --git a/tests/unit/test_az281_engine_filename_schema.py b/tests/unit/test_az281_engine_filename_schema.py new file mode 100644 index 0000000..b98e2b1 --- /dev/null +++ b/tests/unit/test_az281_engine_filename_schema.py @@ -0,0 +1,100 @@ +"""AZ-281 — EngineFilenameSchema helper AC tests. + +Verifies the contract at ``_docs/02_document/contracts/shared_helpers/engine_filename_schema.md`` v1.0.0. +""" + +from __future__ import annotations + +import ast +import random +from pathlib import Path + +import pytest + +from gps_denied_onboard._types.manifests import EngineCacheKey, HostCapabilities +from gps_denied_onboard.helpers import EngineFilenameSchema, EngineFilenameSchemaError + + +def test_ac1_reference_example_builds_exact_string() -> None: + assert ( + EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16") + == "ultravpr__sm87_jp6.2_trt10.3_fp16.engine" + ) + + +def test_ac2_roundtrip_identity_over_10_random_tuples() -> None: + rng = random.Random(2026) + for _ in range(10): + model = rng.choice(["ultravpr", "netvlad", "megaloc", "selavpr", "salad", "mix_vpr"]) + sm = rng.choice([72, 86, 87]) + jp = rng.choice(["5.1", "6.0", "6.2", "6.3"]) + trt = rng.choice(["8.6", "10.0", "10.3", "10.4"]) + prec = rng.choice(["fp16", "int8", "mixed"]) + filename = EngineFilenameSchema.build(model, sm, jp, trt, prec) + parsed = EngineFilenameSchema.parse(filename) + assert parsed == EngineCacheKey(model, sm, jp, trt, prec) + + +def test_ac3_matches_host_exact_match() -> None: + filename = EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16") + host = HostCapabilities(sm=87, jetpack="6.2", trt="10.3") + assert EngineFilenameSchema.matches_host(filename, host) is True + + +def test_ac4_matches_host_tuple_mismatch_returns_false() -> None: + filename = EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16") + host_mismatch = HostCapabilities(sm=72, jetpack="6.2", trt="10.3") + assert EngineFilenameSchema.matches_host(filename, host_mismatch) is False + host_mismatch_trt = HostCapabilities(sm=87, jetpack="6.2", trt="10.4") + assert EngineFilenameSchema.matches_host(filename, host_mismatch_trt) is False + + +def test_ac5_precision_enum_strictness() -> None: + with pytest.raises(EngineFilenameSchemaError, match=r"precision"): + EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "bf16") + + +def test_ac6_model_name_character_set_rejection() -> None: + with pytest.raises(EngineFilenameSchemaError, match=r"a-z0-9_"): + EngineFilenameSchema.build("UltraVPR", 87, "6.2", "10.3", "fp16") + + +def test_ac7_reserved_separator_collision_rejected() -> None: + with pytest.raises(EngineFilenameSchemaError, match=r"__"): + EngineFilenameSchema.build("ultra__vpr", 87, "6.2", "10.3", "fp16") + + +def test_ac8_three_segment_version_rejected() -> None: + with pytest.raises(EngineFilenameSchemaError, match=r"major.*minor"): + EngineFilenameSchema.build("ultravpr", 87, "6.2.1", "10.3", "fp16") + + +def test_ac9_parse_rejects_malformed_filename() -> None: + with pytest.raises(EngineFilenameSchemaError): + EngineFilenameSchema.parse("not_an_engine_file.engine") + + +def test_ac10_parse_requires_engine_suffix() -> None: + with pytest.raises(EngineFilenameSchemaError, match=r"\.engine"): + EngineFilenameSchema.parse("ultravpr__sm87_jp6.2_trt10.3_fp16") + + +def test_ac11_no_upward_imports_to_components() -> None: + module_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "gps_denied_onboard" + / "helpers" + / "engine_filename_schema.py" + ) + tree = ast.parse(module_path.read_text(encoding="utf-8")) + bad: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("gps_denied_onboard.components"): + bad.append(node.module) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("gps_denied_onboard.components"): + bad.append(alias.name) + assert not bad, f"engine_filename_schema must not import components.*; found: {bad}" diff --git a/tests/unit/test_az283_descriptor_normaliser.py b/tests/unit/test_az283_descriptor_normaliser.py new file mode 100644 index 0000000..e1db64b --- /dev/null +++ b/tests/unit/test_az283_descriptor_normaliser.py @@ -0,0 +1,122 @@ +"""AZ-283 — DescriptorNormaliser helper AC tests. + +Verifies the contract at ``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import numpy as np +import pytest + +from gps_denied_onboard.helpers import DescriptorNormaliser, DescriptorNormaliserError + + +def test_ac1_unit_vector_example() -> None: + out = DescriptorNormaliser.l2_normalise(np.array([3.0, 4.0], dtype=np.float32)) + np.testing.assert_allclose(out, np.array([0.6, 0.8], dtype=np.float32), atol=1e-6) + assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6) + + +def test_ac2_batch_normalisation() -> None: + batch = np.array([[3.0, 4.0], [1.0, 0.0]], dtype=np.float32) + out = DescriptorNormaliser.l2_normalise_batch(batch) + np.testing.assert_allclose(out[0], np.array([0.6, 0.8], dtype=np.float32), atol=1e-6) + np.testing.assert_allclose(out[1], np.array([1.0, 0.0], dtype=np.float32), atol=1e-6) + for row in out: + assert float(np.linalg.norm(row)) == pytest.approx(1.0, abs=1e-6) + + +def test_ac3_fp16_dtype_preservation() -> None: + rng = np.random.default_rng(2026) + x = rng.standard_normal(512).astype(np.float16) + out = DescriptorNormaliser.l2_normalise(x) + assert out.dtype == np.float16 + assert float(np.linalg.norm(out.astype(np.float32))) == pytest.approx(1.0, abs=1e-3) + + +def test_ac4_fp32_dtype_preservation() -> None: + rng = np.random.default_rng(2026) + x = rng.standard_normal(512).astype(np.float32) + out = DescriptorNormaliser.l2_normalise(x) + assert out.dtype == np.float32 + assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6) + + +def test_ac5_zero_vector_handling() -> None: + zeros = np.zeros(128, dtype=np.float32) + out = DescriptorNormaliser.l2_normalise(zeros) + np.testing.assert_array_equal(out, zeros) + assert not np.any(np.isnan(out)) + + +def test_ac5b_zero_row_in_batch_remains_zero() -> None: + batch = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float32) + out = DescriptorNormaliser.l2_normalise_batch(batch) + np.testing.assert_array_equal(out[0], np.zeros(3, dtype=np.float32)) + np.testing.assert_allclose(out[1], np.array([1.0, 0.0, 0.0], dtype=np.float32)) + + +def test_ac6_idempotence_fp32() -> None: + rng = np.random.default_rng(2026) + x = rng.standard_normal(64).astype(np.float32) + once = DescriptorNormaliser.l2_normalise(x) + twice = DescriptorNormaliser.l2_normalise(once) + assert once.tobytes() == twice.tobytes() + + +def test_ac7_idempotence_fp16_within_half_precision_tol() -> None: + rng = np.random.default_rng(2026) + x = rng.standard_normal(64).astype(np.float16) + once = DescriptorNormaliser.l2_normalise(x) + twice = DescriptorNormaliser.l2_normalise(once) + np.testing.assert_allclose(twice.astype(np.float32), once.astype(np.float32), atol=1e-3) + + +def test_ac8_no_in_place_mutation() -> None: + x = np.array([3.0, 4.0, 0.0], dtype=np.float32) + snapshot = x.copy() + _ = DescriptorNormaliser.l2_normalise(x) + np.testing.assert_array_equal(x, snapshot) + + +def test_ac9_metric_is_inner_product_exact_string() -> None: + assert DescriptorNormaliser.descriptor_metric() == "inner_product" + + +def test_ac10_float64_dtype_rejected() -> None: + with pytest.raises(DescriptorNormaliserError, match=r"float16.*float32|float32.*float16"): + DescriptorNormaliser.l2_normalise(np.array([1.0, 2.0], dtype=np.float64)) + + +def test_ac11_shape_contract_single_rejects_2d() -> None: + with pytest.raises(DescriptorNormaliserError, match=r"1-D"): + DescriptorNormaliser.l2_normalise(np.zeros((2, 3), dtype=np.float32)) + + +def test_ac11_shape_contract_batch_rejects_1d() -> None: + with pytest.raises(DescriptorNormaliserError, match=r"2-D"): + DescriptorNormaliser.l2_normalise_batch(np.zeros(128, dtype=np.float32)) + + +def test_ac12_no_upward_imports_to_components() -> None: + module_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "gps_denied_onboard" + / "helpers" + / "descriptor_normaliser.py" + ) + tree = ast.parse(module_path.read_text(encoding="utf-8")) + bad: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("gps_denied_onboard.components"): + bad.append(node.module) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("gps_denied_onboard.components"): + bad.append(alias.name) + assert not bad, f"descriptor_normaliser must not import components.*; found: {bad}" diff --git a/tests/unit/test_runtime_root_env_gate.py b/tests/unit/test_runtime_root_env_gate.py index e10ca6f..8d4dc14 100644 --- a/tests/unit/test_runtime_root_env_gate.py +++ b/tests/unit/test_runtime_root_env_gate.py @@ -1,7 +1,13 @@ -"""Runtime-root env-var fail-fast — AZ-263 AC-8.""" +"""Runtime-root env-var fail-fast — AZ-263 AC-8 (updated by AZ-270 to pass a Config). + +AZ-270 swapped ``compose_root()`` to ``compose_root(config: Config)``; the +env-var fail-fast still happens inside ``compose_root`` before any factory +construction, so this AC-8 contract is intact. +""" import pytest +from gps_denied_onboard.config import Config from gps_denied_onboard.runtime_root import ConfigurationError, compose_root @@ -23,7 +29,7 @@ def test_compose_root_fails_fast_on_missing_required(monkeypatch: pytest.MonkeyP # Act / Assert with pytest.raises(ConfigurationError) as excinfo: - compose_root() + compose_root(Config()) assert "Missing required environment variable" in str(excinfo.value) @@ -45,7 +51,7 @@ def test_compose_root_names_the_first_missing_var(monkeypatch: pytest.MonkeyPatc # Act with pytest.raises(ConfigurationError) as excinfo: - compose_root() + compose_root(Config()) # Assert msg = str(excinfo.value)