[autodev] Step 13 partial: helpers 1-4 cycle-1 doc sync

Batch 5a of the cycle-1 doc sync. For each of the four
foundation helpers (imu_preintegrator, se3_utils,
lightglue_runtime, wgs_converter):

- Append "Cycle-1 operational reality" section to the
  existing common-helpers/<NN>_*.md, documenting what the
  shipped implementation actually exposes vs. the design-
  intent sketch (interfaces, exception types, public
  constants, AZ-task lineage).

Specific cycle-1 facts captured per helper:

- imu_preintegrator (AZ-276): make_imu_preintegrator
  factory, BMI088-class noise defaults, single
  ImuPreintegrationError exception, actual return type is
  PreintegratedCombinedMeasurements (consumer builds the
  CombinedImuFactor), destructive reset_with_bias semantics,
  first-sample-not-integrated dt=0 handling.
- se3_utils (AZ-277): SE3 = gtsam.Pose3 re-export,
  Se3InvalidMatrixError, strict caller-orthogonalisation
  invariant, _DEFAULT_ROT_ATOL=1e-6 and small-angle Taylor
  cutoff for exp_map, is_valid_rotation predicate, strict
  dtype=float64 everywhere.
- lightglue_runtime (AZ-278 / R14 fix): EngineHandle
  Protocol-typed constructor, LightGlueRuntimeError +
  LightGlueConcurrentAccessError, non-blocking concurrent-
  access guard (raises rather than serialises),
  match_batch equal-length precondition, composition-root
  single-instance into C2.5 + C3.
- wgs_converter (AZ-279 + AZ-490): WEB_MERCATOR_MAX_LAT_DEG
  and MAX_ZOOM constants, WgsConversionError, ECEF arrays
  are ndarray(3,) float64, new horizontal_distance_m method
  (AZ-490 takeoff-origin bounded-delta gate), slippy-map
  tile math hand-rolled to match satellite-provider on-disk
  layout.

Two contract files (imu_preintegrator.md and
wgs_converter.md) need follow-up minor revisions to match
shipped surface; queued for the next contracts-folder
sweep, noted inline in each helper's new section.

Also refresh D-CROSS-CVE-1 opencv-pin leftover replay
timestamp (8-min debounce — gtsam upstream state cannot
change in that window).

