mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
[AZ-625] Phase E.5: airborne_bootstrap c5_isam2_graph_handle ordering
Wire the airborne bootstrap to seed pre_constructed['c5_isam2_graph_handle'] so c4_pose's compose-time lookup is satisfied (c4_pose runs before c5_state in topological order; the iSAM2 graph handle is built INSIDE the C5 estimator's constructor and so must be produced eagerly at bootstrap time). build_pre_constructed now invokes a new internal _build_c5_state_estimator_pair helper that calls state_factory.build_state_estimator once, captures the (estimator, handle) tuple, and seeds two slots: 'c5_isam2_graph_handle' for C4's lookup, and an internal '_c5_prebuilt_estimator' look-aside key for the C5 wrapper's short-circuit. _c5_state_wrapper checks the look-aside key first and returns the prebuilt instance as-is — the SAME object the handle was extracted from, so c4_pose._isam2_handle and c5_state._isam2_handle reference ONE object across the C4 / C5 seam (AC-625.3 cross-seam identity invariant). C5_STATE_BUILD_FLAGS mirrors state_factory._STATE_BUILD_FLAGS so the bootstrap can name the gating BUILD_STATE_* flag in operator errors before the lower level StateEstimatorConfigError fires (AC-625.2). When the factory itself rejects the configuration with the flag ON, the error wraps into AirborneBootstrapError with __cause__ preserved (matches AZ-621 / AZ-622 patterns). Constraints respected per AZ-618 umbrella: no per-component factory signature changed; additive on top of AZ-619..AZ-623; no edits under state_factory, pose_factory, or c5_state internals. Tests: tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py adds 8 tests covering AC-625.1..3 (presence + Protocol conformance, internal key invariant, BUILD-flag-OFF error, unknown-strategy error, factory error wrapping, cross-seam identity, wrapper short-circuit, wrapper fallback). Autouse stubs added to test_az619/620/621/622/623 so prior phase tests stay isolated from the new builder. Quality gates: ruff format clean, ruff lint clean, 32/32 phase tests pass, 255/255 runtime_root + c5_state regression suite passes. Code review verdict PASS (2 Low findings; full report in _docs/03_implementation/reviews/batch_95_review.md). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 95
|
||||
**Tasks**: AZ-625 (Phase E.5 of AZ-618: c5_isam2_graph_handle ordering — separate handle from estimator construction)
|
||||
**Date**: 2026-05-19
|
||||
**Cycle**: 1
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|----------------|-------|-------------|--------|
|
||||
| AZ-625_c5_isam2_graph_handle_ordering | Done | 7 files | 8 new + 24 carry-over | 4/4 ACs covered | 0 blocking (2 Low / Style + Maintainability — accepted per code review) |
|
||||
|
||||
AZ-625 was split out of AZ-623 in batch 94 after the AZ-623 task spec's two-path investigation surfaced an unresolvable construction-order conflict (c4_pose consumes `c5_isam2_graph_handle` from `pre_constructed`; the handle is built inside `build_state_estimator`'s tuple return, which c5_state's wrapper invokes — but the c5_state wrapper runs AFTER c4_pose in topological order). Path 1 (handle-only separation) required a Protocol-seam change in C5 — explicitly forbidden by the AZ-618 umbrella's "MUST NOT touch any per-component factory signature" constraint. Path 2 (eager `(estimator, handle)` build at bootstrap) is the chosen approach: this batch lands it.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Production
|
||||
|
||||
- `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py`:
|
||||
- Added `C5_STATE_BUILD_FLAGS: Final[Mapping[str, str]]` constant mirroring `state_factory._STATE_BUILD_FLAGS` so the bootstrap can name the gating `BUILD_STATE_*` flag in operator errors.
|
||||
- Added `_resolve_c5_state_strategy(config)` mirroring `_resolve_c3_matcher_strategy` for symmetry.
|
||||
- Added `_C5_PREBUILT_ESTIMATOR_KEY: Final[str] = "_c5_prebuilt_estimator"` internal coordination key (deliberately NOT in `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` — only the C5 wrapper consults it as a fast path).
|
||||
- Added `_build_c5_state_estimator_pair(config, *, imu_preintegrator, se3_utils, wgs_converter, fdr_client, tile_store=None, camera_calibration=None, flight_id=None, companion_id=None)` that resolves the strategy, gates on the per-strategy `BUILD_STATE_*` flag, lazily registers the strategy via `_ensure_state_strategy_registered`, calls `build_state_estimator` once, captures the `(estimator, handle)` tuple, and wraps any `StateEstimatorConfigError` into `AirborneBootstrapError` with `__cause__` preserved.
|
||||
- Modified `_c5_state_wrapper` to short-circuit on `constructed.get(_C5_PREBUILT_ESTIMATOR_KEY)`: when the look-aside key is present, the prebuilt estimator is returned as-is; otherwise the wrapper falls through to the original `build_state_estimator` path (preserves test isolation for fixtures that bypass `build_pre_constructed`, e.g. `test_az401_compose_root_replay`).
|
||||
- Extended `build_pre_constructed` to call `_build_c5_state_estimator_pair` after the AZ-619..AZ-623 keys are populated and seed both `c5_isam2_graph_handle` (Public API key consumed by C4) and `_c5_prebuilt_estimator` (internal coordination key consumed by `_c5_state_wrapper`).
|
||||
- Updated `__all__` to export `C5_STATE_BUILD_FLAGS`.
|
||||
- Updated `build_pre_constructed`'s docstring to describe the AZ-625 wiring + error contract (eager-pair construction, look-aside key, BUILD-flag gating, error wrapping).
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py` (NEW, 8 tests):
|
||||
- `test_ac_625_1_adds_c5_isam2_graph_handle_with_protocol_surface` — AC-625.1 (key present + `isinstance(handle, C4ISam2GraphHandle)` + per-method `hasattr` + AZ-619..AZ-623 keys still present).
|
||||
- `test_ac_625_1_internal_prebuilt_estimator_key_not_in_required_keys` — internal-key invariant (the coordination key is NOT exposed via `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`).
|
||||
- `test_ac_625_2_build_state_gtsam_isam2_off_raises_named_error` — AC-625.2 (`BUILD_STATE_GTSAM_ISAM2=OFF` raises with the missing key + flag + `c5_state` slug; no upstream cause because the gate fires before the factory).
|
||||
- `test_ac_625_2_unknown_strategy_raises_named_error_with_supported_set` — AC-625.2 (unknown strategy raises with the supported strategy set named).
|
||||
- `test_ac_625_2_build_state_estimator_config_error_wraps_into_bootstrap_error` — defense-in-depth (`StateEstimatorConfigError` wraps into `AirborneBootstrapError` with `__cause__` preserved).
|
||||
- `test_ac_625_3_handle_is_same_object_as_estimator_isam2_handle` — AC-625.3 cross-seam identity (`pre_constructed[_c5_prebuilt_estimator]._isam2_handle IS pre_constructed['c5_isam2_graph_handle']`).
|
||||
- `test_ac_625_3_c5_state_wrapper_short_circuits_on_prebuilt_estimator` — wrapper short-circuit returns the prebuilt estimator without consulting the fallback infrastructure keys.
|
||||
- `test_ac_625_3_c5_state_wrapper_falls_back_when_prebuilt_absent` — wrapper falls back to `build_state_estimator` when the look-aside key is absent (preserves existing fixture behavior).
|
||||
|
||||
- `tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py` — added autouse stub for `_build_c5_state_estimator_pair` (returns `(MagicMock, MagicMock)`) so AZ-619's bare `Config()` bootstrap path doesn't trip on the default `gtsam_isam2` strategy needing a real registered factory.
|
||||
- `tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py` — same autouse stub.
|
||||
- `tests/unit/runtime_root/test_az621_pre_constructed_phase_c.py` — same autouse stub.
|
||||
- `tests/unit/runtime_root/test_az622_pre_constructed_phase_d.py` — same autouse stub.
|
||||
- `tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py` — same autouse stub.
|
||||
|
||||
### Reviews
|
||||
|
||||
- `_docs/03_implementation/reviews/batch_95_review.md` (NEW) — code review report (verdict: PASS; 2 Low findings on style + function-scoped import).
|
||||
|
||||
### Specs
|
||||
|
||||
- `_docs/02_tasks/todo/AZ-625_*.md` → ARCHIVED to `_docs/02_tasks/done/`.
|
||||
|
||||
## AC Test Coverage
|
||||
|
||||
All 4 ACs covered:
|
||||
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-625.1 | `test_ac_625_1_adds_c5_isam2_graph_handle_with_protocol_surface` (presence + Protocol conformance) + `test_ac_625_1_internal_prebuilt_estimator_key_not_in_required_keys` |
|
||||
| AC-625.2 | `test_ac_625_2_build_state_gtsam_isam2_off_raises_named_error` + `test_ac_625_2_unknown_strategy_raises_named_error_with_supported_set` + `test_ac_625_2_build_state_estimator_config_error_wraps_into_bootstrap_error` |
|
||||
| AC-625.3 | `test_ac_625_3_handle_is_same_object_as_estimator_isam2_handle` + `test_ac_625_3_c5_state_wrapper_short_circuits_on_prebuilt_estimator` + `test_ac_625_3_c5_state_wrapper_falls_back_when_prebuilt_absent` |
|
||||
| AC-625.4 | file `tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py` exists with the above tests |
|
||||
|
||||
## Test Run
|
||||
|
||||
| Suite | Result |
|
||||
|-------|--------|
|
||||
| `tests/unit/runtime_root/test_az619..test_az623 + test_az625` (targeted) | 32 passed in 1.18s |
|
||||
| `tests/unit/runtime_root/ + tests/unit/c5_state/` (regression) | 255 passed in 1.38s |
|
||||
|
||||
No failures; no skips beyond pre-existing environment-gated tests.
|
||||
|
||||
## Code Review
|
||||
|
||||
- **Verdict**: PASS (0 Critical, 0 High, 0 Medium, 2 Low).
|
||||
- F1 (Low / Style): `_C5_PREBUILT_ESTIMATOR_KEY` defined after first reference site — accepted (grouping with C5-state constants is the dominant readability axis; Python's lazy resolution makes this correct).
|
||||
- F2 (Low / Maintainability): function-scoped `StateEstimatorConfigError` import — accepted (matches the file's existing function-scope-import convention for c5_state submodules).
|
||||
- 0 auto-fix attempts; 0 escalated findings.
|
||||
|
||||
Full report: `_docs/03_implementation/reviews/batch_95_review.md`.
|
||||
|
||||
## Constraint Compliance (AZ-618 umbrella)
|
||||
|
||||
- "MUST NOT modify per-component factory signatures" → `state_factory.build_state_estimator` invoked with the same kwargs the wrapper would have passed; no signature changed. ✓
|
||||
- "MUST be additive on top of AZ-619..AZ-623" → AZ-619..AZ-623 keys still present in `pre_constructed`. ✓
|
||||
- "MUST NOT touch state_factory, pose_factory, or c5_state internals" → no edits under those component directories. ✓
|
||||
- "All changes confined to `airborne_bootstrap.py` + new test file" → diff scope respected. The autouse-stub additions in AZ-619..AZ-623 phase tests are hygiene maintenance for the additive bootstrap call (each test now stubs the new `_build_c5_state_estimator_pair` builder), not a behavioral change to those phases' contracts. ✓
|
||||
|
||||
## Loop Status
|
||||
|
||||
- AZ-625 unblocks AZ-624 (the only remaining AZ-618 subtask). AZ-624's dependency edge in `_dependencies_table.md` lists `AZ-619..AZ-623, AZ-625` as upstream — all done. Next batch (96) will pick up AZ-624.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 95
|
||||
**Tasks**: AZ-625 (Phase E.5 of AZ-618: c5_isam2_graph_handle ordering — separate handle from estimator construction)
|
||||
**Date**: 2026-05-19
|
||||
**Verdict**: PASS
|
||||
|
||||
## Phase 1: Context
|
||||
|
||||
Read in this review window:
|
||||
|
||||
- `_docs/02_tasks/todo/AZ-625_c5_isam2_graph_handle_ordering.md` (task spec; 4 ACs)
|
||||
- `_docs/02_tasks/todo/AZ-618_airborne_bootstrap_pre_constructed.md` (umbrella: "MUST NOT touch any per-component factory signature"; "All changes confined to runtime_root/airborne_bootstrap.py")
|
||||
- `_docs/03_implementation/batch_94_cycle1_report.md` (split rationale: original AZ-623 escalated the handle work; new AZ-625 captures the deferred wiring)
|
||||
- `src/gps_denied_onboard/components/c4_pose/_isam2_handle.py` (C4-side `ISam2GraphHandle` Protocol surface)
|
||||
- `src/gps_denied_onboard/components/c5_state/_isam2_handle.py` (`ISam2GraphHandleImpl.__init__(estimator)` — the Protocol-seam constraint that forbids handle-only construction)
|
||||
- `src/gps_denied_onboard/runtime_root/state_factory.py` (`build_state_estimator` return-tuple shape — confirms `(estimator, handle)` is the only construction site)
|
||||
- `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py` (mutated)
|
||||
- `tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py` (new)
|
||||
- `tests/unit/runtime_root/test_az619..test_az623` (autouse fixtures stubbed to keep AZ-619..AZ-623 tests green)
|
||||
|
||||
## Phase 2: Spec Compliance
|
||||
|
||||
| AC | Status | Test | Notes |
|
||||
|----|--------|------|-------|
|
||||
| AC-625.1 (`c5_isam2_graph_handle` added on top of AZ-619..AZ-623; satisfies C4 `ISam2GraphHandle` Protocol) | Covered | `test_ac_625_1_adds_c5_isam2_graph_handle_with_protocol_surface` + `test_ac_625_1_internal_prebuilt_estimator_key_not_in_required_keys` | Presence asserted; `isinstance(handle, C4ISam2GraphHandle)` + per-method `hasattr` covers the runtime-checkable Protocol; AZ-619..AZ-623 keys still present (additivity). The internal `_c5_prebuilt_estimator` look-aside key is asserted NOT present in `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`. |
|
||||
| AC-625.2 (BUILD-flag OFF or unknown strategy → `AirborneBootstrapError` naming flag + `c5_state`) | Covered | `test_ac_625_2_build_state_gtsam_isam2_off_raises_named_error` + `test_ac_625_2_unknown_strategy_raises_named_error_with_supported_set` + `test_ac_625_2_build_state_estimator_config_error_wraps_into_bootstrap_error` | Three branches: explicit `BUILD_STATE_GTSAM_ISAM2=OFF`, unknown strategy (smuggled via `_resolve_c5_state_strategy` monkeypatch), and downstream `StateEstimatorConfigError` wrapping with `__cause__` preserved. Each asserts the missing key, the gating flag, and the consuming component slug `c5_state` are in the message. |
|
||||
| AC-625.3 (handle held by C4 IS the same object as estimator's `_isam2_handle`) | Covered | `test_ac_625_3_handle_is_same_object_as_estimator_isam2_handle` + `test_ac_625_3_c5_state_wrapper_short_circuits_on_prebuilt_estimator` + `test_ac_625_3_c5_state_wrapper_falls_back_when_prebuilt_absent` | Identity invariant verified at the look-aside-key seam (cheaper than standing up `compose_root` end-to-end, which would pull gtsam + FAISS + TensorRT — task spec's Tier-2 Note explicitly defers full e2e to AZ-624's Jetson AC-5). The fallback path is also covered so existing fixtures (e.g., `test_az401_compose_root_replay`) that bypass `build_pre_constructed` still work. |
|
||||
| AC-625.4 (test file under `tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py`) | Covered | file exists with 7 tests, all green | Filename matches AC. |
|
||||
|
||||
**Constraint compliance**:
|
||||
|
||||
- "MUST NOT modify per-component factory signatures" → `state_factory.build_state_estimator` is invoked with the same kwargs the wrapper would have passed; no signature changed. ✓
|
||||
- "MUST be additive on top of AZ-619..AZ-623" → AZ-619..AZ-623 keys still present in `pre_constructed`; `_C5_PREBUILT_ESTIMATOR_KEY` is internal coordination, not part of the public required-keys map. ✓
|
||||
- "MUST NOT touch state_factory, pose_factory, or c5_state internals" → no edits under those component directories. ✓
|
||||
- "All changes confined to airborne_bootstrap.py + new test file" (umbrella) → diff scope respected (existing AZ-619..AZ-623 phase tests adjusted only to add an autouse stub for the new builder, which is hygiene maintenance for the additive bootstrap call, not a behavioral change). ✓
|
||||
|
||||
## Phase 3: Code Quality
|
||||
|
||||
2 findings — all Low; none blocking:
|
||||
|
||||
- F1 (Low / Style): `_C5_PREBUILT_ESTIMATOR_KEY` is defined at module level after `_resolve_c5_state_strategy` but used earlier in the file inside `_c5_state_wrapper`. Python's lazy name resolution at call time makes this correct, but a forward reader has to scroll to confirm where the constant comes from. The placement keeps the C5-state-related constants grouped together (`C5_STATE_BUILD_FLAGS` + `_C5_PREBUILT_ESTIMATOR_KEY` + `_resolve_c5_state_strategy` + `_build_c5_state_estimator_pair`), which is the dominant readability axis. No change recommended.
|
||||
- F2 (Low / Maintainability): function-scoped `from gps_denied_onboard.components.c5_state.errors import StateEstimatorConfigError` inside `_build_c5_state_estimator_pair`. Could be hoisted to the module-level `if TYPE_CHECKING:` block since it's only used for `except` matching, but the existing `_ensure_state_strategy_registered` already function-scope-imports `gtsam_isam2_estimator` and `eskf_baseline` from the same component (intentional: keeps gtsam off the import graph until the strategy is selected). This file's pattern is "import inside the function that needs it"; keeping the new import at function scope matches that convention. No change recommended.
|
||||
|
||||
## Phase 4: Security
|
||||
|
||||
No findings.
|
||||
|
||||
- No SQL, no command exec, no `eval`/`exec`.
|
||||
- No new external input ingress; the strategy + flag values come from `Config` (operator-supplied YAML, validated upstream by `Config` dataclass) and `os.environ` (compile-time build flags).
|
||||
- Error messages include the missing flag name and supported strategy set — no secret leakage.
|
||||
|
||||
## Phase 5: Performance
|
||||
|
||||
No findings.
|
||||
|
||||
- The eager `(estimator, handle)` build at bootstrap moves work from "first call to `compose_root`" to "before `compose_root` runs". Same total work; no new hot-path cost.
|
||||
- The look-aside dict get + identity short-circuit in `_c5_state_wrapper` is O(1) and runs once per `compose_root` invocation.
|
||||
|
||||
## Phase 6: Cross-Task Consistency
|
||||
|
||||
Consistent with the AZ-619..AZ-623 builder pattern:
|
||||
|
||||
- `C5_STATE_BUILD_FLAGS` mirrors `C3_MATCHER_BUILD_FLAGS` (AZ-622) verbatim — both surface the per-strategy `BUILD_*` flag matrix at the airborne layer for operator-error messages, and both note that mutations MUST mirror the per-component factory's table.
|
||||
- `_build_c5_state_estimator_pair` follows the validation order shared by AZ-621 (`_build_c7_inference`) and AZ-622 (`_build_c3_lightglue_runtime`): resolve strategy → check flag matrix → check OFF gate → register strategy lazily → delegate to factory and wrap upstream errors.
|
||||
- The `_C5_PREBUILT_ESTIMATOR_KEY` look-aside key is documented as internal coordination at definition site AND in the wrapper docstring.
|
||||
|
||||
The 5 phase tests (AZ-619..AZ-623) had their autouse `_stub_c5_builders` fixture extended with one more `setattr` to stub `_build_c5_state_estimator_pair`. The stub is opaque (returns `(MagicMock, MagicMock)`), keeping each phase's assertions focused on its own contract. AZ-625's test file owns the (estimator, handle) pair contract.
|
||||
|
||||
## Phase 7: Architecture Compliance
|
||||
|
||||
- Layer direction: `airborne_bootstrap` (Layer 5 — entry / composition) imports `state_factory.build_state_estimator` (Layer 5 — runtime_root) and `components.c5_state.errors.StateEstimatorConfigError` (Layer 3 — domain). Layer 5 importing Layer 3 is allowed (composition root is the registration site per ADR-001). ✓
|
||||
- Public API respect: `c5_state.errors` is technically not in c5_state's "Public API" file list in `module-layout.md` (which lists only `__init__.py` and `interface.py`). However, the bootstrap is the documented exception per ADR-002 + ADR-001 (the build-flag gate / single-registration-site combo permits the airborne bootstrap to reach component internals to wrap their errors into `AirborneBootstrapError`). The existing line 440 `from gps_denied_onboard.components.c5_state import gtsam_isam2_estimator` already establishes this precedent in the same file; AZ-625 does not introduce a new exception. (Possible follow-up: list `errors.py` explicitly in c5_state's Public API row of `module-layout.md`. NOT in scope for AZ-625.)
|
||||
- No new module cycles introduced (the import graph still has `airborne_bootstrap → state_factory → c5_state`, no back-edge).
|
||||
- No duplicate symbols across components.
|
||||
- No cross-cutting concerns re-implemented locally.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| F1 | Low | Style | `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py:649` | `_C5_PREBUILT_ESTIMATOR_KEY` defined after first reference site |
|
||||
| F2 | Low | Maintainability | `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py:748` | Function-scoped `StateEstimatorConfigError` import (consistent with existing pattern) |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: `_C5_PREBUILT_ESTIMATOR_KEY` defined after first reference site** (Low / Style)
|
||||
|
||||
- Location: `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py:649`
|
||||
- Description: The constant is referenced at line 391 (`_c5_state_wrapper`) but defined at line 649. Python resolves the name lazily so this is correct.
|
||||
- Suggestion: leave as-is — grouping with other C5-state constants is the dominant readability axis.
|
||||
- Task: AZ-625
|
||||
|
||||
**F2: Function-scoped `StateEstimatorConfigError` import** (Low / Maintainability)
|
||||
|
||||
- Location: `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py:748`
|
||||
- Description: The exception is imported at function scope inside `_build_c5_state_estimator_pair`.
|
||||
- Suggestion: Matches the file's existing function-scope-import pattern for c5_state submodules; no change.
|
||||
- Task: AZ-625
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
- 0 Critical, 0 High → not FAIL.
|
||||
- 0 Medium → not PASS_WITH_WARNINGS for medium.
|
||||
- 2 Low → still PASS overall (no Medium / High / Critical).
|
||||
|
||||
**Verdict: PASS**.
|
||||
@@ -8,8 +8,8 @@ status: in_progress
|
||||
sub_step:
|
||||
phase: 16
|
||||
name: batch-loop
|
||||
detail: "batch 94 done; next: batch 95 = AZ-625 (handle ordering, 3cp); AZ-624 blocked on AZ-625"
|
||||
detail: "batch 95 done; next: batch 96 = AZ-624 (Phase F, 3cp)"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
last_completed_batch: 94
|
||||
last_completed_batch: 95
|
||||
|
||||
@@ -91,6 +91,7 @@ __all__ = [
|
||||
"AIRBORNE_MAIN_PRODUCER_ID",
|
||||
"AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS",
|
||||
"C3_MATCHER_BUILD_FLAGS",
|
||||
"C5_STATE_BUILD_FLAGS",
|
||||
"C7_AIRBORNE_BUILD_FLAGS",
|
||||
"FAISS_BUILD_FLAG",
|
||||
"AirborneBootstrapError",
|
||||
@@ -155,6 +156,28 @@ flag would unblock the bootstrap with a different runtime selection.
|
||||
"""
|
||||
|
||||
|
||||
C5_STATE_BUILD_FLAGS: Final[Mapping[str, str]] = {
|
||||
"gtsam_isam2": "BUILD_STATE_GTSAM_ISAM2",
|
||||
"eskf": "BUILD_STATE_ESKF",
|
||||
}
|
||||
"""Per-strategy ``BUILD_STATE_*`` flag matrix consumed by the airborne
|
||||
c5_state estimator pair builder (AZ-625 / Phase E.5).
|
||||
|
||||
Mirrors :data:`gps_denied_onboard.runtime_root.state_factory._STATE_BUILD_FLAGS`
|
||||
verbatim — both this constant and the state factory's table read the same
|
||||
compile-time flags. ANY mutation of this matrix MUST be mirrored in
|
||||
``state_factory._STATE_BUILD_FLAGS`` (and vice versa).
|
||||
|
||||
Surfaced here so :func:`_build_c5_state_estimator_pair` can name the
|
||||
gating flag in an :class:`AirborneBootstrapError` (AC-625.2) when the
|
||||
configured C5 state strategy's flag is OFF in this binary, *before*
|
||||
:func:`build_state_estimator` has a chance to raise the lower-level
|
||||
:class:`StateEstimatorConfigError` (which is the
|
||||
state-factory-internal error type, not the operator-facing
|
||||
bootstrap-error contract this module owns).
|
||||
"""
|
||||
|
||||
|
||||
C3_MATCHER_BUILD_FLAGS: Final[Mapping[str, str]] = {
|
||||
"disk_lightglue": "BUILD_MATCHER_DISK_LIGHTGLUE",
|
||||
"aliked_lightglue": "BUILD_MATCHER_ALIKED_LIGHTGLUE",
|
||||
@@ -360,6 +383,18 @@ def _c4_pose_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
|
||||
|
||||
|
||||
def _c5_state_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
|
||||
# AZ-625 fast path: when build_pre_constructed has eagerly built the
|
||||
# (estimator, handle) pair, the estimator lives under the private
|
||||
# _c5_prebuilt_estimator key. Returning the prebuilt instance keeps
|
||||
# c4_pose's c5_isam2_graph_handle pointing at the SAME estimator's
|
||||
# _isam2_handle — the AC-625.3 cross-seam identity invariant.
|
||||
prebuilt = constructed.get(_C5_PREBUILT_ESTIMATOR_KEY)
|
||||
if prebuilt is not None:
|
||||
return prebuilt
|
||||
# Fallback path: tests / fixtures that bypass build_pre_constructed
|
||||
# (for example, the existing test_az401_compose_root_replay.py suite
|
||||
# which seeds pre_constructed manually) still drive the wrapper
|
||||
# through build_state_estimator directly.
|
||||
imu_preintegrator = _require(constructed, "c5_imu_preintegrator", "c5_state")
|
||||
se3_utils = _require(constructed, "c5_se3_utils", "c5_state")
|
||||
wgs_converter = _require(constructed, "c5_wgs_converter", "c5_state")
|
||||
@@ -594,6 +629,174 @@ def _resolve_c3_matcher_strategy(config: Config) -> str:
|
||||
return getattr(block, "strategy", "disk_lightglue")
|
||||
|
||||
|
||||
def _resolve_c5_state_strategy(config: Config) -> str:
|
||||
"""Return the configured C5 state strategy, defaulting to gtsam_isam2.
|
||||
|
||||
Mirrors :func:`_resolve_c3_matcher_strategy` for the C5 slot.
|
||||
Reuses :class:`gps_denied_onboard.components.c5_state.config.C5StateConfig`'s
|
||||
own default ``"gtsam_isam2"`` when ``config.components`` carries no
|
||||
``c5_state`` block (early-bootstrap tests with bare ``Config()``).
|
||||
"""
|
||||
components = getattr(config, "components", None) or {}
|
||||
if not isinstance(components, Mapping):
|
||||
return "gtsam_isam2"
|
||||
block = components.get("c5_state")
|
||||
if block is None:
|
||||
return "gtsam_isam2"
|
||||
return getattr(block, "strategy", "gtsam_isam2")
|
||||
|
||||
|
||||
_C5_PREBUILT_ESTIMATOR_KEY: Final[str] = "_c5_prebuilt_estimator"
|
||||
"""Internal coordination key under which :func:`build_pre_constructed` stores
|
||||
the pre-built :class:`StateEstimator` instance.
|
||||
|
||||
The C5 state estimator and its :class:`ISam2GraphHandle` are constructed as
|
||||
a single tuple by :func:`build_state_estimator`; the handle is the iSAM2
|
||||
graph wrapper held INSIDE the estimator. C4 (``c4_pose``) reaches into
|
||||
``pre_constructed['c5_isam2_graph_handle']`` at compose time — but the C4
|
||||
wrapper runs BEFORE the C5 wrapper in topological order
|
||||
(``_C5_STATE_DEPENDS_ON: ('c1_vio', 'c4_pose')``). The handle therefore
|
||||
MUST exist in ``pre_constructed`` before either wrapper runs, which means
|
||||
the bootstrap MUST build the (estimator, handle) pair eagerly (AZ-625).
|
||||
|
||||
Storing the prebuilt estimator under this internal key lets the C5 wrapper
|
||||
short-circuit on it and return the SAME instance the handle was extracted
|
||||
from, so ``c4_pose._isam2_handle`` and ``c5_state._isam2_handle`` reference
|
||||
ONE object across the C4 / C5 seam (AC-625.3 identity contract).
|
||||
|
||||
Deliberately NOT in :data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` — it is
|
||||
an internal coordination key, not a Public API surface that any component
|
||||
queries directly (only the C5 wrapper consults it, and only as a fast
|
||||
path).
|
||||
"""
|
||||
|
||||
|
||||
def _build_c5_state_estimator_pair(
|
||||
config: Config,
|
||||
*,
|
||||
imu_preintegrator: Any,
|
||||
se3_utils: Any,
|
||||
wgs_converter: Any,
|
||||
fdr_client: Any,
|
||||
tile_store: Any | None = None,
|
||||
camera_calibration: CameraCalibration | None = None,
|
||||
flight_id: str | None = None,
|
||||
companion_id: str | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
"""Build the ``(StateEstimator, ISam2GraphHandle)`` tuple eagerly.
|
||||
|
||||
The C5 estimator and its iSAM2 graph handle are produced together by
|
||||
:func:`gps_denied_onboard.runtime_root.state_factory.build_state_estimator`
|
||||
— the handle is the wrapper around the estimator's internal
|
||||
``_isam2`` + ``_smoother`` substrate (see
|
||||
:class:`gps_denied_onboard.components.c5_state._isam2_handle.\
|
||||
ISam2GraphHandleImpl`). The handle's constructor takes the estimator
|
||||
as input, so the two cannot be separately constructed without a
|
||||
Protocol-seam change in C5 — explicitly forbidden by the AZ-618
|
||||
umbrella's "MUST NOT touch any per-component factory signature"
|
||||
constraint.
|
||||
|
||||
Building the pair eagerly at bootstrap time is the AZ-625 fix: the
|
||||
handle reaches ``pre_constructed['c5_isam2_graph_handle']`` so
|
||||
:func:`compose_root` can satisfy C4's lookup in topological order
|
||||
(``c4_pose`` runs before ``c5_state``); the estimator reaches a
|
||||
private coordination slot (``_c5_prebuilt_estimator``) so
|
||||
:func:`_c5_state_wrapper` can short-circuit and return the SAME
|
||||
instance the handle is bound to. The cross-seam identity invariant
|
||||
is verified by AC-625.3.
|
||||
|
||||
Validation order matches the rest of the airborne bootstrap:
|
||||
|
||||
1. Resolve the configured C5 state strategy
|
||||
(default ``"gtsam_isam2"``).
|
||||
2. Look it up in :data:`C5_STATE_BUILD_FLAGS`. An unknown strategy
|
||||
is an :class:`AirborneBootstrapError` naming the supported set
|
||||
(AC-625.2).
|
||||
3. Read the gating ``BUILD_STATE_*`` flag with the SAME default
|
||||
ladder the state factory uses
|
||||
(:func:`os.environ.get(flag, "ON").upper() == "OFF"`); an
|
||||
explicit OFF raises :class:`AirborneBootstrapError` naming the
|
||||
flag and the consuming component slug ``c5_state`` (AC-625.2).
|
||||
4. Lazily register the strategy via
|
||||
:func:`_ensure_state_strategy_registered` — same hook the C5
|
||||
wrapper uses, so a binary configured for the ESKF baseline does
|
||||
not import gtsam at bootstrap time.
|
||||
5. Delegate to :func:`build_state_estimator` with the
|
||||
infrastructure kwargs the wrapper would have passed; surface
|
||||
any :class:`StateEstimatorConfigError` as an
|
||||
:class:`AirborneBootstrapError` so the operator-facing error
|
||||
contract is uniform.
|
||||
|
||||
The optional kwargs ``tile_store`` / ``camera_calibration`` /
|
||||
``flight_id`` / ``companion_id`` exist for AZ-389 orthorectifier
|
||||
wiring; they are forwarded to :func:`build_state_estimator` which
|
||||
only consumes them when ``c5_state.orthorectifier.enabled`` is
|
||||
True. Until AZ-624 wires the operator-supplied flight metadata
|
||||
into ``pre_constructed``, callers pass the available defaults
|
||||
(today: ``tile_store=constructed['c6_tile_store']``, the rest
|
||||
``None``).
|
||||
|
||||
Raises:
|
||||
AirborneBootstrapError: when the configured strategy is not in
|
||||
:data:`C5_STATE_BUILD_FLAGS`, when the strategy's
|
||||
``BUILD_STATE_*`` flag is OFF, or when
|
||||
:func:`build_state_estimator` itself rejects the
|
||||
configuration (the original
|
||||
:class:`StateEstimatorConfigError` is preserved as
|
||||
``__cause__``).
|
||||
"""
|
||||
from gps_denied_onboard.components.c5_state.errors import StateEstimatorConfigError
|
||||
|
||||
strategy = _resolve_c5_state_strategy(config)
|
||||
flag = C5_STATE_BUILD_FLAGS.get(strategy)
|
||||
if flag is None:
|
||||
raise AirborneBootstrapError(
|
||||
f"airborne_bootstrap: cannot construct "
|
||||
f"pre_constructed['c5_isam2_graph_handle'] because "
|
||||
f"config.components['c5_state'].strategy={strategy!r} is "
|
||||
f"not in the airborne BUILD-flag matrix "
|
||||
f"{sorted(C5_STATE_BUILD_FLAGS.keys())!r}. Consuming "
|
||||
f"component: c5_state. Reconfigure the C5 state strategy "
|
||||
f"to one of the supported strategies."
|
||||
)
|
||||
# Mirror state_factory._STATE_BUILD_FLAGS gate: default "ON" when
|
||||
# unset; only explicit "OFF" blocks. Keeping the default identical
|
||||
# to state_factory means AZ-625's pre-check fires before
|
||||
# build_state_estimator's own gate, so the operator sees the
|
||||
# bootstrap-error contract instead of the lower-level config error.
|
||||
if os.environ.get(flag, "ON").upper() == "OFF":
|
||||
raise AirborneBootstrapError(
|
||||
f"airborne_bootstrap: cannot construct "
|
||||
f"pre_constructed['c5_isam2_graph_handle'] because the "
|
||||
f"gating flag {flag}=ON is required for the configured "
|
||||
f"strategy={strategy!r}, but {flag} is OFF in this binary. "
|
||||
f"Consuming component: c5_state. Set {flag}=ON, or "
|
||||
f"reconfigure config.components['c5_state'].strategy to a "
|
||||
f"strategy whose BUILD_STATE_* flag is ON."
|
||||
)
|
||||
_ensure_state_strategy_registered(config)
|
||||
try:
|
||||
estimator, handle = build_state_estimator(
|
||||
config,
|
||||
imu_preintegrator=imu_preintegrator,
|
||||
se3_utils=se3_utils,
|
||||
wgs_converter=wgs_converter,
|
||||
fdr_client=fdr_client,
|
||||
tile_store=tile_store,
|
||||
camera_calibration=camera_calibration,
|
||||
flight_id=flight_id,
|
||||
companion_id=companion_id,
|
||||
)
|
||||
except StateEstimatorConfigError as exc:
|
||||
raise AirborneBootstrapError(
|
||||
f"airborne_bootstrap: cannot construct "
|
||||
f"pre_constructed['c5_isam2_graph_handle'] for "
|
||||
f"strategy={strategy!r} (gating flag {flag} is ON). "
|
||||
f"Consuming component: c5_state. Upstream error: {exc}"
|
||||
) from exc
|
||||
return estimator, handle
|
||||
|
||||
|
||||
def _is_build_flag_on(flag_name: str) -> bool:
|
||||
"""Read a compile-time ``BUILD_*`` flag from the environment.
|
||||
|
||||
@@ -945,7 +1148,7 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
instance, gated by :data:`C3_MATCHER_BUILD_FLAGS` per the
|
||||
configured strategy) + ``c3_feature_extractor`` (the shared
|
||||
:class:`gps_denied_onboard.helpers.feature_extractor.FeatureExtractor`
|
||||
used by C2.5). AZ-623 (Phase E) adds the four stateless / cached c5
|
||||
used by C2.5). AZ-623 (Phase E) added the four stateless / cached c5
|
||||
helpers: ``c282_ransac_filter`` (shared
|
||||
:class:`gps_denied_onboard.helpers.ransac_filter.RansacFilter`),
|
||||
``c5_imu_preintegrator`` (per-calibration-path-cached
|
||||
@@ -954,12 +1157,16 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
: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.
|
||||
AZ-625 (Phase E.5) adds ``c5_isam2_graph_handle`` and seeds an
|
||||
internal coordination key (``_c5_prebuilt_estimator``) by
|
||||
eagerly invoking :func:`build_state_estimator` once at bootstrap
|
||||
time and capturing the
|
||||
``(StateEstimator, ISam2GraphHandle)`` tuple — the handle reaches
|
||||
``c4_pose`` via ``pre_constructed`` (C4 runs before C5 in topo
|
||||
order), and the prebuilt estimator lets the C5 wrapper
|
||||
short-circuit without re-invoking the factory. Phase F (AZ-624)
|
||||
will wire ``runtime_root.main()`` and verify AC-1..AC-5
|
||||
end-to-end.
|
||||
|
||||
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
|
||||
@@ -997,9 +1204,14 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
the strategy is unknown), or if the LightGlue engine load
|
||||
fails; OR (AZ-623) if ``config.runtime.camera_calibration_path``
|
||||
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.
|
||||
``c5_imu_preintegrator`` build; OR (AZ-625) if the
|
||||
configured C5 state strategy's
|
||||
:data:`C5_STATE_BUILD_FLAGS` flag is OFF (or the strategy
|
||||
is unknown), or if :func:`build_state_estimator` itself
|
||||
rejects the configuration when the
|
||||
``(StateEstimator, ISam2GraphHandle)`` pair is built
|
||||
eagerly. The message names the consuming component slug(s)
|
||||
and the relevant gating flag(s) or missing inputs.
|
||||
"""
|
||||
constructed: dict[str, Any] = {}
|
||||
constructed["c13_fdr"] = make_fdr_client(AIRBORNE_MAIN_PRODUCER_ID, config)
|
||||
@@ -1015,6 +1227,16 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
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)
|
||||
estimator, handle = _build_c5_state_estimator_pair(
|
||||
config,
|
||||
imu_preintegrator=constructed["c5_imu_preintegrator"],
|
||||
se3_utils=constructed["c5_se3_utils"],
|
||||
wgs_converter=constructed["c5_wgs_converter"],
|
||||
fdr_client=constructed["c13_fdr"],
|
||||
tile_store=constructed["c6_tile_store"],
|
||||
)
|
||||
constructed["c5_isam2_graph_handle"] = handle
|
||||
constructed[_C5_PREBUILT_ESTIMATOR_KEY] = estimator
|
||||
return constructed
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,14 @@ def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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())
|
||||
# AZ-625 Phase E.5: the C5 (estimator, handle) pair builder is also
|
||||
# stubbed so the bare Config() bootstrap path doesn't trip on the
|
||||
# default gtsam_isam2 strategy needing a real registered factory.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_state_estimator_pair",
|
||||
lambda *_args, **_kwargs: (object(), object()),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_619_1_default_config_seeds_c13_fdr_and_clock() -> None:
|
||||
|
||||
@@ -124,6 +124,17 @@ def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"_build_c5_wgs_converter",
|
||||
lambda _config: MagicMock(name="WgsConverter"),
|
||||
)
|
||||
# AZ-625 Phase E.5: stub the C5 (estimator, handle) pair builder so
|
||||
# the bare Config() bootstrap path doesn't trip on the default
|
||||
# gtsam_isam2 strategy needing a real registered factory.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_state_estimator_pair",
|
||||
lambda *_args, **_kwargs: (
|
||||
MagicMock(name="StateEstimator"),
|
||||
MagicMock(name="ISam2GraphHandle"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_620_1_adds_c6_descriptor_index_and_c6_tile_store(
|
||||
|
||||
@@ -127,6 +127,17 @@ def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"_build_c5_wgs_converter",
|
||||
lambda _config: MagicMock(name="WgsConverter"),
|
||||
)
|
||||
# AZ-625 Phase E.5: stub the C5 (estimator, handle) pair builder so
|
||||
# the bare Config() bootstrap path doesn't trip on the default
|
||||
# gtsam_isam2 strategy needing a real registered factory.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_state_estimator_pair",
|
||||
lambda *_args, **_kwargs: (
|
||||
MagicMock(name="StateEstimator"),
|
||||
MagicMock(name="ISam2GraphHandle"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_621_1_adds_c7_inference(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -131,6 +131,17 @@ def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"_build_c5_wgs_converter",
|
||||
lambda _config: MagicMock(name="WgsConverter"),
|
||||
)
|
||||
# AZ-625 Phase E.5: stub the C5 (estimator, handle) pair builder so
|
||||
# the bare / minimal Config()s used here don't trip on the default
|
||||
# gtsam_isam2 strategy needing a real registered factory.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_state_estimator_pair",
|
||||
lambda *_args, **_kwargs: (
|
||||
MagicMock(name="StateEstimator"),
|
||||
MagicMock(name="ISam2GraphHandle"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_622_1_adds_c3_lightglue_runtime_and_c3_feature_extractor(
|
||||
|
||||
@@ -127,6 +127,19 @@ def _stub_az619_to_az622_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"_build_c3_feature_extractor",
|
||||
lambda _config: MagicMock(name="FeatureExtractor"),
|
||||
)
|
||||
# AZ-625 Phase E.5: stub the C5 (estimator, handle) pair builder so
|
||||
# the AZ-623 tests stay focused on the four Phase E helpers without
|
||||
# the new gtsam_isam2-strategy registration path firing for every
|
||||
# config that has camera_calibration_path populated. The downstream
|
||||
# AZ-625 test file owns the (estimator, handle) pair contract.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_state_estimator_pair",
|
||||
lambda *_args, **_kwargs: (
|
||||
MagicMock(name="StateEstimator"),
|
||||
MagicMock(name="ISam2GraphHandle"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_623_1_adds_c282_ransac_and_c5_helpers(
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
"""AZ-625 — Phase E.5 of AZ-618: c5_isam2_graph_handle ordering.
|
||||
|
||||
Verifies the contract at
|
||||
``_docs/02_tasks/todo/AZ-625_c5_isam2_graph_handle_ordering.md``:
|
||||
|
||||
* AC-625.1: ``build_pre_constructed(default_config)`` adds key
|
||||
``c5_isam2_graph_handle`` on top of AZ-619..AZ-623; the value
|
||||
satisfies the C4 :class:`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 :class:`AirborneBootstrapError`
|
||||
whose message names the gating flag and the consuming component
|
||||
slug ``c5_state``.
|
||||
* AC-625.3: ``compose_root(config, pre_constructed=...)`` produces a
|
||||
runtime where the handle held by C4 IS the same handle exposed by
|
||||
the C5 estimator's ``_isam2_handle``. We exercise the cross-seam
|
||||
identity invariant via the bootstrap's
|
||||
``_c5_prebuilt_estimator`` look-aside key + the
|
||||
``_c5_state_wrapper`` short-circuit, so the unit-test path does
|
||||
not need to stand up the full ``compose_root`` graph (which would
|
||||
pull in gtsam, FAISS, TensorRT — all out of scope per the AZ-625
|
||||
task spec's Tier-2 Note).
|
||||
|
||||
AC-625.4 (this file exists with the above tests) is satisfied by the
|
||||
existence of this module.
|
||||
|
||||
The tests stub the heavy ``build_state_estimator`` seam through
|
||||
:func:`_build_c5_state_estimator_pair`'s ``__module__`` attribute path
|
||||
so they exercise the bootstrap-error contract + identity-share
|
||||
contract without standing up gtsam or constructing a real
|
||||
:class:`GtsamIsam2StateEstimator`. The upstream AZ-619..AZ-623
|
||||
builders are stubbed at the airborne_bootstrap module boundary,
|
||||
mirroring the prior phase pattern (see
|
||||
:mod:`tests.unit.runtime_root.test_az623_pre_constructed_phase_e`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c4_pose._isam2_handle import (
|
||||
ISam2GraphHandle as C4ISam2GraphHandle,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.fdr_client import client as fdr_client_module
|
||||
from gps_denied_onboard.runtime_root import airborne_bootstrap
|
||||
from gps_denied_onboard.runtime_root.airborne_bootstrap import (
|
||||
AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS,
|
||||
C5_STATE_BUILD_FLAGS,
|
||||
AirborneBootstrapError,
|
||||
build_pre_constructed,
|
||||
clear_imu_preintegrator_cache,
|
||||
)
|
||||
|
||||
_C5_PREBUILT_ESTIMATOR_KEY = "_c5_prebuilt_estimator"
|
||||
"""Mirror of ``airborne_bootstrap._C5_PREBUILT_ESTIMATOR_KEY`` for
|
||||
test-side identity assertions. Internal coordination key (deliberately
|
||||
NOT exposed via __all__); duplicated here so the test does not import
|
||||
a private name.
|
||||
"""
|
||||
|
||||
|
||||
def _config_with_calibration_path(path: str) -> Config:
|
||||
"""Return a fresh ``Config`` whose ``runtime.camera_calibration_path`` is set.
|
||||
|
||||
Mirrors the helper in
|
||||
:mod:`tests.unit.runtime_root.test_az623_pre_constructed_phase_e` —
|
||||
AZ-625 still walks the AZ-623 ``_build_c5_imu_preintegrator``
|
||||
builder which empty-checks the same field.
|
||||
"""
|
||||
base = Config()
|
||||
runtime = dataclasses.replace(base.runtime, camera_calibration_path=path)
|
||||
return dataclasses.replace(base, runtime=runtime)
|
||||
|
||||
|
||||
class _FakeIsam2GraphHandle:
|
||||
"""Lightweight stand-in for the production
|
||||
:class:`gps_denied_onboard.components.c5_state._isam2_handle.\
|
||||
ISam2GraphHandleImpl` used by AC-625.1's Protocol-conformance
|
||||
assertion.
|
||||
|
||||
Implements every method named by the C4 ``ISam2GraphHandle``
|
||||
Protocol so :func:`isinstance` against the runtime-checkable
|
||||
Protocol returns ``True`` — the production
|
||||
:class:`ISam2GraphHandleImpl` is the same shape.
|
||||
"""
|
||||
|
||||
def get_pose_key(self, frame_id: int) -> int:
|
||||
del frame_id
|
||||
return 0
|
||||
|
||||
def add_factor(self, factor: Any) -> None:
|
||||
del factor
|
||||
|
||||
def update(self, graph: Any, values: Any, timestamps: Any | None = None) -> None:
|
||||
del graph, values, timestamps
|
||||
|
||||
def compute_marginals(self) -> Any:
|
||||
return None
|
||||
|
||||
def last_anchor_age_ms(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class _FakeStateEstimator:
|
||||
"""Stand-in for ``GtsamIsam2StateEstimator``.
|
||||
|
||||
The only attribute AC-625.3 inspects is ``_isam2_handle`` — the
|
||||
production estimator stores the same handle there
|
||||
(:class:`gps_denied_onboard.components.c5_state.gtsam_isam2_estimator.\
|
||||
GtsamIsam2StateEstimator.__init__` builds
|
||||
``ISam2GraphHandleImpl(self)`` and assigns it to ``self._isam2_handle``).
|
||||
"""
|
||||
|
||||
def __init__(self, handle: _FakeIsam2GraphHandle) -> None:
|
||||
self._isam2_handle = handle
|
||||
|
||||
|
||||
def _stub_state_pair(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> tuple[_FakeStateEstimator, _FakeIsam2GraphHandle]:
|
||||
"""Replace ``_build_c5_state_estimator_pair`` with a fixed (estimator, handle).
|
||||
|
||||
Returns the same tuple values the stub injects, so individual
|
||||
tests can perform identity-share assertions against them without
|
||||
re-discovering the sentinels through ``pre_constructed``.
|
||||
"""
|
||||
handle = _FakeIsam2GraphHandle()
|
||||
estimator = _FakeStateEstimator(handle)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_state_estimator_pair",
|
||||
lambda *_args, **_kwargs: (estimator, handle),
|
||||
)
|
||||
return estimator, handle
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_caches() -> Iterator[None]:
|
||||
# Arrange: every test starts with empty FdrClient cache + empty
|
||||
# ImuPreintegrator cache so the AZ-619..AZ-623 builders behind
|
||||
# build_pre_constructed do not pick up stale instances.
|
||||
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_az623_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange: stub the AZ-620 (Phase B) C6 builders, AZ-621 (Phase C) C7
|
||||
# inference builder, AZ-622 (Phase D) C3 builders, and AZ-623 (Phase E)
|
||||
# c5 helper builders so AZ-625 stays focused on the Phase E.5 contract.
|
||||
# Sentinels are opaque — AZ-625 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"),
|
||||
)
|
||||
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_625_1_adds_c5_isam2_graph_handle_with_protocol_surface(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange: stub the (estimator, handle) pair builder; default Config()
|
||||
# with a populated camera_calibration_path so AZ-623's
|
||||
# _build_c5_imu_preintegrator empty-check passes (autouse fixture has
|
||||
# already stubbed the builder, so the path is only structurally set).
|
||||
config = _config_with_calibration_path("/tmp/az625-fixture-calib.json")
|
||||
_, handle = _stub_state_pair(monkeypatch)
|
||||
|
||||
# Act
|
||||
pre_constructed = build_pre_constructed(config)
|
||||
|
||||
# Assert: the new key is present and identifies the stubbed handle.
|
||||
assert "c5_isam2_graph_handle" in pre_constructed
|
||||
assert pre_constructed["c5_isam2_graph_handle"] is handle
|
||||
|
||||
# Protocol-conformance: the handle must satisfy the C4 consumer's
|
||||
# runtime-checkable Protocol — get_pose_key + add_factor + update +
|
||||
# compute_marginals + last_anchor_age_ms.
|
||||
assert isinstance(handle, C4ISam2GraphHandle), (
|
||||
"c5_isam2_graph_handle must satisfy the C4 ISam2GraphHandle Protocol; "
|
||||
f"missing attributes on {type(handle).__name__}"
|
||||
)
|
||||
for method_name in (
|
||||
"get_pose_key",
|
||||
"add_factor",
|
||||
"update",
|
||||
"compute_marginals",
|
||||
"last_anchor_age_ms",
|
||||
):
|
||||
assert hasattr(handle, method_name), (
|
||||
f"c5_isam2_graph_handle is missing {method_name!r}; "
|
||||
f"the C4 consumer (OpenCVGtsamPoseEstimator) dispatches via attribute access"
|
||||
)
|
||||
|
||||
# AZ-619..AZ-623 keys remain populated (additivity invariant).
|
||||
assert {
|
||||
"c13_fdr",
|
||||
"clock",
|
||||
"c6_descriptor_index",
|
||||
"c6_tile_store",
|
||||
"c7_inference",
|
||||
"c3_lightglue_runtime",
|
||||
"c3_feature_extractor",
|
||||
"c282_ransac_filter",
|
||||
"c5_imu_preintegrator",
|
||||
"c5_se3_utils",
|
||||
"c5_wgs_converter",
|
||||
}.issubset(pre_constructed.keys()), (
|
||||
f"AZ-625 must be additive on top of AZ-619..AZ-623; got "
|
||||
f"keys: {sorted(pre_constructed.keys())}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac_625_1_internal_prebuilt_estimator_key_not_in_required_keys() -> None:
|
||||
"""The ``_c5_prebuilt_estimator`` look-aside key is internal coordination
|
||||
only — it MUST NOT appear under
|
||||
:data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` because no consuming
|
||||
component queries it via the public surface (only
|
||||
``_c5_state_wrapper`` consults it as a fast path).
|
||||
"""
|
||||
# Assert: the look-aside key is not in any consumer's required-keys row.
|
||||
for slug, required_keys in AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS.items():
|
||||
assert _C5_PREBUILT_ESTIMATOR_KEY not in required_keys, (
|
||||
f"{_C5_PREBUILT_ESTIMATOR_KEY!r} is an internal coordination key; "
|
||||
f"it must not be exposed via "
|
||||
f"AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS[{slug!r}]"
|
||||
)
|
||||
|
||||
|
||||
def test_ac_625_2_build_state_gtsam_isam2_off_raises_named_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange: explicitly set BUILD_STATE_GTSAM_ISAM2=OFF (the gating
|
||||
# flag check uses os.environ.get(flag, "ON").upper() == "OFF" — same
|
||||
# default ladder as state_factory). Default config resolves to
|
||||
# gtsam_isam2 strategy. Do NOT stub _build_c5_state_estimator_pair
|
||||
# here — we want the real flag-OFF guard to fire.
|
||||
monkeypatch.setenv("BUILD_STATE_GTSAM_ISAM2", "OFF")
|
||||
config = _config_with_calibration_path("/tmp/az625-flag-off-fixture-calib.json")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as excinfo:
|
||||
build_pre_constructed(config)
|
||||
|
||||
message = str(excinfo.value)
|
||||
# The missing key, the gating flag, and the consuming component
|
||||
# slug must all appear in the operator-facing message.
|
||||
assert "c5_isam2_graph_handle" in message
|
||||
assert C5_STATE_BUILD_FLAGS["gtsam_isam2"] in message, (
|
||||
f"BUILD_STATE_GTSAM_ISAM2 missing from error: {message!r}"
|
||||
)
|
||||
assert "c5_state" in message
|
||||
# The flag-OFF branch raises directly — there is no upstream cause.
|
||||
assert excinfo.value.__cause__ is None
|
||||
|
||||
|
||||
def test_ac_625_2_unknown_strategy_raises_named_error_with_supported_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Defence-in-depth for the strategy-resolution path.
|
||||
|
||||
Even when BUILD_STATE_* flags are ON, an unknown C5 strategy must
|
||||
raise :class:`AirborneBootstrapError` naming the supported set
|
||||
(``gtsam_isam2`` / ``eskf``). The error must fire BEFORE
|
||||
``build_state_estimator`` is consulted — otherwise the operator
|
||||
sees ``StateEstimatorConfigError`` rather than the bootstrap-error
|
||||
contract this module owns.
|
||||
"""
|
||||
|
||||
# Arrange: smuggle an unknown strategy past the upstream
|
||||
# KNOWN_STATE_STRATEGIES validator (lives in
|
||||
# ``components.c5_state.config``) by stubbing the bootstrap's own
|
||||
# strategy resolver. The bootstrap's own check is what we want to
|
||||
# exercise here.
|
||||
config = _config_with_calibration_path("/tmp/az625-unknown-fixture.json")
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_resolve_c5_state_strategy",
|
||||
lambda _config: "lqg_baseline_does_not_exist",
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as excinfo:
|
||||
build_pre_constructed(config)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "c5_isam2_graph_handle" in message
|
||||
assert "lqg_baseline_does_not_exist" in message
|
||||
assert "c5_state" in message
|
||||
# The supported set is enumerated so the operator sees the fix.
|
||||
for supported in C5_STATE_BUILD_FLAGS:
|
||||
assert supported in message, (
|
||||
f"supported strategy {supported!r} missing from error: {message!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac_625_2_build_state_estimator_config_error_wraps_into_bootstrap_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When the BUILD flag is ON but the state-factory itself rejects the
|
||||
config, ``StateEstimatorConfigError`` must wrap into
|
||||
:class:`AirborneBootstrapError` with the cause chain preserved
|
||||
(mirrors AZ-621 / AZ-622's wrapping pattern)."""
|
||||
# Arrange
|
||||
from gps_denied_onboard.components.c5_state.errors import (
|
||||
StateEstimatorConfigError,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("BUILD_STATE_GTSAM_ISAM2", "ON")
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_ensure_state_strategy_registered",
|
||||
lambda _config: None,
|
||||
)
|
||||
|
||||
def _raise_config_error(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise StateEstimatorConfigError("simulated state factory rejection")
|
||||
|
||||
# _build_c5_state_estimator_pair calls the imported reference
|
||||
# ``build_state_estimator`` from the airborne_bootstrap module
|
||||
# namespace; monkeypatch that attribute directly.
|
||||
monkeypatch.setattr(airborne_bootstrap, "build_state_estimator", _raise_config_error)
|
||||
config = _config_with_calibration_path("/tmp/az625-cfg-err-fixture.json")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as excinfo:
|
||||
build_pre_constructed(config)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "c5_isam2_graph_handle" in message
|
||||
assert "c5_state" in message
|
||||
assert "gtsam_isam2" in message
|
||||
assert isinstance(excinfo.value.__cause__, StateEstimatorConfigError)
|
||||
|
||||
|
||||
def test_ac_625_3_handle_is_same_object_as_estimator_isam2_handle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Cross-seam identity: the handle that c4_pose receives via
|
||||
``pre_constructed['c5_isam2_graph_handle']`` IS the SAME object
|
||||
that the c5_state estimator exposes as ``_isam2_handle``.
|
||||
|
||||
AC-625.3's ``compose_root(...)`` end-to-end form requires gtsam +
|
||||
FAISS + TensorRT and is exercised by the Jetson tier-2 e2e harness
|
||||
(AZ-624 AC-5). At unit-test scope the equivalent invariant is
|
||||
``pre_constructed[_c5_prebuilt_estimator]._isam2_handle is
|
||||
pre_constructed['c5_isam2_graph_handle']`` — which is what the
|
||||
bootstrap's seeding ordering must guarantee.
|
||||
"""
|
||||
# Arrange
|
||||
config = _config_with_calibration_path("/tmp/az625-identity-fixture.json")
|
||||
estimator, handle = _stub_state_pair(monkeypatch)
|
||||
|
||||
# Act
|
||||
pre_constructed = build_pre_constructed(config)
|
||||
|
||||
# Assert: identity-share across the C4 / C5 seam.
|
||||
assert pre_constructed["c5_isam2_graph_handle"] is handle
|
||||
assert pre_constructed[_C5_PREBUILT_ESTIMATOR_KEY] is estimator
|
||||
assert (
|
||||
pre_constructed[_C5_PREBUILT_ESTIMATOR_KEY]._isam2_handle
|
||||
is (pre_constructed["c5_isam2_graph_handle"])
|
||||
)
|
||||
|
||||
|
||||
def test_ac_625_3_c5_state_wrapper_short_circuits_on_prebuilt_estimator(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The :func:`_c5_state_wrapper` returns the prebuilt estimator as-is
|
||||
when ``_c5_prebuilt_estimator`` is present in ``constructed``.
|
||||
|
||||
This is the seam that lets AC-625.3 hold under the live
|
||||
``compose_root`` topo walk: the C5 wrapper does NOT re-invoke
|
||||
:func:`build_state_estimator` (which would produce a different
|
||||
``(estimator, handle)`` pair than the one C4 has already
|
||||
consumed). Verified by ensuring the wrapper does not consult the
|
||||
fallback infra-key set at all when the look-aside key is set.
|
||||
"""
|
||||
# Arrange: build a constructed dict with ONLY the look-aside key.
|
||||
# If the wrapper short-circuits correctly it must NOT read any of
|
||||
# c5_imu_preintegrator / c5_se3_utils / c5_wgs_converter / c13_fdr —
|
||||
# the absence of those keys would otherwise raise
|
||||
# AirborneBootstrapError via _require.
|
||||
estimator, _handle = _stub_state_pair(monkeypatch)
|
||||
constructed = {_C5_PREBUILT_ESTIMATOR_KEY: estimator}
|
||||
|
||||
# Act
|
||||
returned = airborne_bootstrap._c5_state_wrapper(Config(), constructed)
|
||||
|
||||
# Assert
|
||||
assert returned is estimator
|
||||
|
||||
|
||||
def test_ac_625_3_c5_state_wrapper_falls_back_when_prebuilt_absent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The :func:`_c5_state_wrapper` falls back to the original
|
||||
:func:`build_state_estimator` path when ``_c5_prebuilt_estimator``
|
||||
is absent.
|
||||
|
||||
Test isolation contract: existing fixtures (e.g. the
|
||||
``test_az401_compose_root_replay`` suite) seed ``pre_constructed``
|
||||
manually without going through :func:`build_pre_constructed` and
|
||||
therefore have no look-aside key. The fallback must still work —
|
||||
AZ-625's seam is additive, never replacing the existing one.
|
||||
"""
|
||||
# Arrange: no _c5_prebuilt_estimator. Stub the heavy
|
||||
# build_state_estimator seam so the fallback path returns
|
||||
# deterministically.
|
||||
fallback_estimator = _FakeStateEstimator(_FakeIsam2GraphHandle())
|
||||
fallback_handle = MagicMock(name="FallbackHandle")
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_ensure_state_strategy_registered",
|
||||
lambda _config: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"build_state_estimator",
|
||||
lambda *_args, **_kwargs: (fallback_estimator, fallback_handle),
|
||||
)
|
||||
constructed = {
|
||||
"c5_imu_preintegrator": MagicMock(name="ImuPreintegrator"),
|
||||
"c5_se3_utils": MagicMock(name="Se3Utils"),
|
||||
"c5_wgs_converter": MagicMock(name="WgsConverter"),
|
||||
"c13_fdr": MagicMock(name="FdrClient"),
|
||||
}
|
||||
|
||||
# Act
|
||||
returned = airborne_bootstrap._c5_state_wrapper(Config(), constructed)
|
||||
|
||||
# Assert: fallback path returned the build_state_estimator-built
|
||||
# estimator (not the prebuilt sentinel — there is none).
|
||||
assert returned is fallback_estimator
|
||||
Reference in New Issue
Block a user