[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 <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 02:03:36 +03:00
parent 8e71f6c002
commit 3acc7f33dd
24 changed files with 2381 additions and 97 deletions
@@ -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
@@ -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.
+1 -1
View File
@@ -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
+4
View File
@@ -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]
+38
View File
@@ -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
)
@@ -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
+23 -2
View File
@@ -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",
]
+214 -13
View File
@@ -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,
)
@@ -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",
@@ -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
@@ -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
``<major>.<minor>``, 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<model>[a-z0-9_]+)__sm(?P<sm>\d+)_jp(?P<jetpack>\d+\.\d+)_trt(?P<trt>\d+\.\d+)_"
r"(?P<precision>fp16|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 '<major>.<minor>' 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))}}}"
)
+162 -18
View File
@@ -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
+330 -35
View File
@@ -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 "<none>"
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
+270
View File
@@ -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") == []
+256
View File
@@ -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}"
+122
View File
@@ -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)
@@ -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}"
@@ -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}"
+9 -3
View File
@@ -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)