Bumps _docs/_autodev_state.md sub_step detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 17:33:59 +03:00
parent 12aba8139f
commit 4fdf1968af
6 changed files with 71 additions and 5 deletions
@@ -30,3 +30,19 @@ class ImuPreintegrator:
- Bias drift is the responsibility of the consumers (C1 + C5) who call `reset_with_bias(...)` whenever their estimate of the IMU bias changes.
- The preintegrator does not own a clock — every `integrate_*` call requires a monotonic timestamp on the IMU sample.
## Cycle-1 operational reality
The shipped surface in `src/gps_denied_onboard/helpers/imu_preintegrator.py` (AZ-276) extends the sketch above; this section is the authoritative inventory of what cycle-1 consumers actually see. Sketch return types in § Interface remain accurate for *intent*; the precise types appear here.
- **Factory** — `make_imu_preintegrator(calibration: CameraCalibration) -> ImuPreintegrator` reads gyro/accel noise covariances from `calibration.metadata["imu_noise_model"]` and constructs `gtsam.PreintegrationCombinedParams.MakeSharedU(gravity_m_s2)`. Every key in the noise block is optional and independently defaulted, so partial blocks are honoured.
- **BMI088-class defaults** (used when `imu_noise_model` is absent — bring-up + unit tests only; production deployments MUST supply a per-deployment noise model in `CameraCalibration.metadata`): `accel_noise_density=1.86e-3 m/s²/√Hz`, `gyro_noise_density=1.87e-4 rad/s/√Hz`, `accel_bias_rw=4.33e-4 m/s³/√Hz`, `gyro_bias_rw=2.66e-5 rad/s²/√Hz`, `integration_noise=1e-8`, `gravity_m_s2=9.80665`.
- **`ImuPreintegrationError`** — single public exception type. Raised on (a) non-monotonic `sample.ts_ns`, (b) GTSAM's lower-level PIM rejection (wrapped, not propagated), (c) `current_preintegration()` / `reset_for_new_keyframe()` called with zero samples since last reset. Carries offending vs. previous `ts_ns` in its message so consumers can write FDR `kind="imu.skew"` events per AZ-276 Risk 2.
- **Actual return type** of `current_preintegration()` and `reset_for_new_keyframe()` is `gtsam.PreintegratedCombinedMeasurements` (the PIM), not a constructed `CombinedImuFactor`. Consumers build the factor at attach time as `gtsam.CombinedImuFactor(*keys, pim)` — the helper cannot know the GTSAM keys. The `CombinedImuFactor` alias is still re-exported (so consumers do NOT import GTSAM directly), but it names the **type** the consumer constructs, not the helper's return value. Contract `imu_preintegrator.md` v1.0.0 still reflects the original "returns CombinedImuFactor" wording — a contract minor revision is queued for the next contracts-folder sweep.
- **`reset_with_bias` is destructive in cycle-1** — it discards the partial integration accumulator (re-initialises the PIM with the new bias and a clean `last_ts_ns=None`). The contract's "subsequent samples only" wording is honoured at the new-window granularity: consumers MUST close the prior window via `reset_for_new_keyframe()` BEFORE changing bias if they want to retain its contribution.
- **First-sample dt handling** — the first sample after a reset is recorded (`_last_ts_ns` updated, `_sample_count` incremented) but NOT integrated (GTSAM rejects `dt==0`). The second sample is the first integration call. This matters for AC-1 length counting: 100 samples ⇒ 99 GTSAM integrations.
### Cycle-1 task lineage
- AZ-276 — initial helper, contract producer.
- No cycle-1 follow-up tasks touched this helper.
@@ -28,3 +28,19 @@ def adjoint(pose: SE3) -> Matrix6
## Caveats
- Library-grade Lie-algebra functions exist in `manifpy` and `pylie`; we use GTSAM's primitives directly to avoid pulling in a second math library. If a future strategy needs richer manifold ops, evaluate `manifpy` then.
## Cycle-1 operational reality
The shipped surface in `src/gps_denied_onboard/helpers/se3_utils.py` (AZ-277) extends the sketch above; this section is the authoritative inventory of what cycle-1 consumers actually see.
- **Type alias** — `SE3 = gtsam.Pose3` is re-exported by the helper. Consumers MUST import `SE3` from `helpers.se3_utils` and never `gtsam.Pose3` directly (keeps the Lie-algebra backend swappable without touching C1/C4/C5).
- **`Se3InvalidMatrixError`** — single public exception type. Raised on (a) wrong array shape, (b) `dtype != float64`, (c) bottom row != `[0, 0, 0, 1]`, (d) rotation drift `‖R^TR I‖_F > atol`, (e) negative-determinant rotation (mirror), (f) non-ndarray inputs. `matrix_to_se3` and `exp_map` raise this; `se3_to_matrix`, `log_map`, `adjoint` are no-throw on the typed input.
- **Strict caller-orthogonalisation invariant** — the helper does NOT silently re-orthogonalise. AC-7 / `matrix_to_se3` always validates `‖R^TR I‖_F ≤ atol` and rejects drift. Callers (C4 in particular, since `solvePnPRansac` output is not orthogonal to numerical precision) MUST run their own orthogonalisation (`cv2.Rodrigues` round-trip or `scipy.linalg.polar`) before calling `matrix_to_se3`. Default tolerance: `_DEFAULT_ROT_ATOL = 1e-6`; callers can pass a looser `atol` for relaxed contexts (none in cycle-1).
- **`exp_map` near-identity fallback** — twist vectors with `‖xi‖ < _SMALL_ANGLE_THRESHOLD = 1e-10` return the identity `SE3()` instead of delegating to GTSAM's `Pose3.Expmap`. This guards against the `sin(theta)/theta` under-flow that surfaces when iSAM2's relinearisation produces a near-identity twist after a converged step.
- **`is_valid_rotation(R_3x3, *, atol=1e-6)`** — predicate (no exception) for "is this matrix safe to feed to `matrix_to_se3`?". Returns False for non-ndarray, wrong shape, wrong dtype, orthogonality drift > atol, or negative determinant. Cycle-1 consumers: C4's `MarginalsAdapter` short-circuit (`opencv_gtsam_marginals.py` from AZ-358) and the contract test for AC-7.
- **`dtype=float64` everywhere** — every public function enforces `float64`. `np.ndarray` returned from `se3_to_matrix`, `log_map`, `adjoint` is `np.ascontiguousarray(..., dtype=np.float64)` so callers can pass it through to GTSAM/Eigen without a copy.
### Cycle-1 task lineage
- AZ-277 — initial helper, contract producer.
- No cycle-1 follow-up tasks touched this helper.
@@ -28,3 +28,20 @@ class LightGlueRuntime:
## Caveats
- The features fed in MUST come from the same backbone as the LightGlue engine was trained for (DISK in production-default; ALIKED / XFeat in alternates). Mixing backbones is a runtime error caught by the matcher's input shape check.
## Cycle-1 operational reality
The shipped surface in `src/gps_denied_onboard/helpers/lightglue_runtime.py` (AZ-278) is the structural fix for R14 (re-rank vs. matcher double-load of the LightGlue engine). Composition root wires ONE `LightGlueRuntime` instance and constructor-injects it into both C2.5 (`InlierBasedReranker`) and C3 (`CrossDomainMatcher`).
- **Constructor** — `LightGlueRuntime(engine_handle: EngineHandle)`. `EngineHandle` is a `Protocol` from `_types/manifests.py` (descriptor_dim, forward(...)) — Layer 1 helper invariant means we do NOT import `gps_denied_onboard.components.*`. C7's `InferenceRuntime.deserialize_engine(LIGHTGLUE_ENGINE_CACHE_ENTRY)` returns the concrete handle at takeoff; the composition root passes it in.
- **Construction guards** — `LightGlueRuntimeError` is raised for: `engine_handle is None`; `engine_handle` missing the `descriptor_dim` Protocol attribute; `descriptor_dim < 1`.
- **Descriptor-dim mismatch** — both `match` and `match_batch` validate every `KeypointSet.descriptors` against the engine's `descriptor_dim` and raise `LightGlueRuntimeError` on mismatch (catches "DISK features fed into an ALIKED-trained LightGlue" regressions).
- **Concurrent-access guard is non-blocking** — the runtime owns a `threading.Lock` but never `.acquire(blocking=True)`. Concurrent entry raises `LightGlueConcurrentAccessError` immediately rather than serialising. This is intentional: if you see this exception, the composition root wired the runtime into more than one thread by mistake — fix the composition, do NOT add blocking serialisation. The lock guards the body of both `match` and `match_batch`; `descriptor_dim()` is lock-free.
- **`match_batch` equal-length precondition** — `LightGlueRuntimeError` if `len(features_a_list) != len(features_b_list)`. Iteration uses `zip(..., strict=True)`. Indexed validation labels (`features_a_list[i]`) so a downstream test failure points to the offending pair.
- **`descriptor_dim()` accessor** — returns the engine's descriptor dim as a plain `int` (cached on construction so per-call overhead is one attribute lookup).
- **Public exceptions** — `LightGlueRuntimeError` (construction / descriptor-dim mismatch / batch-length mismatch) and `LightGlueConcurrentAccessError` (composition-root violation). Both subclass `RuntimeError`.
### Cycle-1 task lineage
- AZ-278 — initial helper, contract producer.
- R14 structural fix: composition-root single-instance injection into C2.5 + C3 lands in `runtime_root/airborne_bootstrap.py` (the `lightglue_runtime` pre-constructed key consumed by both `_C2_5_STRATEGIES` and `_C3_STRATEGIES`).
@@ -41,3 +41,19 @@ class WgsConverter:
- The static-only design satisfies the coderule.mdc constraint ("only use static methods for pure self-contained computations"). If a future deployment needs alternative datum support, switch to an instance-based factory then.
- Tile-coordinate math is zoom-level-sensitive; callers MUST pass the right zoom level for the tile in question (typically zoomLevel from `TileMetadata`).
## Cycle-1 operational reality
The shipped surface in `src/gps_denied_onboard/helpers/wgs_converter.py` (AZ-279, extended by AZ-490) is the canonical entry point for every geodesy hop in the system. Stateless and `pyproj`-backed (`EPSG:4326 ↔ EPSG:4978`), with module-level `Transformer` instances cached on import.
- **Public constants** — `WEB_MERCATOR_MAX_LAT_DEG = 85.0511287798066` (the slippy-map cutoff; outside this band, `latlon_to_tile_xy` raises) and `MAX_ZOOM = 22` (slippy-map upper bound; exposed so callers can validate operator input without hard-coding the limit).
- **`WgsConversionError`** — single public exception type (subclasses `ValueError`). Raised on: non-finite `lat/lon/alt`; latitude/longitude out of WGS-84 range; non-`ndarray` or wrong-shape ECEF input; non-`float64` ECEF input; `zoom` not a non-bool `int` or out of `[0, MAX_ZOOM]`; tile `(x, y)` out of `[0, 2**zoom)`; latitude outside the Web-Mercator band for `latlon_to_tile_xy`.
- **ECEF arrays are `np.ndarray` of shape `(3,)` and `dtype=float64`** — the Interface sketch above uses "Vector3" as a placeholder. `latlonalt_to_ecef` returns a freshly-allocated array; `ecef_to_latlonalt` and `local_enu_to_latlonalt` validate input shape/dtype and raise `WgsConversionError` on mismatch.
- **`horizontal_distance_m(a: LatLonAlt, b: LatLonAlt) -> float`** — new method added in AZ-490 for C5's `set_takeoff_origin` bounded-delta gate. Computes the geodesic horizontal distance in metres via the same ECEF transformer used by `latlonalt_to_local_enu`: convert `b` into the local-ENU frame anchored at `a`, then `hypot(east, north)`. Altitude is ignored (flat-distance on the WGS-84 ellipsoid, NOT a 3-D distance). Accuracy ≤ sub-mm vs. Vincenty for separations ≤ a few km — the bounded-delta gate operates at ≤ ~1 km, so AZ-490's "geodesic horizontal distance" AC is satisfied.
- **Slippy-map tile math** — hand-rolled (NOT `pyproj`) to match OSM's `{zoom}/{x}/{y}.jpg` convention byte-equal so files produced by `satellite-provider` round-trip exactly. `latlon_to_tile_xy` clamps the output into `[0, n-1]` after `floor` — out-of-band latitude is rejected before this clamp via the Web-Mercator range check. `tile_xy_to_latlon_bounds` returns a `BoundingBox(min_lat_deg, min_lon_deg, max_lat_deg, max_lon_deg)` matching the tile's outer extent.
- **`pyproj` import** — `from pyproj import Transformer` is tagged `# type: ignore[import-not-found]` because `pyproj` ships type stubs in a separate package; the project pin does not add the stubs. Don't drop the ignore comment in mypy passes.
### Cycle-1 task lineage
- AZ-279 — initial helper, contract producer (`latlonalt_to_*`, `*_to_latlonalt`, `latlon_to_tile_xy`, `tile_xy_to_latlon_bounds`).
- AZ-490 — `horizontal_distance_m` addition for C5's takeoff-origin bounded-delta gate. Contract minor revision (v1.0.0 → v1.1.0) is queued for the next contracts-folder sweep.