Decompose Step 6 snapshot: 140 task specs + contract docs

Closes out greenfield Step 6 (Decompose) for all 14 components
(C1-C13 + cross-cutting helpers/replay). Covers tasks AZ-266..AZ-446
plus the _dependencies_table.md and component contract documents.

State file updated to greenfield Step 7 (Implement), not_started.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 00:39:48 +03:00
parent 8171fcb29e
commit 880eabcb3f
172 changed files with 22897 additions and 35 deletions
@@ -0,0 +1,82 @@
# Contract: descriptor_normaliser
**Component**: shared_helpers / `helpers.descriptor_normaliser` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-283 — `_docs/02_tasks/todo/AZ-283_descriptor_normaliser.md`
**Consumer tasks**: every C2 task that produces a query embedding before FAISS lookup; every C2.5 task that pre-processes descriptors for re-rank; every C3 task that pre-processes descriptors for cross-domain matching; every C10 task that builds the corpus side of the FAISS index during pre-flight provisioning
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
L2-normalise descriptors so cosine similarity aligns with FAISS's Euclidean / inner-product metric. Required because FAISS HNSW operates on Euclidean / inner-product spaces but the upstream backbones (UltraVPR, MegaLoc, MixVPR, etc.) emit raw cosine-similar embeddings. The same normalisation MUST be applied at both the **corpus** side (C10 during F1 provisioning) and the **query** side (C2 at runtime) — otherwise the index returns garbage. Centralising the helper guarantees they don't drift apart. Per `_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md`.
## Shape
### For function / method APIs
```python
class DescriptorNormaliser:
@staticmethod
def l2_normalise(descriptor: np.ndarray) -> np.ndarray: ... # shape (D,)
@staticmethod
def l2_normalise_batch(descriptors: np.ndarray) -> np.ndarray: ... # shape (N, D)
@staticmethod
def descriptor_metric() -> str: ... # always "inner_product"
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `l2_normalise` | `(descriptor: (D,)) -> (D,)` | `DescriptorNormaliserError` if shape is not 1-D, `D < 1`, or dtype is not `float16` / `float32` | sync, hot-path |
| `l2_normalise_batch` | `(descriptors: (N, D)) -> (N, D)` | `DescriptorNormaliserError` if shape is not 2-D, `N < 1`, `D < 1`, or dtype is not `float16` / `float32` | sync, hot-path |
| `descriptor_metric` | `() -> str` | none | sync, in-memory, returns `"inner_product"` |
Numpy arrays. dtype contract: `float16` in → `float16` out; `float32` in → `float32` out (no silent up-cast). The helper does NOT mutate inputs in place — it returns a new array.
## Invariants
- **Stateless**: no module-level state; static methods only. Stateless static-only design satisfies `coderule.mdc`.
- **dtype-preserving**: `float16` in → `float16` out; `float32` in → `float32` out. The helper does NOT silently up-cast or down-cast. Other dtypes (e.g., `float64`, `int8`) are rejected.
- **Zero-norm vector handling**: a zero-norm input vector is returned as the zero vector (no division-by-zero, no exception). Callers must filter or accept that such descriptors will match nothing on FAISS lookup. Documented invariant.
- **No in-place mutation**: every call returns a new numpy array; the input is never modified.
- **Single source of truth for metric**: `descriptor_metric()` always returns `"inner_product"`. C6's `DescriptorIndex.search_topk` and C10's index-build code MUST call this helper for the FAISS index distance metric — never hard-code `"l2"` or `"cosine"`.
- **L2 idempotence**: `l2_normalise(l2_normalise(x)) == l2_normalise(x)` byte-equal for non-zero `x`. Re-normalising an already-normalised vector is a no-op (within `atol=0` for `float32`; within `atol=1e-3` for `float16` due to half-precision rounding).
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, numpy, and stdlib. No `gps_denied_onboard.components.*` imports.
## Non-Goals
- Whitening / mean-subtraction — out of scope; consumers that need it apply it before / after this helper.
- PCA / dimensionality reduction — owned elsewhere (or out of scope entirely).
- GPU-accelerated normalisation — out of scope for v1.0.0; numpy / numpy-CUDA is fine for descriptor vector sizes (≤ 8192 dims) at the per-frame rate.
- Quantisation (PQ, IVF) — owned by C6 / C10 around the FAISS index, not by this helper.
- Auto-detection of descriptor dim — the helper is shape-agnostic for any `D >= 1`; consumers ensure the corpus and query side use the same `D`.
## Versioning Rules
- **Breaking changes** (function renamed/removed, signature changed, dtype contract relaxed, return value of `descriptor_metric()` changed) require a new major version + a re-build of every FAISS index built with the previous version (since the index metric is baked into the corpus-side normalisation).
- **Non-breaking additions** (new helper function, new optional kwarg with safe default) require a minor version bump.
- Changing `descriptor_metric()` return value is ALWAYS a major version because it forces every downstream FAISS index to be rebuilt.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-unit-vector | `np.array([3.0, 4.0], dtype=float32)` | `np.array([0.6, 0.8], dtype=float32)`; norm ≈ 1.0 within `atol=1e-6` | Round-trip happy path |
| valid-batch | `np.array([[3.0, 4.0], [1.0, 0.0]], dtype=float32)` | rows `[0.6, 0.8]` and `[1.0, 0.0]`; each row's norm ≈ 1.0 | Batch path |
| valid-fp16-roundtrip | random `float16` descriptor of dim 512 | `result.dtype == float16`; norm ≈ 1.0 within `atol=1e-3` | dtype preservation |
| valid-fp32-roundtrip | random `float32` descriptor of dim 512 | `result.dtype == float32`; norm ≈ 1.0 within `atol=1e-6` | dtype preservation |
| valid-zero-vector | `np.zeros(128, dtype=float32)` | returned as `np.zeros(128, dtype=float32)`; no exception, no NaN | Zero-norm invariant |
| valid-idempotent-fp32 | `l2_normalise(l2_normalise(x))` for `float32` `x` | byte-equal to `l2_normalise(x)` | Idempotence (fp32) |
| valid-idempotent-fp16 | `l2_normalise(l2_normalise(x))` for `float16` `x` | matches within `atol=1e-3` | Idempotence (fp16, looser due to half-precision) |
| valid-no-mutation | call `l2_normalise(x)`; check `x` afterward | `x` is bit-identical to its original value | No in-place mutation |
| valid-metric | `descriptor_metric()` | returns the string `"inner_product"` | Single source of truth |
| invalid-dtype-float64 | `np.array([1.0, 2.0], dtype=float64)` | `DescriptorNormaliserError` mentions `float16` / `float32` only | dtype contract |
| invalid-shape-2d-on-single | `np.zeros((2, 3), dtype=float32)` passed to `l2_normalise` (single) | `DescriptorNormaliserError` mentions 1-D shape required | Shape contract (single) |
| invalid-shape-1d-on-batch | `np.zeros(128, dtype=float32)` passed to `l2_normalise_batch` | `DescriptorNormaliserError` mentions 2-D shape required | Shape contract (batch) |
| no-upward-imports | static import scan | only `_types`, numpy, stdlib | Layer 1 invariant |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md` | autodev decompose Step 2 |
@@ -0,0 +1,92 @@
# Contract: engine_filename_schema
**Component**: shared_helpers / `helpers.engine_filename_schema` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-281 — `_docs/02_tasks/todo/AZ-281_engine_filename_schema.md`
**Consumer tasks**: every C7 task that writes / reads `.engine` files via the inference runtime; every C10 task that compiles engines through C7 and writes them to the cache root
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
Self-describing `.engine` filename schema per D-C10-7. TensorRT engines are NOT portable across `(SM, JetPack, TRT, precision)` tuples; encoding the tuple in the filename makes mismatch instantly visible at takeoff load (F2) so refusing-to-deserialize-on-mismatch becomes trivial. Per `_docs/02_document/common-helpers/06_helper_engine_filename_schema.md`.
## Shape
### For function / method APIs
```python
class EngineFilenameSchema:
@staticmethod
def build(model_name: str, sm: int, jetpack: str, trt: str, precision: str) -> str: ...
@staticmethod
def parse(filename: str) -> EngineCacheKey: ...
@staticmethod
def matches_host(filename: str, host_capabilities: HostCapabilities) -> bool: ...
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `build` | `(model_name, sm, jetpack, trt, precision) -> str` | `EngineFilenameSchemaError` if any input fails validation (see Invariants) | sync, pure |
| `parse` | `(filename) -> EngineCacheKey` | `EngineFilenameSchemaError` if filename does not match the format | sync, pure |
| `matches_host` | `(filename, host_capabilities) -> bool` | `EngineFilenameSchemaError` only if the filename itself is malformed (returns False on tuple mismatch — that's the expected "not a match" path) | sync, pure |
`EngineCacheKey` and `HostCapabilities` are imported from `gps_denied_onboard._types.manifests`. The `EngineCacheKey` Protocol exposes: `model_name: str`, `sm: int`, `jetpack: str`, `trt: str`, `precision: str` (where `precision in {"fp16", "int8", "mixed"}`).
### Filename format
```
{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine
```
Example: `ultravpr__sm87_jp6.2_trt10.3_fp16.engine`
## Invariants
- **Stateless**: no module-level state; static methods only. The static-only design satisfies the coderule.mdc constraint ("only use static methods for pure self-contained computations") because filename parsing is a pure mathematical function of its arguments.
- **Format strictness**: filenames MUST follow `{model}__sm{SM}_jp{JP}_trt{TRT}_{precision}.engine` exactly. The double underscore (`__`) after `model` is intentional — it is the field separator that lets `model` itself contain single underscores (e.g., `ultra_vpr__sm87_...`).
- **Field validation**:
- `model_name`: non-empty, only `[a-z0-9_]` characters (no double underscores), max 64 chars.
- `sm`: positive integer (e.g., 87 for Jetson Orin Nano Super; 86 for Orin AGX; 72 for Xavier).
- `jetpack`: dotted version string `<major>.<minor>` (e.g., `6.2`); each segment is a non-negative integer.
- `trt`: dotted version string `<major>.<minor>` (e.g., `10.3`); same rules as `jetpack`.
- `precision`: strictly one of `"fp16"`, `"int8"`, `"mixed"`.
- The dotted-version format must round-trip cleanly through filesystems — no `/` or `\` in `model_name` or version segments.
- **`matches_host` is exact-match**: returns True iff every tuple element matches exactly (`sm == current_sm`, `jetpack == current_jetpack`, `trt == current_trt`). Precision and model_name do not affect host-matching but ARE preserved in the parsed key.
- **Round-trip identity**: `parse(build(*args)) == EngineCacheKey(*args)` for any valid args. `build(parse(filename)._asdict())` returns the same filename for any valid filename.
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, `re`, and stdlib. No `gps_denied_onboard.components.*` imports.
## Non-Goals
- Versioning of the schema itself — there is no `schema_version` field. Adding a new tuple dimension is a Plan-phase carryforward (see Caveats in `_docs/02_document/common-helpers/06_helper_engine_filename_schema.md`).
- Engine compilation / compatibility resolution — owned by C7.
- Hot-loading engines / lazy materialisation — owned by C7.
- Filename collision detection across cache roots — owned by C10's Manifest.
## Versioning Rules
- **Breaking changes** (filename format changed, separator changed, new mandatory field added, precision enum reduced) require a new major version + a re-write pass over every existing `.engine` filename in the cache root.
- **Non-breaking additions** (new accessor function, new optional kwarg with safe default, new `precision` enum value appended) require a minor version bump.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-build-ultravpr | `("ultravpr", 87, "6.2", "10.3", "fp16")` | `"ultravpr__sm87_jp6.2_trt10.3_fp16.engine"` | Reference example from helper doc |
| valid-roundtrip | `parse(build(*args))` for 10 random valid tuples | each round-trip returns deep-equal `EngineCacheKey` | Round-trip invariant |
| valid-matches-host-true | filename built for `(sm=87, jp=6.2, trt=10.3)`, host with same | `matches_host` returns True | Exact match |
| valid-matches-host-false-sm | filename built for `sm=87`, host with `sm=72` | `matches_host` returns False (no exception) | Tuple mismatch |
| valid-matches-host-false-trt | filename built for `trt=10.3`, host with `trt=10.4` | `matches_host` returns False | Patch-version mismatch is still a mismatch |
| invalid-precision-enum | `build(..., precision="bf16")` | `EngineFilenameSchemaError` mentions allowed enum | Precision strictness |
| invalid-model-uppercase | `build("UltraVPR", ...)` | `EngineFilenameSchemaError` mentions `[a-z0-9_]` | Model-name strictness |
| invalid-model-double-underscore | `build("ultra__vpr", ...)` | `EngineFilenameSchemaError` mentions reserved separator | Separator collision guard |
| invalid-jetpack-format | `jetpack="6.2.1"` | `EngineFilenameSchemaError` mentions dotted `<major>.<minor>` format | Version strictness |
| invalid-parse-malformed | `parse("not_an_engine_file.bin")` | `EngineFilenameSchemaError` raised | Parse strictness |
| invalid-parse-missing-suffix | `parse("ultravpr__sm87_jp6.2_trt10.3_fp16")` (no `.engine`) | `EngineFilenameSchemaError` raised | Suffix required |
| no-upward-imports | static import scan | only `_types`, `re`, stdlib | Layer 1 invariant |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/06_helper_engine_filename_schema.md` | autodev decompose Step 2 |
@@ -0,0 +1,82 @@
# Contract: imu_preintegrator
**Component**: shared_helpers / `helpers.imu_preintegrator` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-276 — `_docs/02_tasks/todo/AZ-276_imu_preintegrator.md`
**Consumer tasks**: every C1 VIO task that consumes IMU windows; every C5 state-estimator task that builds GTSAM `CombinedImuFactor`s
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
Centralise GTSAM `CombinedImuFactor` preintegration so C1 (VIO) and C5 (StateEstimator) cannot drift into two slightly-different IMU integrations of the same FC IMU window. The helper owns the GTSAM `PreintegrationCombinedParams` + `PreintegratedCombinedMeasurements` lifecycle; consumers feed samples and read closed factors. Per `_docs/02_document/common-helpers/01_helper_imu_preintegrator.md`.
## Shape
### For function / method APIs
```python
class ImuPreintegrator:
def __init__(self, params: PreintegrationCombinedParams) -> None: ...
def reset_with_bias(self, bias: ImuBias) -> None: ...
def integrate_sample(self, sample: ImuSample) -> None: ...
def integrate_window(self, window: ImuWindow) -> None: ...
def current_preintegration(self) -> CombinedImuFactor: ...
def reset_for_new_keyframe(self) -> CombinedImuFactor: ...
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `reset_with_bias` | `(bias: ImuBias) -> None` | none | sync, in-memory |
| `integrate_sample` | `(sample: ImuSample) -> None` | `ImuPreintegrationError` if `sample.ts_ns` is not strictly monotonic vs. last sample | sync, hot-path |
| `integrate_window` | `(window: ImuWindow) -> None` | `ImuPreintegrationError` on monotonicity violation | sync, hot-path |
| `current_preintegration` | `() -> CombinedImuFactor` | `ImuPreintegrationError` if zero samples integrated since last reset | sync |
| `reset_for_new_keyframe` | `() -> CombinedImuFactor` | `ImuPreintegrationError` if zero samples integrated since last reset | sync; clears internal state |
`ImuSample`, `ImuWindow`, `ImuBias` types are imported from `gps_denied_onboard._types.nav`. `CombinedImuFactor` is the GTSAM-native factor type (re-exported from `helpers.imu_preintegrator` so consumers do not import GTSAM directly).
### Construction
```python
def make_imu_preintegrator(calibration: CameraCalibration) -> ImuPreintegrator: ...
```
`make_imu_preintegrator` reads gyro/accel noise covariances from `CameraCalibration` (which carries the IMU noise model per-deployment per `_docs/02_document/components/01_c1_vio/description.md`) and returns an instance with the right `PreintegrationCombinedParams`. Composition root binds one instance per writer thread.
## Invariants
- **Single-threaded by design**: no internal lock. The composition root binds ONE preintegrator instance to ONE writer thread; concurrent calls from multiple threads are undefined behaviour. The contract test asserts the helper does not acquire any locks.
- **Strict monotonic timestamps**: every sample fed through `integrate_sample` / `integrate_window` MUST have `ts_ns` strictly greater than the previously-integrated sample's `ts_ns`. Violations raise `ImuPreintegrationError`; the preintegrator state is NOT mutated by a rejected sample.
- **Bias drift is the consumer's responsibility**: the preintegrator never re-estimates bias internally. Consumers (C1, C5) call `reset_with_bias(...)` whenever their bias estimate changes; until then, integration uses the last-set bias.
- **No clock ownership**: every IMU sample carries its own monotonic timestamp. The preintegrator never reads a wall clock and never injects timestamps.
- **Consumers receive GTSAM types**: `current_preintegration()` and `reset_for_new_keyframe()` return GTSAM `CombinedImuFactor` instances that consumers attach to their factor graphs. The factor object is owned by the caller after return (no lingering references inside the helper).
- **`reset_for_new_keyframe` is destructive**: it returns the closed factor AND resets internal accumulators. Callers MUST capture the return value or lose the integration.
## Non-Goals
- Bias estimation / re-bias logic — owned by C1 and C5.
- Multi-threaded sample feeding — out of scope; helper is single-thread by contract.
- IMU sample acquisition / FC adapter integration — owned by C8.
- Serialising preintegrated factors to FDR records — owned by C13 / E-CC-FDR-CLIENT.
## Versioning Rules
- **Breaking changes** (method renamed/removed, parameter type changed, return type changed, monotonicity invariant relaxed) require a new major version + a deprecation pass through C1 and C5.
- **Non-breaking additions** (new optional method, new diagnostic accessor that does not mutate state) require a minor version bump.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-monotonic-sequence | 100 samples with strictly increasing `ts_ns`, then `current_preintegration()` | factor returned with `deltaTij` matching the time span; non-zero `delta_pose` | Round-trip happy path |
| valid-window-then-keyframe | one `integrate_window(N samples)` then `reset_for_new_keyframe()` | factor returned; subsequent `current_preintegration()` raises `ImuPreintegrationError` (state cleared) | Confirms destructive reset |
| invalid-non-monotonic-sample | sample with `ts_ns < last_ts_ns` | `ImuPreintegrationError` raised; internal state unchanged (next valid sample integrates as if rejected sample never came) | Strict-monotonic invariant |
| valid-rebias | `reset_with_bias(bias_a)`, integrate 50 samples, `reset_with_bias(bias_b)`, integrate 50 more, `current_preintegration()` | factor reflects bias_b applied to second half | Re-bias mid-window |
| invalid-empty-preintegration | `current_preintegration()` after `reset_for_new_keyframe()` with no further samples | `ImuPreintegrationError` mentions "no samples since reset" | Guard against empty factor |
| determinism | same `(bias, samples)` integrated twice into two instances | deep-equal `CombinedImuFactor` outputs | Pure-function determinism |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/01_helper_imu_preintegrator.md` | autodev decompose Step 2 |
@@ -0,0 +1,93 @@
# Contract: lightglue_runtime
**Component**: shared_helpers / `helpers.lightglue_runtime` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-278 — `_docs/02_tasks/todo/AZ-278_lightglue_runtime.md`
**Consumer tasks**: C2.5 InlierBasedReranker (single-pair LightGlue inlier counter); C3 CrossDomainMatcher (heavier matching pass)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
Single owner of the LightGlue inference engine. C2.5 does single-pair LightGlue matching for inlier counting on K=10 candidates per frame; C3 does the heavier matching pass on the surviving N=3 candidates. Both consume the SAME LightGlue engine — sharing avoids paying the engine-build / GPU-memory cost twice and structurally prevents the C2.5 ↔ C3 import cycle (R14 fix in `_docs/02_document/epics.md`). Per `_docs/02_document/common-helpers/03_helper_lightglue_runtime.md`.
## Shape
### For function / method APIs
```python
class LightGlueRuntime:
def __init__(self, engine_handle: EngineHandle) -> None: ...
def descriptor_dim(self) -> int: ...
def match(
self,
features_a: KeypointSet,
features_b: KeypointSet,
) -> CorrespondenceSet: ...
def match_batch(
self,
features_a_list: list[KeypointSet],
features_b_list: list[KeypointSet],
) -> list[CorrespondenceSet]: ...
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `__init__` | `(engine_handle: EngineHandle) -> None` | `LightGlueRuntimeError` if `engine_handle` is None or descriptor_dim < 1 | sync, one-time |
| `descriptor_dim` | `() -> int` | none | sync, in-memory |
| `match` | `(KeypointSet, KeypointSet) -> CorrespondenceSet` | `LightGlueRuntimeError` if descriptor dims mismatch the engine's expected dim, or if a concurrent caller tries to enter | sync, GPU-bound |
| `match_batch` | `(list[KeypointSet], list[KeypointSet]) -> list[CorrespondenceSet]` | same as `match` | sync, GPU-bound |
`EngineHandle`, `KeypointSet`, and `CorrespondenceSet` are imported from `gps_denied_onboard._types`. `EngineHandle` is a Protocol (NOT a concrete class) so this helper does not import any Layer 2+ component; the production handle is created by C7's `InferenceRuntime.deserialize_engine` and injected by the composition root.
### Construction
The composition root constructs the runtime once at takeoff:
```python
engine_handle = inference_runtime.deserialize_engine(LIGHTGLUE_ENGINE_CACHE_ENTRY)
runtime = LightGlueRuntime(engine_handle)
# inject the SAME instance into both consumers
c2_5_reranker = InlierBasedReranker(..., lightglue_runtime=runtime, ...)
c3_matcher = CrossDomainMatcher(..., lightglue_runtime=runtime, ...)
```
## Invariants
- **Serial-access invariant** (R14 cross-component): the runtime owns ONE CUDA stream. Concurrent calls to `match` / `match_batch` from multiple threads are FORBIDDEN. The composition root binds the runtime to the single F3 hot-path thread (per `_docs/02_document/epics.md` R14 entry). The helper's contract test asserts a guard exists that rejects concurrent entry with `LightGlueConcurrentAccessError`.
- **Backbone consistency**: features fed in MUST come from the same backbone as the LightGlue engine was trained for (DISK in production-default; ALIKED / XFeat alternates). Mixing backbones is a runtime error caught by the input shape check (`descriptor_dim` mismatch raises `LightGlueRuntimeError`). The helper does NOT silently coerce dimensions.
- **No shared mutable state**: the runtime exposes no `set_*` / `update_*` methods. Once constructed with an `engine_handle`, its behaviour is fixed for its lifetime.
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, numpy, and stdlib. NO `gps_denied_onboard.components.*` imports — neither C2.5 nor C3 nor C7 — under any circumstance. This is the structural fix for R14: the helper sits below the components in the layering, so the C2.5 ↔ C3 cycle becomes impossible to express.
- **Engine handle is opaque**: the helper does not know whether the handle wraps a TensorRT engine, an ONNX session, or a PyTorch model. It calls a fixed Protocol surface (`forward(...)`, `descriptor_dim`); the implementation owner is C7.
## Non-Goals
- Engine compilation / serialisation — owned by C7 (via `EngineFilenameSchema` + the inference runtime).
- Engine cache management / takeoff load — owned by C10 (`CacheProvisioner`).
- Backbone-specific feature extraction (DISK, ALIKED, XFeat) — owned by C3 / C7.
- Multi-GPU sharding — out of scope; production target is single-GPU Tier-2.
- Mixed-backbone matching (cross-DISK-ALIKED) — out of scope; consumers ensure backbone consistency before calling.
## Versioning Rules
- **Breaking changes** (method renamed/removed, signature changed, `EngineHandle` Protocol changed, serial-access invariant relaxed) require a new major version + a deprecation pass through C2.5 and C3.
- **Non-breaking additions** (new optional kwarg with safe default, new diagnostic accessor) require a minor version bump.
- Changing the underlying engine format (TensorRT → ONNX) is NOT a contract change because the helper's surface treats the handle as opaque — but it IS a C7 contract change and must follow C7's versioning rules.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-single-pair | two `KeypointSet`s of matching descriptor dim | `CorrespondenceSet` returned with `len > 0` for a synthetic-overlap pair | Round-trip happy path (C2.5 use) |
| valid-batch-3 | three pairs of `KeypointSet`s | three `CorrespondenceSet`s returned in order | Batch path (C3 use) |
| invalid-dim-mismatch | features with `descriptor_dim` not matching the engine | `LightGlueRuntimeError` mentions the expected vs actual dim | Backbone-consistency invariant |
| invalid-concurrent-access | two threads call `match` simultaneously | `LightGlueConcurrentAccessError` raised in the second-entering thread | R14 serial-access invariant |
| invalid-empty-handle | `LightGlueRuntime(engine_handle=None)` | `LightGlueRuntimeError` raised at construction | Construction guard |
| no-upward-imports | static import scan | only `_types`, numpy, stdlib — no `components.*` | R14 structural fix |
| determinism-given-engine | same `(features_a, features_b)` matched twice with the same engine handle | byte-equal `CorrespondenceSet` outputs | Pure-function determinism downstream of the engine |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/03_helper_lightglue_runtime.md` (R14 fix) | autodev decompose Step 2 |
@@ -0,0 +1,95 @@
# Contract: ransac_filter
**Component**: shared_helpers / `helpers.ransac_filter` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-282 — `_docs/02_tasks/todo/AZ-282_ransac_filter.md`
**Consumer tasks**: every C2.5 task that runs RANSAC over single-pair LightGlue matches; every C3 task that runs RANSAC over 2D-2D correspondences for the per-candidate inlier count; every C3.5 task that recomputes residual after AdHoP refinement; every C4 task that computes the per-frame final reprojection residual for FDR provenance
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
Thin, deterministic wrapper around OpenCV's RANSAC + reprojection-residual computation. Keeps the four call sites (C2.5, C3, C3.5, C4) on one canonical inlier-filtering algorithm and one canonical residual definition (median pixel residual). Per `_docs/02_document/common-helpers/07_helper_ransac_filter.md`.
## Shape
### For function / method APIs
```python
class RansacFilter:
@staticmethod
def filter_correspondences(
correspondences: np.ndarray, # shape (N, 4): [x_a, y_a, x_b, y_b]
ransac_threshold_px: float,
min_inliers: int,
) -> RansacResult: ...
@staticmethod
def compute_reprojection_residual(
correspondences: np.ndarray, # shape (I, 4): inlier set
K: np.ndarray, # shape (3, 3): camera intrinsics
distortion: np.ndarray, # shape (5,) or (8,): OpenCV distortion model
pose: SE3,
) -> float: ...
```
`RansacResult` is a frozen dataclass:
```python
@dataclass(frozen=True)
class RansacResult:
inlier_correspondences: np.ndarray # shape (I, 4)
inlier_count: int # I
outlier_count: int # N - I
median_residual_px: float
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `filter_correspondences` | `(correspondences, ransac_threshold_px, min_inliers) -> RansacResult` | `RansacFilterError` if `correspondences` shape != `(N, 4)`, `ransac_threshold_px <= 0`, `min_inliers < 0`, or `N < 4` (RANSAC needs ≥4 points for homography) | sync, CPU |
| `compute_reprojection_residual` | `(correspondences, K, distortion, pose) -> float` | `RansacFilterError` on shape / dtype mismatch (correspondences must be `(I, 4)`, `K` must be `(3, 3)`, distortion must be `(5,)` or `(8,)`); returns `NaN` if `I == 0` | sync, CPU |
`SE3` is the type alias from `helpers.se3_utils` (re-exported GTSAM `Pose3`). All numpy arrays use `dtype=float64`.
## Invariants
- **Stateless**: no module-level state; static methods only. Stateless static-only design satisfies `coderule.mdc` ("only use static methods for pure self-contained computations").
- **Deterministic given fixed seed**: `cv2.findHomography(..., cv2.RANSAC)` is non-deterministic by default. The helper sets `cv2.setRNGSeed(0)` (or uses the explicit `seed` kwarg where the OpenCV API supports it) so the same input correspondences always produce the same `RansacResult`. Deterministic behaviour is part of the contract.
- **Median residual semantics**: `compute_reprojection_residual` returns the MEDIAN reprojection residual in pixels (NOT the mean — outliers in the 2D residual distribution should not bias the consumer's quality signal). Returns `NaN` if `correspondences.shape[0] == 0`.
- **OpenCV-internal RANSAC ownership note**: for C4's `solvePnPRansac` (2D-3D RANSAC), OpenCV does its own internal RANSAC. THIS helper's `filter_correspondences` is for the standalone 2D-2D case (C3, C2.5, C3.5). C4 uses ONLY `compute_reprojection_residual` from this helper.
- **Min-inliers semantics**: `min_inliers` is informational — `RansacResult.inlier_count` may be less than `min_inliers`. The helper does NOT raise when the count falls short; the consumer decides whether to proceed (`InsufficientInliersError` etc. live in the consuming components).
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, `helpers.se3_utils` (allowed — same Layer 1), `cv2`, `numpy`, and stdlib. No `gps_denied_onboard.components.*` imports.
## Non-Goals
- 2D-3D RANSAC inside `solvePnPRansac` — OpenCV does it internally; this helper does not wrap it.
- Per-component RANSAC threshold defaults — they are documented per-component in C2.5, C3, C3.5, C4 specs. This helper takes the threshold as a parameter; defaults belong to the consumers.
- Adaptive RANSAC (PROSAC, USAC) — out of scope for v1.0.0.
- GPU-accelerated RANSAC — out of scope for v1.0.0.
- Confidence / iteration-count tuning of the underlying `cv2.findHomography` call — exposed only via the `ransac_threshold_px` parameter; if a future consumer needs to tune iterations, that's a minor-version contract addition.
## Versioning Rules
- **Breaking changes** (function renamed/removed, signature changed, return shape changed, residual statistic changed from median to mean) require a new major version + a deprecation pass through C2.5, C3, C3.5, C4.
- **Non-breaking additions** (new optional kwarg with safe default, new accessor on `RansacResult`) require a minor version bump.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-clean-correspondences | 100 perfect homography correspondences | `inlier_count == 100`, `outlier_count == 0`, `median_residual_px ≈ 0.0` | Round-trip happy path |
| valid-mixed | 80 inliers + 20 outlier correspondences with threshold 1.5 px | `inlier_count``[78, 82]` (RANSAC noise tolerance), `outlier_count == 100 - inlier_count` | Mixed-quality input |
| valid-determinism | same input run twice through `filter_correspondences` | byte-equal `RansacResult` outputs | Deterministic-seed invariant |
| valid-residual-zero-on-clean | 4 perfect 2D-2D correspondences with known pose | `median_residual_px ≈ 0.0` | Clean residual |
| valid-residual-nan-on-empty | empty inlier array | returns `NaN` (no exception) | Empty-input semantics |
| invalid-shape | `correspondences.shape = (10, 3)` | `RansacFilterError`; mentions `(N, 4)` shape | Shape contract |
| invalid-threshold | `ransac_threshold_px = -1.0` | `RansacFilterError`; mentions positive threshold | Threshold guard |
| invalid-too-few-points | `correspondences.shape = (3, 4)` | `RansacFilterError`; mentions minimum 4 points | RANSAC point-count guard |
| invalid-K-shape | `K.shape = (4, 4)` in residual call | `RansacFilterError`; mentions `(3, 3)` shape | K shape contract |
| no-upward-imports | static import scan | only `_types`, `helpers.se3_utils`, `cv2`, `numpy`, stdlib | Layer 1 invariant |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/07_helper_ransac_filter.md` | autodev decompose Step 2 |
@@ -0,0 +1,78 @@
# Contract: se3_utils
**Component**: shared_helpers / `helpers.se3_utils` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-277 — `_docs/02_tasks/todo/AZ-277_se3_utils.md`
**Consumer tasks**: every C1 VIO task that produces relative poses, every C2.5 / C3 / C3.5 task that handles 4x4 → SE(3) conversion, every C4 task that converts `solvePnPRansac` output into a GTSAM factor, every C5 task that builds iSAM2 graph keys, every C8 task that encodes pose for FC emission
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
Centralise SE(3) ↔ 4×4-matrix conversion and Lie-algebra exponential / logarithm / adjoint so every component that crosses the matrix-vs-pose boundary uses the same numerical convention. Per `_docs/02_document/common-helpers/02_helper_se3_utils.md`. Backed by GTSAM `Pose3` primitives where available; pure numpy fallback otherwise.
## Shape
### For function / method APIs
```python
def matrix_to_se3(T_4x4: np.ndarray) -> SE3: ...
def se3_to_matrix(pose: SE3) -> np.ndarray: ...
def exp_map(xi: np.ndarray) -> SE3: ... # xi shape (6,)
def log_map(pose: SE3) -> np.ndarray: ... # returns shape (6,)
def adjoint(pose: SE3) -> np.ndarray: ... # returns shape (6, 6)
def is_valid_rotation(R_3x3: np.ndarray, *, atol: float = 1e-6) -> bool: ...
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `matrix_to_se3` | `(T_4x4) -> SE3` | `Se3InvalidMatrixError` if shape != (4,4), bottom row != [0,0,0,1], or rotation is not orthogonal within `atol` | sync, pure |
| `se3_to_matrix` | `(SE3) -> np.ndarray (4,4)` | none | sync, pure |
| `exp_map` | `(xi: (6,)) -> SE3` | `Se3InvalidMatrixError` if shape != (6,) | sync, pure |
| `log_map` | `(SE3) -> np.ndarray (6,)` | none | sync, pure |
| `adjoint` | `(SE3) -> np.ndarray (6,6)` | none | sync, pure |
| `is_valid_rotation` | `(R_3x3) -> bool` | none (returns False for any invalid input) | sync, pure |
`SE3` is a type alias for the GTSAM `Pose3` (re-exported from `helpers.se3_utils` so consumers do not import GTSAM directly). All numpy arrays use `dtype=float64`; passing `float32` raises `Se3InvalidMatrixError`.
## Invariants
- **Stateless**: no module-level state; every function is pure. The same input always produces the same output (deep-equal).
- **Right-handed convention**: rotation order is right-handed; `T_4x4` follows the standard `[[R, t], [0, 1]]` block layout.
- **Orthogonal-rotation guarantee on the way in**: callers MUST orthogonalise their rotation matrices before `matrix_to_se3`. The helper rejects matrices whose `R^T R` deviates from `I` by more than `atol`. The helper does NOT silently re-orthogonalise.
- **Positive-determinant rotation**: `det(R) ≈ +1`. Mirror matrices (`det(R) ≈ -1`) are rejected.
- **Round-trip identity**: `se3_to_matrix(matrix_to_se3(T)) == T` for any valid `T` within numerical tolerance (`np.allclose(..., atol=1e-9)`).
- **Lie-algebra round-trip**: `exp_map(log_map(p)) == p` for any non-degenerate `p` within `atol=1e-9`. Near-identity edge cases (twist norm < 1e-10) MUST not raise — the implementation falls back to the small-angle Taylor expansion documented in GTSAM.
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, GTSAM, numpy, and stdlib. No `gps_denied_onboard.components.*` imports.
## Non-Goals
- Quaternion utilities (`Rotation` / `Quaternion`) — out of scope; consumers that need a quaternion are expected to convert via numpy's `from_matrix` / `from_quat` paths inline.
- SE(2) / planar pose helpers — out of scope.
- Pose interpolation / Slerp — out of scope (consumers that need it implement it locally on top of `exp_map` / `log_map`).
- Manifold operators richer than exp/log/adjoint (e.g., parallel transport, twist composition Jacobians) — out of scope; revisit when a consumer needs them.
## Versioning Rules
- **Breaking changes** (function renamed/removed, signature changed, error type changed, dtype contract relaxed) require a new major version + a deprecation pass through C1, C2.5, C3, C3.5, C4, C5, C8.
- **Non-breaking additions** (new helper function, new optional kwarg with safe default) require a minor version bump.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-roundtrip-4x4 | random valid `T_4x4` | `np.allclose(se3_to_matrix(matrix_to_se3(T)), T, atol=1e-9)` | Round-trip happy path |
| valid-roundtrip-lie | random `xi` of norm ≈ 1.0 | `np.allclose(log_map(exp_map(xi)), xi, atol=1e-9)` | Lie-algebra round-trip |
| valid-near-identity | `xi = [1e-12]*6` | `exp_map(xi)` returns identity within `atol=1e-9`; no exception | Small-angle stability |
| invalid-non-orthogonal | `T_4x4` whose `R` has `R^T R - I` of norm 1e-3 | `Se3InvalidMatrixError` raised; helper does NOT silently re-orthogonalise | Strict caller-orthogonalisation rule |
| invalid-mirror | `T_4x4` with `det(R) = -1` | `Se3InvalidMatrixError` raised | Positive-det invariant |
| invalid-bottom-row | `T_4x4` with bottom row `[0,0,0,2]` | `Se3InvalidMatrixError` raised | Block-layout guard |
| invalid-dtype | `T_4x4` with `dtype=float32` | `Se3InvalidMatrixError` raised mentioning dtype | dtype contract |
| determinism | same `T_4x4` through `matrix_to_se3 → se3_to_matrix` twice | byte-equal numpy outputs | Pure-function determinism |
| no-upward-imports | static import scan of `helpers.se3_utils` | only `_types`, GTSAM, numpy, stdlib | Layer 1 invariant |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/02_helper_se3_utils.md` | autodev decompose Step 2 |
@@ -0,0 +1,77 @@
# Contract: sha256_sidecar
**Component**: shared_helpers / `helpers.sha256_sidecar` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-280 — `_docs/02_tasks/todo/AZ-280_sha256_sidecar.md`
**Consumer tasks**: every C6 task that writes the FAISS index / descriptor sidecar; every C7 task that writes engine cache files + INT8 calibration cache; every C10 task that writes the Manifest; every C11 task that verifies tile artifacts before serving them
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
Centralise the atomic-write + SHA-256 content-hash sidecar pattern (D-C10-3). Every persistent artifact that takeoff-load (F2) must verify gets written atomically AND has a `.sha256` sidecar that the verifier can independently recompute. Without a shared helper, C6 / C7 / C10 / C11 each grow their own slightly-different implementation; the takeoff-load gate breaks the moment one of them drifts. Per `_docs/02_document/common-helpers/05_helper_sha256_sidecar.md`.
## Shape
### For function / method APIs
```python
class Sha256Sidecar:
@staticmethod
def write_atomic(path: Path, payload: bytes) -> str: ... # returns hex digest
@staticmethod
def write_atomic_and_sidecar(path: Path, payload: bytes) -> str: ... # returns hex digest
@staticmethod
def verify(path: Path) -> bool: ... # checks payload hash against sidecar
@staticmethod
def aggregate_hash(paths: list[Path]) -> str: ... # for Manifest covering many files
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `write_atomic` | `(path, payload) -> str` | `Sha256SidecarError` if parent dir missing or filesystem rejects rename; underlying `OSError` is wrapped | sync, I/O |
| `write_atomic_and_sidecar` | `(path, payload) -> str` | same as `write_atomic` plus failure to write the sidecar atomically | sync, I/O |
| `verify` | `(path) -> bool` | `Sha256SidecarError` if `path` exists but `path.sha256` is missing or malformed (returns `False` if `path` itself is missing) | sync, I/O |
| `aggregate_hash` | `(list[Path]) -> str` | `Sha256SidecarError` if any path is missing | sync, I/O |
`Path` is `pathlib.Path`. Hex digests are lowercase 64-char strings.
## Invariants
- **Atomic write**: `write_atomic` writes to a temp file in the same directory as `path` and renames to `path` once the bytes are flushed. The rename is filesystem-level — partial files NEVER appear at `path`.
- **Sidecar format**: `write_atomic_and_sidecar` writes `<path>.sha256` containing ONLY the lowercase hex digest, no JSON wrapper, no trailing newline. Keeps verification trivial (`open(...).read().strip() == expected`).
- **Verify is independent**: `verify(path)` recomputes the digest from the file's bytes and compares to the sidecar; it does NOT trust the sidecar's value alone.
- **Aggregate hash is order-deterministic**: `aggregate_hash` sorts the input paths first (case-sensitive, full path) so two runs that read the same files always yield the same aggregate. The aggregate is the SHA-256 of the concatenation of `<filename>\0<file-hex-digest>\n` lines (in sorted order).
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, `atomicwrites`, `hashlib`, `pathlib`, and stdlib. No `gps_denied_onboard.components.*` imports.
- **Production filesystem requirement**: the atomic rename is filesystem-level — works on POSIX local filesystems, not on NFS / SMB / overlayfs. The cache root MUST live on a local filesystem in production. Documented in the contract's Caveats section; not enforced at runtime (it would require an OS-specific check that adds no value when the deployment is locked).
## Non-Goals
- Cryptographic signing — the sidecar protects against accidental corruption + file-replacement-after-staging, NOT against an attacker with write access. Threat model treats the operator workstation as trusted; the companion's write access is restricted to F4 (mid-flight tile gen) which has its own per-flight signing key path (out of scope for this helper).
- Streaming hashing of files larger than RAM — the helper's API takes `payload: bytes`, so the entire payload is in memory at write time. Files larger than RAM are out of scope (and outside the operational constraints of the cache root anyway).
- Compression / on-disk encoding — payload is written verbatim.
- Sidecar format versioning — there is no version byte; if the format ever changes, the verifier rejects the old format and forces a re-write.
## Versioning Rules
- **Breaking changes** (sidecar format changed, function renamed/removed, return type changed, atomicity invariant relaxed) require a new major version + a deprecation pass through C6, C7, C10, C11.
- **Non-breaking additions** (new helper function, new optional kwarg with safe default) require a minor version bump.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-write-and-verify | random 1 MiB payload, write to tmp path, then `verify` | `verify` returns True; sidecar contains the hex digest of the payload | Round-trip happy path |
| valid-aggregate-deterministic | 3 files written with the helper, then `aggregate_hash` called twice with paths in different order | both calls return the same hex digest | Order-deterministic invariant |
| valid-atomic-no-partial | inject a fault between temp write and rename (e.g., raise `OSError` mid-write); call `verify` afterward | `path` does NOT exist (or pre-existing version unchanged); no partial file at the target name | Atomicity invariant |
| invalid-sidecar-mismatch | manually overwrite `path` with different bytes after the sidecar was written | `verify(path)` returns False | Independent verification |
| invalid-missing-sidecar | `verify` on a path whose `.sha256` was deleted | `Sha256SidecarError` raised mentioning the missing sidecar | Strict sidecar requirement |
| invalid-malformed-sidecar | sidecar contains `not a hex digest` | `Sha256SidecarError` raised mentioning malformed digest | Sidecar format strictness |
| invalid-missing-file-in-aggregate | `aggregate_hash` on a list including a non-existent path | `Sha256SidecarError` raised mentioning the missing path | Aggregate input validation |
| no-upward-imports | static import scan | only `_types`, `atomicwrites`, `hashlib`, `pathlib`, stdlib | Layer 1 invariant |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/05_helper_sha256_sidecar.md` | autodev decompose Step 2 |
@@ -0,0 +1,88 @@
# Contract: wgs_converter
**Component**: shared_helpers / `helpers.wgs_converter` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-279 — `_docs/02_tasks/todo/AZ-279_wgs_converter.md`
**Consumer tasks**: every C4 pose-estimation task that compares pose-in-WGS to pose-in-ENU; every C5 state-estimator task that initialises the iSAM2 graph from a WGS origin; every C6 task that maps a tile bbox to lat/lon; every C8 task that encodes pose for FC emission; every C10 / C11 task that resolves a bbox to a tile-id list; every C12 task where the operator enters a bbox
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
## Purpose
Centralise WGS84 ↔ local-tangent-plane (ENU) ↔ tile-pixel coordinate conversions. Required by every component that interacts with geographic positions. Per `_docs/02_document/common-helpers/04_helper_wgs_converter.md`. Backed by `pyproj` for the geodesy primitives; tile_xy math uses the standard slippy-map convention so it matches `satellite-provider`'s on-disk layout.
## Shape
### For function / method APIs
```python
class WgsConverter:
@staticmethod
def latlonalt_to_ecef(p: LatLonAlt) -> np.ndarray: ... # shape (3,)
@staticmethod
def ecef_to_latlonalt(p_ecef: np.ndarray) -> LatLonAlt: ...
@staticmethod
def latlonalt_to_local_enu(origin: LatLonAlt, p: LatLonAlt) -> np.ndarray: ... # shape (3,)
@staticmethod
def local_enu_to_latlonalt(origin: LatLonAlt, p_enu: np.ndarray) -> LatLonAlt: ...
@staticmethod
def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]: ...
@staticmethod
def tile_xy_to_latlon_bounds(zoom: int, x: int, y: int) -> BoundingBox: ...
```
| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `latlonalt_to_ecef` | `(LatLonAlt) -> np.ndarray (3,)` | `WgsConversionError` if lat / lon / alt are out of range | sync, pure |
| `ecef_to_latlonalt` | `(np.ndarray (3,)) -> LatLonAlt` | `WgsConversionError` on shape mismatch | sync, pure |
| `latlonalt_to_local_enu` | `(origin, p) -> np.ndarray (3,)` | `WgsConversionError` on origin / point validation | sync, pure |
| `local_enu_to_latlonalt` | `(origin, p_enu) -> LatLonAlt` | `WgsConversionError` on origin / shape | sync, pure |
| `latlon_to_tile_xy` | `(zoom, lat, lon) -> (int, int)` | `WgsConversionError` if zoom < 0 or > 22, lat out of `[-85.0511, 85.0511]`, lon out of `[-180, 180]` | sync, pure |
| `tile_xy_to_latlon_bounds` | `(zoom, x, y) -> BoundingBox` | `WgsConversionError` if `x` or `y` out of `[0, 2^zoom)` | sync, pure |
`LatLonAlt` and `BoundingBox` are imported from `gps_denied_onboard._types`. Numpy arrays use `dtype=float64`. `WgsConversionError` is the only exception type the public surface raises.
## Invariants
- **Stateless**: no module-level state; static methods only. The static-only design satisfies the coderule.mdc constraint ("only use static methods for pure self-contained computations") because every operation is a pure mathematical function of its arguments.
- **WGS84 ellipsoid only**: all conversions use the WGS84 ellipsoid; no datum-shift logic. If a future deployment needs alternative datum support, switch to an instance-based factory then.
- **Slippy-map tile convention**: `latlon_to_tile_xy` matches OSM / `satellite-provider`'s on-disk `{zoom}/{x}/{y}.jpg` layout. Latitude is clamped to the Web-Mercator-valid range `[-85.0511, 85.0511]`; values outside raise `WgsConversionError`.
- **ENU sign convention**: `latlonalt_to_local_enu` returns `(east, north, up)` in metres. Origin altitude IS used (height above ellipsoid); zero altitude is NOT silently substituted.
- **Round-trip identity**: `local_enu_to_latlonalt(origin, latlonalt_to_local_enu(origin, p)) ≈ p` within `atol=1e-6` metres (lat/lon to ~1 m, alt to ~1 cm) for `p` within 100 km of `origin`. Beyond 100 km the tangent-plane approximation degrades — the contract documents this limit.
- **Zoom-level dependence**: `tile_xy_to_latlon_bounds` and `latlon_to_tile_xy` are sensitive to `zoom`; callers MUST pass the right zoom for the tile in question (typically `zoomLevel` from `TileMetadata`).
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, `pyproj`, numpy, and stdlib. NO `gps_denied_onboard.components.*` imports.
## Non-Goals
- Datum-shift logic / non-WGS84 datums — out of scope for v1.0.0.
- UTM / MGRS conversions — out of scope.
- Geoid-height corrections (orthometric vs. ellipsoidal altitude) — out of scope; consumers using altitude do so under the ellipsoid convention or apply geoid correction themselves.
- Vincenty / great-circle distance helpers — out of scope.
- Coordinate transforms involving rotation (body-frame ↔ ECEF) — owned by `helpers.se3_utils` plus the per-deployment `CameraCalibration`.
## Versioning Rules
- **Breaking changes** (function renamed/removed, signature changed, ENU sign convention flipped, return shape changed) require a new major version + a deprecation pass through C4, C5, C6, C8, C10, C11, C12.
- **Non-breaking additions** (new helper function, new optional kwarg with safe default) require a minor version bump.
- Adding a new datum is a major version (the static-only design assumes WGS84).
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-roundtrip-ecef | `LatLonAlt(50.0, 30.0, 100.0)` | `ecef_to_latlonalt(latlonalt_to_ecef(p))` matches `p` within `atol=1e-9 deg, 1e-6 m` | Round-trip happy path |
| valid-roundtrip-enu | origin + point ~10 km away | `local_enu_to_latlonalt(origin, latlonalt_to_local_enu(origin, p))` matches `p` within 1 m horizontal + 1 cm vertical | ENU round-trip |
| valid-tile-roundtrip-z18 | `(zoom=18, lat=50.45, lon=30.52)` | `latlon_to_tile_xy` returns valid `(x, y)`; `tile_xy_to_latlon_bounds(zoom, x, y)` contains the input lat/lon | Slippy-map convention |
| valid-tile-bounds-z18 | `(zoom=18, x=148000, y=89400)` | bounds returned with non-zero area; corners at expected slippy-map lat/lon | Tile bounds |
| invalid-lat-out-of-range | lat = 95.0 in `latlon_to_tile_xy` | `WgsConversionError` mentions Web-Mercator latitude range | Slippy-map invariant |
| invalid-zoom-too-high | zoom = 25 | `WgsConversionError` mentions zoom range `[0, 22]` | Zoom guard |
| invalid-tile-xy-out-of-range | `(zoom=18, x=2^18, y=0)` | `WgsConversionError` mentions tile-xy range | Tile-xy guard |
| invalid-shape | `ecef_to_latlonalt(np.array([1.0, 2.0]))` (shape (2,)) | `WgsConversionError` mentions expected shape (3,) | Shape contract |
| no-upward-imports | static import scan | only `_types`, `pyproj`, numpy, stdlib | Layer 1 invariant |
| determinism | same input through any function twice | byte-equal outputs | Pure-function determinism |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/04_helper_wgs_converter.md` | autodev decompose Step 2 |