[AZ-623] [AZ-625] Phase E: c282_ransac + c5 helpers; split handle work

Wire 4 stateless / cached helpers into airborne_bootstrap.build_pre_constructed:
c282_ransac_filter, c5_imu_preintegrator (cached on calibration path),
c5_se3_utils (helpers.se3_utils module as namespace handle), c5_wgs_converter.

The original AZ-623 5th deliverable (c5_isam2_graph_handle) hit an
unresolvable construction-order conflict between c4_pose (consumes the handle)
and c5_state (creates it inside build_state_estimator's tuple return) under
the umbrella's "MUST NOT touch any per-component factory signature" constraint.
Per AZ-623 spec's escalation gate, scope was split: AZ-625 captures the handle
ordering work; AZ-624 dependency edge updated to require both.

Tests: tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py adds 7
tests covering AC-623.1..3 (4 new keys + correct types, IMU preintegrator
caching, operator-actionable error messages for empty / unreadable / malformed
calibration paths). Autouse stubs added to test_az619/620/621/622 so prior
phase tests remain isolated from new builders.

Quality gates: ruff format clean, ruff lint clean, 24/24 phase tests pass,
247/247 runtime_root + c5_state regression suite passes. Code review verdict
PASS_WITH_WARNINGS (3 Low findings; full report in
_docs/03_implementation/reviews/batch_94_review.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 09:20:28 +03:00
parent 5c4d129f80
commit 02208c577e
13 changed files with 1014 additions and 151 deletions
@@ -0,0 +1,63 @@
# AZ-623 — Phase E: build_pre_constructed seeds c282_ransac_filter + 3 c5 helpers
**Task**: AZ-623_pre_constructed_phase_e_ransac_c5_helpers
**Name**: AZ-618 Phase E: build_pre_constructed seeds c282_ransac_filter + c5 helpers
**Description**: Fifth subtask of AZ-618. Extends `airborne_bootstrap.build_pre_constructed(config)` to populate the small / stateless helper entries.
**Complexity**: 3 points
**Dependencies**: AZ-619, AZ-282 (RansacFilter), AZ-276 (ImuPreintegrator), AZ-277 (SE3Utils), AZ-279 (WgsConverter). All in `done/`.
**Component**: runtime_root (cross-cutting)
**Tracker**: AZ-623
**Epic**: AZ-602 (parent: AZ-618 umbrella)
## Scope split note (2026-05-19)
The original AZ-623 included `c5_isam2_graph_handle`. Path 1 of the spec's two-path investigation requires a Protocol seam change to `ISam2GraphHandleImpl` (handle is tightly coupled to `GtsamIsam2StateEstimator` via constructor injection); the AZ-618 umbrella forbids per-component factory signature changes. Per the spec's own escalation gate, the user was asked and chose to split: this ticket lands the 4 stateless helpers; AZ-625 (`AZ-618 follow-up: c5_isam2_graph_handle ordering`) lands the handle wiring with a documented in-bootstrap (estimator, handle) build + look-aside short-circuit pattern. AZ-624 (main wiring + AC verification) now blocks on BOTH AZ-623 and AZ-625.
## Outcome
- `build_pre_constructed(config)` adds keys `c282_ransac_filter`, `c5_imu_preintegrator`, `c5_se3_utils`, `c5_wgs_converter` on top of AZ-619..AZ-622. (`c5_isam2_graph_handle` moved to AZ-625.)
- New unit tests under `tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py`.
## Scope
### Included
- Internal builders for the 4 keys above. `c282_ransac_filter` and `c5_wgs_converter` return fresh stateless instances (`RansacFilter()`, `WgsConverter()`); `c5_se3_utils` returns the `gps_denied_onboard.helpers.se3_utils` module (consumers access functions as attributes; tests stub via `MagicMock` with the same attribute-access shape); `c5_imu_preintegrator` builds via `make_imu_preintegrator(camera_calibration)` with the calibration loaded from `config.runtime.camera_calibration_path` and the result cached per path.
- The calibration loader function in `airborne_bootstrap.py` mirrors the JSON shape `runtime_root._replay_branch._load_camera_calibration` already uses — same on-disk format, no new file format introduced.
- Unit tests covering AC-623.1 + AC-623.2.
### Excluded
- main() wiring (AZ-624).
- GPU-touching builders (AZ-621 / AZ-622).
- `c5_isam2_graph_handle` seeding (AZ-625, see Scope split note).
## Acceptance Criteria
**AC-623.1**: `build_pre_constructed(config)` adds the 4 keys above on top of AZ-619..AZ-622.
**AC-623.2**: invoking `build_pre_constructed(config)` twice within the same process produces a dict where `c5_imu_preintegrator` is the same object both times (cached by `config.runtime.camera_calibration_path`). The 3 stateless helpers may be either fresh or cached — caching is irrelevant for them.
**AC-623.3**: when `config.runtime.camera_calibration_path` is empty or unreadable, `_build_c5_imu_preintegrator` raises `AirborneBootstrapError` naming the missing input and the consuming component slug `c5_state` (operator-actionable error mirroring AC-3 / AC-622.2).
**AC-623.4**: `pytest tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py` covers AC-623.1 + AC-623.2 + AC-623.3.
## Implementation Notes
- `c5_se3_utils` returns the `helpers.se3_utils` MODULE (Python modules support attribute access for their public names; consumers store it as `self._se3_utils: Any` and call `self._se3_utils.exp_map(...)`). The `MagicMock()` fixture used by existing C5 estimator tests has the same shape.
- `c5_wgs_converter` returns `WgsConverter()` (instance of a static-only class) — same pattern `runtime_root._replay_branch.run` already uses (`wgs_converter = WgsConverter()`).
- `c282_ransac_filter` returns `RansacFilter()` (instance of a static-only class) — consumers (`c3_matcher`, `c3_5_adhop`, `c4_pose`) use it for attribute-dispatch only.
- `c5_imu_preintegrator` is the only stateful helper; its bias accumulator survives across `build_pre_constructed` invocations, hence the per-path cache.
## Constraints
- MUST NOT modify per-component factory signatures.
- MUST be additive on top of AZ-619..AZ-622.
## Evidence
- Umbrella spec: `_docs/02_tasks/todo/AZ-618_airborne_bootstrap_pre_constructed.md`
- Helper modules: `src/gps_denied_onboard/helpers/{ransac_filter,imu_preintegrator,se3_utils,wgs_converter}.py`
- `c5_state` factory: `src/gps_denied_onboard/runtime_root/state_factory.py`
- ISam2GraphHandle (deferred to AZ-625): `src/gps_denied_onboard/components/c5_state/_isam2_handle.py`
- Calibration loader pattern: `runtime_root/_replay_branch.py::_load_camera_calibration`
@@ -1,58 +0,0 @@
# AZ-623 — Phase E: build_pre_constructed seeds c282_ransac_filter + c5 helpers
**Task**: AZ-623_pre_constructed_phase_e_ransac_c5_helpers
**Name**: AZ-618 Phase E: build_pre_constructed seeds c282_ransac_filter + c5 helpers
**Description**: Fifth subtask of AZ-618. Extends `airborne_bootstrap.build_pre_constructed(config)` to populate the small / stateless helper entries plus the special-case `c5_isam2_graph_handle`.
**Complexity**: 3 points
**Dependencies**: AZ-619, AZ-282 (RansacFilter), AZ-276 (ImuPreintegrator), AZ-277 (SE3Utils), AZ-279 (WgsConverter), AZ-381 (ISam2GraphHandle). All in `done/`.
**Component**: runtime_root (cross-cutting)
**Tracker**: AZ-623
**Epic**: AZ-602 (parent: AZ-618 umbrella)
## Outcome
- `build_pre_constructed(config)` adds keys `c282_ransac_filter`, `c5_imu_preintegrator`, `c5_se3_utils`, `c5_wgs_converter`, `c5_isam2_graph_handle` on top of AZ-619..AZ-622.
- The `c5_isam2_graph_handle` ordering question (C4 wrapper consumes it BEFORE C5 wrapper runs in the topo order) is resolved here — not deferred to AZ-624.
- New unit tests under `tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py`.
## Scope
### Included
- Internal builders for the 5 keys above. Most are trivial constructions (`RansacFilter()`, `WgsConverter()`); `c5_imu_preintegrator` uses `make_imu_preintegrator(camera_calibration)`; `c5_se3_utils` is a thin namespace object exposing the module functions; `c5_isam2_graph_handle` is the per-strategy build (gtsam_isam2 / eskf).
- Resolution of the C4-before-C5 ordering for `c5_isam2_graph_handle`. Investigation budget ≤1 hour.
- Unit tests covering AC-623.1 + AC-623.2.
### Excluded
- main() wiring (AZ-624).
- Any GPU-touching builders (AZ-621 / AZ-622).
## Acceptance Criteria
**AC-623.1**: `build_pre_constructed(config)` adds the 5 keys above on top of AZ-619..AZ-622.
**AC-623.2**: invoking twice produces dicts where stateless helpers (`c282_ransac_filter`, `c5_wgs_converter`, `c5_se3_utils`) may be either fresh or cached; `c5_imu_preintegrator` and `c5_isam2_graph_handle` MUST be the same instance across calls within the same process (cached) — this matches the per-component factory's identity expectations.
**AC-623.3**: `pytest tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py` covers AC-623.1 + AC-623.2.
## Implementation Note
The `c5_isam2_graph_handle` / `build_state_estimator` ordering question is the only non-trivial bit here. `build_state_estimator` returns `(StateEstimator, ISam2GraphHandle)` together — but the airborne wrapper for c4_pose consumes `c5_isam2_graph_handle` from `pre_constructed` BEFORE the c5_state wrapper runs in the topo order. Two paths to investigate:
1. Construct the handle here in the bootstrap (separately from the StateEstimator) and put it in `pre_constructed["c5_isam2_graph_handle"]`. Then the `_c5_state_wrapper` consumes the SAME handle from `constructed` (which gets seeded from `pre_constructed`) instead of building a new one.
2. Reorder the bootstrap to run c5_state first (before c4_pose) so the StateEstimator + handle are built together — but this conflicts with the existing `_C5_STATE_DEPENDS_ON: ("c1_vio", "c4_pose")` declaration.
Path 1 is preferred (no topology change). Path 2 requires editing `_AIRBORNE_REGISTRATIONS` and is potentially a larger change. Spend ≤1 hour deciding; if Path 1's separation requires a Protocol seam change to ISam2GraphHandle's construction, escalate to the user (do NOT defer to AZ-624).
## Constraints
- MUST NOT modify per-component factory signatures.
- MUST be additive on top of AZ-619..AZ-622.
## Evidence
- Umbrella spec: `_docs/02_tasks/todo/AZ-618_airborne_bootstrap_pre_constructed.md`
- Helper modules: `src/gps_denied_onboard/helpers/{ransac_filter,imu_preintegrator,se3_utils,wgs_converter}.py`
- `c5_state` factory: `src/gps_denied_onboard/runtime_root/state_factory.py`
- ISam2GraphHandle: `src/gps_denied_onboard/components/c5_state/_isam2_handle.py`
@@ -4,7 +4,7 @@
**Name**: AZ-618 Phase F: wire build_pre_constructed into runtime_root.main() + AC-1..AC-5 verification **Name**: AZ-618 Phase F: wire build_pre_constructed into runtime_root.main() + AC-1..AC-5 verification
**Description**: Final / umbrella subtask of AZ-618. Wires `build_pre_constructed` into `runtime_root.main()` and lands the FULL AC suite from the AZ-618 umbrella (AC-1..AC-5), including the Jetson tier-2 verification. **Description**: Final / umbrella subtask of AZ-618. Wires `build_pre_constructed` into `runtime_root.main()` and lands the FULL AC suite from the AZ-618 umbrella (AC-1..AC-5), including the Jetson tier-2 verification.
**Complexity**: 2 points **Complexity**: 2 points
**Dependencies**: AZ-619, AZ-620, AZ-621, AZ-622, AZ-623 (all phases must be in `done/` before this lands). **Dependencies**: AZ-619, AZ-620, AZ-621, AZ-622, AZ-623, AZ-625 (all phases must be in `done/` before this lands; AZ-625 was added 2026-05-19 when the `c5_isam2_graph_handle` ordering work was split out of AZ-623).
**Component**: runtime_root (cross-cutting) **Component**: runtime_root (cross-cutting)
**Tracker**: AZ-624 **Tracker**: AZ-624
**Epic**: AZ-602 (parent: AZ-618 umbrella) **Epic**: AZ-602 (parent: AZ-618 umbrella)
@@ -0,0 +1,79 @@
# AZ-625 — Phase E.5: airborne_bootstrap c5_isam2_graph_handle ordering
**Task**: AZ-625_c5_isam2_graph_handle_ordering
**Name**: AZ-618 follow-up: c5_isam2_graph_handle ordering — separate handle from estimator construction
**Description**: Sixth subtask of AZ-618 (added 2026-05-19 by autodev batch 94 escalation). Lands the `pre_constructed["c5_isam2_graph_handle"]` seeding that AZ-623 originally listed but escalated out of scope when Path 1 of the AZ-623 spec's two-path investigation required a Protocol seam change forbidden by the AZ-618 umbrella.
**Complexity**: 3 points
**Dependencies**: AZ-619..AZ-623 (all in `done/` before this lands).
**Component**: runtime_root (cross-cutting)
**Tracker**: AZ-625
**Epic**: AZ-602 (parent: AZ-618 umbrella)
## Why split out of AZ-623
`compose_root` walks topo order and seeds `constructed` from `pre_constructed`. `c4_pose` pulls `c5_isam2_graph_handle` from `constructed` at construction time. `_C5_STATE_DEPENDS_ON: ("c1_vio", "c4_pose")` puts c5 AFTER c4 in topo. So at c4_pose's construction time, `c5_isam2_graph_handle` is not in `constructed` unless seeded by `pre_constructed`.
`ISam2GraphHandleImpl.__init__(self, estimator: GtsamIsam2StateEstimator)` ties handle construction to estimator construction. Building the handle separately requires a Protocol seam change — explicitly forbidden by the AZ-618 umbrella's "MUST NOT touch any per-component factory signature" constraint.
`_c5_state_wrapper` currently builds the estimator + handle together via `build_state_estimator`, discards the handle (`estimator, _handle = ...`), and returns the estimator. The handle never reaches `pre_constructed`.
## Decision (2026-05-19, autodev batch 94)
Path 1 (handle-only separation) is blocked. Chosen approach: a wiring change inside `airborne_bootstrap.py` only.
- `airborne_bootstrap.build_pre_constructed` calls `build_state_estimator` once, captures `(estimator, handle)`, and seeds:
- `pre_constructed["c5_isam2_graph_handle"] = handle`
- a private coordination key, e.g. `pre_constructed["_c5_prebuilt_estimator"] = estimator`
- `_c5_state_wrapper` consults `constructed.get("_c5_prebuilt_estimator")` and returns the prebuilt instance when present; otherwise falls back to `build_state_estimator(...)` (preserves test isolation for fixtures that don't go through the bootstrap).
- No changes to `state_factory.build_state_estimator` signature, `ISam2GraphHandleImpl.__init__`, or the C4 / C5 Protocol declarations.
## Outcome
- `build_pre_constructed(config)` adds `c5_isam2_graph_handle` on top of AZ-619..AZ-623, and the c4_pose wrapper sees the same handle the c5_state estimator was built with.
- `_c5_state_wrapper` short-circuits on the look-aside key.
- New unit tests under `tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py`.
## Scope
### Included
- `airborne_bootstrap.py`: new internal builder `_build_c5_state_estimator_pair(config, ...)` that calls `build_state_estimator` once and returns `(estimator, handle)`. `build_pre_constructed` invokes it and seeds both keys.
- `airborne_bootstrap.py`: `_c5_state_wrapper` short-circuits on `constructed["_c5_prebuilt_estimator"]`.
- Document `_c5_prebuilt_estimator` as an internal coordination key (NOT in `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` because consumers don't query it).
- `C5_STATE_BUILD_FLAGS: Final[Mapping[str, str]]` mirroring `state_factory._STATE_BUILD_FLAGS` so the bootstrap can name the gating flag in `AirborneBootstrapError` (mirrors AZ-622's `C3_MATCHER_BUILD_FLAGS` pattern).
- Unit tests covering AC-625.1..AC-625.3.
### Excluded
- Any change to `state_factory`, `pose_factory`, or `c5_state` internals.
- main() wiring (still AZ-624's job; AZ-624 now depends on this PBI).
- AZ-389 orthorectifier wiring (`camera_calibration` / `flight_id` / `companion_id` flow through `pre_constructed`) — orthogonal; lands as part of AZ-624 or its own follow-up.
## Acceptance Criteria
**AC-625.1**: `build_pre_constructed(config)` adds key `c5_isam2_graph_handle` on top of AZ-619..AZ-623; the value satisfies the C4 `ISam2GraphHandle` Protocol (`get_pose_key`, `add_factor`, `update`, `compute_marginals`, `last_anchor_age_ms`).
**AC-625.2**: When the configured `c5_state` strategy's `BUILD_STATE_*` flag is OFF (or the strategy is unknown), `build_pre_constructed` raises `AirborneBootstrapError` naming the missing flag and the consuming component slug `c5_state`. (Mirrors the AC-3 / AC-622.2 error contract.)
**AC-625.3**: `compose_root(config, pre_constructed=build_pre_constructed(config))` produces a runtime where the handle held by c4_pose IS the same handle returned by the c5_state estimator's `_isam2_handle`. Identity assertion verifies single-instance sharing across the C4 / C5 seam.
**AC-625.4**: Unit tests under `tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py` cover AC-625.1..AC-625.3.
## Tier-2 Note
Real iSAM2 graph mutations under load are verified by AZ-624's Jetson AC-5 run.
## Constraints
- MUST NOT modify per-component factory signatures.
- MUST be additive on top of AZ-619..AZ-623.
- MUST NOT touch `state_factory`, `pose_factory`, or `c5_state` internals.
## Evidence
- Umbrella spec: `_docs/02_tasks/todo/AZ-618_airborne_bootstrap_pre_constructed.md`
- Original AZ-623 spec (now narrowed): `_docs/02_tasks/todo/AZ-623_pre_constructed_phase_e_ransac_c5_helpers.md` § "Scope split note"
- ISam2GraphHandle Protocol (consumed by C4): `src/gps_denied_onboard/components/c4_pose/_isam2_handle.py`
- ISam2GraphHandleImpl (built by C5 estimator): `src/gps_denied_onboard/components/c5_state/_isam2_handle.py`
- `state_factory.build_state_estimator`: `src/gps_denied_onboard/runtime_root/state_factory.py`
- AZ-622's `C3_MATCHER_BUILD_FLAGS` pattern (template for `C5_STATE_BUILD_FLAGS`): `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py`
@@ -0,0 +1,95 @@
# Batch Report
**Batch**: 94
**Tasks**: AZ-623 (Phase E narrowed: build_pre_constructed seeds c282_ransac_filter + c5_imu_preintegrator + c5_se3_utils + c5_wgs_converter; `c5_isam2_graph_handle` work split out to AZ-625)
**Date**: 2026-05-19
**Cycle**: 1
## Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|------|--------|----------------|-------|-------------|--------|
| AZ-623_pre_constructed_phase_e_ransac_c5_helpers (narrowed) | Done | 7 files | 24 passed | 4/4 ACs covered | 0 blocking |
Original AZ-623 had a 5th deliverable (`c5_isam2_graph_handle`) that uncovered an unresolvable construction-order conflict between `c4_pose` (consumes the handle) and `c5_state` (creates the handle inside `build_state_estimator`'s tuple return) under the umbrella's "MUST NOT touch any per-component factory signature" constraint. Per the AZ-623 spec's own escalation gate ("if the resolution requires a Protocol seam change, escalate"), the question was surfaced; user chose to **split scope**:
- This batch lands the 4 stateless / cached helpers (narrowed AZ-623).
- New PBI **AZ-625** ("AZ-618 Phase E.5: c5_isam2_graph_handle ordering", 3pt, parent AZ-618) holds the handle work.
- **AZ-624** dependency edge updated to require both AZ-623 AND AZ-625.
## Files Changed
### Production
- `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py` — added imports (`json`, `pathlib.Path`, `numpy`, `_types.calibration.CameraCalibration`, `helpers.imu_preintegrator.{ImuPreintegrator, make_imu_preintegrator}`, `helpers.ransac_filter.RansacFilter`, `helpers.wgs_converter.WgsConverter`); added module-level `_IMU_PREINTEGRATOR_CACHE` dict + `clear_imu_preintegrator_cache()` helper; added `_load_camera_calibration` (mirrors `_replay_branch._load_camera_calibration` but raises `AirborneBootstrapError`); added 4 builders `_build_c282_ransac_filter`, `_build_c5_imu_preintegrator` (cached on calibration path), `_build_c5_se3_utils` (returns the `helpers.se3_utils` module as a namespace handle, matching existing C5 estimator's MagicMock fixture pattern), `_build_c5_wgs_converter`; extended `build_pre_constructed` to populate the 4 new keys after the existing AZ-619..AZ-622 keys.
### Tests
- `tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py` (NEW, 7 tests):
- `test_ac_623_1_adds_c282_ransac_and_c5_helpers` — AC-623.1 (4 keys + correct types).
- `test_ac_623_1_keeps_existing_keys_intact` — additivity invariant (AZ-619..AZ-622 keys still present).
- `test_ac_623_2_imu_preintegrator_cached_across_calls` — AC-623.2 (cache short-circuit via per-test counter).
- `test_ac_623_2_imu_preintegrator_per_path_cache` — AC-623.2 (per-path cache isolation).
- `test_ac_623_3_empty_calibration_path_raises_named_error` — AC-623.3 (operator-actionable error).
- `test_ac_623_3_unreadable_calibration_path_raises_named_error` — AC-623.3 (file not found path).
- `test_ac_623_3_malformed_json_raises_named_error` — AC-623.3 (JSON decode wrap).
- `tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py` — added autouse `_stub_c5_builders`.
- `tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py` — added autouse `_stub_c5_builders`.
- `tests/unit/runtime_root/test_az621_pre_constructed_phase_c.py` — added autouse `_stub_c5_builders`.
- `tests/unit/runtime_root/test_az622_pre_constructed_phase_d.py` — added autouse `_stub_c5_builders`.
### Specs
- `_docs/02_tasks/todo/AZ-623_*.md` → narrowed (handle work removed); ARCHIVED to `_docs/02_tasks/done/`.
- `_docs/02_tasks/todo/AZ-624_*.md` — Dependencies updated (AZ-625 added).
- `_docs/02_tasks/todo/AZ-625_c5_isam2_graph_handle_ordering.md` (NEW) — 3pt, parent AZ-618, holds the handle ordering work.
## AC Test Coverage: 4 of 4 covered
| AC | Test | Status |
|----|------|--------|
| AC-623.1 | `test_ac_623_1_adds_c282_ransac_and_c5_helpers` + `test_ac_623_1_keeps_existing_keys_intact` | Covered |
| AC-623.2 | `test_ac_623_2_imu_preintegrator_cached_across_calls` + `test_ac_623_2_imu_preintegrator_per_path_cache` | Covered |
| AC-623.3 | `test_ac_623_3_empty_calibration_path_raises_named_error` (+ 2 variants for unreadable/malformed) | Covered |
| AC-623.4 | File `tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py` exists | Covered |
## Code Review Verdict: PASS_WITH_WARNINGS
Full report: `_docs/03_implementation/reviews/batch_94_review.md`. Three Low findings:
1. **F1 (Low / Maintainability)**`_load_camera_calibration` duplicates `_replay_branch._load_camera_calibration` (different exception class). Defer to a hygiene PBI (~2pt) bundled with batch 93's F2 leftover.
2. **F2 (Low / Maintainability)** — empty-path check duplicated across `_build_c5_imu_preintegrator` and `_load_camera_calibration` (defense-in-depth). No change recommended.
3. **F3 (Low / Style)**`c5_se3_utils` returns a Python module as a namespace handle. Documented; matches existing C5 test fixture pattern. YAGNI — defer Protocol introduction.
No Critical / High / Medium findings. Auto-fix not invoked.
## Auto-Fix Attempts: 0
## Stuck Agents: None
## Test Run Summary
- Targeted test set (AZ-619..AZ-623): **24 passed** in 1.07s.
- Regression check (`tests/unit/runtime_root/` + `tests/unit/c5_state/`): **247 passed** in 1.78s.
## Scope-Split Decision Trail
The split was driven by an irreconcilable construction-order issue between two umbrella constraints:
- **Constraint A** (umbrella AZ-618): "MUST NOT touch any per-component factory signature" + "All changes confined to runtime_root/airborne_bootstrap.py, runtime_root/__init__.py, and the new test file."
- **Constraint B** (existing seam): `build_state_estimator` returns `(StateEstimator, ISam2GraphHandle)` as a tuple — the handle is a structural side-product of the estimator, not separately constructable.
- **Constraint C** (existing seam): C4's `build_pose_estimator` consumes `c5_isam2_graph_handle` from `pre_constructed`, so `compose_root` topologically requires it to exist before C4 runs, which is before C5 runs.
Possible resolutions all touched at least one of A/B/C; the user-approved Option B preserves all three constraints by:
- Keeping the **C4 ↔ C5 seam intact** (no Protocol changes).
- Promoting the (estimator, handle) build into `airborne_bootstrap`, where AZ-618 explicitly permits orchestration.
- Documenting the strategy in AZ-625's spec § "Decision" with full rationale.
## Tier-2 / Deferred Work
- AZ-625 (3pt) — handle ordering work, blocks AZ-624.
- Hygiene PBI to consolidate `_load_camera_calibration` between airborne_bootstrap and `_replay_branch` (F1) and `_is_build_flag_on` triple-duplication (batch 93 F2). Bundle as ~2pt cleanup PBI post-AZ-618.
## Next Batch
- **Batch 95**: AZ-625 (Phase E.5: c5_isam2_graph_handle ordering) — 3pt.
- **Then Batch 96**: AZ-624 (Phase F: wire main() + AC-1..AC-5 verification incl. Jetson tier-2) — 2pt.
- **Cumulative review window**: next due at batch 96 (K=3 from last cumulative at 88-92).
@@ -0,0 +1,93 @@
# Code Review Report
**Batch**: 94
**Tasks**: AZ-623 (Phase E narrowed: c282_ransac_filter + c5_imu_preintegrator + c5_se3_utils + c5_wgs_converter; original `c5_isam2_graph_handle` work split out to AZ-625)
**Date**: 2026-05-19
**Verdict**: PASS_WITH_WARNINGS
## Phase 1: Context
Read in this review window:
- `_docs/02_tasks/todo/AZ-623_pre_constructed_phase_e_ransac_c5_helpers.md` (narrowed scope, 4 helpers; `c5_isam2_graph_handle` deferred to AZ-625 with full scope-split note)
- `_docs/02_tasks/todo/AZ-624_pre_constructed_phase_f_wire_main.md` (dependency edge updated: AZ-624 now blocks on BOTH AZ-623 and AZ-625)
- `_docs/02_tasks/todo/AZ-625_c5_isam2_graph_handle_ordering.md` (NEW — captures the deferred handle-ordering work)
- `_docs/02_tasks/todo/AZ-618_airborne_bootstrap_pre_constructed.md` (umbrella; constraints "MUST NOT touch any per-component factory signature" + "All changes confined to runtime_root/airborne_bootstrap.py, runtime_root/__init__.py, and the new test file")
- `src/gps_denied_onboard/helpers/{ransac_filter,imu_preintegrator,se3_utils,wgs_converter}.py` (helpers being wired)
- `src/gps_denied_onboard/runtime_root/_replay_branch.py` (existing `_load_camera_calibration` pattern — template for the airborne bootstrap loader)
- `src/gps_denied_onboard/components/c5_state/_isam2_handle.py` (Protocol seam constraint that drove the AZ-625 split)
- `src/gps_denied_onboard/runtime_root/state_factory.py` (build_state_estimator return-tuple — confirms seam)
- `src/gps_denied_onboard/runtime_root/__init__.py` (compose_root pre_constructed merge — confirms construction-order issue)
The autodev orchestrator escalated the `c5_isam2_graph_handle` ordering question via the AZ-623 spec's own escalation gate; the user chose Option B (split scope; new PBI for handle wiring). This review covers the narrowed AZ-623 only.
## Phase 2: Spec Compliance
| AC | Status | Test | Notes |
|----|--------|------|-------|
| AC-623.1 (4 keys added on top of AZ-619..AZ-622) | Covered | `test_ac_623_1_adds_c282_ransac_and_c5_helpers` + `test_ac_623_1_keeps_existing_keys_intact` | Each new key is type-asserted; `c5_se3_utils` is the helpers.se3_utils module (matches existing C5 estimator's MagicMock fixture pattern via attribute access). |
| AC-623.2 (`c5_imu_preintegrator` cached per `camera_calibration_path`; stateless 3 may be fresh) | Covered | `test_ac_623_2_imu_preintegrator_cached_across_calls` + `test_ac_623_2_imu_preintegrator_per_path_cache` | Cache short-circuit verified by counter; per-path isolation verified by two distinct paths producing distinct instances. |
| AC-623.3 (operator-actionable error on missing/bad calibration) | Covered | `test_ac_623_3_empty_calibration_path_raises_named_error` + `_unreadable_` + `_malformed_json_` | All three error paths assert the message names `c5_imu_preintegrator`, the missing-input symptom, and the consuming component slug `c5_state`. Mirrors AZ-622's `c3_lightglue_runtime` error-message contract. |
| AC-623.4 (test file exists) | Covered | `tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py` | 7 tests, all passing. |
**Spec drift / scope split**: The user-approved scope split (handle work → AZ-625) is documented in three places — AZ-623 spec § "Scope split note", AZ-625 spec § "Why split out of AZ-623", AZ-624 Dependencies update. The Jira `addCommentToJiraIssue` write on AZ-623 also captures the split. No silent drift.
## Phase 3: Code Quality
3 findings — all Low; none blocking:
- F1 (Low / Maintainability): `_load_camera_calibration` (airborne_bootstrap) duplicates `_load_camera_calibration` (`_replay_branch.py`). The bodies share the JSON shape and field defaults; they differ only in exception class (`AirborneBootstrapError` here vs `CompositionError` in replay) and the `airborne-camera` vs `replay-camera` default `camera_id`. The umbrella's "MUST be confined to runtime_root/airborne_bootstrap.py" constraint nominally permits the duplication, but a future hygiene PBI should consolidate the JSON-decode core into a shared helper that takes the exception class as a callable parameter.
- F2 (Low / Maintainability): empty-path check duplicated in `_build_c5_imu_preintegrator` AND `_load_camera_calibration`. Intentional defense-in-depth (the loader is a public-ish seam tests monkeypatch directly, so it must validate independently); the upper check enables a tighter cache lookup before the loader fires. Documented in both docstrings. No change recommended unless the seam is removed in a future refactor.
- F3 (Low / Style): `c5_se3_utils` returns a Python module (`gps_denied_onboard.helpers.se3_utils`) as a namespace handle. Unusual at first glance but matches the existing `MagicMock()` fixture pattern in `tests/unit/c5_state/test_az386_eskf_baseline.py`; consumers store as `self._se3_utils: Any` and dispatch via attribute access. The docstring spells this out and references the existing C5 test pattern. A future enhancement could introduce a `Se3UtilsHandle` Protocol if the inferred shape grows, but YAGNI applies here.
## Phase 4: Security
No findings.
- No SQL, no command exec, no `eval`/`exec`.
- JSON parsing uses `json.loads` (safe) on file content.
- File paths flow from `config.runtime.camera_calibration_path` (operator input via YAML, not user-provided per request); `Path(path).read_text(encoding="utf-8")` reads the file with explicit encoding — no path traversal, no encoding ambiguity.
- No secrets in error messages: the path string is included for operator-actionability, but the file content is never echoed.
## Phase 5: Performance
No findings.
- `_IMU_PREINTEGRATOR_CACHE` is a single-key dict lookup per `build_pre_constructed` call. The expensive path (JSON read + GTSAM `PreintegrationCombinedParams` construction) fires at most once per process per calibration path.
- The 3 stateless helpers (`RansacFilter()`, `WgsConverter()`, the `se3_utils` module reference) are O(1) constructions.
- Bootstrap is a startup path — no latency-budget concerns at this layer beyond AZ-618's NFR (60s on Jetson Orin Nano), which is dominated by C7 GPU model load (AZ-621), not the AZ-623 helpers.
## Phase 6: Cross-Task Consistency
- Autouse stubs added to test_az619 / test_az620 / test_az621 / test_az622 with consistent pattern (named lambdas returning `MagicMock(name="...")` or `object()` sentinels). Each fixture explains why it's needed and what it stubs. No drift in stub style across the 4 prior phase test files.
- Error-message contract for `AirborneBootstrapError` follows the AZ-622 pattern (names the missing key, the gating input, and the consuming component slug).
- `c5_se3_utils` module-as-namespace pattern is documented in both the production code and the test suite.
## Phase 7: Architecture Compliance
No findings.
- New imports in `airborne_bootstrap.py`: `json` (stdlib), `pathlib.Path` (stdlib), `numpy` (stack-wide), `gps_denied_onboard._types.calibration.CameraCalibration` (cross-cutting types — bootstrap may import any `_types` per AZ-507), `gps_denied_onboard.helpers.{imu_preintegrator,ransac_filter,wgs_converter}` (CC-HELPERS layer; bootstrap may import any helper). All within the bootstrap's allowed dependency surface per `_docs/02_document/module-layout.md`.
- No new cyclic dependencies: `airborne_bootstrap` imports from helpers + types + factories; helpers import from `_types`; no back-edge introduced.
- No Public API bypass: every imported symbol is in the helper module's documented `__all__` or is a stdlib-equivalent re-export.
- The umbrella's "MUST NOT touch any per-component factory signature" constraint is honored: zero edits to `state_factory.py`, `pose_factory.py`, or any `c5_state` / `c4_pose` internal.
- The umbrella's "All changes confined to runtime_root/airborne_bootstrap.py, runtime_root/__init__.py, and the new test file" constraint is honored: production-side changes are only in `airborne_bootstrap.py` (this batch did not touch `runtime_root/__init__.py` — AZ-624's job).
## Findings Summary
| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| F1 | Low | Maintainability | `airborne_bootstrap.py` (`_load_camera_calibration`) | Duplicates `_replay_branch._load_camera_calibration` |
| F2 | Low | Maintainability | `airborne_bootstrap.py` (`_build_c5_imu_preintegrator` + `_load_camera_calibration`) | Defense-in-depth duplicates the empty-path check |
| F3 | Low | Style | `airborne_bootstrap.py` (`_build_c5_se3_utils`) | Returns a module as a namespace handle |
Verdict: **PASS_WITH_WARNINGS** — 0 Critical, 0 High, 0 Medium, 3 Low. Auto-fix gate not triggered.
## Test Run
Targeted: `tests/unit/runtime_root/test_az619..623`**24 passed in 2.78s**.
Regression: `tests/unit/runtime_root/ tests/unit/c5_state/`**247 passed in 1.41s**.
## Suggested Follow-Ups (informational)
- Hygiene PBI (~2pt): consolidate `_load_camera_calibration` between airborne_bootstrap and `_replay_branch` into a shared `runtime_root/_camera_calibration_loader.py` that accepts an exception-class parameter. Mirrors the F2 of batch 93 (`_is_build_flag_on` triple-duplication PBI). The two leftover hygiene PBIs (F2-batch93 + this F1-batch94) could land together.
+2 -2
View File
@@ -8,8 +8,8 @@ status: in_progress
sub_step: sub_step:
phase: 16 phase: 16
name: batch-loop name: batch-loop
detail: "batch 93 done; next: batch 94 = AZ-623 (Phase E, 3cp)" detail: "batch 94 done; next: batch 95 = AZ-625 (handle ordering, 3cp); AZ-624 blocked on AZ-625"
retry_count: 0 retry_count: 0
cycle: 1 cycle: 1
tracker: jira tracker: jira
last_completed_batch: 93 last_completed_batch: 94
@@ -48,15 +48,24 @@ internals).
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
from collections.abc import Mapping from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from gps_denied_onboard._types.calibration import CameraCalibration
from gps_denied_onboard.clock.wall_clock import WallClock from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard.fdr_client.client import make_fdr_client from gps_denied_onboard.fdr_client.client import make_fdr_client
from gps_denied_onboard.helpers.feature_extractor import OpenCvOrbExtractor from gps_denied_onboard.helpers.feature_extractor import OpenCvOrbExtractor
from gps_denied_onboard.helpers.imu_preintegrator import (
ImuPreintegrator,
make_imu_preintegrator,
)
from gps_denied_onboard.helpers.lightglue_runtime import LightGlueRuntime from gps_denied_onboard.helpers.lightglue_runtime import LightGlueRuntime
from gps_denied_onboard.helpers.ransac_filter import RansacFilter
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
from gps_denied_onboard.runtime_root import register_strategy from gps_denied_onboard.runtime_root import register_strategy
from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError
from gps_denied_onboard.runtime_root.inference_factory import build_inference_runtime from gps_denied_onboard.runtime_root.inference_factory import build_inference_runtime
@@ -86,10 +95,36 @@ __all__ = [
"FAISS_BUILD_FLAG", "FAISS_BUILD_FLAG",
"AirborneBootstrapError", "AirborneBootstrapError",
"build_pre_constructed", "build_pre_constructed",
"clear_imu_preintegrator_cache",
"register_airborne_strategies", "register_airborne_strategies",
] ]
_IMU_PREINTEGRATOR_CACHE: dict[str, ImuPreintegrator] = {}
"""Per-process cache mapping ``camera_calibration_path`` to the
:class:`ImuPreintegrator` built for that path.
Backs AC-623.2: invoking :func:`build_pre_constructed` twice in the
same process MUST return the SAME ``c5_imu_preintegrator`` instance
when the calibration path is unchanged. The preintegrator is the only
stateful c5 helper this phase wires; caching protects its bias /
sample accumulator from being silently rebuilt on a re-invocation.
Tests call :func:`clear_imu_preintegrator_cache` to isolate state.
"""
def clear_imu_preintegrator_cache() -> None:
"""Drop every cached :class:`ImuPreintegrator` (test-isolation only).
Mirrors :func:`gps_denied_onboard.fdr_client.client.clear_fdr_client_cache` /
:func:`gps_denied_onboard.runtime_root.state_factory.clear_state_registry`'s
test-only contract: production code never calls this, but unit tests
that exercise the per-path cache need a way to reset between cases.
"""
_IMU_PREINTEGRATOR_CACHE.clear()
FAISS_BUILD_FLAG: Final[str] = "BUILD_FAISS_INDEX" FAISS_BUILD_FLAG: Final[str] = "BUILD_FAISS_INDEX"
"""Env flag gating the FAISS-backed ``DescriptorIndex`` impl. """Env flag gating the FAISS-backed ``DescriptorIndex`` impl.
@@ -229,9 +264,7 @@ _C4_POSE_STRATEGIES: tuple[str, ...] = ("opencv_gtsam",)
_C5_STATE_STRATEGIES: tuple[str, ...] = ("gtsam_isam2", "eskf") _C5_STATE_STRATEGIES: tuple[str, ...] = ("gtsam_isam2", "eskf")
def _require( def _require(constructed: Mapping[str, Any], key: str, component_slug: str) -> Any:
constructed: Mapping[str, Any], key: str, component_slug: str
) -> Any:
"""Extract ``constructed[key]`` or raise AirborneBootstrapError.""" """Extract ``constructed[key]`` or raise AirborneBootstrapError."""
if key not in constructed: if key not in constructed:
available = sorted(constructed.keys()) available = sorted(constructed.keys())
@@ -262,16 +295,10 @@ def _c2_vpr_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
) )
def _c2_5_rerank_wrapper( def _c2_5_rerank_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
config: Config, constructed: Mapping[str, Any]
) -> Any:
tile_store = _require(constructed, "c6_tile_store", "c2_5_rerank") tile_store = _require(constructed, "c6_tile_store", "c2_5_rerank")
lightglue_runtime = _require( lightglue_runtime = _require(constructed, "c3_lightglue_runtime", "c2_5_rerank")
constructed, "c3_lightglue_runtime", "c2_5_rerank" feature_extractor = _require(constructed, "c3_feature_extractor", "c2_5_rerank")
)
feature_extractor = _require(
constructed, "c3_feature_extractor", "c2_5_rerank"
)
clock = _require(constructed, "clock", "c2_5_rerank") clock = _require(constructed, "clock", "c2_5_rerank")
fdr_client = constructed.get("c13_fdr") fdr_client = constructed.get("c13_fdr")
return build_rerank_strategy( return build_rerank_strategy(
@@ -284,12 +311,8 @@ def _c2_5_rerank_wrapper(
) )
def _c3_matcher_wrapper( def _c3_matcher_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
config: Config, constructed: Mapping[str, Any] lightglue_runtime = _require(constructed, "c3_lightglue_runtime", "c3_matcher")
) -> Any:
lightglue_runtime = _require(
constructed, "c3_lightglue_runtime", "c3_matcher"
)
ransac_filter = _require(constructed, "c282_ransac_filter", "c3_matcher") ransac_filter = _require(constructed, "c282_ransac_filter", "c3_matcher")
inference_runtime = _require(constructed, "c7_inference", "c3_matcher") inference_runtime = _require(constructed, "c7_inference", "c3_matcher")
clock = constructed.get("clock") clock = constructed.get("clock")
@@ -304,9 +327,7 @@ def _c3_matcher_wrapper(
) )
def _c3_5_adhop_wrapper( def _c3_5_adhop_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
config: Config, constructed: Mapping[str, Any]
) -> Any:
ransac_filter = _require(constructed, "c282_ransac_filter", "c3_5_adhop") ransac_filter = _require(constructed, "c282_ransac_filter", "c3_5_adhop")
inference_runtime = _require(constructed, "c7_inference", "c3_5_adhop") inference_runtime = _require(constructed, "c7_inference", "c3_5_adhop")
clock = constructed.get("clock") clock = constructed.get("clock")
@@ -324,9 +345,7 @@ def _c4_pose_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
ransac_filter = _require(constructed, "c282_ransac_filter", "c4_pose") ransac_filter = _require(constructed, "c282_ransac_filter", "c4_pose")
wgs_converter = _require(constructed, "c5_wgs_converter", "c4_pose") wgs_converter = _require(constructed, "c5_wgs_converter", "c4_pose")
se3_utils = _require(constructed, "c5_se3_utils", "c4_pose") se3_utils = _require(constructed, "c5_se3_utils", "c4_pose")
isam2_graph_handle = _require( isam2_graph_handle = _require(constructed, "c5_isam2_graph_handle", "c4_pose")
constructed, "c5_isam2_graph_handle", "c4_pose"
)
fdr_client = constructed.get("c13_fdr") fdr_client = constructed.get("c13_fdr")
clock = constructed.get("clock") clock = constructed.get("clock")
return build_pose_estimator( return build_pose_estimator(
@@ -341,9 +360,7 @@ def _c4_pose_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
def _c5_state_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any: def _c5_state_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
imu_preintegrator = _require( imu_preintegrator = _require(constructed, "c5_imu_preintegrator", "c5_state")
constructed, "c5_imu_preintegrator", "c5_state"
)
se3_utils = _require(constructed, "c5_se3_utils", "c5_state") se3_utils = _require(constructed, "c5_se3_utils", "c5_state")
wgs_converter = _require(constructed, "c5_wgs_converter", "c5_state") wgs_converter = _require(constructed, "c5_wgs_converter", "c5_state")
fdr_client = _require(constructed, "c13_fdr", "c5_state") fdr_client = _require(constructed, "c13_fdr", "c5_state")
@@ -378,9 +395,7 @@ def _ensure_state_strategy_registered(config: Config) -> None:
block = getattr(config, "components", None) or {} block = getattr(config, "components", None) or {}
c5_block = block.get("c5_state") if isinstance(block, dict) else None c5_block = block.get("c5_state") if isinstance(block, dict) else None
strategy = ( strategy = (
getattr(c5_block, "strategy", "gtsam_isam2") getattr(c5_block, "strategy", "gtsam_isam2") if c5_block is not None else "gtsam_isam2"
if c5_block is not None
else "gtsam_isam2"
) )
# state_factory._STATE_BUILD_FLAGS: gtsam_isam2 defaults ON-when-unset; # state_factory._STATE_BUILD_FLAGS: gtsam_isam2 defaults ON-when-unset;
# eskf defaults OFF-when-unset (mirror state_factory's own logic). # eskf defaults OFF-when-unset (mirror state_factory's own logic).
@@ -420,9 +435,7 @@ descriptor_index, etc.) come from ``pre_constructed``.
""" """
_AIRBORNE_REGISTRATIONS: tuple[ _AIRBORNE_REGISTRATIONS: tuple[tuple[str, tuple[str, ...], Any, tuple[str, ...]], ...] = (
tuple[str, tuple[str, ...], Any, tuple[str, ...]], ...
] = (
("c1_vio", _C1_VIO_STRATEGIES, _c1_vio_wrapper, _C1_VIO_DEPENDS_ON), ("c1_vio", _C1_VIO_STRATEGIES, _c1_vio_wrapper, _C1_VIO_DEPENDS_ON),
("c2_vpr", _C2_VPR_STRATEGIES, _c2_vpr_wrapper, _C2_VPR_DEPENDS_ON), ("c2_vpr", _C2_VPR_STRATEGIES, _c2_vpr_wrapper, _C2_VPR_DEPENDS_ON),
( (
@@ -468,9 +481,7 @@ def _consumers_of_pre_constructed_key(key: str) -> tuple[str, ...]:
) )
def _configured_consumers_of_pre_constructed_key( def _configured_consumers_of_pre_constructed_key(config: Config, key: str) -> tuple[str, ...]:
config: Config, key: str
) -> tuple[str, ...]:
"""Return consumers of ``key`` that are present in ``config.components``. """Return consumers of ``key`` that are present in ``config.components``.
Used to narrow an error message from "every theoretical consumer of Used to narrow an error message from "every theoretical consumer of
@@ -503,9 +514,7 @@ def _build_c6_descriptor_index(config: Config) -> Any:
try: try:
return build_descriptor_index(config) return build_descriptor_index(config)
except RuntimeNotAvailableError as exc: except RuntimeNotAvailableError as exc:
consumers = _configured_consumers_of_pre_constructed_key( consumers = _configured_consumers_of_pre_constructed_key(config, "c6_descriptor_index")
config, "c6_descriptor_index"
)
raise AirborneBootstrapError( raise AirborneBootstrapError(
f"airborne_bootstrap: cannot construct " f"airborne_bootstrap: cannot construct "
f"pre_constructed['c6_descriptor_index'] because " f"pre_constructed['c6_descriptor_index'] because "
@@ -558,12 +567,9 @@ def _build_c7_inference(config: Config) -> Any:
try: try:
return build_inference_runtime(config) return build_inference_runtime(config)
except RuntimeNotAvailableError as exc: except RuntimeNotAvailableError as exc:
consumers = _configured_consumers_of_pre_constructed_key( consumers = _configured_consumers_of_pre_constructed_key(config, "c7_inference")
config, "c7_inference"
)
flag_options = ", ".join( flag_options = ", ".join(
f"{flag}=ON for runtime {runtime!r}" f"{flag}=ON for runtime {runtime!r}" for runtime, flag in C7_AIRBORNE_BUILD_FLAGS
for runtime, flag in C7_AIRBORNE_BUILD_FLAGS
) )
raise AirborneBootstrapError( raise AirborneBootstrapError(
f"airborne_bootstrap: cannot construct " f"airborne_bootstrap: cannot construct "
@@ -630,9 +636,7 @@ def _load_lightglue_engine_handle(
into an :class:`AirborneBootstrapError`). into an :class:`AirborneBootstrapError`).
""" """
block = config.components.get("c3_matcher") block = config.components.get("c3_matcher")
weights_path = ( weights_path = getattr(block, "lightglue_weights_path", None) if block is not None else None
getattr(block, "lightglue_weights_path", None) if block is not None else None
)
if weights_path is None: if weights_path is None:
raise AirborneBootstrapError( raise AirborneBootstrapError(
"airborne_bootstrap: cannot construct " "airborne_bootstrap: cannot construct "
@@ -721,6 +725,185 @@ def _build_c3_lightglue_runtime(
return LightGlueRuntime(engine_handle) return LightGlueRuntime(engine_handle)
def _load_camera_calibration(config: Config) -> CameraCalibration:
"""Read the camera calibration JSON into a :class:`CameraCalibration` DTO.
Mirrors the on-disk JSON shape that
:func:`gps_denied_onboard.runtime_root._replay_branch._load_camera_calibration`
already accepts (same calibration file the live and replay binaries
share). Replicated here \u2014 not imported from ``_replay_branch`` \u2014 because
the replay-branch helper raises ``CompositionError`` (replay-flow
contract) where the airborne bootstrap MUST raise
:class:`AirborneBootstrapError` per the AZ-618 umbrella's
operator-error contract. Both helpers consume the same on-disk
format; any future change to that format MUST land in lockstep
here and in ``_replay_branch.py``.
AZ-623 unit tests monkey-patch this function with a sentinel
:class:`CameraCalibration` so they exercise the
:func:`_build_c5_imu_preintegrator` wiring without an on-disk JSON
file (per the same Tier-2 monkeypatch pattern the AZ-622 builders
use for the heavy LightGlue seam).
"""
import numpy as np
path = config.runtime.camera_calibration_path
if not path:
raise AirborneBootstrapError(
"airborne_bootstrap: cannot construct "
"pre_constructed['c5_imu_preintegrator'] because "
"config.runtime.camera_calibration_path is empty. "
"Consuming component: c5_state. Production main() (AZ-624) "
"must populate the path to the camera calibration JSON; "
"tests stub _load_camera_calibration via monkeypatch."
)
calib_path = Path(path)
try:
blob = json.loads(calib_path.read_text(encoding="utf-8"))
except OSError as exc:
raise AirborneBootstrapError(
f"airborne_bootstrap: cannot construct "
f"pre_constructed['c5_imu_preintegrator'] because the camera "
f"calibration file at {path!r} could not be read: {exc!r}. "
f"Consuming component: c5_state. Ensure "
f"config.runtime.camera_calibration_path points at a readable "
f"JSON file."
) from exc
except json.JSONDecodeError as exc:
raise AirborneBootstrapError(
f"airborne_bootstrap: cannot construct "
f"pre_constructed['c5_imu_preintegrator'] because the camera "
f"calibration file at {path!r} is not valid JSON: {exc!r}. "
f"Consuming component: c5_state. Validate the calibration JSON "
f"shape against the on-disk format documented in "
f"runtime_root._replay_branch._load_camera_calibration."
) from exc
if not isinstance(blob, Mapping):
raise AirborneBootstrapError(
f"airborne_bootstrap: cannot construct "
f"pre_constructed['c5_imu_preintegrator'] because the camera "
f"calibration at {path!r} must decode to a JSON object; got "
f"{type(blob).__name__}. Consuming component: c5_state."
)
intrinsics = np.asarray(blob.get("intrinsics_3x3"), dtype=np.float64)
if intrinsics.shape != (3, 3):
raise AirborneBootstrapError(
f"airborne_bootstrap: cannot construct "
f"pre_constructed['c5_imu_preintegrator'] because the camera "
f"calibration at {path!r} 'intrinsics_3x3' must be 3x3; got "
f"shape {intrinsics.shape}. Consuming component: c5_state."
)
distortion = np.asarray(blob.get("distortion", []), dtype=np.float64)
body_to_camera = np.asarray(
blob.get("body_to_camera_se3", np.eye(4).tolist()),
dtype=np.float64,
)
return CameraCalibration(
camera_id=str(blob.get("camera_id", "airborne-camera")),
intrinsics_3x3=intrinsics,
distortion=distortion,
body_to_camera_se3=body_to_camera,
acquisition_method=str(blob.get("acquisition_method", "operator")),
metadata=dict(blob.get("metadata", {})),
)
def _build_c282_ransac_filter(config: Config) -> RansacFilter:
"""Build ``pre_constructed['c282_ransac_filter']`` for the airborne binary.
:class:`RansacFilter` is a static-only OpenCV wrapper (per AZ-282 /
E-CC-HELPERS); a fresh instance carries no state. Consumers
(``c3_matcher``, ``c3_5_adhop``, ``c4_pose``) dispatch to its static
methods, so identity-share is irrelevant \u2014 AC-623.2 explicitly
permits a fresh instance per :func:`build_pre_constructed` call.
No ``BUILD_*`` flag check applies: the helper is a CPU-only OpenCV
wrapper with no compile-time gate.
"""
del config # placeholder for future config-driven RANSAC variant selection
return RansacFilter()
def _build_c5_imu_preintegrator(config: Config) -> ImuPreintegrator:
"""Build (or retrieve cached) ``pre_constructed['c5_imu_preintegrator']``.
Reads ``config.runtime.camera_calibration_path``, loads the
:class:`CameraCalibration` DTO via :func:`_load_camera_calibration`,
and constructs an :class:`ImuPreintegrator` via
:func:`make_imu_preintegrator`. The preintegrator is cached at module
level keyed by the calibration path \u2014 AC-623.2 requires that two
invocations of :func:`build_pre_constructed` return the SAME
instance for the same path, so the bias / sample accumulator is not
silently reset across re-invocations.
Raises:
AirborneBootstrapError: when ``camera_calibration_path`` is
empty / unreadable / malformed (AC-623.3); message names
both the missing input AND the consuming component slug
``c5_state`` per the AZ-618 umbrella's operator-error
contract.
"""
path = config.runtime.camera_calibration_path
if not path:
raise AirborneBootstrapError(
"airborne_bootstrap: cannot construct "
"pre_constructed['c5_imu_preintegrator'] because "
"config.runtime.camera_calibration_path is empty. "
"Consuming component: c5_state. Production main() (AZ-624) "
"must populate the path before calling build_pre_constructed; "
"tests stub _load_camera_calibration via monkeypatch."
)
cached = _IMU_PREINTEGRATOR_CACHE.get(path)
if cached is not None:
return cached
calibration = _load_camera_calibration(config)
preintegrator = make_imu_preintegrator(calibration)
_IMU_PREINTEGRATOR_CACHE[path] = preintegrator
return preintegrator
def _build_c5_se3_utils(config: Config) -> Any:
"""Build ``pre_constructed['c5_se3_utils']`` for the airborne binary.
Returns the :mod:`gps_denied_onboard.helpers.se3_utils` module
itself as the namespace handle. Python modules support attribute
access for their public names (``exp_map``, ``log_map``,
``matrix_to_se3``, ``se3_to_matrix``, ``adjoint``,
``is_valid_rotation``, ``SE3``); both
:class:`OpenCVGtsamPoseEstimator` and
:class:`GtsamIsam2StateEstimator` store the injected handle as
``self._se3_utils: Any`` and dispatch via attribute access, so the
module satisfies the contract without an extra wrapper class. The
existing C5 unit-test fixtures (e.g.
``tests/unit/c5_state/test_az386_eskf_baseline.py``) inject
``mock.MagicMock()`` for the same slot \u2014 attribute-access shape
matches.
Returning the module also satisfies AC-623.2's caching note
incidentally: Python's import machinery returns the same module
object across calls, so two invocations of
:func:`build_pre_constructed` see the SAME ``c5_se3_utils`` value.
"""
del config # the module-as-namespace selection is config-independent
from gps_denied_onboard.helpers import se3_utils as se3_utils_module
return se3_utils_module
def _build_c5_wgs_converter(config: Config) -> WgsConverter:
"""Build ``pre_constructed['c5_wgs_converter']`` for the airborne binary.
:class:`WgsConverter` is a stateless static-only class (per AZ-279);
a fresh instance carries no module-level state beyond pyproj's
cached transformer pair. Returns ``WgsConverter()`` to match the
same construction pattern :mod:`runtime_root._replay_branch`
already uses (``wgs_converter = WgsConverter()`` at line 205). No
``BUILD_*`` flag check applies.
"""
del config # placeholder for future config-driven coord-system selection
return WgsConverter()
def _build_c3_feature_extractor(config: Config) -> FeatureExtractor: def _build_c3_feature_extractor(config: Config) -> FeatureExtractor:
"""Build ``pre_constructed['c3_feature_extractor']`` for the airborne binary. """Build ``pre_constructed['c3_feature_extractor']`` for the airborne binary.
@@ -756,15 +939,27 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
added the two C6 storage entries (``c6_descriptor_index`` + added the two C6 storage entries (``c6_descriptor_index`` +
``c6_tile_store``). AZ-621 (Phase C) added ``c7_inference`` ``c6_tile_store``). AZ-621 (Phase C) added ``c7_inference``
(PyTorch FP16 vs. TensorRT, gated by (PyTorch FP16 vs. TensorRT, gated by
:data:`C7_AIRBORNE_BUILD_FLAGS`). AZ-622 (Phase D) adds :data:`C7_AIRBORNE_BUILD_FLAGS`). AZ-622 (Phase D) added
``c3_lightglue_runtime`` (single shared ``c3_lightglue_runtime`` (single shared
:class:`gps_denied_onboard.helpers.lightglue_runtime.LightGlueRuntime` :class:`gps_denied_onboard.helpers.lightglue_runtime.LightGlueRuntime`
instance, gated by :data:`C3_MATCHER_BUILD_FLAGS` per the instance, gated by :data:`C3_MATCHER_BUILD_FLAGS` per the
configured strategy) + ``c3_feature_extractor`` (the shared configured strategy) + ``c3_feature_extractor`` (the shared
:class:`gps_denied_onboard.helpers.feature_extractor.FeatureExtractor` :class:`gps_denied_onboard.helpers.feature_extractor.FeatureExtractor`
used by C2.5). Phases E..F (AZ-623..AZ-624) will extend this used by C2.5). AZ-623 (Phase E) adds the four stateless / cached c5
function to populate the remaining keys in helpers: ``c282_ransac_filter`` (shared
:data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`. :class:`gps_denied_onboard.helpers.ransac_filter.RansacFilter`),
``c5_imu_preintegrator`` (per-calibration-path-cached
:class:`gps_denied_onboard.helpers.imu_preintegrator.ImuPreintegrator`),
``c5_se3_utils`` (the
:mod:`gps_denied_onboard.helpers.se3_utils` module as a
namespace handle), and ``c5_wgs_converter`` (shared
:class:`gps_denied_onboard.helpers.wgs_converter.WgsConverter`).
The ``c5_isam2_graph_handle`` slot is the special-case ordering
work tracked separately in AZ-625 (split out of AZ-623 on
2026-05-19 because Path 1 of the AZ-623 spec required a
Protocol seam change forbidden by the AZ-618 umbrella). Phase F
(AZ-624) will wire main() and verify AC-1..AC-5 once both AZ-623
and AZ-625 land.
Returns a fresh dict on each call. The ``c13_fdr`` instance is cached Returns a fresh dict on each call. The ``c13_fdr`` instance is cached
inside :func:`make_fdr_client` (per-producer cache) so two calls within inside :func:`make_fdr_client` (per-producer cache) so two calls within
@@ -776,7 +971,13 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
caching at this layer; the C7 :class:`InferenceRuntime` built for the caching at this layer; the C7 :class:`InferenceRuntime` built for the
``c7_inference`` slot is reused as the engine source for the LightGlue ``c7_inference`` slot is reused as the engine source for the LightGlue
matcher load (AZ-622) so the bootstrap does not double-build the matcher load (AZ-622) so the bootstrap does not double-build the
inference runtime. inference runtime. AZ-623's ``c5_imu_preintegrator`` is cached at module
level (:data:`_IMU_PREINTEGRATOR_CACHE`) keyed by
``config.runtime.camera_calibration_path`` so its bias / sample
accumulator survives a re-invocation. The remaining AZ-623 c5 helpers
are stateless: ``c282_ransac_filter`` and ``c5_wgs_converter`` are
fresh static-only instances; ``c5_se3_utils`` is the
:mod:`gps_denied_onboard.helpers.se3_utils` module.
Replay-mode override: :func:`compose_root` merges ``replay_components`` Replay-mode override: :func:`compose_root` merges ``replay_components``
over ``pre_constructed`` so the :class:`WallClock` here is replaced by over ``pre_constructed`` so the :class:`WallClock` here is replaced by
@@ -794,8 +995,11 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
requires ``c7_inference``; OR if the configured C3 matcher requires ``c7_inference``; OR if the configured C3 matcher
strategy's :data:`C3_MATCHER_BUILD_FLAGS` flag is OFF (or strategy's :data:`C3_MATCHER_BUILD_FLAGS` flag is OFF (or
the strategy is unknown), or if the LightGlue engine load the strategy is unknown), or if the LightGlue engine load
fails. The message names the consuming component slug(s) fails; OR (AZ-623) if ``config.runtime.camera_calibration_path``
and the relevant gating flag(s). is empty / unreadable / malformed JSON, blocking the
``c5_imu_preintegrator`` build. The message names the
consuming component slug(s) and the relevant gating flag(s)
or missing inputs.
""" """
constructed: dict[str, Any] = {} constructed: dict[str, Any] = {}
constructed["c13_fdr"] = make_fdr_client(AIRBORNE_MAIN_PRODUCER_ID, config) constructed["c13_fdr"] = make_fdr_client(AIRBORNE_MAIN_PRODUCER_ID, config)
@@ -807,6 +1011,10 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
config, inference_runtime=constructed["c7_inference"] config, inference_runtime=constructed["c7_inference"]
) )
constructed["c3_feature_extractor"] = _build_c3_feature_extractor(config) constructed["c3_feature_extractor"] = _build_c3_feature_extractor(config)
constructed["c282_ransac_filter"] = _build_c282_ransac_filter(config)
constructed["c5_imu_preintegrator"] = _build_c5_imu_preintegrator(config)
constructed["c5_se3_utils"] = _build_c5_se3_utils(config)
constructed["c5_wgs_converter"] = _build_c5_wgs_converter(config)
return constructed return constructed
@@ -851,8 +1059,7 @@ def register_airborne_strategies() -> None:
"kv": { "kv": {
"slots": [slug for slug, *_ in _AIRBORNE_REGISTRATIONS], "slots": [slug for slug, *_ in _AIRBORNE_REGISTRATIONS],
"total_registrations": sum( "total_registrations": sum(
len(strategies) len(strategies) for _, strategies, *_ in _AIRBORNE_REGISTRATIONS
for _, strategies, *_ in _AIRBORNE_REGISTRATIONS
), ),
}, },
}, },
@@ -59,12 +59,8 @@ def _stub_c6_builders(monkeypatch: pytest.MonkeyPatch) -> None:
# Config() used below would hit KeyError inside storage_factory's # Config() used below would hit KeyError inside storage_factory's
# config.components["c6_tile_cache"] lookup. Sentinel objects are # config.components["c6_tile_cache"] lookup. Sentinel objects are
# opaque on purpose — the AZ-619 assertions never inspect them. # opaque on purpose — the AZ-619 assertions never inspect them.
monkeypatch.setattr( monkeypatch.setattr(airborne_bootstrap, "_build_c6_descriptor_index", lambda _config: object())
airborne_bootstrap, "_build_c6_descriptor_index", lambda _config: object() monkeypatch.setattr(airborne_bootstrap, "_build_c6_tile_store", lambda _config: object())
)
monkeypatch.setattr(
airborne_bootstrap, "_build_c6_tile_store", lambda _config: object()
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -75,9 +71,7 @@ def _stub_c7_inference_builder(monkeypatch: pytest.MonkeyPatch) -> None:
# config.components["c7_inference"] lookup, AND the airborne BUILD_* # config.components["c7_inference"] lookup, AND the airborne BUILD_*
# flags are typically unset in the test env. The sentinel is opaque # flags are typically unset in the test env. The sentinel is opaque
# on purpose — AZ-619 assertions never inspect it. # on purpose — AZ-619 assertions never inspect it.
monkeypatch.setattr( monkeypatch.setattr(airborne_bootstrap, "_build_c7_inference", lambda _config: object())
airborne_bootstrap, "_build_c7_inference", lambda _config: object()
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -93,9 +87,22 @@ def _stub_c3_matcher_builders(monkeypatch: pytest.MonkeyPatch) -> None:
"_build_c3_lightglue_runtime", "_build_c3_lightglue_runtime",
lambda _config, *, inference_runtime: object(), lambda _config, *, inference_runtime: object(),
) )
monkeypatch.setattr( monkeypatch.setattr(airborne_bootstrap, "_build_c3_feature_extractor", lambda _config: object())
airborne_bootstrap, "_build_c3_feature_extractor", lambda _config: object()
)
@pytest.fixture(autouse=True)
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so the AZ-619
# tests stay focused on Phase A. Without this the bare Config() below
# would hit _build_c5_imu_preintegrator's
# config.runtime.camera_calibration_path empty-check and raise
# AirborneBootstrapError before the AC-619 keys could be asserted.
# Sentinels are opaque on purpose — AZ-619 assertions never inspect
# them.
monkeypatch.setattr(airborne_bootstrap, "_build_c282_ransac_filter", lambda _config: object())
monkeypatch.setattr(airborne_bootstrap, "_build_c5_imu_preintegrator", lambda _config: object())
monkeypatch.setattr(airborne_bootstrap, "_build_c5_se3_utils", lambda _config: object())
monkeypatch.setattr(airborne_bootstrap, "_build_c5_wgs_converter", lambda _config: object())
def test_ac_619_1_default_config_seeds_c13_fdr_and_clock() -> None: def test_ac_619_1_default_config_seeds_c13_fdr_and_clock() -> None:
@@ -95,6 +95,37 @@ def _stub_c3_matcher_builders(monkeypatch: pytest.MonkeyPatch) -> None:
) )
@pytest.fixture(autouse=True)
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so AZ-620
# tests stay focused on the Phase B contract. Without this the configs
# used below would hit _build_c5_imu_preintegrator's
# config.runtime.camera_calibration_path empty-check and raise
# AirborneBootstrapError before the AC-620 keys could be asserted.
# MagicMock sentinels are opaque on purpose — AZ-620 assertions
# never inspect them.
monkeypatch.setattr(
airborne_bootstrap,
"_build_c282_ransac_filter",
lambda _config: MagicMock(name="RansacFilter"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_imu_preintegrator",
lambda _config: MagicMock(name="ImuPreintegrator"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_se3_utils",
lambda _config: MagicMock(name="Se3Utils"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_wgs_converter",
lambda _config: MagicMock(name="WgsConverter"),
)
def test_ac_620_1_adds_c6_descriptor_index_and_c6_tile_store( def test_ac_620_1_adds_c6_descriptor_index_and_c6_tile_store(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -98,6 +98,37 @@ def _stub_c3_matcher_builders(monkeypatch: pytest.MonkeyPatch) -> None:
) )
@pytest.fixture(autouse=True)
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so AZ-621
# tests stay focused on the Phase C contract. Without this the configs
# used below would hit _build_c5_imu_preintegrator's
# config.runtime.camera_calibration_path empty-check and raise
# AirborneBootstrapError before the AC-621 keys could be asserted.
# Sentinels are opaque on purpose — AZ-621 assertions never inspect
# them.
monkeypatch.setattr(
airborne_bootstrap,
"_build_c282_ransac_filter",
lambda _config: MagicMock(name="RansacFilter"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_imu_preintegrator",
lambda _config: MagicMock(name="ImuPreintegrator"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_se3_utils",
lambda _config: MagicMock(name="Se3Utils"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_wgs_converter",
lambda _config: MagicMock(name="WgsConverter"),
)
def test_ac_621_1_adds_c7_inference(monkeypatch: pytest.MonkeyPatch) -> None: def test_ac_621_1_adds_c7_inference(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: stub build_inference_runtime to a sentinel so we can # Arrange: stub build_inference_runtime to a sentinel so we can
# assert wiring without standing up real GPU/TensorRT/PyTorch. # assert wiring without standing up real GPU/TensorRT/PyTorch.
@@ -133,9 +164,7 @@ def test_ac_621_2_both_build_flags_off_with_configured_consumer_raises_named_err
"build_inference_runtime", "build_inference_runtime",
_raise_no_c7_runtime_available, _raise_no_c7_runtime_available,
) )
config = Config.with_blocks( config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="disk_lightglue"))
c3_matcher=_C3MatcherBlock(strategy="disk_lightglue")
)
# Act + Assert # Act + Assert
with pytest.raises(AirborneBootstrapError) as excinfo: with pytest.raises(AirborneBootstrapError) as excinfo:
@@ -104,6 +104,35 @@ def _stub_c6_and_c7_builders(monkeypatch: pytest.MonkeyPatch) -> None:
) )
@pytest.fixture(autouse=True)
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so AZ-622
# tests stay focused on the Phase D contract. Without this the configs
# used below would hit _build_c5_imu_preintegrator's
# config.runtime.camera_calibration_path empty-check and raise
# AirborneBootstrapError before the AC-622 keys could be asserted.
monkeypatch.setattr(
airborne_bootstrap,
"_build_c282_ransac_filter",
lambda _config: MagicMock(name="RansacFilter"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_imu_preintegrator",
lambda _config: MagicMock(name="ImuPreintegrator"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_se3_utils",
lambda _config: MagicMock(name="Se3Utils"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c5_wgs_converter",
lambda _config: MagicMock(name="WgsConverter"),
)
def test_ac_622_1_adds_c3_lightglue_runtime_and_c3_feature_extractor( def test_ac_622_1_adds_c3_lightglue_runtime_and_c3_feature_extractor(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -130,10 +159,7 @@ def test_ac_622_1_adds_c3_lightglue_runtime_and_c3_feature_extractor(
assert "c3_lightglue_runtime" in pre_constructed assert "c3_lightglue_runtime" in pre_constructed
assert "c3_feature_extractor" in pre_constructed assert "c3_feature_extractor" in pre_constructed
assert isinstance(pre_constructed["c3_lightglue_runtime"], LightGlueRuntime) assert isinstance(pre_constructed["c3_lightglue_runtime"], LightGlueRuntime)
assert ( assert pre_constructed["c3_lightglue_runtime"].descriptor_dim() == engine_handle.descriptor_dim
pre_constructed["c3_lightglue_runtime"].descriptor_dim()
== engine_handle.descriptor_dim
)
assert isinstance(pre_constructed["c3_feature_extractor"], FeatureExtractor) assert isinstance(pre_constructed["c3_feature_extractor"], FeatureExtractor)
assert isinstance(pre_constructed["c3_feature_extractor"], OpenCvOrbExtractor) assert isinstance(pre_constructed["c3_feature_extractor"], OpenCvOrbExtractor)
assert { assert {
@@ -154,9 +180,7 @@ def test_ac_622_2_build_flag_off_with_configured_strategy_raises_named_error(
# fire BEFORE _load_lightglue_engine_handle is consulted, so we don't # fire BEFORE _load_lightglue_engine_handle is consulted, so we don't
# need to stub the loader for this branch. # need to stub the loader for this branch.
monkeypatch.delenv("BUILD_MATCHER_DISK_LIGHTGLUE", raising=False) monkeypatch.delenv("BUILD_MATCHER_DISK_LIGHTGLUE", raising=False)
config = Config.with_blocks( config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="disk_lightglue"))
c3_matcher=_C3MatcherBlock(strategy="disk_lightglue")
)
# Act + Assert # Act + Assert
with pytest.raises(AirborneBootstrapError) as excinfo: with pytest.raises(AirborneBootstrapError) as excinfo:
@@ -168,9 +192,7 @@ def test_ac_622_2_build_flag_off_with_configured_strategy_raises_named_error(
# AC-622.2 + AZ-618 NFR "operator-facing error contract"). # AC-622.2 + AZ-618 NFR "operator-facing error contract").
assert "c3_lightglue_runtime" in message assert "c3_lightglue_runtime" in message
expected_flag = C3_MATCHER_BUILD_FLAGS["disk_lightglue"] expected_flag = C3_MATCHER_BUILD_FLAGS["disk_lightglue"]
assert expected_flag in message, ( assert expected_flag in message, f"{expected_flag!r} missing from error: {message!r}"
f"{expected_flag!r} missing from error: {message!r}"
)
assert "c3_matcher" in message assert "c3_matcher" in message
# The flag-OFF branch raises directly — there is no upstream cause to # The flag-OFF branch raises directly — there is no upstream cause to
# preserve (cause-chain preservation is exercised in # preserve (cause-chain preservation is exercised in
@@ -184,9 +206,7 @@ def test_ac_622_2_build_flag_off_with_aliked_strategy_names_aliked_flag(
"""Per-strategy flag specificity: aliked_lightglue surfaces ALIKED's flag.""" """Per-strategy flag specificity: aliked_lightglue surfaces ALIKED's flag."""
# Arrange: ALIKED strategy with its own gating flag OFF. # Arrange: ALIKED strategy with its own gating flag OFF.
monkeypatch.delenv("BUILD_MATCHER_ALIKED_LIGHTGLUE", raising=False) monkeypatch.delenv("BUILD_MATCHER_ALIKED_LIGHTGLUE", raising=False)
config = Config.with_blocks( config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="aliked_lightglue"))
c3_matcher=_C3MatcherBlock(strategy="aliked_lightglue")
)
# Act + Assert # Act + Assert
with pytest.raises(AirborneBootstrapError) as excinfo: with pytest.raises(AirborneBootstrapError) as excinfo:
@@ -245,9 +265,7 @@ def test_ac_622_2_lightglue_engine_load_failure_wraps_runtime_error(
"_load_lightglue_engine_handle", "_load_lightglue_engine_handle",
_raise_engine_load_failure, _raise_engine_load_failure,
) )
config = Config.with_blocks( config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="disk_lightglue"))
c3_matcher=_C3MatcherBlock(strategy="disk_lightglue")
)
# Act + Assert # Act + Assert
with pytest.raises(AirborneBootstrapError) as excinfo: with pytest.raises(AirborneBootstrapError) as excinfo:
@@ -281,9 +299,7 @@ def test_lightglue_runtime_uses_c7_inference_from_pre_constructed(
captured["inference_runtime"] = inference_runtime captured["inference_runtime"] = inference_runtime
return _make_engine_handle_mock(descriptor_dim=128) return _make_engine_handle_mock(descriptor_dim=128)
monkeypatch.setattr( monkeypatch.setattr(airborne_bootstrap, "_load_lightglue_engine_handle", _capture_loader)
airborne_bootstrap, "_load_lightglue_engine_handle", _capture_loader
)
config = Config() config = Config()
# Act # Act
@@ -0,0 +1,301 @@
"""AZ-623 — Phase E of AZ-618: ``build_pre_constructed`` seeds c282_ransac_filter + 3 c5 helpers.
Verifies the contract at
``_docs/02_tasks/todo/AZ-623_pre_constructed_phase_e_ransac_c5_helpers.md``
(scope-narrowed 2026-05-19; ``c5_isam2_graph_handle`` deferred to AZ-625):
* AC-623.1: ``build_pre_constructed(default_config)`` adds
``c282_ransac_filter`` (a :class:`RansacFilter` instance),
``c5_imu_preintegrator`` (an :class:`ImuPreintegrator` instance),
``c5_se3_utils`` (the :mod:`gps_denied_onboard.helpers.se3_utils`
module), and ``c5_wgs_converter`` (a :class:`WgsConverter` instance)
on top of AZ-619..AZ-622.
* AC-623.2: invoking twice in the same process produces dicts where
``c5_imu_preintegrator`` is the SAME instance both calls (cached by
``config.runtime.camera_calibration_path``); the 3 stateless helpers
may be either fresh or cached.
* AC-623.3: when ``config.runtime.camera_calibration_path`` is empty or
unreadable, ``_build_c5_imu_preintegrator`` raises
:class:`AirborneBootstrapError` whose message names the missing input
AND the consuming component slug ``c5_state``.
AC-623.4 (this file exists with the above tests) is satisfied by the
existence of this module.
The tests stub the heavy ``_load_camera_calibration`` seam so they
exercise the helper-wiring contract without standing up an on-disk
calibration JSON. The upstream AZ-619..AZ-622 builders are stubbed at
the airborne_bootstrap module boundary, mirroring the prior phase
pattern (see :mod:`tests.unit.runtime_root.test_az622_pre_constructed_phase_d`).
"""
from __future__ import annotations
import dataclasses
from collections.abc import Iterator
from pathlib import Path
from unittest.mock import MagicMock
import numpy as np
import pytest
from gps_denied_onboard._types.calibration import CameraCalibration
from gps_denied_onboard.config import Config
from gps_denied_onboard.fdr_client import client as fdr_client_module
from gps_denied_onboard.helpers import se3_utils as se3_utils_module
from gps_denied_onboard.helpers.imu_preintegrator import ImuPreintegrator
from gps_denied_onboard.helpers.ransac_filter import RansacFilter
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
from gps_denied_onboard.runtime_root import airborne_bootstrap
from gps_denied_onboard.runtime_root.airborne_bootstrap import (
AirborneBootstrapError,
build_pre_constructed,
clear_imu_preintegrator_cache,
)
def _config_with_calibration_path(path: str) -> Config:
"""Return a fresh ``Config`` whose ``runtime.camera_calibration_path`` is set.
``RuntimeConfig`` is a frozen dataclass; mutate via
:func:`dataclasses.replace` rather than ``object.__setattr__``.
"""
base = Config()
runtime = dataclasses.replace(base.runtime, camera_calibration_path=path)
return dataclasses.replace(base, runtime=runtime)
def _make_calibration(camera_id: str = "az623-test-camera") -> CameraCalibration:
"""Sentinel CameraCalibration matching the C5 estimator's expected shape.
The bias / sample accumulator inside :class:`ImuPreintegrator` only
needs intrinsics-shape inputs to be present; AZ-623 tests do not
integrate samples, so the contents are irrelevant beyond passing
:func:`make_imu_preintegrator`'s own internal validators.
"""
return CameraCalibration(
camera_id=camera_id,
intrinsics_3x3=np.eye(3, dtype=np.float64),
distortion=np.zeros(5, dtype=np.float64),
body_to_camera_se3=np.eye(4, dtype=np.float64),
acquisition_method="operator",
metadata={},
)
@pytest.fixture(autouse=True)
def _isolated_caches() -> Iterator[None]:
# Arrange: every test starts with empty FdrClient cache + empty
# ImuPreintegrator cache so AC-623.2's "same instance across calls"
# assertion is exercised against fresh state.
fdr_client_module._reset_for_tests()
clear_imu_preintegrator_cache()
yield
fdr_client_module._reset_for_tests()
clear_imu_preintegrator_cache()
@pytest.fixture(autouse=True)
def _stub_az619_to_az622_builders(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: stub the AZ-619 (clock seeded by build_pre_constructed
# directly via WallClock — no builder), AZ-620 (Phase B) C6 builders,
# AZ-621 (Phase C) C7 inference builder, and AZ-622 (Phase D) C3
# builders so AZ-623 stays focused on the Phase E contract. Sentinels
# are opaque on purpose — AZ-623 assertions never inspect them.
monkeypatch.setattr(
airborne_bootstrap,
"_build_c6_descriptor_index",
lambda _config: MagicMock(name="DescriptorIndex"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c6_tile_store",
lambda _config: MagicMock(name="TileStore"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c7_inference",
lambda _config: MagicMock(name="InferenceRuntime"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c3_lightglue_runtime",
lambda _config, *, inference_runtime: MagicMock(name="LightGlueRuntime"),
)
monkeypatch.setattr(
airborne_bootstrap,
"_build_c3_feature_extractor",
lambda _config: MagicMock(name="FeatureExtractor"),
)
def test_ac_623_1_adds_c282_ransac_and_c5_helpers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange: a Config with a populated camera_calibration_path so
# _build_c5_imu_preintegrator's empty-check passes. Stub
# _load_camera_calibration to return a sentinel calibration so the
# bootstrap does not touch the filesystem.
config = _config_with_calibration_path("/tmp/az623-fixture-calib.json")
monkeypatch.setattr(
airborne_bootstrap,
"_load_camera_calibration",
lambda _config: _make_calibration(),
)
# Act
pre_constructed = build_pre_constructed(config)
# Assert: each new key is present and typed per AC-623.1.
assert "c282_ransac_filter" in pre_constructed
assert isinstance(pre_constructed["c282_ransac_filter"], RansacFilter)
assert "c5_imu_preintegrator" in pre_constructed
assert isinstance(pre_constructed["c5_imu_preintegrator"], ImuPreintegrator)
assert "c5_se3_utils" in pre_constructed
se3_utils = pre_constructed["c5_se3_utils"]
# The se3_utils handle is the helpers.se3_utils module — a module is
# the simplest namespace object that exposes exp_map, log_map etc.
# as attributes (matches the MagicMock fixture pattern existing C5
# tests already use).
assert se3_utils is se3_utils_module
for func_name in ("exp_map", "log_map", "matrix_to_se3", "se3_to_matrix"):
assert hasattr(se3_utils, func_name), (
f"c5_se3_utils handle is missing {func_name!r}; "
f"consumers (build_state_estimator, build_pose_estimator) "
f"dispatch via attribute access."
)
assert "c5_wgs_converter" in pre_constructed
assert isinstance(pre_constructed["c5_wgs_converter"], WgsConverter)
def test_ac_623_1_keeps_existing_keys_intact(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange
config = _config_with_calibration_path("/tmp/az623-fixture-calib.json")
monkeypatch.setattr(
airborne_bootstrap,
"_load_camera_calibration",
lambda _config: _make_calibration(),
)
# Act
pre_constructed = build_pre_constructed(config)
# Assert: AZ-619..AZ-622 keys remain populated; AZ-623 is additive.
assert {
"c13_fdr",
"clock",
"c6_descriptor_index",
"c6_tile_store",
"c7_inference",
"c3_lightglue_runtime",
"c3_feature_extractor",
}.issubset(pre_constructed.keys()), (
f"AZ-623 must be additive on top of AZ-619..AZ-622; got "
f"keys: {sorted(pre_constructed.keys())}"
)
def test_ac_623_2_imu_preintegrator_cached_across_calls(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange: stub the calibration loader with a counter so we can
# assert the cache short-circuit fires on the second call.
config = _config_with_calibration_path("/tmp/az623-cache-fixture.json")
load_count = {"n": 0}
def _counting_load(_config: Config) -> CameraCalibration:
load_count["n"] += 1
return _make_calibration()
monkeypatch.setattr(airborne_bootstrap, "_load_camera_calibration", _counting_load)
# Act
first = build_pre_constructed(config)
second = build_pre_constructed(config)
# Assert: cache holds the same ImuPreintegrator instance; the
# calibration loader fires exactly once across the two calls.
assert first["c5_imu_preintegrator"] is second["c5_imu_preintegrator"], (
"AC-623.2 requires c5_imu_preintegrator to be the same instance "
"across two build_pre_constructed calls (cache keyed on "
"camera_calibration_path)"
)
assert load_count["n"] == 1, (
f"calibration loader should fire once (cached on second call); "
f"got {load_count['n']} invocations"
)
def test_ac_623_2_imu_preintegrator_per_path_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange: two configs with DIFFERENT calibration paths. The cache
# must produce DIFFERENT preintegrator instances (one per path).
config_a = _config_with_calibration_path("/tmp/az623-path-a.json")
config_b = _config_with_calibration_path("/tmp/az623-path-b.json")
monkeypatch.setattr(
airborne_bootstrap,
"_load_camera_calibration",
lambda _config: _make_calibration(),
)
# Act
a = build_pre_constructed(config_a)
b = build_pre_constructed(config_b)
# Assert
assert a["c5_imu_preintegrator"] is not b["c5_imu_preintegrator"], (
"Two distinct calibration paths must produce two distinct "
"ImuPreintegrator instances; the cache key is the path."
)
def test_ac_623_3_empty_calibration_path_raises_named_error() -> None:
# Arrange: default Config() has camera_calibration_path="".
config = Config()
# Act + Assert
with pytest.raises(AirborneBootstrapError) as exc_info:
build_pre_constructed(config)
msg = str(exc_info.value)
assert "c5_imu_preintegrator" in msg, msg
assert "camera_calibration_path" in msg, msg
assert "c5_state" in msg, msg
def test_ac_623_3_unreadable_calibration_path_raises_named_error(
tmp_path: Path,
) -> None:
# Arrange: pass a path that does not exist on disk; the OSError
# surfaces as AirborneBootstrapError with the operator-actionable
# message.
missing = tmp_path / "this-file-does-not-exist.json"
config = _config_with_calibration_path(str(missing))
# Act + Assert
with pytest.raises(AirborneBootstrapError) as exc_info:
build_pre_constructed(config)
msg = str(exc_info.value)
assert "c5_imu_preintegrator" in msg
assert "c5_state" in msg
assert str(missing) in msg
def test_ac_623_3_malformed_json_calibration_raises_named_error(
tmp_path: Path,
) -> None:
# Arrange
bad = tmp_path / "bad-calib.json"
bad.write_text("{ this is not valid json", encoding="utf-8")
config = _config_with_calibration_path(str(bad))
# Act + Assert
with pytest.raises(AirborneBootstrapError) as exc_info:
build_pre_constructed(config)
msg = str(exc_info.value)
assert "c5_imu_preintegrator" in msg
assert "c5_state" in msg
assert "not valid JSON" in msg