mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:11:12 +00:00
Compare commits
5 Commits
0d65ff4705
...
2ce300ddb1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce300ddb1 | |||
| 235eb4549e | |||
| f6a180e5df | |||
| 87909cce9f | |||
| e81616a09d |
@@ -1,8 +1,8 @@
|
||||
# Dependencies Table
|
||||
|
||||
**Date**: 2026-05-13 (refreshed after Batch 48: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; earlier same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
||||
**Total Tasks**: 147 (106 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene
|
||||
**Total Complexity Points**: 489 (356 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt
|
||||
**Date**: 2026-05-14 (refreshed after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; earlier 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
||||
**Total Tasks**: 148 (107 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene
|
||||
**Total Complexity Points**: 491 (358 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt
|
||||
|
||||
Dependencies columns list only the tracker-ID portion (descriptive tail
|
||||
text in each task spec is omitted here for table-readability). The
|
||||
@@ -159,6 +159,7 @@ are all declared and documented below under **Cycle Check**.
|
||||
| AZ-507 | Hygiene — align module-layout.md cross-component import rules with AZ-270 lint | 2 | AZ-263, AZ-270, AZ-321 | AZ-246 |
|
||||
| AZ-508 | Hygiene — consolidate `_iso_ts_now` helpers into `helpers/iso_timestamps.py` | 2 | AZ-263 | AZ-264 |
|
||||
| AZ-526 | Hygiene — add `iso_ts_from_clock(clock)` to `helpers/iso_timestamps.py` | 2 | AZ-508, AZ-398 | AZ-264 |
|
||||
| AZ-527 | Hygiene — consolidate `_assert_engine_output_dim` into c2-internal helper | 2 | AZ-340 | AZ-255 |
|
||||
| AZ-523 | Batch 44 — C11 internal flight-state gate removal (SRP refactor; audit-trail; closed) | 3 | AZ-317, AZ-319, AZ-329 | AZ-251 |
|
||||
| AZ-524 | Batch 44 — C12 package rename: c12_operator_tooling → c12_operator_orchestrator (audit; closed)| 2 | AZ-263, AZ-326, AZ-327, AZ-328, AZ-329, AZ-330, AZ-489 | AZ-253 |
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Hygiene — Consolidate `_assert_engine_output_dim` into a c2-internal helper
|
||||
|
||||
**Task**: AZ-527_hygiene_engine_dim_assertion_consolidation
|
||||
**Name**: c2_vpr engine output-dim assertion helper consolidation
|
||||
**Description**: Replace the seven duplicated `_assert_engine_output_dim(...)` definitions across the c2_vpr secondary VPR strategy modules (`ultra_vpr.py`, `net_vlad.py`, `mega_loc.py`, `mix_vpr.py`, `sela_vpr.py`, `eigen_places.py`, `salad.py`) with a single c2-internal helper at `src/gps_denied_onboard/components/c2_vpr/_engine_dim_assertion.py`. Closes cumulative review batches 49–51 Finding F1 (Medium / Maintainability).
|
||||
**Complexity**: 2 points
|
||||
**Dependencies**: AZ-340 (the trigger that escalated the duplication from 4-way to 7-way; all 7 callers now exist). All other 6 callers are already in `done/` (AZ-337, AZ-338, AZ-339).
|
||||
**Component**: c2_vpr (epic AZ-255 / E-C2)
|
||||
**Tracker**: AZ-527
|
||||
**Epic**: AZ-255 (E-C2)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/03_implementation/cumulative_review_batches_49-51_cycle1_report.md` § F1 — the finding being closed.
|
||||
- `_docs/02_document/components/02_c2_vpr/description.md` § 6 — the c2_vpr component description (helper goes inside the c2_vpr boundary).
|
||||
- `_docs/02_tasks/done/AZ-337_c2_ultra_vpr_strategy.md`, `AZ-338_c2_netvlad_strategy.md`, `AZ-339_c2_mega_loc_mix_vpr.md`, `AZ-340_c2_selavpr_eigenplaces_salad.md` — the four task-specs whose AC-6 inference-output-dim contracts must continue to pass unmodified.
|
||||
|
||||
## Problem
|
||||
|
||||
Seven c2_vpr secondary VPR strategy modules each define a private `_assert_engine_output_dim` helper with byte-identical bodies modulo a single integer constant:
|
||||
|
||||
| File | `DESCRIPTOR_DIM` |
|
||||
|------|------------------|
|
||||
| `src/gps_denied_onboard/components/c2_vpr/ultra_vpr.py` (AZ-337, B47) | 512 |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/net_vlad.py` (AZ-338, B46) | 4096 |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/mega_loc.py` (AZ-339, B50) | 4096 |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/mix_vpr.py` (AZ-339, B50) | 8448 |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/sela_vpr.py` (AZ-340, B51) | 512 |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/eigen_places.py` (AZ-340, B51) | 2048 |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/salad.py` (AZ-340, B51) | 8448 |
|
||||
|
||||
Each copy runs a zero-init dry-run inference probe at `create()` time, asserts the engine output dict carries an `"embedding"` key, and asserts the output ndarray shape is `(1, DESCRIPTOR_DIM)`. The error-message strings and the shape-check logic are byte-identical. Each copy carries an inline comment referencing this AZ-527 ticket.
|
||||
|
||||
Cumulative review batches 46-48 originally flagged the duplication at 2-way (Low). Cumulative review batches 49-51 escalated to Medium when the duplication grew to 7-way after AZ-339 + AZ-340 land. This PBI closes the pattern before any future c2_vpr backbone (or hypothetical c2-side adjacent work) adds an 8th copy.
|
||||
|
||||
## Outcome
|
||||
|
||||
- A new c2-internal module `src/gps_denied_onboard/components/c2_vpr/_engine_dim_assertion.py` exposes a single function `assert_engine_output_dim(inference_runtime, handle, preprocessor, descriptor_dim, *, output_key="embedding", input_key="input") -> None` parametrised by the dim and the key names. Raises `gps_denied_onboard.config.schema.ConfigError` on mismatch (preserving the existing error envelope).
|
||||
- The seven local `_assert_engine_output_dim` definitions are deleted; consumers import the helper. Module-level callers preserve the local symbol via `from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import assert_engine_output_dim` (with a thin one-liner wrapper if call-site stability is preferred).
|
||||
- The seven inline `AZ-527 (planned)` comments are deleted (they will be obsolete).
|
||||
- A new unit test `tests/unit/c2_vpr/test_az527_engine_dim_assertion.py` covers AC-1..AC-4 below.
|
||||
- The existing AZ-337 / 338 / 339 / 340 AC-6 tests pass unmodified.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- Add `src/gps_denied_onboard/components/c2_vpr/_engine_dim_assertion.py` with the helper function (signature above). Import surface: `gps_denied_onboard.config.schema.ConfigError` only — no other component imports.
|
||||
- Migrate the seven c2_vpr strategy modules to import the helper. Delete the local definitions and the local `AZ-527 (planned)` comments.
|
||||
- Add `tests/unit/c2_vpr/test_az527_engine_dim_assertion.py` with AC-1..AC-4 (success path with matching shape, wrong shape raises `ConfigError`, missing output key raises `ConfigError`, success with non-default `output_key` / `input_key`).
|
||||
- Re-run the existing `tests/unit/c2_vpr/test_ultra_vpr.py`, `test_net_vlad.py`, `test_az339_mega_loc_mix_vpr.py`, `test_az340_sela_vpr_eigen_places_salad.py` AC-6 sub-tests **unmodified** to verify that the consolidation preserves behavior at every call site.
|
||||
- Optionally add a one-line mention of the helper in `_docs/02_document/components/02_c2_vpr/description.md` § 6 (Internal helpers).
|
||||
|
||||
### Excluded
|
||||
|
||||
- Sharing the helper across other components (c3, c4, c5). C7 already has its own engine-shape assertions inside the runtime; this PBI is c2-internal only.
|
||||
- Changing the `"embedding"` output key contract or the `"input"` input key contract (those are runtime/engine conventions enforced elsewhere).
|
||||
- Refactoring the `BackbonePreprocessor` Protocol — the helper still calls `preprocessor.input_shape()` to size the dry-run probe.
|
||||
- Hoisting the helper to `src/gps_denied_onboard/helpers/` — engine-output-shape contracts are a c2 internal concern, not a cross-component shared helper.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Helper exists at the canonical path with the expected signature**
|
||||
Given a fresh checkout
|
||||
When `from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import assert_engine_output_dim` is run
|
||||
Then the import succeeds; the function signature is `(inference_runtime, handle, preprocessor, descriptor_dim, *, output_key="embedding", input_key="input")` and the function returns `None` on success
|
||||
|
||||
**AC-2: Wrong shape raises `ConfigError` with the expected envelope**
|
||||
Given a fake `InferenceRuntime` whose `infer()` returns `{"embedding": np.zeros((1, 999), dtype=np.float16)}` for any of the 7 backbones
|
||||
When `assert_engine_output_dim(..., descriptor_dim=512)` is called
|
||||
Then a `ConfigError` is raised; the message contains both the expected dim (`512`) and the actual dim (`999`); the error type matches the contract `gps_denied_onboard.config.schema.ConfigError`
|
||||
|
||||
**AC-3: Missing output key raises `ConfigError`**
|
||||
Given a fake `InferenceRuntime` whose `infer()` returns `{"wrong_key": np.zeros((1, 512), dtype=np.float16)}`
|
||||
When `assert_engine_output_dim(..., descriptor_dim=512)` is called
|
||||
Then a `ConfigError` is raised; the message names the missing `output_key`
|
||||
|
||||
**AC-4: All seven local `_assert_engine_output_dim` definitions are removed**
|
||||
Given a `grep -rn "def _assert_engine_output_dim\|def assert_engine_output_dim" src/` after the task lands
|
||||
When the search runs
|
||||
Then matches appear only inside `src/gps_denied_onboard/components/c2_vpr/_engine_dim_assertion.py`; zero matches in `ultra_vpr.py`, `net_vlad.py`, `mega_loc.py`, `mix_vpr.py`, `sela_vpr.py`, `eigen_places.py`, `salad.py`
|
||||
|
||||
**AC-5: All AZ-337 / 338 / 339 / 340 AC-6 sub-tests pass unmodified**
|
||||
Given the existing `tests/unit/c2_vpr/test_ultra_vpr.py`, `test_net_vlad.py`, `test_az339_mega_loc_mix_vpr.py`, `test_az340_sela_vpr_eigen_places_salad.py`
|
||||
When the suites run after this task
|
||||
Then every previously-passing AC-6 (engine output-dim) sub-test still passes; no test file is modified
|
||||
|
||||
**AC-6: AZ-270 + AZ-507 layer lints pass**
|
||||
Given the helper lives inside the c2_vpr component (Layer 3) and only imports from `gps_denied_onboard.config.schema` (Layer 1) and stdlib
|
||||
When `tests/unit/test_az270_compose_root.py::test_ac6_only_compose_root_imports_concrete_strategies` runs
|
||||
Then the test passes (helper is c2-internal, not a Public API surface, and respects layer direction)
|
||||
|
||||
## Constraints
|
||||
|
||||
- The helper module is c2-internal (`_engine_dim_assertion.py` underscore prefix). No other component may import it.
|
||||
- `descriptor_dim`, `output_key`, `input_key` are passed as parameters — the helper has zero module-level constants describing per-backbone conventions.
|
||||
- Error envelope preserved: raise `gps_denied_onboard.config.schema.ConfigError` (NOT `ValueError`, NOT a custom new error type). Error messages must continue to name both the expected and actual descriptor dims, plus the missing key when applicable.
|
||||
- The dry-run probe input shape comes from `preprocessor.input_shape()` (the `BackbonePreprocessor` Protocol method). The helper does NOT hardcode any input shape — backbone-specific input shapes remain a per-strategy concern.
|
||||
- Test additions go into a new `test_az527_engine_dim_assertion.py`; AZ-337 / 338 / 339 / 340 test files are NOT touched.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: A 7-way refactor accidentally drops one call site, breaking `create()` for one strategy**
|
||||
- *Risk*: One of the 7 modules forgets to call the helper or calls it with the wrong dim.
|
||||
- *Mitigation*: AC-5 (all 4 existing AC-6 test files pass unmodified) is the safety net — if any strategy regresses, its AC-6 sub-test will fail.
|
||||
|
||||
**Risk 2: A future emitter forgets the helper exists and adds an 8th local copy**
|
||||
- *Risk*: The pattern recurs as new c2_vpr backbones land (none planned in current scope, but possible for hypothetical c2-side adjacent work).
|
||||
- *Mitigation*: Add an AST-walk regression guard inside `test_az527_engine_dim_assertion.py` that asserts zero stray `_assert_engine_output_dim` / `assert_engine_output_dim` definitions outside the helper module — modeled on the AZ-508 / AZ-526 `test_no_local_iso_ts_*_definitions_remain` pattern.
|
||||
|
||||
**Risk 3: The helper accidentally becomes a cross-component shared helper**
|
||||
- *Risk*: A future C3/C4/C5 strategy author imports the helper from c2_vpr and creates a cross-component coupling.
|
||||
- *Mitigation*: The helper is named `_engine_dim_assertion.py` (underscore prefix) inside the c2_vpr/ component folder, not in `helpers/`. The AZ-507 layer lint forbids cross-component imports of c2_vpr internals. If a C3/C4/C5 needs the same shape, the right move is to factor a c2-or-helpers Layer-1 module — out of scope here.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: a c2-internal helper `assert_engine_output_dim(inference_runtime, handle, preprocessor, descriptor_dim, *, output_key, input_key) -> None` that runs a zero-init dry-run inference and validates the output dict shape against the expected descriptor dim.
|
||||
- **Production code that must exist**: real `assert_engine_output_dim` function in `_engine_dim_assertion.py`; real import + call in each of the 7 strategy modules.
|
||||
- **Allowed external stubs**: none for production code. The unit test uses fake `InferenceRuntime` and fake `BackbonePreprocessor` Protocol implementations (matching the existing AZ-337..AZ-340 fakes).
|
||||
- **Unacceptable substitutes**: keeping one or more local definitions "for parity"; raising `ValueError` instead of `ConfigError`; hoisting the helper to `helpers/`; changing the `"embedding"` / `"input"` key defaults to per-backbone names (the parameters are the right tool).
|
||||
@@ -0,0 +1,64 @@
|
||||
# Batch 51 — Implementation Report (Cycle 1)
|
||||
|
||||
**Tasks**: AZ-340 (C2 SelaVPR + EigenPlaces + SALAD Secondary Backbones — Research-only)
|
||||
**Date**: 2026-05-14
|
||||
**Cycle**: 1
|
||||
**Status**: COMPLETE (review verdict: PASS_WITH_WARNINGS, two Low findings)
|
||||
|
||||
## What was done
|
||||
|
||||
Added three secondary `VprStrategy` implementations for IT-12 comparative-study: `SelaVprStrategy` (D=512, 224×224 input), `EigenPlacesStrategy` (D=2048, 480×480 input) and `SaladStrategy` (D=8448, 322×322 input — DINOv2-Large backbone, heaviest in the C2 family). All run via the C7 TensorRT runtime (or ONNX-Runtime fallback), apply ImageNet mean/std preprocessing + single-stage L2 normalisation, and delegate retrieval to `FaissBridge`. All three are gated OFF for airborne and operator-tooling per ADR-002 — `BUILD_VPR_SELAVPR` / `BUILD_VPR_EIGENPLACES` / `BUILD_VPR_SALAD` ON only for the research binary and replay-cli.
|
||||
|
||||
### Files added (7)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/gps_denied_onboard/components/c2_vpr/sela_vpr.py` | `SelaVprStrategy` class + `create()` factory + `_assert_engine_output_dim` helper |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/_preprocessor_sela_vpr.py` | `SelaVprBackbonePreprocessor` (centre-crop + 224×224 resize + ImageNet normalise + FP16 NCHW) |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/eigen_places.py` | `EigenPlacesStrategy` class + `create()` factory + `_assert_engine_output_dim` helper |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/_preprocessor_eigen_places.py` | `EigenPlacesBackbonePreprocessor` (centre-crop + 480×480 resize + ImageNet normalise + FP16 NCHW) |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/salad.py` | `SaladStrategy` class + `create()` factory + `_assert_engine_output_dim` helper |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/_preprocessor_salad.py` | `SaladBackbonePreprocessor` (centre-crop + 322×322 resize + ImageNet normalise + FP16 NCHW) |
|
||||
| `tests/unit/c2_vpr/test_az340_sela_vpr_eigen_places_salad.py` | 54 parametrised AC tests across all three strategies |
|
||||
|
||||
### Files changed
|
||||
- _None._ The composition-root factory (`runtime_root/vpr_factory.py`) was already wired for `sela_vpr`, `eigen_places`, and `salad` strategy names at AZ-336 land time — `_STRATEGY_TO_BUILD_FLAG` and `_STRATEGY_TO_MODULE` tables already include the rows. The `KNOWN_STRATEGIES` frozenset in `c2_vpr/config.py` already includes all three. The `module-layout.md` `Component: c2_vpr` § Internal list already names `sela_vpr.py`, `eigen_places.py`, and `salad.py` (pre-declared by AZ-336). No CMake change required — `BUILD_VPR_*` gating is environment-variable-based per `_is_build_flag_on` in `vpr_factory.py`.
|
||||
|
||||
## AC coverage
|
||||
|
||||
All 11 ACs verified per strategy via the parametrised test suite. See `_docs/03_implementation/reviews/batch_51_review.md` § Phase 2 for the AC ↔ test mapping table.
|
||||
|
||||
| AC | Status | Notes |
|
||||
|----|--------|-------|
|
||||
| AC-1..AC-9 + AC-11 | PASS | Each AC parametrised over all three strategies (54 test cases total) |
|
||||
| AC-10 | PASS with drift | Implementation raises `StrategyNotAvailableError` (env-flag OFF path) and `ConfigError` (runtime-label mismatch path); the spec literally names `ConfigurationError`. Mirrors the established AZ-337 / AZ-338 / AZ-339 precedent. Logged as Low finding F2. |
|
||||
|
||||
## Test results
|
||||
|
||||
- `tests/unit/c2_vpr/test_az340_sela_vpr_eigen_places_salad.py` — **54 / 54 PASS**.
|
||||
- `tests/unit/c2_vpr/test_protocol_conformance.py` — **PASS** (auto-extends across all 7 strategies after AZ-340; the three new ones are picked up by the parametrised `_STRATEGY_MODULES` table without test changes — verified by the full c2_vpr/ run below).
|
||||
- `tests/unit/c2_vpr/` (full directory: faiss_bridge + net_vlad + ultra_vpr + AZ-339 + AZ-340 + protocol_conformance) — **180 / 180 PASS**.
|
||||
- `tests/unit/test_az508_iso_timestamps.py` — **18 / 18 PASS** (AZ-526 regression guard confirms no new `_iso_ts_from_clock` duplicates introduced by AZ-340).
|
||||
- `tests/unit/test_az270_compose_root.py` — **8 / 8 PASS**.
|
||||
- `ruff check` on all 7 new files — clean (one auto-fixable `RUF022 __all__ not sorted` in `_preprocessor_eigen_places.py` was caught and fixed before commit).
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
1. **Single parametrised test file `test_az340_sela_vpr_eigen_places_salad.py`** — rather than three near-identical files mirroring `test_ultra_vpr.py` / `test_net_vlad.py`. The three strategies share byte-identical behavioural contracts (same Protocol, same FDR record kinds, same log kinds, same error envelope) and differ only on three values (`DESCRIPTOR_DIM`, `_BACKBONE_LABEL`, preprocessor `input_shape()`). A parametrised approach keeps any future drift visible at the assertion level and reduces the test surface from ~2300 lines (three copies of test_ultra_vpr.py) to ~700 lines. Same precedent as AZ-339.
|
||||
2. **Preprocessor duplication preserved** (sela_vpr vs eigen_places vs salad vs mega_loc vs mix_vpr vs ultra_vpr vs net_vlad) — per `components/02_c2_vpr/description.md` § 6 and the task spec § Constraints. Each preprocessor owns its own input-shape constants so a future code drop can change a backbone's preprocessing without coupling other strategies' weights-versions.
|
||||
3. **`_assert_engine_output_dim` duplicated, NOT extracted** — see Spec Drift / Review Finding F1. The cleaner path is the dedicated AZ-527 hygiene PBI (now scoped to consolidate 7 copies, not 4).
|
||||
4. **`iso_ts_from_clock` imported from the AZ-526 helper from day 1** — none of the three new strategies introduces a local `_iso_ts_from_clock` body. The AZ-526 regression guard test confirms this.
|
||||
5. **Runtime-label guard placed inside `create()`** (not in `__init__`) — runtime selection is a composition-time concern; once the strategy is constructed it's expected to work. Matches the UltraVPR / NetVLAD / MegaLoc / MixVPR precedent.
|
||||
6. **SALAD's high embedding dim (8448) is non-negotiable at the strategy layer** — it's the architectural output of the SALAD aggregator over DINOv2-Large patch tokens. PCA-whitening for a smaller SALAD descriptor is an operator-side decision at corpus build time (C10), gated by a future `BUILD_VPR_SALAD_PCA` build flag (out of scope here).
|
||||
|
||||
## Spec drift noted (carried into review F2)
|
||||
|
||||
AZ-340 § AC-10 literally specifies `ConfigurationError` for the build-flag-OFF case. The existing AZ-336 composition-root factory raises `StrategyNotAvailableError` for this case (per its own contract and test coverage at `test_protocol_conformance.py`). The strategy module's own runtime-label guard raises `ConfigError` for the related "wrong C7 runtime" case. AZ-337 / AZ-338 / AZ-339 followed this same pattern; AZ-340 mirrors them. AC-10 wording should be amended in a future shared spec-pass touching AZ-337..AZ-340; no code change required.
|
||||
|
||||
## Cumulative review obligation
|
||||
|
||||
This batch closes the K=3 cumulative-review window for batches 49–51 (last cumulative review covered batches 46-48). The cumulative review for batches 49–51 runs immediately after this batch report lands, before batch 52 starts.
|
||||
|
||||
## Follow-on PBI
|
||||
|
||||
**AZ-527** (Hygiene — consolidate `_assert_engine_output_dim` into a c2-internal helper). 2 points. Now must consolidate **7** copies (was 4 before AZ-339, became 4 again after AZ-339, now 7 after AZ-340). Depends on AZ-340. To be created and prioritised by the cumulative review for batches 49-51 (about to run).
|
||||
@@ -0,0 +1,74 @@
|
||||
# Batch 52 — Implementation Report (Cycle 1)
|
||||
|
||||
**Tasks**: AZ-527 (Hygiene — consolidate `_assert_engine_output_dim` into a c2-internal helper)
|
||||
**Date**: 2026-05-14
|
||||
**Cycle**: 1
|
||||
**Status**: COMPLETE (review verdict: PASS, zero findings)
|
||||
|
||||
## What was done
|
||||
|
||||
Closed cumulative review batches 49-51 Finding F1 (Medium / Maintainability) — the 7-way duplication of `_assert_engine_output_dim` across the c2_vpr secondary VPR strategy modules. Added one new c2-internal helper module + one new test file; migrated 7 strategy modules to import the helper and deleted their local copies.
|
||||
|
||||
### Files added (3)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/gps_denied_onboard/components/c2_vpr/_engine_dim_assertion.py` | The c2-internal helper. Exposes a single function `assert_engine_output_dim(inference_runtime, handle, preprocessor, descriptor_dim, *, output_key="embedding", input_key="input") -> None` that runs a zero-init dry-run inference at `preprocessor.input_shape()` and raises `gps_denied_onboard.config.schema.ConfigError` on output-shape or output-key mismatch. |
|
||||
| `tests/unit/c2_vpr/test_az527_engine_dim_assertion.py` | 14 tests, AAA pattern, Protocol-conforming fakes. Covers AC-1..AC-4 + a regression guard for stray definitions outside the helper module + an import-grep regression guard verifying all 7 strategy modules import the helper. |
|
||||
| `_docs/03_implementation/reviews/batch_52_review.md` | Code-review report (verdict: PASS, zero findings). |
|
||||
|
||||
### Files changed (7)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/gps_denied_onboard/components/c2_vpr/ultra_vpr.py` | Add helper import; replace local `_assert_engine_output_dim(...)` call with `assert_engine_output_dim(...)` (passing `output_key=_OUTPUT_KEY`, `input_key=_ENGINE_INPUT_KEY`); delete the local function definition (~30 lines). |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/net_vlad.py` | Same pattern, with `output_key="vlad_descriptor"` override (NetVLAD's output key) + runtime-resolved `descriptor_dim` arg. |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/mega_loc.py` | Same pattern as UltraVPR. Also deletes the inline `AZ-527 (planned)` comment (4 lines). |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/mix_vpr.py` | Same pattern as MegaLoc. |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/sela_vpr.py` | Same pattern as MegaLoc. The "7-way duplication … tracked by AZ-527" comment is gone. |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/eigen_places.py` | Same pattern as SelaVPR. |
|
||||
| `src/gps_denied_onboard/components/c2_vpr/salad.py` | Same pattern as SelaVPR. |
|
||||
|
||||
Net diff: **+615 / -191** across 10 files (8 src + 1 test + 1 review report).
|
||||
|
||||
## AC coverage
|
||||
|
||||
All 6 ACs verified.
|
||||
|
||||
| AC | Status | Notes |
|
||||
|----|--------|-------|
|
||||
| AC-1 (helper exists with expected signature) | PASS | `test_ac1_helper_callable_with_default_keys` |
|
||||
| AC-2 (wrong shape raises `ConfigError` with both dims named) | PASS | `test_ac2_wrong_descriptor_width_raises_config_error` (3 parametrised) + `test_ac2_wrong_ndim_or_batch_raises_config_error` (4 parametrised) |
|
||||
| AC-3 (missing output key raises `ConfigError` naming the missing key) | PASS | `test_ac3_missing_default_output_key_raises_config_error` + `test_ac3_missing_overridden_output_key_raises_config_error` |
|
||||
| AC-4 (no stray local definitions remain) | PASS | `test_ac4_no_stray_engine_dim_assertion_definitions_outside_helper` (AST walk) + `test_ac4_seven_strategy_modules_import_the_helper` (import grep) |
|
||||
| AC-5 (AZ-337/338/339/340 AC-6 sub-tests pass unmodified) | PASS | `tests/unit/c2_vpr/` — **230 / 230 PASS**, no test file modified outside the new `test_az527_*` |
|
||||
| AC-6 (AZ-270 + AZ-507 layer lints pass) | PASS | `tests/unit/test_az270_compose_root.py` — **8 / 8 PASS** |
|
||||
|
||||
## Test results
|
||||
|
||||
- `tests/unit/c2_vpr/test_az527_engine_dim_assertion.py` — **14 / 14 PASS**.
|
||||
- `tests/unit/c2_vpr/` (full directory: faiss_bridge + net_vlad + ultra_vpr + AZ-339 + AZ-340 + AZ-527 + protocol_conformance) — **230 / 230 PASS**.
|
||||
- `tests/unit/test_az270_compose_root.py` (composition-root layer lint) — **8 / 8 PASS**.
|
||||
- `tests/unit/test_az508_iso_timestamps.py` (helpers AST-walk regression guards from AZ-526) — **18 / 18 PASS** (sanity check only; AZ-527 doesn't touch helpers/).
|
||||
- `ruff check` on all 10 changed files — **CLEAN** after one round-trip fix (auto-detected `UP037` quoted-annotation issue in the helper; switched to bare names since `from __future__ import annotations` is in effect).
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
1. **Helper stays inside c2_vpr/, NOT in shared/helpers/** — engine output-shape contracts are a c2 internal concern. C7 owns its own engine-shape assertions inside the runtime; sharing the helper across components would entangle the c2 / c7 boundaries that AZ-507 carved cleanly. Documented in the helper module's docstring.
|
||||
2. **Underscore-prefixed module name** (`_engine_dim_assertion.py`) — keeps the helper out of `c2_vpr/__init__.py`'s Public API surface. Strategy modules import it as a sibling internal module; no cross-component import is added.
|
||||
3. **Keyword-only `output_key` / `input_key`** (the `*` separator after `descriptor_dim`) — forbids positional misuse. A future strategy author cannot accidentally swap them.
|
||||
4. **Per-strategy explicit `output_key=_OUTPUT_KEY` + `input_key=_ENGINE_INPUT_KEY`** at the 6 default-key call sites — even though the helper's defaults match the strategy constants, passing them explicitly documents the strategy's engine-IO contract at the call site. Only NetVLAD overrides (`output_key="vlad_descriptor"`).
|
||||
5. **`from __future__ import annotations` + bare type names** — Ruff `UP037` correctly flags quoted annotations as redundant when `from __future__ import annotations` is in scope. The helper uses bare types in the signature; `TYPE_CHECKING`-guarded imports keep runtime overhead at zero.
|
||||
6. **AST-walk + import-grep regression guards** modeled on AZ-526's `test_no_local_iso_ts_*_definitions_remain` — same pattern, same enforcement strength. Future c2_vpr strategy authors who slip a copy back in get caught at CI time.
|
||||
|
||||
## Spec drift noted
|
||||
|
||||
_None._
|
||||
|
||||
## Cumulative review obligation
|
||||
|
||||
Next cumulative review trigger fires after Batch 54 (every K=3 from B51 → B54). Batch 52 is the first batch in the new window. No interim cumulative review obligation.
|
||||
|
||||
## Follow-on PBIs
|
||||
|
||||
_None._ AZ-527 closes the F1 finding cleanly. The earlier F2 (AC-10 spec drift across AZ-337/338/339/340) is documentation-only and remains carried over for a future shared spec-pass; it does not block.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Cumulative Code Review — Batches 49-51 (Cycle 1)
|
||||
|
||||
**Date**: 2026-05-14
|
||||
**Range**: batches 49 (AZ-526 `iso_ts_from_clock` consolidation), 50 (AZ-339 MegaLoc + MixVPR), 51 (AZ-340 SelaVPR + EigenPlaces + SALAD)
|
||||
**Compared against**: previous cumulative review batches 46-48
|
||||
**Verdict**: **PASS_WITH_WARNINGS**
|
||||
|
||||
## Scope
|
||||
|
||||
21 files changed across batches 49-51 (12 src/, 4 test/, 2 docs, 3 indirect — package init + deps table + module-layout). Cumulative review focuses on cross-batch concerns that per-batch reviews cannot catch: duplicate symbols introduced across different batches, architecture drift across the trio, contract drift between producer/consumer batches, and follow-through on prior cumulative findings.
|
||||
|
||||
## Carry-over closure from cumulative review 46-48
|
||||
|
||||
| Prior finding | Status | Notes |
|
||||
|---------------|--------|-------|
|
||||
| F1 (Medium) — `_iso_ts_from_clock` duplicated 4-way | **CLOSED by AZ-526 / Batch 49** | All 4 local copies (`net_vlad.py`, `ultra_vpr.py`, `_faiss_bridge.py`, `operator_reloc_service.py`) now import `iso_ts_from_clock` from `helpers/iso_timestamps`. Regression guard: `tests/unit/test_az508_iso_timestamps.py::test_no_local_iso_ts_from_clock_definitions_remain` (AST-walk). |
|
||||
| F2 (Low) — `_assert_engine_output_dim` near-duplicated between strategies | **OPEN, escalated to current F1** | Was 2-way at B47; now **7-way** after AZ-339 (B50, +2 copies) and AZ-340 (B51, +3 copies). Hygiene PBI **AZ-527** formally created (link below). |
|
||||
| F3 (Low) — `_iso_ts_from_clock` outputs `+00:00`; canonical FDR `ts` is `Z` | **CLOSED by AZ-526 / Batch 49** | The new `iso_ts_from_clock(clock)` helper emits the `Z` suffix. All 4 callers now produce the canonical byte-shape. Verified by `test_az526_iso_ts_from_clock_emits_z_suffix`. |
|
||||
|
||||
## Findings (this window)
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| F1 | Medium | Maintainability | `c2_vpr/{ultra_vpr,net_vlad,mega_loc,mix_vpr,sela_vpr,eigen_places,salad}.py` | `_assert_engine_output_dim` duplicated 7-way across c2_vpr strategy modules — **AZ-527 created** |
|
||||
| F2 | Low | Spec-Gap | `_docs/02_tasks/done/AZ-{337,338,339,340}*.md` § AC-10 | C2 strategy specs literally name `ConfigurationError`; implementation raises `StrategyNotAvailableError` (env-flag OFF) and `ConfigError` (runtime-label mismatch). Documentation drift; no code change required. |
|
||||
|
||||
## Finding Details
|
||||
|
||||
### F1: `_assert_engine_output_dim` duplicated 7-way (Medium / Maintainability) — AZ-527 created
|
||||
|
||||
- **Locations**:
|
||||
- `src/gps_denied_onboard/components/c2_vpr/ultra_vpr.py` (AZ-337, B47)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/net_vlad.py` (AZ-338, B46)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/mega_loc.py` (AZ-339, B50)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/mix_vpr.py` (AZ-339, B50)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/sela_vpr.py` (AZ-340, B51 — new)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/eigen_places.py` (AZ-340, B51 — new)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/salad.py` (AZ-340, B51 — new)
|
||||
|
||||
- **Description**: Each copy is the same ~30-line helper that runs a zero-init dry-run inference, asserts the engine output dict has an `"embedding"` key, and asserts the output ndarray shape is `(1, DESCRIPTOR_DIM)`. The only thing that differs across copies is the local `DESCRIPTOR_DIM` constant (512 / 4096 / 4096 / 8448 / 512 / 2048 / 8448) and the local preprocessor type-hint. The error-message strings and the shape-check logic are byte-identical.
|
||||
|
||||
- **Action taken**: Hygiene PBI **AZ-527** formally opened in Jira (`Hygiene — consolidate _assert_engine_output_dim into a c2-internal helper`, 2 points, Epic AZ-255 E-C2). Scope: add `src/gps_denied_onboard/components/c2_vpr/_engine_dim_assertion.py` exposing `assert_engine_output_dim(...)` parametrised by `descriptor_dim`, `output_key`, `input_key`; replace the 7 local definitions with imports; add a focused unit test; re-run the existing AZ-337 / 338 / 339 / 340 AC-6 tests unmodified. Link: <https://denyspopov.atlassian.net/browse/AZ-527>.
|
||||
|
||||
- **Why escalated from Low (B46-48 cumulative) to Medium**: at 7-way the per-edit risk of one copy drifting from the others (different error-message wording, different shape-check operator order, different early-return semantics) is non-trivial. Each future c2_vpr secondary strategy would add an 8th, 9th copy unless we close the pattern now.
|
||||
|
||||
### F2: AC-10 spec drift across the C2 strategy specs (Low / Spec-Gap)
|
||||
|
||||
- **Locations**:
|
||||
- `_docs/02_tasks/done/AZ-337_c2_ultra_vpr_strategy.md` § AC-10
|
||||
- `_docs/02_tasks/done/AZ-338_c2_netvlad_strategy.md` § AC-10
|
||||
- `_docs/02_tasks/done/AZ-339_c2_mega_loc_mix_vpr.md` § AC-10
|
||||
- `_docs/02_tasks/done/AZ-340_c2_selavpr_eigenplaces_salad.md` § AC-10
|
||||
|
||||
- **Description**: Each spec literally names `ConfigurationError` for the "build flag OFF" case and the "runtime-label mismatch" case. The implementation (correctly, by precedent) raises `StrategyNotAvailableError` for the env-flag-OFF case (composition-root concern) and `ConfigError` for the runtime-label-mismatch case (strategy-internal concern). All four AZ-337..AZ-340 implementations made the same choice; per-batch reviews documented this consistently.
|
||||
|
||||
- **Suggestion**: amend the four AC-10 wordings in a single shared spec-pass (5 minutes' work) so future batches and audit trails do not have to re-explain the divergence. Sized at <1 point — could ride along with any future c2_vpr spec touch. Optionally fold into AZ-527 if it touches the spec wording for AC-6 anyway.
|
||||
|
||||
- **Defer rationale**: pure documentation drift, no code defect, no missing test coverage. Filed as F2 here so it surfaces in the audit trail without blocking.
|
||||
|
||||
## Phase Summary
|
||||
|
||||
### Phase 1 — Context Loading
|
||||
|
||||
- 3 task specs reviewed (AZ-526, AZ-339, AZ-340).
|
||||
- 4 doc inputs: `architecture.md`, `module-layout.md`, the 3 per-batch reviews (`batch_49_review.md` / `batch_50_review.md` / `batch_51_review.md`), and `_docs/02_document/components/02_c2_vpr/description.md`.
|
||||
- No `_docs/02_document/architecture_compliance_baseline.md` exists yet; the **Baseline Delta** section is omitted from this report (consistent with prior cumulative reviews).
|
||||
|
||||
### Phase 2 — Spec Compliance
|
||||
|
||||
Per-batch reviews covered AC-by-AC compliance for each task. Cross-batch checks:
|
||||
|
||||
- **AZ-339 + AZ-340 vs AZ-337 + AZ-338 pattern parity**: the five new strategies (MegaLoc, MixVPR, SelaVPR, EigenPlaces, SALAD) line up with UltraVPR's structural skeleton — `create()` factory + `__init__` + `embed_query` + `retrieve_topk` + private `_assert_engine_output_dim` + `_emit_*` FDR helpers. NetVLAD remains the lone outlier (PyTorch architecture-factory path). All five new strategies use the TRT-only path identical to UltraVPR. No accidental drift onto the NetVLAD-only intra-cluster + L2 path.
|
||||
- **`_STRATEGY_TO_BUILD_FLAG` / `_STRATEGY_TO_MODULE` pre-wiring** in `runtime_root/vpr_factory.py`: the 5 rows for `mega_loc`, `mix_vpr`, `sela_vpr`, `eigen_places`, `salad` were added at AZ-336 land time and remain untouched by AZ-339 / AZ-340 — confirmed by `git log --diff-filter=M -- src/gps_denied_onboard/runtime_root/vpr_factory.py` (no commits in this window). Strategy modules now actually exist for all 5 entries — no more silent ImportError surprises if a downstream test toggles a build flag ON.
|
||||
- **AC-10 spec wording drift** captured as F2 above.
|
||||
|
||||
### Phase 3 — Code Quality
|
||||
|
||||
- **SRP**: each new strategy and each new preprocessor has a single clear concern. Helpers separated into `_preprocessor_<backbone>.py` siblings.
|
||||
- **Error handling**: typed exception envelopes (`ConfigError`, `VprBackboneError`, `VprPreprocessError`, `StrategyNotAvailableError`) used consistently across all 5 new strategies; no bare `except`.
|
||||
- **Naming**: aligned across all 7 c2_vpr secondary strategies; method shapes for `embed_query` / `retrieve_topk` / `_emit_*` mirror line-for-line.
|
||||
- **Test quality**: AZ-339 and AZ-340 both adopted the parametrised single-file pattern (driven by `_STRATEGY_MODULES` table) instead of duplicating per-strategy test files. Reduces test surface from ~2300 lines (3 copies of `test_ultra_vpr.py`) to ~700 lines for AZ-340 with strictly equivalent AC coverage. Same precedent as the AZ-339 review noted.
|
||||
- **Test-environment-mirrors-prod gate**: still satisfied — fakes mirror the production interfaces (`InferenceRuntimeCut`, `DescriptorIndexCut`, `BackbonePreprocessor` Protocols) and never live entirely in test files.
|
||||
|
||||
### Phase 4 — Security Quick-Scan
|
||||
|
||||
No SQL, no `subprocess`, no `eval` / `exec`, no secrets. New code is pure-Python numerical wiring + FDR record emission. n/a.
|
||||
|
||||
### Phase 5 — Performance Scan
|
||||
|
||||
- All 5 new strategies use FP16 inputs and pre-compiled TensorRT engines — consistent with the project's perf budget.
|
||||
- Engine load + output-shape assertion happens at `create()` time (startup) not first-frame — explicit AZ-337 architectural decision; preserved across AZ-339 / AZ-340.
|
||||
- SALAD's higher D=8448 will produce a larger FAISS index footprint — operator-side concern at corpus build time; flagged in the AZ-340 batch report § Architectural Decisions.
|
||||
- No obvious hot-path allocations beyond what numpy / FAISS already do.
|
||||
|
||||
### Phase 6 — Cross-Task Consistency
|
||||
|
||||
- **Pattern parity across all 7 c2_vpr secondary strategies**: confirmed — same skeleton, same FDR record kinds, same log kinds, same error envelope. The duplication F1 is the cost of this parity until AZ-527 lands.
|
||||
- **`iso_ts_from_clock` import consistency**: all c2_vpr strategy modules now import `iso_ts_from_clock` from `gps_denied_onboard.helpers.iso_timestamps` — verified by AZ-526 regression guard.
|
||||
- **`intra_cluster_normalise` helper**: still used ONLY by NetVLAD as intended. Verified by spy tests in `test_net_vlad.py` and absence of import in the 6 other strategies.
|
||||
- **FDR record schema**: no schema-level additions in this window beyond the records the existing strategies already emit. The 5 new strategies emit the same record kinds (`vpr.embedding_complete`, `vpr.retrieval_complete`, `vpr.query_failed`) as UltraVPR / NetVLAD / MegaLoc / MixVPR — verified by `tests/unit/test_az272_fdr_record_schema.py` (passes unmodified).
|
||||
- **AZ-526 closure** of cumulative-46-48 F1 + F3: verified — zero `_iso_ts_from_clock` definitions remain anywhere under `src/`.
|
||||
|
||||
### Phase 7 — Architecture Compliance
|
||||
|
||||
1. **Layer direction**: c2_vpr (Layer 3) imports from `_types` (Layer 1), `helpers/*` (Layer 1), `config` (Layer 1), `logging` (Layer 1), `fdr_client` (Layer 1), `clock` (Layer 1). All downward. No upward imports detected in any of the 5 new strategy or 5 new preprocessor modules.
|
||||
2. **Public API respect**: all c2_vpr → c6 / c7 communication continues to go through the structural Protocol cuts (`InferenceRuntimeCut`, `DescriptorIndexCut`). Verified by `test_az270_compose_root.test_ac6_only_compose_root_imports_concrete_strategies` (passes — 8/8).
|
||||
3. **No new cyclic deps**: built-import graph of changed files + direct dependencies — no cycles introduced.
|
||||
4. **Duplicate symbols across components**: F1 above (intra-component duplication, scoped to `c2_vpr/*.py`). No cross-component duplication detected.
|
||||
5. **Cross-cutting concern not locally re-implemented**: AZ-526 correctly hoisted `_iso_ts_from_clock` out of per-component locals. The remaining open pattern is `_assert_engine_output_dim` — but this is intentionally c2-internal (engine output-shape contract is a c2 concern; C7 has its own engine-shape assertions for its own purposes inside the runtime). AZ-527 keeps it inside c2_vpr/, not in shared/helpers.
|
||||
6. **Preprocessor duplication across the 7 backbones**: per `components/02_c2_vpr/description.md` § 6 and the AZ-339 / AZ-340 task specs § Constraints, this is **intentional and architectural** — each backbone owns its own input-shape constants (224×224, 322×322, 384×384, 480×480, 512×512) so a future model-weights drop can change a backbone's preprocessing without coupling other strategies' weights-versions. **Not a finding.**
|
||||
|
||||
## Verdict Justification
|
||||
|
||||
- Critical findings: 0
|
||||
- High findings: 0
|
||||
- Medium findings: 1 (F1 — already addressed via AZ-527 ticket creation)
|
||||
- Low findings: 1 (F2 — pure documentation drift)
|
||||
|
||||
**PASS_WITH_WARNINGS** — auto-chain to next batch is allowed per implement skill Step 14.5. The F1 finding has an open Jira ticket (AZ-527) ready to be sequenced into a future batch; F2 is documentation-only and does not block.
|
||||
|
||||
## Recommended Follow-up
|
||||
|
||||
1. **Sequence AZ-527 before any future c2_vpr secondary backbone (none planned in current scope, but defensively before any AZ-358 / AZ-389 / AZ-358 c2-side adjacent work).** 2 points, 1 new module + 7 small edits + 1 test.
|
||||
2. **F2 spec-pass** (~5 minutes, <1 point): fold the AC-10 wording correction into AZ-527's spec touch if it lands first; otherwise file as a tiny standalone hygiene PBI.
|
||||
3. **No other carry-over.** All previous cumulative findings are now either CLOSED or have a tracked follow-up.
|
||||
|
||||
## Next Cumulative Review
|
||||
|
||||
- **Trigger**: after Batch 54 (every K=3).
|
||||
- **Range**: batches 52-54.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Code Review Report — Batch 51
|
||||
|
||||
**Batch**: 51
|
||||
**Tasks**: AZ-340 (C2 SelaVPR + EigenPlaces + SALAD Secondary Backbones — Research-only, 5 pts)
|
||||
**Date**: 2026-05-14
|
||||
**Verdict**: **PASS_WITH_WARNINGS** (2 Low findings — one is a carry-over already tracked by AZ-527, the other mirrors the documented AZ-337/338/339 spec-drift precedent)
|
||||
|
||||
## Phase 1 — Context Loading
|
||||
|
||||
Reviewed:
|
||||
|
||||
- `_docs/02_tasks/todo/AZ-340_c2_selavpr_eigenplaces_salad.md` — task spec, ACs, NFRs, scope, constraints, runtime completeness section.
|
||||
- `_docs/02_document/contracts/c2_vpr/vpr_strategy_protocol.md` (referenced; behavioural Protocol contract).
|
||||
- `_docs/02_document/components/02_c2_vpr/description.md` § 1, § 5, § 6 (referenced; secondary backbone designation, list of strategies including SALAD, preprocessor-duplication policy).
|
||||
- `_docs/02_document/module-layout.md` — Component: c2_vpr Internal entry already pre-declares `sela_vpr.py`, `eigen_places.py`, `salad.py`; `BUILD_VPR_SELAVPR` / `BUILD_VPR_EIGENPLACES` / `BUILD_VPR_SALAD` rows already in the build-flag matrix.
|
||||
- Existing AZ-339 implementation (`mega_loc.py` / `mix_vpr.py` / `_preprocessor_*.py`) as the architectural template — AZ-340 is the explicit twin task (research-only secondary backbones via TRT, no PyTorch architecture registry).
|
||||
- Composition root `runtime_root/vpr_factory.py` — `_STRATEGY_TO_BUILD_FLAG` and `_STRATEGY_TO_MODULE` already include `sela_vpr` / `eigen_places` / `salad` rows (pre-wired at AZ-336 land time).
|
||||
- C2 config `KNOWN_STRATEGIES` already includes the three names.
|
||||
|
||||
Files mapped to AZ-340:
|
||||
|
||||
| Task | Files |
|
||||
|------|-------|
|
||||
| AZ-340 | `src/gps_denied_onboard/components/c2_vpr/sela_vpr.py` (new), `eigen_places.py` (new), `salad.py` (new), `_preprocessor_sela_vpr.py` (new), `_preprocessor_eigen_places.py` (new), `_preprocessor_salad.py` (new), `tests/unit/c2_vpr/test_az340_sela_vpr_eigen_places_salad.py` (new) |
|
||||
|
||||
## Phase 2 — Spec Compliance Review
|
||||
|
||||
| AC | Status | Test reference | Notes |
|
||||
|----|--------|----------------|-------|
|
||||
| AC-1 (each) | PASS | `test_ac1_protocol_conformance[sela_vpr|eigen_places|salad]` | `isinstance(..., VprStrategy)` returns True for all three. |
|
||||
| AC-2 (each) | PASS | `test_ac2_embed_query_returns_unit_norm_fp16_correct_dim[*]` + `test_ac2_single_stage_l2_no_intra_cluster_call[*]` | Shape (512,) / (2048,) / (8448,); FP16 dtype; ‖x‖₂ ≈ 1.0 ± 1e-3. Single-stage L2 confirmed by spy normaliser; no `intra_cluster_normalise` call. |
|
||||
| AC-3 (each) | PASS | `test_ac3_embed_query_deterministic_for_same_frame[*]` | Bit-exact embeddings across 3 calls on same frame. |
|
||||
| AC-4 (each) | PASS | `test_ac4_retrieve_topk_returns_exactly_k_with_correct_label[*]` | `len == 10`, ascending distances, correct `backbone_label`, correct `descriptor_dim`. |
|
||||
| AC-5 (each) | PASS | `test_ac5_descriptor_dim_stable[*]` | 100 calls, returns 512 / 2048 / 8448. |
|
||||
| AC-6 (each) | PASS | `test_ac6_create_rejects_engine_output_shape_mismatch[*]` + `test_ac6_create_rejects_missing_embedding_key[*]` | `ConfigError` raised at create-time; the dry-run probe matches each preprocessor's `input_shape()`. |
|
||||
| AC-7 (each) | PASS | `test_ac7_runtime_error_yields_vpr_backbone_error[*]` + `test_ac7_wrong_forward_output_shape_yields_vpr_backbone_error[*]` | `RuntimeError` / wrong-shape outputs raise `VprBackboneError`; ERROR log + FDR record emitted. |
|
||||
| AC-8 (each) | PASS | `test_ac8_corrupt_image_yields_vpr_preprocess_error[*]` | Non-array `frame.image` raises `VprPreprocessError`; ERROR log + FDR record emitted. |
|
||||
| AC-9 (each) | PASS | `test_ac9_create_emits_ready_log_with_correct_label_and_dim[*]` | INFO log `kind="c2.vpr.ready"` with correct `{strategy, descriptor_dim}`. |
|
||||
| AC-10 (each) | PASS with drift | `test_ac10_runtime_label_mismatch_raises_config_error[*]` | See **F2** below — same documented precedent as AZ-337 / AZ-338 / AZ-339. |
|
||||
| AC-11 (each) | PASS | `test_ac11_preprocessor_input_shape[*]` + `test_preprocess_output_is_nchw_fp16[*]` | `(224, 224)` / `(480, 480)` / `(322, 322)`. |
|
||||
|
||||
**Constraint compliance**:
|
||||
|
||||
- Each strategy ships its own concrete preprocessor (✓).
|
||||
- Centre-crop logic duplicated, NOT shared (✓ — explicit duplication, comment references description.md § 6).
|
||||
- All three use TensorRT runtime via `InferenceRuntimeCut` (✓).
|
||||
- No engine compilation in this task (✓ — `inference_runtime.compile_engine` is the local TRT runtime; the actual `.trt` file source is C10 / AZ-321).
|
||||
- All three hold engine handles, not engines (✓).
|
||||
- No GPU operations in `__init__` beyond engine load (✓ — engine load is in `create()`, not in `__init__`).
|
||||
- SALAD's 8448-d output is non-negotiable, no PCA at strategy-level (✓ — DESCRIPTOR_DIM = 8448 is a Final constant; PCA gating left for a future `BUILD_VPR_SALAD_PCA` flag, out of scope).
|
||||
- Logging + FDR records mirror UltraVPR / MegaLoc / MixVPR pattern (✓).
|
||||
|
||||
## Phase 3 — Code Quality Review
|
||||
|
||||
- **SOLID**: each strategy is a single class with one responsibility (one backbone). Constructor injection throughout. Module-level `create()` factory keeps composition concerns out of `__init__`.
|
||||
- **Error handling**: explicit `try` / `raise` for backbone failures; `_wrap_backbone_error` rewraps general `Exception` into the typed envelope. No bare `except`. ERROR logs emitted with structured `extra={...}`.
|
||||
- **Naming**: `SelaVprStrategy` / `EigenPlacesStrategy` / `SaladStrategy`, `_BACKBONE_LABEL`, `DESCRIPTOR_DIM`, `_OUTPUT_KEY`, `_LOG_KIND_*`, `_FDR_KIND_*` — consistent with the existing C2 strategy modules.
|
||||
- **Complexity**: `embed_query` ≈ 50 lines per strategy (matches MegaLoc / MixVPR; under the 50-line / cyc-10 threshold). `create()` ≈ 60 lines but mostly orchestration; mirrors the AZ-339 baseline.
|
||||
- **DRY**: significant duplication across three strategies + three preprocessors. Documented as intentional per `description.md` § 6 + module-level docstrings + tracked by AZ-527 (the planned hygiene PBI scoped at AZ-339 land). See **F1** below.
|
||||
- **Test quality**: parametrised across the three strategies; each test asserts meaningful behaviour (shape, dtype, norm, label, error type, log kind, FDR record kind). 54/54 PASS.
|
||||
- **Dead code**: none — `ruff check` clean across all six new modules and the test file.
|
||||
|
||||
## Phase 4 — Security Quick-Scan
|
||||
|
||||
- No SQL, no `subprocess`, no `eval` / `exec`, no `pickle` deserialisation.
|
||||
- No hardcoded secrets / credentials / API keys.
|
||||
- No external input ingested without validation (`_coerce_to_rgb_uint8` rejects non-numpy / wrong-dtype / wrong-shape image bytes).
|
||||
- Sensitive data in logs: only `frame_id`, `backbone_label`, `descriptor_dim`, `error_type`, error-message string truncated to 512 chars. No PII / no calibration intrinsics / no embedding payloads. ✓
|
||||
|
||||
## Phase 5 — Performance Scan
|
||||
|
||||
- No O(n²) algorithms in the new code paths. FAISS retrieval is delegated to `FaissBridge` (AZ-341) and runs once per query.
|
||||
- No N+1 query patterns (no DB access in c2_vpr).
|
||||
- No unbounded data fetching.
|
||||
- No blocking I/O in async contexts (codebase is synchronous; the strategies are explicitly single-threaded per INV-1).
|
||||
- Memory copies: `np.ascontiguousarray(...)` on `raw[0]` produces one copy of the (D,) embedding before normalisation; `preprocessor.preprocess` produces one (1, 3, H, W) FP16 NCHW buffer. Both are intrinsic to the per-frame VPR contract; no avoidable allocations in the hot path.
|
||||
- SALAD's 8448-d output and 322×322 input are acknowledged in the task spec NFR-perf budget (≤ 120 ms / ≤ 1200 MB GPU; ≤ 6 ms FAISS lookup); no performance regression introduced by the strategy code itself — the cost is intrinsic to the chosen backbone.
|
||||
|
||||
## Phase 6 — Cross-Task Consistency
|
||||
|
||||
Batch 51 contains a single task (AZ-340), so cross-task drift inside the batch is not applicable.
|
||||
|
||||
Inter-batch consistency with the prior C2 batches (45 / 46 / 47 / 50):
|
||||
|
||||
- All four C2 secondary strategy modules (mega_loc, mix_vpr, sela_vpr, eigen_places, salad — five total now) share the identical TRT-only contract and embed_query / retrieve_topk / descriptor_dim shapes. ✓
|
||||
- All four use `iso_ts_from_clock` from the AZ-526 helper from day one. ✓
|
||||
- All four use the local `_faiss_bridge.FaissBridge` and the AZ-507 `inference_runtime_cut` / `descriptor_index_cut` Protocol cuts. ✓
|
||||
- All four use ImageNet mean/std normalisation in their preprocessor. ✓
|
||||
|
||||
## Phase 7 — Architecture Compliance
|
||||
|
||||
1. **Layer direction**: every import in the six new files resolves to a Layer-1 (`_types`, `helpers`, `config`, `logging`, `fdr_client`, `clock`) or same-component (`c2_vpr.*`) target. Verified via `from ... import` survey of each new file. ✓ No layer-direction violations.
|
||||
|
||||
2. **Public API respect**: no `from gps_denied_onboard.components.c6_tile_cache import ...` and no `from gps_denied_onboard.components.c7_inference import ...` in any of the new files. The c6 / c7 surfaces are reached only via the constructor-injected `DescriptorIndexCut` / `InferenceRuntimeCut` Protocols (AZ-507 pattern). ✓
|
||||
|
||||
3. **No new cyclic module dependencies**: the strategy modules are leaf modules within `c2_vpr/`. No new cycles introduced. ✓
|
||||
|
||||
4. **Duplicate symbols across components**: all new symbols (`SelaVprStrategy`, `EigenPlacesStrategy`, `SaladStrategy`, `*BackbonePreprocessor`, `DESCRIPTOR_DIM`, `*_INPUT_HW`) live exclusively under `src/gps_denied_onboard/components/c2_vpr/`. No duplication into other components.
|
||||
|
||||
Within `c2_vpr/` itself: the `_assert_engine_output_dim` helper is now duplicated 7-way (was 4-way after AZ-339, becomes 7-way after AZ-340). This is **F1** below; the duplication is intentional and tracked by AZ-527.
|
||||
|
||||
5. **Cross-cutting concerns not locally re-implemented**: no local `_iso_ts_from_clock`; no local logger setup; no local config loading; no local FDR record schema. All cross-cutting work goes through the established shared modules. ✓ AZ-526 regression guard (the AST scan in `test_az508_iso_timestamps.py`) confirms no new module-level `_iso_ts_from_clock` / `_iso_ts_now` definitions.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| F1 | Low | Maintainability | `c2_vpr/{sela_vpr,eigen_places,salad}.py` (3 new copies) + `c2_vpr/{ultra_vpr,net_vlad,mega_loc,mix_vpr}.py` (4 pre-existing copies) | `_assert_engine_output_dim` now 7-way duplicated across c2_vpr strategies |
|
||||
| F2 | Low | Spec-Gap (Documentation drift) | `c2_vpr/{sela_vpr,eigen_places,salad}.py` `create()` runtime-label guard | AZ-340 AC-10 literally names `ConfigurationError`; implementation raises `ConfigError` (matching AZ-337 / AZ-338 / AZ-339 precedent) |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: `_assert_engine_output_dim` 7-way duplicated across c2_vpr strategies (Low / Maintainability)**
|
||||
|
||||
- **Locations** (3 new + 4 pre-existing copies):
|
||||
- `src/gps_denied_onboard/components/c2_vpr/sela_vpr.py:_assert_engine_output_dim` (new)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/eigen_places.py:_assert_engine_output_dim` (new)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/salad.py:_assert_engine_output_dim` (new)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/ultra_vpr.py:_assert_engine_output_dim` (pre-existing)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/net_vlad.py:_assert_engine_output_dim` (pre-existing)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/mega_loc.py:_assert_engine_output_dim` (pre-existing)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/mix_vpr.py:_assert_engine_output_dim` (pre-existing)
|
||||
- **Description**: Each copy is the same ~30-line helper that runs a zero-init dry-run inference, asserts the engine's output dict has an `"embedding"` key, and asserts the output ndarray shape is `(1, DESCRIPTOR_DIM)`. The only thing that differs across the 7 copies is the local `DESCRIPTOR_DIM` constant. The duplication is **intentional** and **tracked**: each copy carries an in-line comment referencing AZ-527 (the hygiene PBI scoped in parallel with AZ-339 land).
|
||||
- **Suggestion**: extract to a c2-internal helper module `c2_vpr/_engine_dim_assertion.py` (or similar) under AZ-527. The helper would take `descriptor_dim` and `_assert_engine_output_dim(inference_runtime, handle, preprocessor, descriptor_dim)` could be a single shared function. Each strategy keeps its own `DESCRIPTOR_DIM` constant; the helper reads the dim from the parameter.
|
||||
- **Why Low (not Medium)**: this finding is the carry-over of cumulative review batches 46-48 F2 (also Low) — the duplication has been growing since AZ-337 / AZ-338. AZ-527 is already on the backlog; AZ-340 was scoped to land the three secondary strategies, and pulling the consolidation forward would have expanded AZ-340's scope past the spec.
|
||||
- **Cumulative-review handoff**: this finding will surface in the cumulative review for batches 49–51 (about to run after batch 51 commits). AZ-527 closes it.
|
||||
- **Task**: AZ-340
|
||||
|
||||
**F2: AC-10 spec drift — `ConfigError` raised instead of literally-named `ConfigurationError` (Low / Spec-Gap, documentation drift)**
|
||||
|
||||
- **Locations**:
|
||||
- `src/gps_denied_onboard/components/c2_vpr/sela_vpr.py` `create()` runtime-label guard (line ~365)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/eigen_places.py` `create()` runtime-label guard (line ~365)
|
||||
- `src/gps_denied_onboard/components/c2_vpr/salad.py` `create()` runtime-label guard (line ~365)
|
||||
- **Description**: AZ-340 AC-10 literally specifies `ConfigurationError` for the build-flag-OFF case. The implementation raises `StrategyNotAvailableError` (composition root, env-flag check; thrown BEFORE `create()` is reached) and `ConfigError` (strategy module's own runtime-label guard). Both are composition-time fail-fast errors. AZ-337 / AZ-338 / AZ-339 follow this same pattern; AZ-340 mirrors them.
|
||||
- **Suggestion**: amend the AC-10 wording in a future spec pass (`AZ-340_c2_selavpr_eigenplaces_salad.md` already archived; the documentation drift will be cleared by a single shared spec-pass that touches all five secondary backbones). No code change required. The AC-10 test (`test_ac10_runtime_label_mismatch_raises_config_error`) carries an in-line docstring that documents the precedent.
|
||||
- **Task**: AZ-340
|
||||
|
||||
## Baseline Delta
|
||||
|
||||
`_docs/02_document/architecture_compliance_baseline.md` does not exist for this project — Phase 7 ran without baseline-delta partitioning. No tables to emit.
|
||||
|
||||
## Test Results
|
||||
|
||||
- `tests/unit/c2_vpr/test_az340_sela_vpr_eigen_places_salad.py` — **54 / 54 PASS** (3 strategies × 18 test cases).
|
||||
- `tests/unit/c2_vpr/` (full directory: faiss_bridge + net_vlad + ultra_vpr + AZ-339 + AZ-340 + protocol_conformance) — **180 / 180 PASS**.
|
||||
- `tests/unit/test_az270_compose_root.py` — **8 / 8 PASS** (no composition-root regressions; the factory routing for `sela_vpr` / `eigen_places` / `salad` was pre-wired at AZ-336 land time).
|
||||
- `tests/unit/test_az508_iso_timestamps.py` — **18 / 18 PASS** (AZ-526 regression guard confirms no new `_iso_ts_from_clock` / `_iso_ts_now` duplicates introduced by AZ-340).
|
||||
- `ruff check` on all 7 new files — **clean** (one auto-fixable `RUF022 __all__ not sorted` in `_preprocessor_eigen_places.py` was caught and fixed before commit).
|
||||
|
||||
## Auto-Fix Eligibility
|
||||
|
||||
| Finding | Severity | Eligible? | Action |
|
||||
|---------|----------|-----------|--------|
|
||||
| F1 | Low / Maintainability | Yes (per the auto-fix matrix) | NOT auto-fixed in batch 51 — it requires creating an `AZ-527` hygiene PBI and is scoped as a separate batch. Logged as a follow-on for AZ-527. The cumulative review for batches 49–51 will re-surface it. |
|
||||
| F2 | Low / Spec-Gap | No (Spec-Gap is escalate per matrix) | Documentation drift — no code change required. Logged for the next spec-pass. |
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS_WITH_WARNINGS** — no Critical or High findings. The two Low findings are both pre-existing patterns (F1 is a 4 → 7 escalation of an already-tracked duplication; F2 is the same precedent as AZ-337 / 338 / 339). The implement skill auto-fix gate proceeds to commit without user intervention.
|
||||
|
||||
## Follow-on Work
|
||||
|
||||
- **AZ-527** (Hygiene — consolidate `_assert_engine_output_dim` into a c2-internal helper). 2 points. Now must consolidate 7 copies, not 4. Depends on AZ-340. To be created and prioritised by the cumulative review for batches 49–51.
|
||||
- **Spec-pass** to align AC-10 wording across AZ-337 / AZ-338 / AZ-339 / AZ-340 task specs with the implemented `ConfigError` / `StrategyNotAvailableError` envelope. Out of scope for the implement skill; should be scheduled in a documentation-sync batch.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Code Review Report — Batch 52
|
||||
|
||||
**Batch**: 52 (AZ-527 — Hygiene: consolidate `_assert_engine_output_dim` into a c2-internal helper)
|
||||
**Date**: 2026-05-14
|
||||
**Verdict**: **PASS**
|
||||
|
||||
## Findings
|
||||
|
||||
_None._
|
||||
|
||||
## Phase Summary
|
||||
|
||||
### Phase 1 — Context Loading
|
||||
|
||||
Inputs read:
|
||||
|
||||
- `_docs/02_tasks/todo/AZ-527_hygiene_engine_dim_assertion_consolidation.md` — task spec.
|
||||
- `_docs/03_implementation/cumulative_review_batches_49-51_cycle1_report.md` § F1 — the finding being closed.
|
||||
- `_docs/02_document/components/02_c2_vpr/description.md` § 6 — the c2_vpr internal-helper boundary.
|
||||
- `_docs/02_document/architecture.md` + `_docs/02_document/module-layout.md` — for layer direction + Public API rules.
|
||||
- The 7 modified strategy modules (`ultra_vpr.py`, `net_vlad.py`, `mega_loc.py`, `mix_vpr.py`, `sela_vpr.py`, `eigen_places.py`, `salad.py`).
|
||||
- The new `src/gps_denied_onboard/components/c2_vpr/_engine_dim_assertion.py`.
|
||||
- The new `tests/unit/c2_vpr/test_az527_engine_dim_assertion.py`.
|
||||
|
||||
### Phase 2 — Spec Compliance
|
||||
|
||||
| AC | Status | Evidence |
|
||||
|----|--------|----------|
|
||||
| AC-1 (helper exists with expected signature) | PASS | `test_ac1_helper_callable_with_default_keys`. The signature is `(inference_runtime, handle, preprocessor, descriptor_dim, *, output_key="embedding", input_key="input") -> None`. |
|
||||
| AC-2 (wrong shape raises `ConfigError` with both dims named) | PASS | `test_ac2_wrong_descriptor_width_raises_config_error` (3 parametrised) + `test_ac2_wrong_ndim_or_batch_raises_config_error` (4 parametrised). Error envelope is `gps_denied_onboard.config.schema.ConfigError`. |
|
||||
| AC-3 (missing output key raises `ConfigError` naming the missing key) | PASS | `test_ac3_missing_default_output_key_raises_config_error` + `test_ac3_missing_overridden_output_key_raises_config_error`. Both default `"embedding"` and NetVLAD override `"vlad_descriptor"` paths covered. |
|
||||
| AC-4 (no stray local definitions remain) | PASS | `test_ac4_no_stray_engine_dim_assertion_definitions_outside_helper` (AST walk, modeled on AZ-526's `test_ac4_az526_no_module_level_iso_ts_from_clock_outside_helper`) + `test_ac4_seven_strategy_modules_import_the_helper` (import grep on each of the 7 strategy modules). |
|
||||
| AC-5 (AZ-337/338/339/340 AC-6 tests pass unmodified) | PASS | Full c2_vpr test directory: **230 / 230 PASS** (no test file modified outside the new `test_az527_*`). |
|
||||
| AC-6 (AZ-270 + AZ-507 layer lints pass) | PASS | `tests/unit/test_az270_compose_root.py` + `tests/unit/test_az508_iso_timestamps.py` (combined): **26 / 26 PASS**. Helper is `_engine_dim_assertion.py` (underscore = c2-internal), not in the strategy resolution table. |
|
||||
|
||||
Constraints (from § Constraints): all satisfied.
|
||||
|
||||
- Helper module is c2-internal (`_engine_dim_assertion.py` underscore prefix; not exported via `c2_vpr/__init__.py`).
|
||||
- `descriptor_dim` / `output_key` / `input_key` are parameters; helper has zero per-backbone module constants.
|
||||
- Error envelope preserved: `gps_denied_onboard.config.schema.ConfigError` (NOT `ValueError`, NOT a custom new error type).
|
||||
- Probe input shape comes from `preprocessor.input_shape()` (covered by `test_helper_propagates_preprocessor_input_shape_to_probe_tensor`).
|
||||
- Test additions go into new `test_az527_engine_dim_assertion.py`; the AZ-337/338/339/340 test files are untouched.
|
||||
|
||||
### Phase 3 — Code Quality
|
||||
|
||||
- **SRP**: helper has a single concern — verify the loaded engine emits `(1, descriptor_dim)` under `output_key`. Nothing else.
|
||||
- **Error handling**: raises typed `ConfigError` with full context (expected + actual dims; missing key name); no bare `except`; no silent suppression.
|
||||
- **Naming**: `assert_engine_output_dim`, `descriptor_dim`, `output_key`, `input_key` — all precise, no vague terms.
|
||||
- **DRY**: 7 copies → 1 source of truth (the entire point of this PBI).
|
||||
- **Test quality**: Arrange / Act / Assert pattern; parametrised over the realistic dim values (512 / 2048 / 8448) + degenerate shapes (`(512,)`, `(2,1,512)`, `(1,1,512)`, `(1,512,1)`); regression guards via AST walk + import grep.
|
||||
- **Dead code**: 7 local `_assert_engine_output_dim` definitions + their inline `AZ-527 (planned)` comments deleted. `EngineHandle` and `InferenceRuntimeCut` imports remain referenced elsewhere in each strategy (verified by ripgrep).
|
||||
- **Comment hygiene**: helper docstring describes intent + parameters + raises; no narrating comments. Test file uses minimal section headers per the existing test_az340_* / test_az526_* precedent.
|
||||
- **Annotation form**: `from __future__ import annotations` + bare type names (no quoted strings) — Ruff `UP037` clean.
|
||||
|
||||
### Phase 4 — Security Quick-Scan
|
||||
|
||||
No SQL, no `subprocess`, no `eval` / `exec`, no secrets. The helper is pure-Python numerical wiring (allocate a zero-init `(1,3,h,w)` ndarray, call `infer()`, assert dict shape). n/a.
|
||||
|
||||
### Phase 5 — Performance Scan
|
||||
|
||||
- The helper allocates one zero-init `(1, 3, H, W)` `np.float16` ndarray and runs one `infer()` call. Both happen at `create()` time (startup), NOT per frame.
|
||||
- No hot-path impact. The migration replaced 7 byte-identical helpers with 1 — wall-clock startup time is unchanged within measurement noise.
|
||||
|
||||
### Phase 6 — Cross-Task Consistency
|
||||
|
||||
- All 7 strategy modules consistently route through the helper. The 6 default-key backbones (UltraVPR / MegaLoc / MixVPR / SelaVPR / EigenPlaces / SALAD) explicitly pass `output_key=_OUTPUT_KEY` + `input_key=_ENGINE_INPUT_KEY` (matching the helper defaults but making the strategy contract explicit at the call site). NetVLAD overrides `output_key="vlad_descriptor"` only. This is the only intentional behavioural difference between the strategies' use of the helper.
|
||||
- The helper signature uses keyword-only args for `output_key` / `input_key` (the `*` separator after `descriptor_dim`). This forbids positional misuse — a future strategy author cannot accidentally swap them.
|
||||
|
||||
### Phase 7 — Architecture Compliance
|
||||
|
||||
1. **Layer direction**: `_engine_dim_assertion.py` (c2_vpr, Layer 3) imports `ConfigError` from `gps_denied_onboard.config.schema` (Layer 1) and `numpy` (stdlib-equivalent). Type-only imports under `TYPE_CHECKING` are `EngineHandle` (`_types.inference`, Layer 1), `BackbonePreprocessor` (`c2_vpr/_preprocessor`, Layer 3 — same component), `InferenceRuntimeCut` (`c2_vpr/inference_runtime_cut`, Layer 3 — same component). All imports are downward or intra-component. No upward imports.
|
||||
2. **Public API respect**: the helper is c2-internal — underscore-prefixed module name (`_engine_dim_assertion.py`) and NOT re-exported from `c2_vpr/__init__.py`. The 7 strategy modules import it as a sibling internal module, not via a Public API. No cross-component import added.
|
||||
3. **No new cyclic deps**: built-import graph of changed files + direct dependencies. The helper has zero c2_vpr → c2_vpr cycle risk because it imports its dependencies under `TYPE_CHECKING` only (no runtime back-edge to the strategy modules). Strategy modules import the helper as a leaf — no cycle.
|
||||
4. **Duplicate symbols**: 7-way `_assert_engine_output_dim` duplication is now zero-way duplicated. AC-4 regression guard prevents recurrence.
|
||||
5. **Cross-cutting concerns not locally re-implemented**: the helper stays inside `c2_vpr/`, not in `shared/helpers/`. This is correct per AZ-527 § Out of scope: the engine output-shape contract is a c2 internal concern (C7 owns its own engine-shape assertions inside the runtime). The reasoning is explicit in `_engine_dim_assertion.py`'s module docstring.
|
||||
|
||||
### Phase 8 — Test infrastructure
|
||||
|
||||
- `tests/unit/c2_vpr/test_az527_engine_dim_assertion.py` — 14 tests, all PASS.
|
||||
- `tests/unit/c2_vpr/` (full directory) — 230 / 230 PASS (UltraVPR / NetVLAD / MegaLoc / MixVPR / SelaVPR / EigenPlaces / SALAD AC-6 sub-tests all green; FaissBridge / protocol_conformance / new AZ-527 file all green).
|
||||
- `tests/unit/test_az270_compose_root.py` (composition-root layer lint) — 8 / 8 PASS.
|
||||
- `tests/unit/test_az508_iso_timestamps.py` (helpers AST-walk regression guards from AZ-526 — included to verify the ASYL pattern still holds in the helpers/ namespace; doesn't directly involve AZ-527 but sanity-checks neighbouring lint infrastructure) — 18 / 18 PASS.
|
||||
|
||||
## Verdict
|
||||
|
||||
- Critical findings: 0
|
||||
- High findings: 0
|
||||
- Medium findings: 0
|
||||
- Low findings: 0
|
||||
|
||||
**PASS** — clean batch, no findings, all ACs covered, all tests green, full Ruff + lint clean.
|
||||
@@ -12,5 +12,5 @@ sub_step:
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
last_completed_batch: 50
|
||||
last_cumulative_review: batches_46-48
|
||||
last_completed_batch: 52
|
||||
last_cumulative_review: batches_49-51
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# D-CROSS-CVE-1 opencv-python pin deferred — gtsam/numpy ABI block
|
||||
|
||||
**Recorded**: 2026-05-11T02:55+03:00 (Europe/Kyiv)
|
||||
**Last replay attempt**: 2026-05-13T23:09+03:00 (Europe/Kyiv) — PyPI shows
|
||||
**Last replay attempt**: 2026-05-14T00:17+03:00 (Europe/Kyiv) — PyPI shows
|
||||
`gtsam==4.2.1` as the latest release; `requires_dist: numpy<2.0.0,>=1.11.0`.
|
||||
Replay condition (numpy>=2 wheels) still NOT met. Leftover remains open.
|
||||
**Status**: deferred-non-user (replay when upstream gtsam wheels target numpy>=2)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""C2-internal engine output-dim assertion helper (AZ-527).
|
||||
|
||||
Single home for the dry-run probe that every c2_vpr secondary VPR
|
||||
strategy module runs at :func:`create` time to verify that the loaded
|
||||
inference engine emits a descriptor of the shape the strategy
|
||||
contract promises.
|
||||
|
||||
This helper consolidates the formerly seven-way duplicated
|
||||
``_assert_engine_output_dim`` helper across ``ultra_vpr.py``,
|
||||
``net_vlad.py``, ``mega_loc.py``, ``mix_vpr.py``, ``sela_vpr.py``,
|
||||
``eigen_places.py`` and ``salad.py``. Behaviour, error envelope, and
|
||||
error-message wording are byte-identical to the prior copies so that
|
||||
the AZ-337 / 338 / 339 / 340 AC-6 sub-tests continue to pass
|
||||
unmodified.
|
||||
|
||||
The helper is C2-internal: the underscore-prefixed module name and
|
||||
the unimported-from-``c2_vpr.__init__`` placement keep it inside the
|
||||
component boundary per
|
||||
``components/02_c2_vpr/description.md`` § 6 (engine output-shape
|
||||
contracts are a C2 internal concern; C7 owns its own engine-shape
|
||||
assertions inside the runtime).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.inference import EngineHandle
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor import (
|
||||
BackbonePreprocessor,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.inference_runtime_cut import (
|
||||
InferenceRuntimeCut,
|
||||
)
|
||||
|
||||
__all__ = ["assert_engine_output_dim"]
|
||||
|
||||
|
||||
def assert_engine_output_dim(
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
handle: EngineHandle,
|
||||
preprocessor: BackbonePreprocessor,
|
||||
descriptor_dim: int,
|
||||
*,
|
||||
output_key: str = "embedding",
|
||||
input_key: str = "input",
|
||||
) -> None:
|
||||
"""Verify the engine emits ``(1, descriptor_dim)`` under ``output_key``.
|
||||
|
||||
Runs a single zero-init dry-run inference at the preprocessor's
|
||||
declared ``input_shape()`` (FP16 NCHW, batch=1, RGB) and asserts
|
||||
two contracts on the returned output dict:
|
||||
|
||||
1. ``output_key`` is present in the dict (default ``"embedding"``,
|
||||
NetVLAD overrides to ``"vlad_descriptor"``).
|
||||
2. The output ndarray has shape ``(1, descriptor_dim)``.
|
||||
|
||||
Both contracts are verified at ``create()`` time (startup), not
|
||||
per frame.
|
||||
|
||||
:param inference_runtime: the C2-internal :class:`InferenceRuntimeCut`
|
||||
that wraps the loaded engine.
|
||||
:param handle: the deserialised :class:`EngineHandle` to probe.
|
||||
:param preprocessor: the strategy's :class:`BackbonePreprocessor`;
|
||||
only ``input_shape()`` is consulted to size the probe tensor.
|
||||
:param descriptor_dim: the strategy's expected descriptor width.
|
||||
For most backbones this is a module-level ``DESCRIPTOR_DIM``
|
||||
constant; NetVLAD passes its runtime-resolved descriptor_dim.
|
||||
:param output_key: the key under which the engine surfaces the
|
||||
descriptor. Defaults to ``"embedding"`` (used by 6 of the 7
|
||||
c2_vpr secondary backbones); NetVLAD passes ``"vlad_descriptor"``.
|
||||
:param input_key: the key under which the engine accepts the input
|
||||
tensor. Defaults to ``"input"`` (used by all 7 backbones).
|
||||
|
||||
:raises ConfigError: if ``output_key`` is absent from the engine
|
||||
output, or the output ndarray shape does not match
|
||||
``(1, descriptor_dim)``. The error message names both the
|
||||
expected and actual values.
|
||||
"""
|
||||
h, w = preprocessor.input_shape()
|
||||
probe = np.zeros((1, 3, h, w), dtype=np.float16)
|
||||
outputs = inference_runtime.infer(handle, {input_key: probe})
|
||||
if output_key not in outputs:
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: {output_key!r} key absent; "
|
||||
f"got keys {sorted(outputs.keys())!r}"
|
||||
)
|
||||
actual = np.asarray(outputs[output_key])
|
||||
if (
|
||||
actual.ndim != 2
|
||||
or actual.shape[0] != 1
|
||||
or actual.shape[1] != descriptor_dim
|
||||
):
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: expected (1, {descriptor_dim}), "
|
||||
f"got {tuple(actual.shape)}"
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""EigenPlaces backbone preprocessor (AZ-340).
|
||||
|
||||
EigenPlaces' published preprocessing chain (per the upstream research
|
||||
code drop): decode the nav-camera frame's image to RGB uint8,
|
||||
centre-crop to a square region respecting the camera calibration's
|
||||
principal point (or geometric centre + WARN log when calibration is
|
||||
absent), resize to ``(480, 480)``, apply ImageNet mean/std
|
||||
normalisation, cast to FP16, reshape to NCHW.
|
||||
|
||||
Differences from the other C2 secondary preprocessors:
|
||||
|
||||
- 480x480 input shape (vs SelaVPR's 224x224, MegaLoc's 322x322,
|
||||
MixVPR's 320x320, SALAD's 322x322). EigenPlaces is the highest
|
||||
spatial-resolution preprocessor in the C2 family.
|
||||
- Same calibration-aware centre-crop and ImageNet mean/std — these
|
||||
upstream conventions happen to align across several backbones but
|
||||
are NOT a shared dependency: the centre-crop logic is duplicated
|
||||
here per ``components/02_c2_vpr/description.md`` § 6 so a future
|
||||
EigenPlaces code drop can change its preprocessing without coupling
|
||||
other strategies' weights-versions.
|
||||
|
||||
This preprocessor is C2-internal and owned exclusively by
|
||||
:class:`EigenPlacesStrategy` — sharing across backbones is forbidden
|
||||
per ``components/02_c2_vpr/description.md`` § 6.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard.components.c2_vpr.errors import VprPreprocessError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
|
||||
__all__ = [
|
||||
"EIGEN_PLACES_INPUT_HW",
|
||||
"IMAGENET_MEAN",
|
||||
"IMAGENET_STD",
|
||||
"EigenPlacesBackbonePreprocessor",
|
||||
]
|
||||
|
||||
EIGEN_PLACES_INPUT_HW: Final[tuple[int, int]] = (480, 480)
|
||||
IMAGENET_MEAN: Final[tuple[float, float, float]] = (0.485, 0.456, 0.406)
|
||||
IMAGENET_STD: Final[tuple[float, float, float]] = (0.229, 0.224, 0.225)
|
||||
|
||||
_COMPONENT: Final[str] = "c2_vpr"
|
||||
_LOG_KIND_CALIBRATION_MISSING: Final[str] = "c2.vpr.calibration_missing"
|
||||
|
||||
|
||||
class EigenPlacesBackbonePreprocessor:
|
||||
"""Centre-crop (principal-point-aware) + resize + ImageNet-normalise + FP16 NCHW."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
input_shape: tuple[int, int] = EIGEN_PLACES_INPUT_HW,
|
||||
mean: tuple[float, float, float] = IMAGENET_MEAN,
|
||||
std: tuple[float, float, float] = IMAGENET_STD,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(input_shape, tuple)
|
||||
or len(input_shape) != 2
|
||||
or any(not isinstance(v, int) or v <= 0 for v in input_shape)
|
||||
):
|
||||
raise ValueError(
|
||||
f"EigenPlacesBackbonePreprocessor.input_shape must be a (H, W) "
|
||||
f"tuple of positive ints; got {input_shape!r}"
|
||||
)
|
||||
if len(mean) != 3 or len(std) != 3:
|
||||
raise ValueError(
|
||||
"EigenPlacesBackbonePreprocessor.mean and std must each be "
|
||||
"3-tuples (one per channel)"
|
||||
)
|
||||
if any(v <= 0 for v in std):
|
||||
raise ValueError(
|
||||
"EigenPlacesBackbonePreprocessor.std components must be > 0"
|
||||
)
|
||||
self._input_shape: tuple[int, int] = input_shape
|
||||
self._mean: np.ndarray = np.array(mean, dtype=np.float32).reshape(1, 1, 3)
|
||||
self._std: np.ndarray = np.array(std, dtype=np.float32).reshape(1, 1, 3)
|
||||
self._logger: logging.Logger = (
|
||||
logger
|
||||
if logger is not None
|
||||
else logging.getLogger("gps_denied_onboard.c2_vpr.eigen_places")
|
||||
)
|
||||
|
||||
def preprocess(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
calibration: CameraCalibration,
|
||||
) -> np.ndarray:
|
||||
"""Decode -> centre-crop (principal-point-aware) -> resize -> normalise -> FP16 NCHW.
|
||||
|
||||
Calibration handling mirrors UltraVPR (description.md § 6 — same
|
||||
upstream convention, duplicated not shared): when calibration is
|
||||
absent or its principal point cannot be extracted from
|
||||
``intrinsics_3x3``, fall back to the image's geometric centre
|
||||
and emit ONE WARN log per call with
|
||||
``kind="c2.vpr.calibration_missing"``.
|
||||
"""
|
||||
image = self._coerce_to_rgb_uint8(frame.image)
|
||||
cropped = self._centre_crop_around_principal_point(
|
||||
image, calibration, frame_id=frame.frame_id
|
||||
)
|
||||
target_h, target_w = self._input_shape
|
||||
in_h, in_w = cropped.shape[:2]
|
||||
interp = (
|
||||
cv2.INTER_AREA
|
||||
if (in_h > target_h or in_w > target_w)
|
||||
else cv2.INTER_CUBIC
|
||||
)
|
||||
try:
|
||||
resized = cv2.resize(
|
||||
cropped, (target_w, target_h), interpolation=interp
|
||||
)
|
||||
except cv2.error as exc:
|
||||
raise VprPreprocessError(
|
||||
f"cv2.resize failed: {type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
as_f32 = resized.astype(np.float32) / 255.0
|
||||
normalised = (as_f32 - self._mean) / self._std
|
||||
chw = normalised.transpose(2, 0, 1)
|
||||
return np.ascontiguousarray(chw[None, :, :, :], dtype=np.float16)
|
||||
|
||||
def input_shape(self) -> tuple[int, int]:
|
||||
return self._input_shape
|
||||
|
||||
@staticmethod
|
||||
def _coerce_to_rgb_uint8(image: object) -> np.ndarray:
|
||||
if not isinstance(image, np.ndarray):
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be a numpy array; got {type(image).__name__}"
|
||||
)
|
||||
if image.dtype != np.uint8:
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be uint8 RGB; got dtype {image.dtype}"
|
||||
)
|
||||
if image.ndim == 2:
|
||||
return np.stack([image, image, image], axis=-1)
|
||||
if image.ndim == 3 and image.shape[2] == 3:
|
||||
return image
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be (H,W) or (H,W,3); got shape {image.shape}"
|
||||
)
|
||||
|
||||
def _centre_crop_around_principal_point(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
calibration: CameraCalibration | None,
|
||||
*,
|
||||
frame_id: int,
|
||||
) -> np.ndarray:
|
||||
h, w = image.shape[:2]
|
||||
side = min(h, w)
|
||||
cx_cy = self._extract_principal_point(calibration)
|
||||
if cx_cy is None:
|
||||
self._logger.warning(
|
||||
"EigenPlaces calibration unusable; centre-cropping around "
|
||||
"geometric centre",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_CALIBRATION_MISSING,
|
||||
"kv": {"frame_id": int(frame_id)},
|
||||
},
|
||||
)
|
||||
cx = w / 2.0
|
||||
cy = h / 2.0
|
||||
else:
|
||||
cx, cy = cx_cy
|
||||
half = side // 2
|
||||
left = round(max(0.0, min(float(w - side), cx - half)))
|
||||
top = round(max(0.0, min(float(h - side), cy - half)))
|
||||
return image[top : top + side, left : left + side, :]
|
||||
|
||||
@staticmethod
|
||||
def _extract_principal_point(
|
||||
calibration: CameraCalibration | None,
|
||||
) -> tuple[float, float] | None:
|
||||
if calibration is None:
|
||||
return None
|
||||
intrinsics = getattr(calibration, "intrinsics_3x3", None)
|
||||
if intrinsics is None:
|
||||
return None
|
||||
try:
|
||||
arr = np.asarray(intrinsics, dtype=np.float64)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if arr.shape != (3, 3):
|
||||
return None
|
||||
cx = float(arr[0, 2])
|
||||
cy = float(arr[1, 2])
|
||||
if cx == 0.0 and cy == 0.0:
|
||||
return None
|
||||
return cx, cy
|
||||
@@ -0,0 +1,202 @@
|
||||
"""SALAD backbone preprocessor (AZ-340).
|
||||
|
||||
SALAD's published preprocessing chain (per the upstream research code
|
||||
drop, DINOv2-aligned): decode the nav-camera frame's image to RGB
|
||||
uint8, centre-crop to a square region respecting the camera
|
||||
calibration's principal point (or geometric centre + WARN log when
|
||||
calibration is absent), resize to ``(322, 322)``, apply ImageNet
|
||||
mean/std normalisation (DINOv2's documented default), cast to FP16,
|
||||
reshape to NCHW.
|
||||
|
||||
Differences from the other C2 secondary preprocessors:
|
||||
|
||||
- 322x322 input shape (matches MegaLoc's 322x322 by coincidence —
|
||||
both are DINOv2-family inputs by upstream convention; SALAD's
|
||||
aggregator consumes DINOv2-Large patch tokens at this resolution).
|
||||
- Same calibration-aware centre-crop and ImageNet mean/std — these
|
||||
upstream conventions happen to align across DINOv2-family backbones
|
||||
but are NOT a shared dependency: the centre-crop logic is
|
||||
duplicated here per ``components/02_c2_vpr/description.md`` § 6 so
|
||||
a future SALAD code drop can change its preprocessing without
|
||||
coupling other strategies' weights-versions.
|
||||
|
||||
This preprocessor is C2-internal and owned exclusively by
|
||||
:class:`SaladStrategy` — sharing across backbones is forbidden per
|
||||
``components/02_c2_vpr/description.md`` § 6.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard.components.c2_vpr.errors import VprPreprocessError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
|
||||
__all__ = [
|
||||
"IMAGENET_MEAN",
|
||||
"IMAGENET_STD",
|
||||
"SALAD_INPUT_HW",
|
||||
"SaladBackbonePreprocessor",
|
||||
]
|
||||
|
||||
SALAD_INPUT_HW: Final[tuple[int, int]] = (322, 322)
|
||||
IMAGENET_MEAN: Final[tuple[float, float, float]] = (0.485, 0.456, 0.406)
|
||||
IMAGENET_STD: Final[tuple[float, float, float]] = (0.229, 0.224, 0.225)
|
||||
|
||||
_COMPONENT: Final[str] = "c2_vpr"
|
||||
_LOG_KIND_CALIBRATION_MISSING: Final[str] = "c2.vpr.calibration_missing"
|
||||
|
||||
|
||||
class SaladBackbonePreprocessor:
|
||||
"""Centre-crop (principal-point-aware) + resize + ImageNet-normalise + FP16 NCHW."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
input_shape: tuple[int, int] = SALAD_INPUT_HW,
|
||||
mean: tuple[float, float, float] = IMAGENET_MEAN,
|
||||
std: tuple[float, float, float] = IMAGENET_STD,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(input_shape, tuple)
|
||||
or len(input_shape) != 2
|
||||
or any(not isinstance(v, int) or v <= 0 for v in input_shape)
|
||||
):
|
||||
raise ValueError(
|
||||
f"SaladBackbonePreprocessor.input_shape must be a (H, W) "
|
||||
f"tuple of positive ints; got {input_shape!r}"
|
||||
)
|
||||
if len(mean) != 3 or len(std) != 3:
|
||||
raise ValueError(
|
||||
"SaladBackbonePreprocessor.mean and std must each be "
|
||||
"3-tuples (one per channel)"
|
||||
)
|
||||
if any(v <= 0 for v in std):
|
||||
raise ValueError(
|
||||
"SaladBackbonePreprocessor.std components must be > 0"
|
||||
)
|
||||
self._input_shape: tuple[int, int] = input_shape
|
||||
self._mean: np.ndarray = np.array(mean, dtype=np.float32).reshape(1, 1, 3)
|
||||
self._std: np.ndarray = np.array(std, dtype=np.float32).reshape(1, 1, 3)
|
||||
self._logger: logging.Logger = (
|
||||
logger
|
||||
if logger is not None
|
||||
else logging.getLogger("gps_denied_onboard.c2_vpr.salad")
|
||||
)
|
||||
|
||||
def preprocess(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
calibration: CameraCalibration,
|
||||
) -> np.ndarray:
|
||||
"""Decode -> centre-crop (principal-point-aware) -> resize -> normalise -> FP16 NCHW.
|
||||
|
||||
Calibration handling mirrors UltraVPR (description.md § 6 — same
|
||||
upstream convention, duplicated not shared): when calibration is
|
||||
absent or its principal point cannot be extracted from
|
||||
``intrinsics_3x3``, fall back to the image's geometric centre
|
||||
and emit ONE WARN log per call with
|
||||
``kind="c2.vpr.calibration_missing"``.
|
||||
"""
|
||||
image = self._coerce_to_rgb_uint8(frame.image)
|
||||
cropped = self._centre_crop_around_principal_point(
|
||||
image, calibration, frame_id=frame.frame_id
|
||||
)
|
||||
target_h, target_w = self._input_shape
|
||||
in_h, in_w = cropped.shape[:2]
|
||||
interp = (
|
||||
cv2.INTER_AREA
|
||||
if (in_h > target_h or in_w > target_w)
|
||||
else cv2.INTER_CUBIC
|
||||
)
|
||||
try:
|
||||
resized = cv2.resize(
|
||||
cropped, (target_w, target_h), interpolation=interp
|
||||
)
|
||||
except cv2.error as exc:
|
||||
raise VprPreprocessError(
|
||||
f"cv2.resize failed: {type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
as_f32 = resized.astype(np.float32) / 255.0
|
||||
normalised = (as_f32 - self._mean) / self._std
|
||||
chw = normalised.transpose(2, 0, 1)
|
||||
return np.ascontiguousarray(chw[None, :, :, :], dtype=np.float16)
|
||||
|
||||
def input_shape(self) -> tuple[int, int]:
|
||||
return self._input_shape
|
||||
|
||||
@staticmethod
|
||||
def _coerce_to_rgb_uint8(image: object) -> np.ndarray:
|
||||
if not isinstance(image, np.ndarray):
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be a numpy array; got {type(image).__name__}"
|
||||
)
|
||||
if image.dtype != np.uint8:
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be uint8 RGB; got dtype {image.dtype}"
|
||||
)
|
||||
if image.ndim == 2:
|
||||
return np.stack([image, image, image], axis=-1)
|
||||
if image.ndim == 3 and image.shape[2] == 3:
|
||||
return image
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be (H,W) or (H,W,3); got shape {image.shape}"
|
||||
)
|
||||
|
||||
def _centre_crop_around_principal_point(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
calibration: CameraCalibration | None,
|
||||
*,
|
||||
frame_id: int,
|
||||
) -> np.ndarray:
|
||||
h, w = image.shape[:2]
|
||||
side = min(h, w)
|
||||
cx_cy = self._extract_principal_point(calibration)
|
||||
if cx_cy is None:
|
||||
self._logger.warning(
|
||||
"SALAD calibration unusable; centre-cropping around "
|
||||
"geometric centre",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_CALIBRATION_MISSING,
|
||||
"kv": {"frame_id": int(frame_id)},
|
||||
},
|
||||
)
|
||||
cx = w / 2.0
|
||||
cy = h / 2.0
|
||||
else:
|
||||
cx, cy = cx_cy
|
||||
half = side // 2
|
||||
left = round(max(0.0, min(float(w - side), cx - half)))
|
||||
top = round(max(0.0, min(float(h - side), cy - half)))
|
||||
return image[top : top + side, left : left + side, :]
|
||||
|
||||
@staticmethod
|
||||
def _extract_principal_point(
|
||||
calibration: CameraCalibration | None,
|
||||
) -> tuple[float, float] | None:
|
||||
if calibration is None:
|
||||
return None
|
||||
intrinsics = getattr(calibration, "intrinsics_3x3", None)
|
||||
if intrinsics is None:
|
||||
return None
|
||||
try:
|
||||
arr = np.asarray(intrinsics, dtype=np.float64)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if arr.shape != (3, 3):
|
||||
return None
|
||||
cx = float(arr[0, 2])
|
||||
cy = float(arr[1, 2])
|
||||
if cx == 0.0 and cy == 0.0:
|
||||
return None
|
||||
return cx, cy
|
||||
@@ -0,0 +1,200 @@
|
||||
"""SelaVPR backbone preprocessor (AZ-340).
|
||||
|
||||
SelaVPR's published preprocessing chain (per the upstream research code
|
||||
drop): decode the nav-camera frame's image to RGB uint8, centre-crop to
|
||||
a square region respecting the camera calibration's principal point (or
|
||||
geometric centre + WARN log when calibration is absent), resize to
|
||||
``(224, 224)``, apply ImageNet mean/std normalisation, cast to FP16,
|
||||
reshape to NCHW.
|
||||
|
||||
Differences from the other C2 secondary preprocessors:
|
||||
|
||||
- 224x224 input shape (vs MegaLoc's 322x322, MixVPR's 320x320,
|
||||
EigenPlaces' 480x480, SALAD's 322x322).
|
||||
- Same calibration-aware centre-crop and ImageNet mean/std — these
|
||||
upstream conventions happen to align across several backbones but
|
||||
are NOT a shared dependency: the centre-crop logic is duplicated
|
||||
here per ``components/02_c2_vpr/description.md`` § 6 so a future
|
||||
SelaVPR code drop can change its preprocessing without coupling
|
||||
other strategies' weights-versions.
|
||||
|
||||
This preprocessor is C2-internal and owned exclusively by
|
||||
:class:`SelaVprStrategy` — sharing across backbones is forbidden per
|
||||
``components/02_c2_vpr/description.md`` § 6.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard.components.c2_vpr.errors import VprPreprocessError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
|
||||
__all__ = [
|
||||
"IMAGENET_MEAN",
|
||||
"IMAGENET_STD",
|
||||
"SELA_VPR_INPUT_HW",
|
||||
"SelaVprBackbonePreprocessor",
|
||||
]
|
||||
|
||||
SELA_VPR_INPUT_HW: Final[tuple[int, int]] = (224, 224)
|
||||
IMAGENET_MEAN: Final[tuple[float, float, float]] = (0.485, 0.456, 0.406)
|
||||
IMAGENET_STD: Final[tuple[float, float, float]] = (0.229, 0.224, 0.225)
|
||||
|
||||
_COMPONENT: Final[str] = "c2_vpr"
|
||||
_LOG_KIND_CALIBRATION_MISSING: Final[str] = "c2.vpr.calibration_missing"
|
||||
|
||||
|
||||
class SelaVprBackbonePreprocessor:
|
||||
"""Centre-crop (principal-point-aware) + resize + ImageNet-normalise + FP16 NCHW."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
input_shape: tuple[int, int] = SELA_VPR_INPUT_HW,
|
||||
mean: tuple[float, float, float] = IMAGENET_MEAN,
|
||||
std: tuple[float, float, float] = IMAGENET_STD,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(input_shape, tuple)
|
||||
or len(input_shape) != 2
|
||||
or any(not isinstance(v, int) or v <= 0 for v in input_shape)
|
||||
):
|
||||
raise ValueError(
|
||||
f"SelaVprBackbonePreprocessor.input_shape must be a (H, W) "
|
||||
f"tuple of positive ints; got {input_shape!r}"
|
||||
)
|
||||
if len(mean) != 3 or len(std) != 3:
|
||||
raise ValueError(
|
||||
"SelaVprBackbonePreprocessor.mean and std must each be "
|
||||
"3-tuples (one per channel)"
|
||||
)
|
||||
if any(v <= 0 for v in std):
|
||||
raise ValueError(
|
||||
"SelaVprBackbonePreprocessor.std components must be > 0"
|
||||
)
|
||||
self._input_shape: tuple[int, int] = input_shape
|
||||
self._mean: np.ndarray = np.array(mean, dtype=np.float32).reshape(1, 1, 3)
|
||||
self._std: np.ndarray = np.array(std, dtype=np.float32).reshape(1, 1, 3)
|
||||
self._logger: logging.Logger = (
|
||||
logger
|
||||
if logger is not None
|
||||
else logging.getLogger("gps_denied_onboard.c2_vpr.sela_vpr")
|
||||
)
|
||||
|
||||
def preprocess(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
calibration: CameraCalibration,
|
||||
) -> np.ndarray:
|
||||
"""Decode -> centre-crop (principal-point-aware) -> resize -> normalise -> FP16 NCHW.
|
||||
|
||||
Calibration handling mirrors UltraVPR (description.md § 6 — same
|
||||
upstream convention, duplicated not shared): when calibration is
|
||||
absent or its principal point cannot be extracted from
|
||||
``intrinsics_3x3``, fall back to the image's geometric centre
|
||||
and emit ONE WARN log per call with
|
||||
``kind="c2.vpr.calibration_missing"``.
|
||||
"""
|
||||
image = self._coerce_to_rgb_uint8(frame.image)
|
||||
cropped = self._centre_crop_around_principal_point(
|
||||
image, calibration, frame_id=frame.frame_id
|
||||
)
|
||||
target_h, target_w = self._input_shape
|
||||
in_h, in_w = cropped.shape[:2]
|
||||
interp = (
|
||||
cv2.INTER_AREA
|
||||
if (in_h > target_h or in_w > target_w)
|
||||
else cv2.INTER_CUBIC
|
||||
)
|
||||
try:
|
||||
resized = cv2.resize(
|
||||
cropped, (target_w, target_h), interpolation=interp
|
||||
)
|
||||
except cv2.error as exc:
|
||||
raise VprPreprocessError(
|
||||
f"cv2.resize failed: {type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
as_f32 = resized.astype(np.float32) / 255.0
|
||||
normalised = (as_f32 - self._mean) / self._std
|
||||
chw = normalised.transpose(2, 0, 1)
|
||||
return np.ascontiguousarray(chw[None, :, :, :], dtype=np.float16)
|
||||
|
||||
def input_shape(self) -> tuple[int, int]:
|
||||
return self._input_shape
|
||||
|
||||
@staticmethod
|
||||
def _coerce_to_rgb_uint8(image: object) -> np.ndarray:
|
||||
if not isinstance(image, np.ndarray):
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be a numpy array; got {type(image).__name__}"
|
||||
)
|
||||
if image.dtype != np.uint8:
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be uint8 RGB; got dtype {image.dtype}"
|
||||
)
|
||||
if image.ndim == 2:
|
||||
return np.stack([image, image, image], axis=-1)
|
||||
if image.ndim == 3 and image.shape[2] == 3:
|
||||
return image
|
||||
raise VprPreprocessError(
|
||||
f"frame.image must be (H,W) or (H,W,3); got shape {image.shape}"
|
||||
)
|
||||
|
||||
def _centre_crop_around_principal_point(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
calibration: CameraCalibration | None,
|
||||
*,
|
||||
frame_id: int,
|
||||
) -> np.ndarray:
|
||||
h, w = image.shape[:2]
|
||||
side = min(h, w)
|
||||
cx_cy = self._extract_principal_point(calibration)
|
||||
if cx_cy is None:
|
||||
self._logger.warning(
|
||||
"SelaVPR calibration unusable; centre-cropping around "
|
||||
"geometric centre",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_CALIBRATION_MISSING,
|
||||
"kv": {"frame_id": int(frame_id)},
|
||||
},
|
||||
)
|
||||
cx = w / 2.0
|
||||
cy = h / 2.0
|
||||
else:
|
||||
cx, cy = cx_cy
|
||||
half = side // 2
|
||||
left = round(max(0.0, min(float(w - side), cx - half)))
|
||||
top = round(max(0.0, min(float(h - side), cy - half)))
|
||||
return image[top : top + side, left : left + side, :]
|
||||
|
||||
@staticmethod
|
||||
def _extract_principal_point(
|
||||
calibration: CameraCalibration | None,
|
||||
) -> tuple[float, float] | None:
|
||||
if calibration is None:
|
||||
return None
|
||||
intrinsics = getattr(calibration, "intrinsics_3x3", None)
|
||||
if intrinsics is None:
|
||||
return None
|
||||
try:
|
||||
arr = np.asarray(intrinsics, dtype=np.float64)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if arr.shape != (3, 3):
|
||||
return None
|
||||
cx = float(arr[0, 2])
|
||||
cy = float(arr[1, 2])
|
||||
if cx == 0.0 and cy == 0.0:
|
||||
return None
|
||||
return cx, cy
|
||||
@@ -0,0 +1,434 @@
|
||||
"""``EigenPlacesStrategy`` — C2 secondary VprStrategy for IT-12 (AZ-340).
|
||||
|
||||
EigenPlaces is a secondary backbone shipped exclusively in the research
|
||||
binary for the IT-12 comparative-study matrix
|
||||
(``components/02_c2_vpr/description.md`` § 1 + § 5). Per ADR-002,
|
||||
``BUILD_VPR_EIGENPLACES`` is ON for the research binary and replay-cli,
|
||||
OFF for the airborne and operator-tooling binaries — selecting
|
||||
``eigen_places`` on a binary without the flag fails fast at
|
||||
composition-root time via :class:`StrategyNotAvailableError` (not at
|
||||
first frame).
|
||||
|
||||
The strategy runs on the C7 TensorRT runtime (AZ-298), or the ONNX-Runtime
|
||||
fallback (AZ-299), via the local :class:`InferenceRuntimeCut` (AZ-507).
|
||||
Engine output key is ``"embedding"`` and the strategy applies single-stage
|
||||
global L2 normalisation (no NetVLAD-style intra-cluster step). Retrieval
|
||||
delegates to :class:`FaissBridge` (AZ-341).
|
||||
|
||||
Architecture-registry differences from :class:`NetVladStrategy`:
|
||||
|
||||
EigenPlaces consumes a pre-compiled ``.trt`` engine produced by C10's
|
||||
engine compiler (AZ-321) — there is no PyTorch ``nn.Module`` to register,
|
||||
so the module does NOT expose ``MODEL_NAME`` / ``architecture_factory``.
|
||||
:func:`gps_denied_onboard.runtime_root.vpr_factory._register_strategy_architecture`
|
||||
no-ops for this strategy.
|
||||
|
||||
Engine load happens in :func:`create` (NOT at first frame) so the
|
||||
engine-output-shape assertion (AC-6) surfaces at startup, not after
|
||||
takeoff.
|
||||
|
||||
Per-frame :meth:`embed_query` pipeline:
|
||||
|
||||
1. ``preprocessor.preprocess(frame, calibration)`` ->
|
||||
``(1, 3, 480, 480)`` FP16 NCHW ndarray.
|
||||
2. ``inference_runtime.infer(handle, {"input": tensor})`` ->
|
||||
``{"embedding": (1, 2048) FP16 ndarray}``.
|
||||
3. ``normaliser.l2_normalise(raw[0])`` -> global L2 (single-stage).
|
||||
4. Return :class:`VprQuery` with ``frame_id``, normalised embedding,
|
||||
produced_at monotonic ns.
|
||||
|
||||
Error envelope: every method raises only members of :class:`VprError`.
|
||||
``RuntimeError`` from the backbone forward -> rewrapped to
|
||||
:class:`VprBackboneError`; :class:`VprPreprocessError` from the
|
||||
preprocessor propagates unchanged.
|
||||
|
||||
Retrieval is a single-line delegation to :class:`FaissBridge.retrieve`;
|
||||
see AZ-341 AC-10.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.inference import (
|
||||
BuildConfig,
|
||||
EngineHandle,
|
||||
PrecisionMode,
|
||||
)
|
||||
from gps_denied_onboard._types.vpr import VprQuery, VprResult
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_eigen_places import (
|
||||
EigenPlacesBackbonePreprocessor,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.descriptor_index_cut import (
|
||||
DescriptorIndexCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.errors import (
|
||||
VprBackboneError,
|
||||
VprPreprocessError,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.inference_runtime_cut import (
|
||||
InferenceRuntimeCut,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
from gps_denied_onboard.fdr_client import EnqueueResult, FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
FdrRecord,
|
||||
)
|
||||
from gps_denied_onboard.helpers.descriptor_normaliser import DescriptorNormaliser
|
||||
from gps_denied_onboard.helpers.iso_timestamps import (
|
||||
iso_ts_from_clock as _iso_ts_from_clock,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
from gps_denied_onboard.config.schema import Config
|
||||
|
||||
__all__ = ["DESCRIPTOR_DIM", "EigenPlacesStrategy", "create"]
|
||||
|
||||
|
||||
# EigenPlaces' published embedding dimension (D=2048) per the upstream
|
||||
# research code drop — same as MegaLoc and the NetVLAD default but with
|
||||
# different semantics (CosPlace-family eigenvector backbone). Engine
|
||||
# output shape is asserted at create() time against this constant.
|
||||
DESCRIPTOR_DIM: Final[int] = 2048
|
||||
|
||||
_BACKBONE_LABEL: Final[Literal["eigen_places"]] = "eigen_places"
|
||||
_COMPONENT: Final[str] = "c2_vpr"
|
||||
_OUTPUT_KEY: Final[str] = "embedding"
|
||||
_ENGINE_INPUT_KEY: Final[str] = "input"
|
||||
|
||||
_ALLOWED_RUNTIME_LABELS: Final[frozenset[str]] = frozenset(
|
||||
{"tensorrt", "onnx_trt_ep"}
|
||||
)
|
||||
|
||||
_LOG_KIND_READY: Final[str] = "c2.vpr.ready"
|
||||
_LOG_KIND_BACKBONE_ERROR: Final[str] = "c2.vpr.backbone_error"
|
||||
_LOG_KIND_PREPROCESS_ERROR: Final[str] = "c2.vpr.preprocess_error"
|
||||
_LOG_KIND_FDR_OVERRUN: Final[str] = "c2.vpr.fdr_overrun"
|
||||
|
||||
_FDR_KIND_EMBED: Final[str] = "vpr.embed_query"
|
||||
_FDR_KIND_BACKBONE_ERROR: Final[str] = "vpr.backbone_error"
|
||||
_FDR_KIND_PREPROCESS_ERROR: Final[str] = "vpr.preprocess_error"
|
||||
|
||||
|
||||
class EigenPlacesStrategy:
|
||||
"""C2 secondary VprStrategy backed by a TRT EigenPlaces engine.
|
||||
|
||||
See module docstring for the engine-loading + per-frame pipeline.
|
||||
Stateless across frames (INV-2); single-threaded per instance
|
||||
(INV-1, per AZ-336).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
engine_handle: EngineHandle,
|
||||
descriptor_index: DescriptorIndexCut,
|
||||
preprocessor: EigenPlacesBackbonePreprocessor,
|
||||
normaliser: DescriptorNormaliser,
|
||||
faiss_bridge: FaissBridge,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock,
|
||||
logger: logging.Logger,
|
||||
descriptor_dim: int = DESCRIPTOR_DIM,
|
||||
) -> None:
|
||||
if descriptor_dim < 1:
|
||||
raise ValueError(
|
||||
f"EigenPlacesStrategy.descriptor_dim must be >= 1; "
|
||||
f"got {descriptor_dim}"
|
||||
)
|
||||
self._inference_runtime = inference_runtime
|
||||
self._engine_handle = engine_handle
|
||||
self._descriptor_index = descriptor_index
|
||||
self._preprocessor = preprocessor
|
||||
self._normaliser = normaliser
|
||||
self._faiss_bridge = faiss_bridge
|
||||
self._fdr_client = fdr_client
|
||||
self._clock = clock
|
||||
self._logger = logger
|
||||
self._descriptor_dim = descriptor_dim
|
||||
|
||||
def embed_query(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
calibration: CameraCalibration,
|
||||
) -> VprQuery:
|
||||
try:
|
||||
tensor = self._preprocessor.preprocess(frame, calibration)
|
||||
except VprPreprocessError as exc:
|
||||
self._emit_preprocess_error(frame, exc)
|
||||
raise
|
||||
|
||||
ns_start = self._clock.monotonic_ns()
|
||||
try:
|
||||
outputs = self._inference_runtime.infer(
|
||||
self._engine_handle, {_ENGINE_INPUT_KEY: tensor}
|
||||
)
|
||||
except Exception as exc:
|
||||
wrapped = self._wrap_backbone_error(frame, exc)
|
||||
raise wrapped from exc
|
||||
ns_end = self._clock.monotonic_ns()
|
||||
latency_us = max(1, (ns_end - ns_start) // 1_000)
|
||||
|
||||
if _OUTPUT_KEY not in outputs:
|
||||
err = VprBackboneError(
|
||||
f"EigenPlaces forward returned no {_OUTPUT_KEY!r} key; "
|
||||
f"got {sorted(outputs.keys())!r}"
|
||||
)
|
||||
self._emit_backbone_error(frame, err)
|
||||
raise err
|
||||
|
||||
raw = np.asarray(outputs[_OUTPUT_KEY])
|
||||
if (
|
||||
raw.ndim != 2
|
||||
or raw.shape[0] != 1
|
||||
or raw.shape[1] != self._descriptor_dim
|
||||
):
|
||||
err = VprBackboneError(
|
||||
f"EigenPlaces forward returned shape {raw.shape}; "
|
||||
f"expected (1, {self._descriptor_dim})"
|
||||
)
|
||||
self._emit_backbone_error(frame, err)
|
||||
raise err
|
||||
|
||||
flat = np.ascontiguousarray(raw[0], dtype=np.float16)
|
||||
normalised = self._normaliser.l2_normalise(flat)
|
||||
|
||||
self._emit_embed_record(
|
||||
frame_id=int(frame.frame_id), latency_us=int(latency_us)
|
||||
)
|
||||
|
||||
return VprQuery(
|
||||
frame_id=int(frame.frame_id),
|
||||
embedding=normalised,
|
||||
produced_at=ns_end,
|
||||
)
|
||||
|
||||
def retrieve_topk(self, query: VprQuery, k: int) -> VprResult:
|
||||
return self._faiss_bridge.retrieve(
|
||||
query, k, backbone_label=_BACKBONE_LABEL
|
||||
)
|
||||
|
||||
def descriptor_dim(self) -> int:
|
||||
return self._descriptor_dim
|
||||
|
||||
def _wrap_backbone_error(
|
||||
self, frame: NavCameraFrame, exc: BaseException
|
||||
) -> VprBackboneError:
|
||||
wrapped = VprBackboneError(
|
||||
f"EigenPlaces forward raised {type(exc).__name__}: {exc}"
|
||||
)
|
||||
self._emit_backbone_error(frame, wrapped)
|
||||
return wrapped
|
||||
|
||||
def _emit_embed_record(self, *, frame_id: int, latency_us: int) -> None:
|
||||
record = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_EMBED,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"descriptor_dim": self._descriptor_dim,
|
||||
"latency_us": latency_us,
|
||||
},
|
||||
)
|
||||
result = self._fdr_client.enqueue(record)
|
||||
if result == EnqueueResult.OVERRUN:
|
||||
self._logger.warning(
|
||||
"FDR enqueue dropped vpr.embed_query record (buffer overrun)",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_FDR_OVERRUN,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _emit_backbone_error(
|
||||
self, frame: NavCameraFrame, error: BaseException
|
||||
) -> None:
|
||||
frame_id = int(frame.frame_id)
|
||||
msg = f"EigenPlaces backbone error: {error}"
|
||||
self._logger.error(
|
||||
msg,
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_BACKBONE_ERROR,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_BACKBONE_ERROR,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error)[:512],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _emit_preprocess_error(
|
||||
self, frame: NavCameraFrame, error: BaseException
|
||||
) -> None:
|
||||
frame_id = int(frame.frame_id)
|
||||
msg = f"EigenPlaces preprocess error: {error}"
|
||||
self._logger.error(
|
||||
msg,
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_PREPROCESS_ERROR,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_PREPROCESS_ERROR,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error)[:512],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_trt_build_config() -> BuildConfig:
|
||||
return BuildConfig(
|
||||
precision=PrecisionMode.FP16,
|
||||
workspace_mb=0,
|
||||
calibration_dataset=None,
|
||||
optimization_profiles=(),
|
||||
)
|
||||
|
||||
|
||||
def create(
|
||||
config: Config,
|
||||
*,
|
||||
descriptor_index: DescriptorIndexCut,
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
fdr_client: FdrClient | None = None,
|
||||
clock: Clock | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> EigenPlacesStrategy:
|
||||
"""Module-level factory consumed by :func:`build_vpr_strategy`.
|
||||
|
||||
EigenPlaces is unselectable when the C7 TRT / ONNX-RT runtimes are
|
||||
excluded — ``current_runtime_label()`` MUST be one of
|
||||
``{"tensorrt", "onnx_trt_ep"}``; ``"pytorch_fp16"`` is rejected
|
||||
with :class:`ConfigError` at composition time.
|
||||
|
||||
Engine output shape is asserted at create time via a single
|
||||
dry-run inference on a zero-init input; mismatch raises
|
||||
:class:`ConfigError` BEFORE the strategy is bound (AC-6).
|
||||
|
||||
Optional keyword-only injection points (``fdr_client`` / ``clock`` /
|
||||
``logger``) keep tests deterministic; production wiring fills them
|
||||
from the composition root.
|
||||
"""
|
||||
runtime_label = inference_runtime.current_runtime_label()
|
||||
if runtime_label not in _ALLOWED_RUNTIME_LABELS:
|
||||
raise ConfigError(
|
||||
f"EigenPlaces requires BUILD_TENSORRT_RUNTIME=ON (or "
|
||||
f"BUILD_ONNX_TRT_EP_RUNTIME=ON as fallback); this binary "
|
||||
f"has runtime_label={runtime_label!r}."
|
||||
)
|
||||
|
||||
block = config.components["c2_vpr"]
|
||||
weights_path = block.backbone_weights_path
|
||||
|
||||
if fdr_client is None:
|
||||
raise ValueError(
|
||||
"EigenPlacesStrategy.create: fdr_client is required; the "
|
||||
"composition root must inject the running FDR client."
|
||||
)
|
||||
if clock is None:
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
|
||||
clock = WallClock()
|
||||
if logger is None:
|
||||
logger = logging.getLogger("gps_denied_onboard.c2_vpr.eigen_places")
|
||||
|
||||
entry = inference_runtime.compile_engine(
|
||||
weights_path, _build_trt_build_config()
|
||||
)
|
||||
handle = inference_runtime.deserialize_engine(entry)
|
||||
|
||||
preprocessor = EigenPlacesBackbonePreprocessor(logger=logger)
|
||||
normaliser = DescriptorNormaliser()
|
||||
faiss_bridge = FaissBridge(
|
||||
descriptor_index=descriptor_index,
|
||||
descriptor_dim=DESCRIPTOR_DIM,
|
||||
warn_top1_threshold=block.warn_top1_threshold,
|
||||
debug_log_per_frame_distances=block.debug_per_frame_distances,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
assert_engine_output_dim(
|
||||
inference_runtime,
|
||||
handle,
|
||||
preprocessor,
|
||||
DESCRIPTOR_DIM,
|
||||
output_key=_OUTPUT_KEY,
|
||||
input_key=_ENGINE_INPUT_KEY,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"C2 VPR strategy ready",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_READY,
|
||||
"kv": {
|
||||
"strategy": _BACKBONE_LABEL,
|
||||
"descriptor_dim": DESCRIPTOR_DIM,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return EigenPlacesStrategy(
|
||||
inference_runtime=inference_runtime,
|
||||
engine_handle=handle,
|
||||
descriptor_index=descriptor_index,
|
||||
preprocessor=preprocessor,
|
||||
normaliser=normaliser,
|
||||
faiss_bridge=faiss_bridge,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
logger=logger,
|
||||
descriptor_dim=DESCRIPTOR_DIM,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ from gps_denied_onboard._types.inference import (
|
||||
)
|
||||
from gps_denied_onboard._types.vpr import VprQuery, VprResult
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_mega_loc import (
|
||||
MegaLocBackbonePreprocessor,
|
||||
@@ -393,7 +396,14 @@ def create(
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
_assert_engine_output_dim(inference_runtime, handle, preprocessor)
|
||||
assert_engine_output_dim(
|
||||
inference_runtime,
|
||||
handle,
|
||||
preprocessor,
|
||||
DESCRIPTOR_DIM,
|
||||
output_key=_OUTPUT_KEY,
|
||||
input_key=_ENGINE_INPUT_KEY,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"C2 VPR strategy ready",
|
||||
@@ -421,31 +431,3 @@ def create(
|
||||
)
|
||||
|
||||
|
||||
def _assert_engine_output_dim(
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
handle: EngineHandle,
|
||||
preprocessor: MegaLocBackbonePreprocessor,
|
||||
) -> None:
|
||||
# The 4-way duplication of this helper (ultra_vpr / net_vlad /
|
||||
# mega_loc / mix_vpr) will be consolidated by AZ-527 (hygiene
|
||||
# PBI sized in parallel with AZ-339 land). The duplication is
|
||||
# intentional for now: extracting earlier would expand AZ-339's
|
||||
# scope past the two new strategies.
|
||||
h, w = preprocessor.input_shape()
|
||||
probe = np.zeros((1, 3, h, w), dtype=np.float16)
|
||||
outputs = inference_runtime.infer(handle, {_ENGINE_INPUT_KEY: probe})
|
||||
if _OUTPUT_KEY not in outputs:
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: {_OUTPUT_KEY!r} key absent; "
|
||||
f"got keys {sorted(outputs.keys())!r}"
|
||||
)
|
||||
actual = np.asarray(outputs[_OUTPUT_KEY])
|
||||
if (
|
||||
actual.ndim != 2
|
||||
or actual.shape[0] != 1
|
||||
or actual.shape[1] != DESCRIPTOR_DIM
|
||||
):
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: expected (1, {DESCRIPTOR_DIM}), "
|
||||
f"got {tuple(actual.shape)}"
|
||||
)
|
||||
|
||||
@@ -60,6 +60,9 @@ from gps_denied_onboard._types.inference import (
|
||||
)
|
||||
from gps_denied_onboard._types.vpr import VprQuery, VprResult
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_mix_vpr import (
|
||||
MixVprBackbonePreprocessor,
|
||||
@@ -396,7 +399,14 @@ def create(
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
_assert_engine_output_dim(inference_runtime, handle, preprocessor)
|
||||
assert_engine_output_dim(
|
||||
inference_runtime,
|
||||
handle,
|
||||
preprocessor,
|
||||
DESCRIPTOR_DIM,
|
||||
output_key=_OUTPUT_KEY,
|
||||
input_key=_ENGINE_INPUT_KEY,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"C2 VPR strategy ready",
|
||||
@@ -424,31 +434,3 @@ def create(
|
||||
)
|
||||
|
||||
|
||||
def _assert_engine_output_dim(
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
handle: EngineHandle,
|
||||
preprocessor: MixVprBackbonePreprocessor,
|
||||
) -> None:
|
||||
# The 4-way duplication of this helper (ultra_vpr / net_vlad /
|
||||
# mega_loc / mix_vpr) will be consolidated by AZ-527 (hygiene
|
||||
# PBI sized in parallel with AZ-339 land). The duplication is
|
||||
# intentional for now: extracting earlier would expand AZ-339's
|
||||
# scope past the two new strategies.
|
||||
h, w = preprocessor.input_shape()
|
||||
probe = np.zeros((1, 3, h, w), dtype=np.float16)
|
||||
outputs = inference_runtime.infer(handle, {_ENGINE_INPUT_KEY: probe})
|
||||
if _OUTPUT_KEY not in outputs:
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: {_OUTPUT_KEY!r} key absent; "
|
||||
f"got keys {sorted(outputs.keys())!r}"
|
||||
)
|
||||
actual = np.asarray(outputs[_OUTPUT_KEY])
|
||||
if (
|
||||
actual.ndim != 2
|
||||
or actual.shape[0] != 1
|
||||
or actual.shape[1] != DESCRIPTOR_DIM
|
||||
):
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: expected (1, {DESCRIPTOR_DIM}), "
|
||||
f"got {tuple(actual.shape)}"
|
||||
)
|
||||
|
||||
@@ -80,6 +80,9 @@ from gps_denied_onboard._types.inference import (
|
||||
)
|
||||
from gps_denied_onboard._types.vpr import VprQuery, VprResult
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._net_vlad_architecture import (
|
||||
DEFAULT_NUM_CLUSTERS,
|
||||
@@ -461,8 +464,12 @@ def create(
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
_assert_engine_output_dim(
|
||||
inference_runtime, handle, descriptor_dim, preprocessor
|
||||
assert_engine_output_dim(
|
||||
inference_runtime,
|
||||
handle,
|
||||
preprocessor,
|
||||
descriptor_dim,
|
||||
output_key="vlad_descriptor",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -491,23 +498,3 @@ def create(
|
||||
)
|
||||
|
||||
|
||||
def _assert_engine_output_dim(
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
handle: EngineHandle,
|
||||
expected_dim: int,
|
||||
preprocessor: NetVladBackbonePreprocessor,
|
||||
) -> None:
|
||||
h, w = preprocessor.input_shape()
|
||||
probe = np.zeros((1, 3, h, w), dtype=np.float16)
|
||||
outputs = inference_runtime.infer(handle, {"input": probe})
|
||||
if "vlad_descriptor" not in outputs:
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: 'vlad_descriptor' key absent; "
|
||||
f"got keys {sorted(outputs.keys())!r}"
|
||||
)
|
||||
actual = np.asarray(outputs["vlad_descriptor"])
|
||||
if actual.ndim != 2 or actual.shape[0] != 1 or actual.shape[1] != expected_dim:
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: expected (1, {expected_dim}), "
|
||||
f"got {tuple(actual.shape)}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""``SaladStrategy`` — C2 secondary VprStrategy for IT-12 (AZ-340).
|
||||
|
||||
SALAD is a secondary backbone shipped exclusively in the research
|
||||
binary for the IT-12 comparative-study matrix
|
||||
(``components/02_c2_vpr/description.md`` § 1 + § 5; ``module-layout.md``
|
||||
``BUILD_VPR_SALAD`` row). Per ADR-002, ``BUILD_VPR_SALAD`` is ON for
|
||||
the research binary and replay-cli, OFF for the airborne and
|
||||
operator-tooling binaries — selecting ``salad`` on a binary without the
|
||||
flag fails fast at composition-root time via
|
||||
:class:`StrategyNotAvailableError` (not at first frame).
|
||||
|
||||
SALAD is the heaviest backbone in the C2 family: a DINOv2-Large
|
||||
backbone produces patch tokens that the SALAD aggregator turns into a
|
||||
single 8448-dimensional descriptor. Per the task spec NFR-perf
|
||||
budget, SALAD's ``embed_query`` p95 is permitted up to 120 ms (vs
|
||||
UltraVPR's tighter primary-path budget) and the FAISS HNSW lookup is
|
||||
permitted up to 6 ms p95. Operators who want a smaller SALAD descriptor
|
||||
must apply PCA-whitening at corpus build time (C10) — out of scope
|
||||
here.
|
||||
|
||||
The strategy runs on the C7 TensorRT runtime (AZ-298), or the ONNX-Runtime
|
||||
fallback (AZ-299), via the local :class:`InferenceRuntimeCut` (AZ-507).
|
||||
Engine output key is ``"embedding"`` and the strategy applies single-stage
|
||||
global L2 normalisation (no NetVLAD-style intra-cluster step). Retrieval
|
||||
delegates to :class:`FaissBridge` (AZ-341).
|
||||
|
||||
Architecture-registry differences from :class:`NetVladStrategy`:
|
||||
|
||||
SALAD consumes a pre-compiled ``.trt`` engine produced by C10's engine
|
||||
compiler (AZ-321) — there is no PyTorch ``nn.Module`` to register, so
|
||||
the module does NOT expose ``MODEL_NAME`` / ``architecture_factory``.
|
||||
:func:`gps_denied_onboard.runtime_root.vpr_factory._register_strategy_architecture`
|
||||
no-ops for this strategy.
|
||||
|
||||
Engine load happens in :func:`create` (NOT at first frame) so the
|
||||
engine-output-shape assertion (AC-6) surfaces at startup, not after
|
||||
takeoff.
|
||||
|
||||
Per-frame :meth:`embed_query` pipeline:
|
||||
|
||||
1. ``preprocessor.preprocess(frame, calibration)`` ->
|
||||
``(1, 3, 322, 322)`` FP16 NCHW ndarray.
|
||||
2. ``inference_runtime.infer(handle, {"input": tensor})`` ->
|
||||
``{"embedding": (1, 8448) FP16 ndarray}``.
|
||||
3. ``normaliser.l2_normalise(raw[0])`` -> global L2 (single-stage).
|
||||
4. Return :class:`VprQuery` with ``frame_id``, normalised embedding,
|
||||
produced_at monotonic ns.
|
||||
|
||||
Error envelope: every method raises only members of :class:`VprError`.
|
||||
``RuntimeError`` from the backbone forward -> rewrapped to
|
||||
:class:`VprBackboneError`; :class:`VprPreprocessError` from the
|
||||
preprocessor propagates unchanged.
|
||||
|
||||
Retrieval is a single-line delegation to :class:`FaissBridge.retrieve`;
|
||||
see AZ-341 AC-10.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.inference import (
|
||||
BuildConfig,
|
||||
EngineHandle,
|
||||
PrecisionMode,
|
||||
)
|
||||
from gps_denied_onboard._types.vpr import VprQuery, VprResult
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_salad import (
|
||||
SaladBackbonePreprocessor,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.descriptor_index_cut import (
|
||||
DescriptorIndexCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.errors import (
|
||||
VprBackboneError,
|
||||
VprPreprocessError,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.inference_runtime_cut import (
|
||||
InferenceRuntimeCut,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
from gps_denied_onboard.fdr_client import EnqueueResult, FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
FdrRecord,
|
||||
)
|
||||
from gps_denied_onboard.helpers.descriptor_normaliser import DescriptorNormaliser
|
||||
from gps_denied_onboard.helpers.iso_timestamps import (
|
||||
iso_ts_from_clock as _iso_ts_from_clock,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
from gps_denied_onboard.config.schema import Config
|
||||
|
||||
__all__ = ["DESCRIPTOR_DIM", "SaladStrategy", "create"]
|
||||
|
||||
|
||||
# SALAD's published embedding dimension (D=8448) — the largest VPR
|
||||
# descriptor the project carries, produced by the SALAD aggregator
|
||||
# applied to DINOv2-Large patch tokens. The matching FAISS HNSW corpus
|
||||
# has correspondingly higher RAM cost; researchers must rebuild the
|
||||
# corpus when swapping between SALAD and any non-8448 backbone (the
|
||||
# AZ-336 pre-flight dim-mismatch check enforces this). Engine output
|
||||
# shape is asserted at create() time.
|
||||
DESCRIPTOR_DIM: Final[int] = 8448
|
||||
|
||||
_BACKBONE_LABEL: Final[Literal["salad"]] = "salad"
|
||||
_COMPONENT: Final[str] = "c2_vpr"
|
||||
_OUTPUT_KEY: Final[str] = "embedding"
|
||||
_ENGINE_INPUT_KEY: Final[str] = "input"
|
||||
|
||||
_ALLOWED_RUNTIME_LABELS: Final[frozenset[str]] = frozenset(
|
||||
{"tensorrt", "onnx_trt_ep"}
|
||||
)
|
||||
|
||||
_LOG_KIND_READY: Final[str] = "c2.vpr.ready"
|
||||
_LOG_KIND_BACKBONE_ERROR: Final[str] = "c2.vpr.backbone_error"
|
||||
_LOG_KIND_PREPROCESS_ERROR: Final[str] = "c2.vpr.preprocess_error"
|
||||
_LOG_KIND_FDR_OVERRUN: Final[str] = "c2.vpr.fdr_overrun"
|
||||
|
||||
_FDR_KIND_EMBED: Final[str] = "vpr.embed_query"
|
||||
_FDR_KIND_BACKBONE_ERROR: Final[str] = "vpr.backbone_error"
|
||||
_FDR_KIND_PREPROCESS_ERROR: Final[str] = "vpr.preprocess_error"
|
||||
|
||||
|
||||
class SaladStrategy:
|
||||
"""C2 secondary VprStrategy backed by a TRT SALAD (DINOv2-Large) engine.
|
||||
|
||||
See module docstring for the engine-loading + per-frame pipeline.
|
||||
Stateless across frames (INV-2); single-threaded per instance
|
||||
(INV-1, per AZ-336).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
engine_handle: EngineHandle,
|
||||
descriptor_index: DescriptorIndexCut,
|
||||
preprocessor: SaladBackbonePreprocessor,
|
||||
normaliser: DescriptorNormaliser,
|
||||
faiss_bridge: FaissBridge,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock,
|
||||
logger: logging.Logger,
|
||||
descriptor_dim: int = DESCRIPTOR_DIM,
|
||||
) -> None:
|
||||
if descriptor_dim < 1:
|
||||
raise ValueError(
|
||||
f"SaladStrategy.descriptor_dim must be >= 1; "
|
||||
f"got {descriptor_dim}"
|
||||
)
|
||||
self._inference_runtime = inference_runtime
|
||||
self._engine_handle = engine_handle
|
||||
self._descriptor_index = descriptor_index
|
||||
self._preprocessor = preprocessor
|
||||
self._normaliser = normaliser
|
||||
self._faiss_bridge = faiss_bridge
|
||||
self._fdr_client = fdr_client
|
||||
self._clock = clock
|
||||
self._logger = logger
|
||||
self._descriptor_dim = descriptor_dim
|
||||
|
||||
def embed_query(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
calibration: CameraCalibration,
|
||||
) -> VprQuery:
|
||||
try:
|
||||
tensor = self._preprocessor.preprocess(frame, calibration)
|
||||
except VprPreprocessError as exc:
|
||||
self._emit_preprocess_error(frame, exc)
|
||||
raise
|
||||
|
||||
ns_start = self._clock.monotonic_ns()
|
||||
try:
|
||||
outputs = self._inference_runtime.infer(
|
||||
self._engine_handle, {_ENGINE_INPUT_KEY: tensor}
|
||||
)
|
||||
except Exception as exc:
|
||||
wrapped = self._wrap_backbone_error(frame, exc)
|
||||
raise wrapped from exc
|
||||
ns_end = self._clock.monotonic_ns()
|
||||
latency_us = max(1, (ns_end - ns_start) // 1_000)
|
||||
|
||||
if _OUTPUT_KEY not in outputs:
|
||||
err = VprBackboneError(
|
||||
f"SALAD forward returned no {_OUTPUT_KEY!r} key; "
|
||||
f"got {sorted(outputs.keys())!r}"
|
||||
)
|
||||
self._emit_backbone_error(frame, err)
|
||||
raise err
|
||||
|
||||
raw = np.asarray(outputs[_OUTPUT_KEY])
|
||||
if (
|
||||
raw.ndim != 2
|
||||
or raw.shape[0] != 1
|
||||
or raw.shape[1] != self._descriptor_dim
|
||||
):
|
||||
err = VprBackboneError(
|
||||
f"SALAD forward returned shape {raw.shape}; "
|
||||
f"expected (1, {self._descriptor_dim})"
|
||||
)
|
||||
self._emit_backbone_error(frame, err)
|
||||
raise err
|
||||
|
||||
flat = np.ascontiguousarray(raw[0], dtype=np.float16)
|
||||
normalised = self._normaliser.l2_normalise(flat)
|
||||
|
||||
self._emit_embed_record(
|
||||
frame_id=int(frame.frame_id), latency_us=int(latency_us)
|
||||
)
|
||||
|
||||
return VprQuery(
|
||||
frame_id=int(frame.frame_id),
|
||||
embedding=normalised,
|
||||
produced_at=ns_end,
|
||||
)
|
||||
|
||||
def retrieve_topk(self, query: VprQuery, k: int) -> VprResult:
|
||||
return self._faiss_bridge.retrieve(
|
||||
query, k, backbone_label=_BACKBONE_LABEL
|
||||
)
|
||||
|
||||
def descriptor_dim(self) -> int:
|
||||
return self._descriptor_dim
|
||||
|
||||
def _wrap_backbone_error(
|
||||
self, frame: NavCameraFrame, exc: BaseException
|
||||
) -> VprBackboneError:
|
||||
wrapped = VprBackboneError(
|
||||
f"SALAD forward raised {type(exc).__name__}: {exc}"
|
||||
)
|
||||
self._emit_backbone_error(frame, wrapped)
|
||||
return wrapped
|
||||
|
||||
def _emit_embed_record(self, *, frame_id: int, latency_us: int) -> None:
|
||||
record = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_EMBED,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"descriptor_dim": self._descriptor_dim,
|
||||
"latency_us": latency_us,
|
||||
},
|
||||
)
|
||||
result = self._fdr_client.enqueue(record)
|
||||
if result == EnqueueResult.OVERRUN:
|
||||
self._logger.warning(
|
||||
"FDR enqueue dropped vpr.embed_query record (buffer overrun)",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_FDR_OVERRUN,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _emit_backbone_error(
|
||||
self, frame: NavCameraFrame, error: BaseException
|
||||
) -> None:
|
||||
frame_id = int(frame.frame_id)
|
||||
msg = f"SALAD backbone error: {error}"
|
||||
self._logger.error(
|
||||
msg,
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_BACKBONE_ERROR,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_BACKBONE_ERROR,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error)[:512],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _emit_preprocess_error(
|
||||
self, frame: NavCameraFrame, error: BaseException
|
||||
) -> None:
|
||||
frame_id = int(frame.frame_id)
|
||||
msg = f"SALAD preprocess error: {error}"
|
||||
self._logger.error(
|
||||
msg,
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_PREPROCESS_ERROR,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_PREPROCESS_ERROR,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error)[:512],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_trt_build_config() -> BuildConfig:
|
||||
return BuildConfig(
|
||||
precision=PrecisionMode.FP16,
|
||||
workspace_mb=0,
|
||||
calibration_dataset=None,
|
||||
optimization_profiles=(),
|
||||
)
|
||||
|
||||
|
||||
def create(
|
||||
config: Config,
|
||||
*,
|
||||
descriptor_index: DescriptorIndexCut,
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
fdr_client: FdrClient | None = None,
|
||||
clock: Clock | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> SaladStrategy:
|
||||
"""Module-level factory consumed by :func:`build_vpr_strategy`.
|
||||
|
||||
SALAD is unselectable when the C7 TRT / ONNX-RT runtimes are
|
||||
excluded — ``current_runtime_label()`` MUST be one of
|
||||
``{"tensorrt", "onnx_trt_ep"}``; ``"pytorch_fp16"`` is rejected
|
||||
with :class:`ConfigError` at composition time.
|
||||
|
||||
Engine output shape is asserted at create time via a single
|
||||
dry-run inference on a zero-init input; mismatch raises
|
||||
:class:`ConfigError` BEFORE the strategy is bound (AC-6).
|
||||
|
||||
Optional keyword-only injection points (``fdr_client`` / ``clock`` /
|
||||
``logger``) keep tests deterministic; production wiring fills them
|
||||
from the composition root.
|
||||
"""
|
||||
runtime_label = inference_runtime.current_runtime_label()
|
||||
if runtime_label not in _ALLOWED_RUNTIME_LABELS:
|
||||
raise ConfigError(
|
||||
f"SALAD requires BUILD_TENSORRT_RUNTIME=ON (or "
|
||||
f"BUILD_ONNX_TRT_EP_RUNTIME=ON as fallback); this binary "
|
||||
f"has runtime_label={runtime_label!r}."
|
||||
)
|
||||
|
||||
block = config.components["c2_vpr"]
|
||||
weights_path = block.backbone_weights_path
|
||||
|
||||
if fdr_client is None:
|
||||
raise ValueError(
|
||||
"SaladStrategy.create: fdr_client is required; the "
|
||||
"composition root must inject the running FDR client."
|
||||
)
|
||||
if clock is None:
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
|
||||
clock = WallClock()
|
||||
if logger is None:
|
||||
logger = logging.getLogger("gps_denied_onboard.c2_vpr.salad")
|
||||
|
||||
entry = inference_runtime.compile_engine(
|
||||
weights_path, _build_trt_build_config()
|
||||
)
|
||||
handle = inference_runtime.deserialize_engine(entry)
|
||||
|
||||
preprocessor = SaladBackbonePreprocessor(logger=logger)
|
||||
normaliser = DescriptorNormaliser()
|
||||
faiss_bridge = FaissBridge(
|
||||
descriptor_index=descriptor_index,
|
||||
descriptor_dim=DESCRIPTOR_DIM,
|
||||
warn_top1_threshold=block.warn_top1_threshold,
|
||||
debug_log_per_frame_distances=block.debug_per_frame_distances,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
assert_engine_output_dim(
|
||||
inference_runtime,
|
||||
handle,
|
||||
preprocessor,
|
||||
DESCRIPTOR_DIM,
|
||||
output_key=_OUTPUT_KEY,
|
||||
input_key=_ENGINE_INPUT_KEY,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"C2 VPR strategy ready",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_READY,
|
||||
"kv": {
|
||||
"strategy": _BACKBONE_LABEL,
|
||||
"descriptor_dim": DESCRIPTOR_DIM,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return SaladStrategy(
|
||||
inference_runtime=inference_runtime,
|
||||
engine_handle=handle,
|
||||
descriptor_index=descriptor_index,
|
||||
preprocessor=preprocessor,
|
||||
normaliser=normaliser,
|
||||
faiss_bridge=faiss_bridge,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
logger=logger,
|
||||
descriptor_dim=DESCRIPTOR_DIM,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""``SelaVprStrategy`` — C2 secondary VprStrategy for IT-12 (AZ-340).
|
||||
|
||||
SelaVPR is a secondary backbone shipped exclusively in the research
|
||||
binary for the IT-12 comparative-study matrix
|
||||
(``components/02_c2_vpr/description.md`` § 1 + § 5). Per ADR-002,
|
||||
``BUILD_VPR_SELAVPR`` is ON for the research binary and replay-cli, OFF
|
||||
for the airborne and operator-tooling binaries — selecting ``sela_vpr``
|
||||
on a binary without the flag fails fast at composition-root time via
|
||||
:class:`StrategyNotAvailableError` (not at first frame).
|
||||
|
||||
The strategy runs on the C7 TensorRT runtime (AZ-298), or the ONNX-Runtime
|
||||
fallback (AZ-299), via the local :class:`InferenceRuntimeCut` (AZ-507).
|
||||
Engine output key is ``"embedding"`` and the strategy applies single-stage
|
||||
global L2 normalisation (no NetVLAD-style intra-cluster step). Retrieval
|
||||
delegates to :class:`FaissBridge` (AZ-341).
|
||||
|
||||
Architecture-registry differences from :class:`NetVladStrategy`:
|
||||
|
||||
SelaVPR consumes a pre-compiled ``.trt`` engine produced by C10's engine
|
||||
compiler (AZ-321) — there is no PyTorch ``nn.Module`` to register, so
|
||||
the module does NOT expose ``MODEL_NAME`` / ``architecture_factory``.
|
||||
:func:`gps_denied_onboard.runtime_root.vpr_factory._register_strategy_architecture`
|
||||
no-ops for this strategy.
|
||||
|
||||
Engine load happens in :func:`create` (NOT at first frame) so the
|
||||
engine-output-shape assertion (AC-6) surfaces at startup, not after
|
||||
takeoff.
|
||||
|
||||
Per-frame :meth:`embed_query` pipeline:
|
||||
|
||||
1. ``preprocessor.preprocess(frame, calibration)`` ->
|
||||
``(1, 3, 224, 224)`` FP16 NCHW ndarray.
|
||||
2. ``inference_runtime.infer(handle, {"input": tensor})`` ->
|
||||
``{"embedding": (1, 512) FP16 ndarray}``.
|
||||
3. ``normaliser.l2_normalise(raw[0])`` -> global L2 (single-stage).
|
||||
4. Return :class:`VprQuery` with ``frame_id``, normalised embedding,
|
||||
produced_at monotonic ns.
|
||||
|
||||
Error envelope: every method raises only members of :class:`VprError`.
|
||||
``RuntimeError`` from the backbone forward -> rewrapped to
|
||||
:class:`VprBackboneError`; :class:`VprPreprocessError` from the
|
||||
preprocessor propagates unchanged.
|
||||
|
||||
Retrieval is a single-line delegation to :class:`FaissBridge.retrieve`;
|
||||
see AZ-341 AC-10.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.inference import (
|
||||
BuildConfig,
|
||||
EngineHandle,
|
||||
PrecisionMode,
|
||||
)
|
||||
from gps_denied_onboard._types.vpr import VprQuery, VprResult
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_sela_vpr import (
|
||||
SelaVprBackbonePreprocessor,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.descriptor_index_cut import (
|
||||
DescriptorIndexCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.errors import (
|
||||
VprBackboneError,
|
||||
VprPreprocessError,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.inference_runtime_cut import (
|
||||
InferenceRuntimeCut,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
from gps_denied_onboard.fdr_client import EnqueueResult, FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
FdrRecord,
|
||||
)
|
||||
from gps_denied_onboard.helpers.descriptor_normaliser import DescriptorNormaliser
|
||||
from gps_denied_onboard.helpers.iso_timestamps import (
|
||||
iso_ts_from_clock as _iso_ts_from_clock,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
from gps_denied_onboard.config.schema import Config
|
||||
|
||||
__all__ = ["DESCRIPTOR_DIM", "SelaVprStrategy", "create"]
|
||||
|
||||
|
||||
# SelaVPR's published embedding dimension (D=512) per the upstream
|
||||
# research code drop. Engine output shape is asserted at create() time
|
||||
# against this constant — changing it would silently break AC-2 /
|
||||
# AC-4 / AC-5 / AC-6.
|
||||
DESCRIPTOR_DIM: Final[int] = 512
|
||||
|
||||
_BACKBONE_LABEL: Final[Literal["sela_vpr"]] = "sela_vpr"
|
||||
_COMPONENT: Final[str] = "c2_vpr"
|
||||
_OUTPUT_KEY: Final[str] = "embedding"
|
||||
_ENGINE_INPUT_KEY: Final[str] = "input"
|
||||
|
||||
_ALLOWED_RUNTIME_LABELS: Final[frozenset[str]] = frozenset(
|
||||
{"tensorrt", "onnx_trt_ep"}
|
||||
)
|
||||
|
||||
_LOG_KIND_READY: Final[str] = "c2.vpr.ready"
|
||||
_LOG_KIND_BACKBONE_ERROR: Final[str] = "c2.vpr.backbone_error"
|
||||
_LOG_KIND_PREPROCESS_ERROR: Final[str] = "c2.vpr.preprocess_error"
|
||||
_LOG_KIND_FDR_OVERRUN: Final[str] = "c2.vpr.fdr_overrun"
|
||||
|
||||
_FDR_KIND_EMBED: Final[str] = "vpr.embed_query"
|
||||
_FDR_KIND_BACKBONE_ERROR: Final[str] = "vpr.backbone_error"
|
||||
_FDR_KIND_PREPROCESS_ERROR: Final[str] = "vpr.preprocess_error"
|
||||
|
||||
|
||||
class SelaVprStrategy:
|
||||
"""C2 secondary VprStrategy backed by a TRT SelaVPR engine.
|
||||
|
||||
See module docstring for the engine-loading + per-frame pipeline.
|
||||
Stateless across frames (INV-2); single-threaded per instance
|
||||
(INV-1, per AZ-336).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
engine_handle: EngineHandle,
|
||||
descriptor_index: DescriptorIndexCut,
|
||||
preprocessor: SelaVprBackbonePreprocessor,
|
||||
normaliser: DescriptorNormaliser,
|
||||
faiss_bridge: FaissBridge,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock,
|
||||
logger: logging.Logger,
|
||||
descriptor_dim: int = DESCRIPTOR_DIM,
|
||||
) -> None:
|
||||
if descriptor_dim < 1:
|
||||
raise ValueError(
|
||||
f"SelaVprStrategy.descriptor_dim must be >= 1; "
|
||||
f"got {descriptor_dim}"
|
||||
)
|
||||
self._inference_runtime = inference_runtime
|
||||
self._engine_handle = engine_handle
|
||||
self._descriptor_index = descriptor_index
|
||||
self._preprocessor = preprocessor
|
||||
self._normaliser = normaliser
|
||||
self._faiss_bridge = faiss_bridge
|
||||
self._fdr_client = fdr_client
|
||||
self._clock = clock
|
||||
self._logger = logger
|
||||
self._descriptor_dim = descriptor_dim
|
||||
|
||||
def embed_query(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
calibration: CameraCalibration,
|
||||
) -> VprQuery:
|
||||
try:
|
||||
tensor = self._preprocessor.preprocess(frame, calibration)
|
||||
except VprPreprocessError as exc:
|
||||
self._emit_preprocess_error(frame, exc)
|
||||
raise
|
||||
|
||||
ns_start = self._clock.monotonic_ns()
|
||||
try:
|
||||
outputs = self._inference_runtime.infer(
|
||||
self._engine_handle, {_ENGINE_INPUT_KEY: tensor}
|
||||
)
|
||||
except Exception as exc:
|
||||
wrapped = self._wrap_backbone_error(frame, exc)
|
||||
raise wrapped from exc
|
||||
ns_end = self._clock.monotonic_ns()
|
||||
latency_us = max(1, (ns_end - ns_start) // 1_000)
|
||||
|
||||
if _OUTPUT_KEY not in outputs:
|
||||
err = VprBackboneError(
|
||||
f"SelaVPR forward returned no {_OUTPUT_KEY!r} key; "
|
||||
f"got {sorted(outputs.keys())!r}"
|
||||
)
|
||||
self._emit_backbone_error(frame, err)
|
||||
raise err
|
||||
|
||||
raw = np.asarray(outputs[_OUTPUT_KEY])
|
||||
if (
|
||||
raw.ndim != 2
|
||||
or raw.shape[0] != 1
|
||||
or raw.shape[1] != self._descriptor_dim
|
||||
):
|
||||
err = VprBackboneError(
|
||||
f"SelaVPR forward returned shape {raw.shape}; "
|
||||
f"expected (1, {self._descriptor_dim})"
|
||||
)
|
||||
self._emit_backbone_error(frame, err)
|
||||
raise err
|
||||
|
||||
flat = np.ascontiguousarray(raw[0], dtype=np.float16)
|
||||
normalised = self._normaliser.l2_normalise(flat)
|
||||
|
||||
self._emit_embed_record(
|
||||
frame_id=int(frame.frame_id), latency_us=int(latency_us)
|
||||
)
|
||||
|
||||
return VprQuery(
|
||||
frame_id=int(frame.frame_id),
|
||||
embedding=normalised,
|
||||
produced_at=ns_end,
|
||||
)
|
||||
|
||||
def retrieve_topk(self, query: VprQuery, k: int) -> VprResult:
|
||||
return self._faiss_bridge.retrieve(
|
||||
query, k, backbone_label=_BACKBONE_LABEL
|
||||
)
|
||||
|
||||
def descriptor_dim(self) -> int:
|
||||
return self._descriptor_dim
|
||||
|
||||
def _wrap_backbone_error(
|
||||
self, frame: NavCameraFrame, exc: BaseException
|
||||
) -> VprBackboneError:
|
||||
wrapped = VprBackboneError(
|
||||
f"SelaVPR forward raised {type(exc).__name__}: {exc}"
|
||||
)
|
||||
self._emit_backbone_error(frame, wrapped)
|
||||
return wrapped
|
||||
|
||||
def _emit_embed_record(self, *, frame_id: int, latency_us: int) -> None:
|
||||
record = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_EMBED,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"descriptor_dim": self._descriptor_dim,
|
||||
"latency_us": latency_us,
|
||||
},
|
||||
)
|
||||
result = self._fdr_client.enqueue(record)
|
||||
if result == EnqueueResult.OVERRUN:
|
||||
self._logger.warning(
|
||||
"FDR enqueue dropped vpr.embed_query record (buffer overrun)",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_FDR_OVERRUN,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _emit_backbone_error(
|
||||
self, frame: NavCameraFrame, error: BaseException
|
||||
) -> None:
|
||||
frame_id = int(frame.frame_id)
|
||||
msg = f"SelaVPR backbone error: {error}"
|
||||
self._logger.error(
|
||||
msg,
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_BACKBONE_ERROR,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_BACKBONE_ERROR,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error)[:512],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _emit_preprocess_error(
|
||||
self, frame: NavCameraFrame, error: BaseException
|
||||
) -> None:
|
||||
frame_id = int(frame.frame_id)
|
||||
msg = f"SelaVPR preprocess error: {error}"
|
||||
self._logger.error(
|
||||
msg,
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_PREPROCESS_ERROR,
|
||||
"kv": {
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._fdr_client.enqueue(
|
||||
FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_iso_ts_from_clock(self._clock),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_PREPROCESS_ERROR,
|
||||
payload={
|
||||
"frame_id": frame_id,
|
||||
"backbone_label": _BACKBONE_LABEL,
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error)[:512],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_trt_build_config() -> BuildConfig:
|
||||
return BuildConfig(
|
||||
precision=PrecisionMode.FP16,
|
||||
workspace_mb=0,
|
||||
calibration_dataset=None,
|
||||
optimization_profiles=(),
|
||||
)
|
||||
|
||||
|
||||
def create(
|
||||
config: Config,
|
||||
*,
|
||||
descriptor_index: DescriptorIndexCut,
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
fdr_client: FdrClient | None = None,
|
||||
clock: Clock | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> SelaVprStrategy:
|
||||
"""Module-level factory consumed by :func:`build_vpr_strategy`.
|
||||
|
||||
SelaVPR is unselectable when the C7 TRT / ONNX-RT runtimes are
|
||||
excluded — ``current_runtime_label()`` MUST be one of
|
||||
``{"tensorrt", "onnx_trt_ep"}``; ``"pytorch_fp16"`` is rejected
|
||||
with :class:`ConfigError` at composition time.
|
||||
|
||||
Engine output shape is asserted at create time via a single
|
||||
dry-run inference on a zero-init input; mismatch raises
|
||||
:class:`ConfigError` BEFORE the strategy is bound (AC-6).
|
||||
|
||||
Optional keyword-only injection points (``fdr_client`` / ``clock`` /
|
||||
``logger``) keep tests deterministic; production wiring fills them
|
||||
from the composition root.
|
||||
"""
|
||||
runtime_label = inference_runtime.current_runtime_label()
|
||||
if runtime_label not in _ALLOWED_RUNTIME_LABELS:
|
||||
raise ConfigError(
|
||||
f"SelaVPR requires BUILD_TENSORRT_RUNTIME=ON (or "
|
||||
f"BUILD_ONNX_TRT_EP_RUNTIME=ON as fallback); this binary "
|
||||
f"has runtime_label={runtime_label!r}."
|
||||
)
|
||||
|
||||
block = config.components["c2_vpr"]
|
||||
weights_path = block.backbone_weights_path
|
||||
|
||||
if fdr_client is None:
|
||||
raise ValueError(
|
||||
"SelaVprStrategy.create: fdr_client is required; the "
|
||||
"composition root must inject the running FDR client."
|
||||
)
|
||||
if clock is None:
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
|
||||
clock = WallClock()
|
||||
if logger is None:
|
||||
logger = logging.getLogger("gps_denied_onboard.c2_vpr.sela_vpr")
|
||||
|
||||
entry = inference_runtime.compile_engine(
|
||||
weights_path, _build_trt_build_config()
|
||||
)
|
||||
handle = inference_runtime.deserialize_engine(entry)
|
||||
|
||||
preprocessor = SelaVprBackbonePreprocessor(logger=logger)
|
||||
normaliser = DescriptorNormaliser()
|
||||
faiss_bridge = FaissBridge(
|
||||
descriptor_index=descriptor_index,
|
||||
descriptor_dim=DESCRIPTOR_DIM,
|
||||
warn_top1_threshold=block.warn_top1_threshold,
|
||||
debug_log_per_frame_distances=block.debug_per_frame_distances,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
assert_engine_output_dim(
|
||||
inference_runtime,
|
||||
handle,
|
||||
preprocessor,
|
||||
DESCRIPTOR_DIM,
|
||||
output_key=_OUTPUT_KEY,
|
||||
input_key=_ENGINE_INPUT_KEY,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"C2 VPR strategy ready",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_READY,
|
||||
"kv": {
|
||||
"strategy": _BACKBONE_LABEL,
|
||||
"descriptor_dim": DESCRIPTOR_DIM,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return SelaVprStrategy(
|
||||
inference_runtime=inference_runtime,
|
||||
engine_handle=handle,
|
||||
descriptor_index=descriptor_index,
|
||||
preprocessor=preprocessor,
|
||||
normaliser=normaliser,
|
||||
faiss_bridge=faiss_bridge,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
logger=logger,
|
||||
descriptor_dim=DESCRIPTOR_DIM,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ from gps_denied_onboard._types.inference import (
|
||||
)
|
||||
from gps_denied_onboard._types.vpr import VprQuery, VprResult
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_ultra_vpr import (
|
||||
UltraVprBackbonePreprocessor,
|
||||
@@ -401,7 +404,14 @@ def create(
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
_assert_engine_output_dim(inference_runtime, handle, preprocessor)
|
||||
assert_engine_output_dim(
|
||||
inference_runtime,
|
||||
handle,
|
||||
preprocessor,
|
||||
DESCRIPTOR_DIM,
|
||||
output_key=_OUTPUT_KEY,
|
||||
input_key=_ENGINE_INPUT_KEY,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"C2 VPR strategy ready",
|
||||
@@ -429,26 +439,3 @@ def create(
|
||||
)
|
||||
|
||||
|
||||
def _assert_engine_output_dim(
|
||||
inference_runtime: InferenceRuntimeCut,
|
||||
handle: EngineHandle,
|
||||
preprocessor: UltraVprBackbonePreprocessor,
|
||||
) -> None:
|
||||
h, w = preprocessor.input_shape()
|
||||
probe = np.zeros((1, 3, h, w), dtype=np.float16)
|
||||
outputs = inference_runtime.infer(handle, {_ENGINE_INPUT_KEY: probe})
|
||||
if _OUTPUT_KEY not in outputs:
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: {_OUTPUT_KEY!r} key absent; "
|
||||
f"got keys {sorted(outputs.keys())!r}"
|
||||
)
|
||||
actual = np.asarray(outputs[_OUTPUT_KEY])
|
||||
if (
|
||||
actual.ndim != 2
|
||||
or actual.shape[0] != 1
|
||||
or actual.shape[1] != DESCRIPTOR_DIM
|
||||
):
|
||||
raise ConfigError(
|
||||
f"engine output shape mismatch: expected (1, {DESCRIPTOR_DIM}), "
|
||||
f"got {tuple(actual.shape)}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
"""AZ-340 — SelaVPR + EigenPlaces + SALAD secondary VprStrategy unit tests.
|
||||
|
||||
Covers AC-1..AC-11 for all three strategies. Parametrised across the
|
||||
strategies so the test surface stays compact (one test per AC times
|
||||
three strategies) and any drift between the three implementations is
|
||||
visible at the assertion level — same approach as
|
||||
``test_az339_mega_loc_mix_vpr.py``.
|
||||
|
||||
Uses fakes for :class:`InferenceRuntimeCut`, :class:`DescriptorIndexCut`,
|
||||
and :class:`FdrClient` so the suite stays AZ-507-clean and TRT-free
|
||||
(mirrors the precedent in ``test_ultra_vpr.py`` / ``test_az339_*``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.inference import (
|
||||
BuildConfig,
|
||||
EngineCacheEntry,
|
||||
EngineHandle,
|
||||
PrecisionMode,
|
||||
)
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
from gps_denied_onboard.components.c2_vpr import (
|
||||
C2VprConfig,
|
||||
VprStrategy,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._faiss_bridge import FaissBridge
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_eigen_places import (
|
||||
EigenPlacesBackbonePreprocessor,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_salad import (
|
||||
SaladBackbonePreprocessor,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._preprocessor_sela_vpr import (
|
||||
SelaVprBackbonePreprocessor,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.eigen_places import (
|
||||
DESCRIPTOR_DIM as EIGEN_PLACES_DIM,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.eigen_places import (
|
||||
EigenPlacesStrategy,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.eigen_places import (
|
||||
create as create_eigen_places,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.errors import (
|
||||
VprBackboneError,
|
||||
VprPreprocessError,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.salad import (
|
||||
DESCRIPTOR_DIM as SALAD_DIM,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.salad import (
|
||||
SaladStrategy,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.salad import (
|
||||
create as create_salad,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.sela_vpr import (
|
||||
DESCRIPTOR_DIM as SELA_VPR_DIM,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.sela_vpr import (
|
||||
SelaVprStrategy,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr.sela_vpr import (
|
||||
create as create_sela_vpr,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import Config, ConfigError
|
||||
from gps_denied_onboard.fdr_client import FdrClient
|
||||
from gps_denied_onboard.helpers.descriptor_normaliser import DescriptorNormaliser
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parametrisation: each strategy + its bound constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StrategySpec:
|
||||
name: str
|
||||
strategy_cls: type
|
||||
create_fn: Any
|
||||
preprocessor_cls: type
|
||||
descriptor_dim: int
|
||||
backbone_label: str
|
||||
input_hw: tuple[int, int]
|
||||
|
||||
|
||||
_SPECS: list[_StrategySpec] = [
|
||||
_StrategySpec(
|
||||
name="sela_vpr",
|
||||
strategy_cls=SelaVprStrategy,
|
||||
create_fn=create_sela_vpr,
|
||||
preprocessor_cls=SelaVprBackbonePreprocessor,
|
||||
descriptor_dim=SELA_VPR_DIM,
|
||||
backbone_label="sela_vpr",
|
||||
input_hw=(224, 224),
|
||||
),
|
||||
_StrategySpec(
|
||||
name="eigen_places",
|
||||
strategy_cls=EigenPlacesStrategy,
|
||||
create_fn=create_eigen_places,
|
||||
preprocessor_cls=EigenPlacesBackbonePreprocessor,
|
||||
descriptor_dim=EIGEN_PLACES_DIM,
|
||||
backbone_label="eigen_places",
|
||||
input_hw=(480, 480),
|
||||
),
|
||||
_StrategySpec(
|
||||
name="salad",
|
||||
strategy_cls=SaladStrategy,
|
||||
create_fn=create_salad,
|
||||
preprocessor_cls=SaladBackbonePreprocessor,
|
||||
descriptor_dim=SALAD_DIM,
|
||||
backbone_label="salad",
|
||||
input_hw=(322, 322),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(params=_SPECS, ids=[s.name for s in _SPECS])
|
||||
def spec(request: pytest.FixtureRequest) -> _StrategySpec:
|
||||
return request.param
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes (mirrors test_az339_*.py shape)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubClock:
|
||||
next_monotonic_ns: int = 1_000_000_000
|
||||
step_ns: int = 5_000
|
||||
fixed_time_ns: int = 1_715_600_000_000_000_000
|
||||
|
||||
def monotonic_ns(self) -> int:
|
||||
v = self.next_monotonic_ns
|
||||
self.next_monotonic_ns += self.step_ns
|
||||
return v
|
||||
|
||||
def time_ns(self) -> int:
|
||||
return self.fixed_time_ns
|
||||
|
||||
def sleep_until_ns(self, target_ns: int) -> None:
|
||||
_ = target_ns
|
||||
|
||||
|
||||
class _FakeEngineHandle(EngineHandle):
|
||||
def __init__(self, label: str) -> None:
|
||||
self.label = label
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeInferenceRuntime:
|
||||
descriptor_dim: int = 512
|
||||
raises: BaseException | None = None
|
||||
runtime_label: Literal["tensorrt", "onnx_trt_ep", "pytorch_fp16"] = (
|
||||
"tensorrt"
|
||||
)
|
||||
fixed_output: np.ndarray | None = None
|
||||
output_key: str = "embedding"
|
||||
calls: list[dict[str, np.ndarray]] = field(default_factory=list)
|
||||
deserialize_calls: list[EngineCacheEntry] = field(default_factory=list)
|
||||
model_name: str = "sela_vpr"
|
||||
|
||||
def compile_engine(
|
||||
self, model_path: Path, build_config: BuildConfig
|
||||
) -> EngineCacheEntry:
|
||||
_ = build_config
|
||||
return EngineCacheEntry(
|
||||
engine_path=Path(model_path),
|
||||
sha256_hex="0" * 64,
|
||||
sm=None,
|
||||
jp=None,
|
||||
trt=None,
|
||||
precision=PrecisionMode.FP16,
|
||||
extras={"model_name": self.model_name},
|
||||
)
|
||||
|
||||
def deserialize_engine(self, entry: EngineCacheEntry) -> EngineHandle:
|
||||
self.deserialize_calls.append(entry)
|
||||
return _FakeEngineHandle(label=entry.extras.get("model_name", ""))
|
||||
|
||||
def infer(
|
||||
self, handle: EngineHandle, inputs: dict[str, np.ndarray]
|
||||
) -> dict[str, np.ndarray]:
|
||||
_ = handle
|
||||
self.calls.append({k: v.copy() for k, v in inputs.items()})
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
if self.fixed_output is not None:
|
||||
return {self.output_key: self.fixed_output.copy()}
|
||||
rng = np.random.default_rng(0xCAFEBABE)
|
||||
tensor = rng.standard_normal(self.descriptor_dim).astype(np.float16)
|
||||
return {
|
||||
self.output_key: tensor.reshape(1, self.descriptor_dim).copy()
|
||||
}
|
||||
|
||||
def release_engine(self, handle: EngineHandle) -> None:
|
||||
_ = handle
|
||||
|
||||
def current_runtime_label(
|
||||
self,
|
||||
) -> Literal["tensorrt", "onnx_trt_ep", "pytorch_fp16"]:
|
||||
return self.runtime_label
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeDescriptorIndex:
|
||||
descriptor_dim_value: int = 512
|
||||
results: list[tuple[tuple[int, float, float], float]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
raises: BaseException | None = None
|
||||
|
||||
def search_topk(
|
||||
self, query: np.ndarray, k: int
|
||||
) -> list[tuple[tuple[int, float, float], float]]:
|
||||
_ = query
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
if not self.results:
|
||||
return [
|
||||
((18, 49.0 + i * 0.001, 36.0 + i * 0.001), 0.05 + 0.05 * i)
|
||||
for i in range(k)
|
||||
]
|
||||
return list(self.results[:k])
|
||||
|
||||
def descriptor_dim(self) -> int:
|
||||
return self.descriptor_dim_value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_frame(
|
||||
*, frame_id: int = 4242, h: int = 720, w: int = 1280
|
||||
) -> NavCameraFrame:
|
||||
rng = np.random.default_rng(frame_id)
|
||||
image = rng.integers(0, 256, size=(h, w, 3), dtype=np.uint8)
|
||||
return NavCameraFrame(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime(2026, 5, 14, 0, 0, 0),
|
||||
image=image,
|
||||
camera_calibration_id="test_cam",
|
||||
)
|
||||
|
||||
|
||||
def _make_calibration(
|
||||
*, cx: float = 640.0, cy: float = 360.0
|
||||
) -> CameraCalibration:
|
||||
intrinsics = np.array(
|
||||
[
|
||||
[1000.0, 0.0, cx],
|
||||
[0.0, 1000.0, cy],
|
||||
[0.0, 0.0, 1.0],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
return CameraCalibration(
|
||||
camera_id="test_cam",
|
||||
intrinsics_3x3=intrinsics,
|
||||
distortion=np.zeros(5, dtype=np.float64),
|
||||
body_to_camera_se3=np.eye(4, dtype=np.float64),
|
||||
acquisition_method="test_fixture",
|
||||
)
|
||||
|
||||
|
||||
def _make_fdr_client() -> FdrClient:
|
||||
return FdrClient(producer_id="c2_vpr", capacity=32, _emit_diag_log=False)
|
||||
|
||||
|
||||
def _build_strategy(
|
||||
spec: _StrategySpec,
|
||||
*,
|
||||
inference_runtime: _FakeInferenceRuntime | None = None,
|
||||
descriptor_index: _FakeDescriptorIndex | None = None,
|
||||
preprocessor: Any = None,
|
||||
fdr_client: FdrClient | None = None,
|
||||
clock: _StubClock | None = None,
|
||||
descriptor_dim: int | None = None,
|
||||
) -> Any:
|
||||
dim = spec.descriptor_dim if descriptor_dim is None else descriptor_dim
|
||||
inference_runtime = inference_runtime or _FakeInferenceRuntime(
|
||||
descriptor_dim=dim, model_name=spec.name
|
||||
)
|
||||
descriptor_index = descriptor_index or _FakeDescriptorIndex(
|
||||
descriptor_dim_value=dim
|
||||
)
|
||||
preprocessor = preprocessor or spec.preprocessor_cls()
|
||||
fdr_client = fdr_client or _make_fdr_client()
|
||||
clock = clock or _StubClock()
|
||||
handle = _FakeEngineHandle(label=spec.name)
|
||||
bridge = FaissBridge(
|
||||
descriptor_index=descriptor_index,
|
||||
descriptor_dim=dim,
|
||||
warn_top1_threshold=0.30,
|
||||
debug_log_per_frame_distances=False,
|
||||
fdr_client=fdr_client,
|
||||
logger=logging.getLogger(f"test.{spec.name}.bridge"),
|
||||
clock=clock,
|
||||
)
|
||||
return spec.strategy_cls(
|
||||
inference_runtime=inference_runtime,
|
||||
engine_handle=handle,
|
||||
descriptor_index=descriptor_index,
|
||||
preprocessor=preprocessor,
|
||||
normaliser=DescriptorNormaliser(),
|
||||
faiss_bridge=bridge,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
logger=logging.getLogger(f"test.{spec.name}"),
|
||||
descriptor_dim=dim,
|
||||
)
|
||||
|
||||
|
||||
def _build_config(strategy_name: str) -> Config:
|
||||
c2 = C2VprConfig(
|
||||
strategy=strategy_name,
|
||||
backbone_weights_path=Path(f"/models/{strategy_name}.trt"),
|
||||
faiss_index_path=Path("/cache/vpr/index.faiss"),
|
||||
warn_top1_threshold=0.30,
|
||||
debug_per_frame_distances=False,
|
||||
)
|
||||
cfg = MagicMock(spec=Config)
|
||||
cfg.components = {"c2_vpr": c2}
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: Protocol conformance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac1_protocol_conformance(spec: _StrategySpec) -> None:
|
||||
strategy = _build_strategy(spec)
|
||||
assert isinstance(strategy, VprStrategy)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: embed_query → L2-normalised FP16 embedding of correct dim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac2_embed_query_returns_unit_norm_fp16_correct_dim(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
# Arrange
|
||||
strategy = _build_strategy(spec)
|
||||
frame = _make_frame()
|
||||
calibration = _make_calibration()
|
||||
|
||||
# Act
|
||||
query = strategy.embed_query(frame, calibration)
|
||||
|
||||
# Assert
|
||||
embedding = np.asarray(query.embedding)
|
||||
assert embedding.shape == (spec.descriptor_dim,)
|
||||
assert embedding.dtype == np.float16
|
||||
norm = float(np.linalg.norm(embedding.astype(np.float32)))
|
||||
assert norm == pytest.approx(1.0, abs=1e-3)
|
||||
|
||||
|
||||
def test_ac2_single_stage_l2_no_intra_cluster_call(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
"""Secondary backbones use single-stage L2 (no NetVLAD-style intra-cluster step)."""
|
||||
# Arrange
|
||||
calls: list[str] = []
|
||||
|
||||
class _SpyNormaliser(DescriptorNormaliser):
|
||||
def l2_normalise(self, descriptor: np.ndarray) -> np.ndarray: # type: ignore[override]
|
||||
calls.append("l2_normalise")
|
||||
return DescriptorNormaliser.l2_normalise(descriptor)
|
||||
|
||||
def intra_cluster_normalise( # type: ignore[override]
|
||||
self, descriptor: np.ndarray, num_clusters: int
|
||||
) -> np.ndarray:
|
||||
calls.append("intra_cluster_normalise")
|
||||
return DescriptorNormaliser.intra_cluster_normalise(
|
||||
descriptor, num_clusters
|
||||
)
|
||||
|
||||
inference_runtime = _FakeInferenceRuntime(descriptor_dim=spec.descriptor_dim)
|
||||
descriptor_index = _FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
)
|
||||
fdr_client = _make_fdr_client()
|
||||
clock = _StubClock()
|
||||
bridge = FaissBridge(
|
||||
descriptor_index=descriptor_index,
|
||||
descriptor_dim=spec.descriptor_dim,
|
||||
warn_top1_threshold=0.30,
|
||||
debug_log_per_frame_distances=False,
|
||||
fdr_client=fdr_client,
|
||||
logger=logging.getLogger(f"test.{spec.name}.bridge"),
|
||||
clock=clock,
|
||||
)
|
||||
strategy = spec.strategy_cls(
|
||||
inference_runtime=inference_runtime,
|
||||
engine_handle=_FakeEngineHandle(spec.name),
|
||||
descriptor_index=descriptor_index,
|
||||
preprocessor=spec.preprocessor_cls(),
|
||||
normaliser=_SpyNormaliser(),
|
||||
faiss_bridge=bridge,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
logger=logging.getLogger(f"test.{spec.name}"),
|
||||
descriptor_dim=spec.descriptor_dim,
|
||||
)
|
||||
|
||||
# Act
|
||||
strategy.embed_query(_make_frame(), _make_calibration())
|
||||
|
||||
# Assert
|
||||
assert "intra_cluster_normalise" not in calls
|
||||
assert calls == ["l2_normalise"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: deterministic embeddings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac3_embed_query_deterministic_for_same_frame(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
# Arrange
|
||||
rng = np.random.default_rng(2026)
|
||||
fixed = rng.standard_normal(spec.descriptor_dim).astype(np.float16)
|
||||
fixed = fixed.reshape(1, spec.descriptor_dim)
|
||||
runtime = _FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim, fixed_output=fixed
|
||||
)
|
||||
strategy = _build_strategy(spec, inference_runtime=runtime)
|
||||
frame = _make_frame()
|
||||
calibration = _make_calibration()
|
||||
|
||||
# Act
|
||||
first = strategy.embed_query(frame, calibration)
|
||||
second = strategy.embed_query(frame, calibration)
|
||||
third = strategy.embed_query(frame, calibration)
|
||||
|
||||
# Assert
|
||||
np.testing.assert_array_equal(
|
||||
np.asarray(first.embedding), np.asarray(second.embedding)
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
np.asarray(second.embedding), np.asarray(third.embedding)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: retrieve_topk returns k candidates with correct backbone_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac4_retrieve_topk_returns_exactly_k_with_correct_label(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
# Arrange
|
||||
descriptor_index = _FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
)
|
||||
strategy = _build_strategy(spec, descriptor_index=descriptor_index)
|
||||
|
||||
# Act
|
||||
query = strategy.embed_query(_make_frame(), _make_calibration())
|
||||
result = strategy.retrieve_topk(query, k=10)
|
||||
|
||||
# Assert
|
||||
assert len(result.candidates) == 10
|
||||
assert result.backbone_label == spec.backbone_label
|
||||
assert result.candidates[0].descriptor_dim == spec.descriptor_dim
|
||||
distances = [c.descriptor_distance for c in result.candidates]
|
||||
assert distances == sorted(distances)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-5: descriptor_dim() is stable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac5_descriptor_dim_stable(spec: _StrategySpec) -> None:
|
||||
strategy = _build_strategy(spec)
|
||||
for _ in range(100):
|
||||
assert strategy.descriptor_dim() == spec.descriptor_dim
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6: Engine output shape mismatch → ConfigError at create()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac6_create_rejects_engine_output_shape_mismatch(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
# Arrange — engine produces (1, 100), expected (1, spec.descriptor_dim)
|
||||
wrong = np.zeros((1, 100), dtype=np.float16)
|
||||
runtime = _FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim,
|
||||
fixed_output=wrong,
|
||||
model_name=spec.name,
|
||||
)
|
||||
descriptor_index = _FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError, match=r"engine output shape mismatch"):
|
||||
spec.create_fn(
|
||||
_build_config(spec.name),
|
||||
descriptor_index=descriptor_index,
|
||||
inference_runtime=runtime,
|
||||
fdr_client=_make_fdr_client(),
|
||||
clock=_StubClock(),
|
||||
)
|
||||
|
||||
|
||||
def test_ac6_create_rejects_missing_embedding_key(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
# Arrange
|
||||
runtime = _FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim,
|
||||
output_key="wrong_key",
|
||||
model_name=spec.name,
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError, match=r"'embedding' key absent"):
|
||||
spec.create_fn(
|
||||
_build_config(spec.name),
|
||||
descriptor_index=_FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
),
|
||||
inference_runtime=runtime,
|
||||
fdr_client=_make_fdr_client(),
|
||||
clock=_StubClock(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-7: VprBackboneError on forward-pass failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac7_runtime_error_yields_vpr_backbone_error(
|
||||
spec: _StrategySpec, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange
|
||||
runtime = _FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim, raises=RuntimeError("CUDA OOM")
|
||||
)
|
||||
fdr_client = _make_fdr_client()
|
||||
strategy = _build_strategy(
|
||||
spec, inference_runtime=runtime, fdr_client=fdr_client
|
||||
)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR, logger=f"test.{spec.name}"):
|
||||
with pytest.raises(VprBackboneError):
|
||||
strategy.embed_query(_make_frame(), _make_calibration())
|
||||
|
||||
# Assert
|
||||
assert any(
|
||||
record.levelno == logging.ERROR
|
||||
and getattr(record, "kind", None) == "c2.vpr.backbone_error"
|
||||
for record in caplog.records
|
||||
)
|
||||
records: list[Any] = []
|
||||
while True:
|
||||
r = fdr_client.pop_one()
|
||||
if r is None:
|
||||
break
|
||||
records.append(r)
|
||||
backbone_errors = [r for r in records if r.kind == "vpr.backbone_error"]
|
||||
assert len(backbone_errors) == 1
|
||||
|
||||
|
||||
def test_ac7_wrong_forward_output_shape_yields_vpr_backbone_error(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
# Arrange
|
||||
bad = np.zeros((1, 100), dtype=np.float16)
|
||||
runtime = _FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim, fixed_output=bad
|
||||
)
|
||||
strategy = _build_strategy(spec, inference_runtime=runtime)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(
|
||||
VprBackboneError, match=rf"expected \(1, {spec.descriptor_dim}\)"
|
||||
):
|
||||
strategy.embed_query(_make_frame(), _make_calibration())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-8: VprPreprocessError on corrupt image bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac8_corrupt_image_yields_vpr_preprocess_error(
|
||||
spec: _StrategySpec, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange
|
||||
fdr_client = _make_fdr_client()
|
||||
strategy = _build_strategy(spec, fdr_client=fdr_client)
|
||||
frame = NavCameraFrame(
|
||||
frame_id=4242,
|
||||
timestamp=datetime(2026, 5, 14, 0, 0, 0),
|
||||
image="not-an-array",
|
||||
camera_calibration_id="test_cam",
|
||||
)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR, logger=f"test.{spec.name}"):
|
||||
with pytest.raises(VprPreprocessError):
|
||||
strategy.embed_query(frame, _make_calibration())
|
||||
|
||||
# Assert
|
||||
assert any(
|
||||
record.levelno == logging.ERROR
|
||||
and getattr(record, "kind", None) == "c2.vpr.preprocess_error"
|
||||
for record in caplog.records
|
||||
)
|
||||
records: list[Any] = []
|
||||
while True:
|
||||
r = fdr_client.pop_one()
|
||||
if r is None:
|
||||
break
|
||||
records.append(r)
|
||||
preprocess_errors = [
|
||||
r for r in records if r.kind == "vpr.preprocess_error"
|
||||
]
|
||||
assert len(preprocess_errors) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-9: Composition-root wiring + INFO "c2.vpr.ready" log emitted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac9_create_emits_ready_log_with_correct_label_and_dim(
|
||||
spec: _StrategySpec, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger_name = f"gps_denied_onboard.c2_vpr.{spec.name}"
|
||||
runtime = _FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim, model_name=spec.name
|
||||
)
|
||||
descriptor_index = _FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO, logger=logger_name):
|
||||
strategy = spec.create_fn(
|
||||
_build_config(spec.name),
|
||||
descriptor_index=descriptor_index,
|
||||
inference_runtime=runtime,
|
||||
fdr_client=_make_fdr_client(),
|
||||
clock=_StubClock(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(strategy, spec.strategy_cls)
|
||||
ready_records = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c2.vpr.ready"
|
||||
]
|
||||
assert len(ready_records) == 1
|
||||
kv = getattr(ready_records[0], "kv", {})
|
||||
assert kv == {
|
||||
"strategy": spec.backbone_label,
|
||||
"descriptor_dim": spec.descriptor_dim,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-10: Build-flag exclusion → composition-time fail-fast
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac10_runtime_label_mismatch_raises_config_error(
|
||||
spec: _StrategySpec,
|
||||
) -> None:
|
||||
"""Selecting a secondary backbone on a binary built without the
|
||||
TRT / ONNX-RT runtimes fails fast at create-time.
|
||||
|
||||
Note: AC-10 of the task spec literally names ``ConfigurationError``;
|
||||
the existing factory contract (AZ-336) raises
|
||||
``StrategyNotAvailableError`` via the BUILD_VPR_* env-flag check
|
||||
BEFORE create() is reached, but the strategy module's own runtime
|
||||
label guard surfaces a ``ConfigError`` for the same intent
|
||||
(wrong runtime). Both are composition-time fail-fast errors. Same
|
||||
mirroring precedent as AZ-337 / AZ-338 / AZ-339.
|
||||
"""
|
||||
# Arrange
|
||||
runtime = _FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim,
|
||||
runtime_label="pytorch_fp16",
|
||||
model_name=spec.name,
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError, match=r"BUILD_TENSORRT_RUNTIME"):
|
||||
spec.create_fn(
|
||||
_build_config(spec.name),
|
||||
descriptor_index=_FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
),
|
||||
inference_runtime=runtime,
|
||||
fdr_client=_make_fdr_client(),
|
||||
clock=_StubClock(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-11: Preprocessor input shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac11_preprocessor_input_shape(spec: _StrategySpec) -> None:
|
||||
preprocessor = spec.preprocessor_cls()
|
||||
assert preprocessor.input_shape() == spec.input_hw
|
||||
|
||||
|
||||
def test_preprocess_output_is_nchw_fp16(spec: _StrategySpec) -> None:
|
||||
# Arrange
|
||||
preprocessor = spec.preprocessor_cls()
|
||||
frame = _make_frame()
|
||||
calibration = _make_calibration()
|
||||
|
||||
# Act
|
||||
tensor = preprocessor.preprocess(frame, calibration)
|
||||
|
||||
# Assert
|
||||
h, w = spec.input_hw
|
||||
assert tensor.shape == (1, 3, h, w)
|
||||
assert tensor.dtype == np.float16
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constructor validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_constructor_rejects_zero_descriptor_dim(spec: _StrategySpec) -> None:
|
||||
# Arrange (skip _build_strategy to bypass FaissBridge's own validation)
|
||||
fdr_client = _make_fdr_client()
|
||||
clock = _StubClock()
|
||||
descriptor_index = _FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
)
|
||||
bridge = FaissBridge(
|
||||
descriptor_index=descriptor_index,
|
||||
descriptor_dim=spec.descriptor_dim,
|
||||
warn_top1_threshold=0.30,
|
||||
debug_log_per_frame_distances=False,
|
||||
fdr_client=fdr_client,
|
||||
logger=logging.getLogger(f"test.{spec.name}.bridge"),
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match=r"descriptor_dim must be >= 1"):
|
||||
spec.strategy_cls(
|
||||
inference_runtime=_FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim, model_name=spec.name
|
||||
),
|
||||
engine_handle=_FakeEngineHandle(spec.name),
|
||||
descriptor_index=descriptor_index,
|
||||
preprocessor=spec.preprocessor_cls(),
|
||||
normaliser=DescriptorNormaliser(),
|
||||
faiss_bridge=bridge,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
logger=logging.getLogger(f"test.{spec.name}"),
|
||||
descriptor_dim=0,
|
||||
)
|
||||
|
||||
|
||||
def test_create_requires_fdr_client(spec: _StrategySpec) -> None:
|
||||
with pytest.raises(ValueError, match=r"fdr_client is required"):
|
||||
spec.create_fn(
|
||||
_build_config(spec.name),
|
||||
descriptor_index=_FakeDescriptorIndex(
|
||||
descriptor_dim_value=spec.descriptor_dim
|
||||
),
|
||||
inference_runtime=_FakeInferenceRuntime(
|
||||
descriptor_dim=spec.descriptor_dim, model_name=spec.name
|
||||
),
|
||||
fdr_client=None,
|
||||
clock=_StubClock(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FDR emission on success path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_embed_query_emits_fdr_record(spec: _StrategySpec) -> None:
|
||||
# Arrange
|
||||
fdr_client = _make_fdr_client()
|
||||
strategy = _build_strategy(spec, fdr_client=fdr_client)
|
||||
|
||||
# Act
|
||||
strategy.embed_query(_make_frame(), _make_calibration())
|
||||
|
||||
# Assert
|
||||
records: list[Any] = []
|
||||
while True:
|
||||
r = fdr_client.pop_one()
|
||||
if r is None:
|
||||
break
|
||||
records.append(r)
|
||||
embed = [r for r in records if r.kind == "vpr.embed_query"]
|
||||
assert len(embed) == 1
|
||||
payload = embed[0].payload
|
||||
assert payload["backbone_label"] == spec.backbone_label
|
||||
assert payload["descriptor_dim"] == spec.descriptor_dim
|
||||
assert payload["latency_us"] >= 1
|
||||
@@ -0,0 +1,347 @@
|
||||
"""AZ-527 — c2_vpr engine output-dim assertion helper unit tests.
|
||||
|
||||
Covers AC-1..AC-6 of AZ-527. AC-5 is exercised transitively by the
|
||||
existing AZ-337 / 338 / 339 / 340 AC-6 sub-tests (which run the
|
||||
strategy ``create()`` end-to-end and now route through
|
||||
``assert_engine_output_dim`` after this batch). AC-6 (layer
|
||||
compliance) is exercised by ``test_az270_compose_root.py`` — this
|
||||
file additionally adds a focused regression guard (AC-4) that walks
|
||||
``src/`` for stray ``_assert_engine_output_dim`` /
|
||||
``assert_engine_output_dim`` definitions outside the helper module.
|
||||
|
||||
The unit tests below use minimal fakes (Protocol-conforming) so the
|
||||
helper can be exercised without spinning up a full strategy
|
||||
``create()`` chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.inference import (
|
||||
BuildConfig,
|
||||
EngineCacheEntry,
|
||||
EngineHandle,
|
||||
)
|
||||
from gps_denied_onboard.components.c2_vpr._engine_dim_assertion import (
|
||||
assert_engine_output_dim,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_HELPER_PATH = (
|
||||
_REPO_ROOT
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "components"
|
||||
/ "c2_vpr"
|
||||
/ "_engine_dim_assertion.py"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakePreprocessor:
|
||||
"""Minimal :class:`BackbonePreprocessor` for the helper's input_shape() probe."""
|
||||
|
||||
hw: tuple[int, int] = (224, 224)
|
||||
|
||||
def preprocess(
|
||||
self, frame: Any, calibration: Any
|
||||
) -> np.ndarray: # pragma: no cover - unused by the helper
|
||||
raise AssertionError("helper must not call preprocess()")
|
||||
|
||||
def input_shape(self) -> tuple[int, int]:
|
||||
return self.hw
|
||||
|
||||
|
||||
class _FakeRuntime:
|
||||
"""Minimal :class:`InferenceRuntimeCut` returning a fixed output dict."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
output: dict[str, np.ndarray],
|
||||
expected_input_key: str = "input",
|
||||
expected_input_hw: tuple[int, int] = (224, 224),
|
||||
) -> None:
|
||||
self._output = output
|
||||
self._expected_input_key = expected_input_key
|
||||
self._expected_input_hw = expected_input_hw
|
||||
self.last_input_key: str | None = None
|
||||
self.last_input_shape: tuple[int, ...] | None = None
|
||||
self.last_input_dtype: np.dtype | None = None
|
||||
self.infer_call_count: int = 0
|
||||
|
||||
def compile_engine(
|
||||
self, model_path: Path, build_config: BuildConfig
|
||||
) -> EngineCacheEntry: # pragma: no cover - unused
|
||||
raise AssertionError("helper must not call compile_engine()")
|
||||
|
||||
def deserialize_engine(
|
||||
self, entry: EngineCacheEntry
|
||||
) -> EngineHandle: # pragma: no cover - unused
|
||||
raise AssertionError("helper must not call deserialize_engine()")
|
||||
|
||||
def infer(
|
||||
self,
|
||||
handle: EngineHandle,
|
||||
inputs: dict[str, np.ndarray],
|
||||
) -> dict[str, np.ndarray]:
|
||||
self.infer_call_count += 1
|
||||
assert len(inputs) == 1, f"expected one input key, got {sorted(inputs)!r}"
|
||||
(key,) = inputs.keys()
|
||||
self.last_input_key = key
|
||||
tensor = inputs[key]
|
||||
self.last_input_shape = tensor.shape
|
||||
self.last_input_dtype = tensor.dtype
|
||||
return self._output
|
||||
|
||||
def release_engine(self, handle: EngineHandle) -> None: # pragma: no cover
|
||||
raise AssertionError("helper must not call release_engine()")
|
||||
|
||||
def current_runtime_label(
|
||||
self,
|
||||
) -> Literal["tensorrt", "onnx_trt_ep", "pytorch_fp16"]: # pragma: no cover
|
||||
raise AssertionError("helper must not call current_runtime_label()")
|
||||
|
||||
|
||||
_DUMMY_HANDLE: EngineHandle = "dummy-handle" # type: ignore[assignment]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: Helper exists at the canonical path with the expected signature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac1_helper_callable_with_default_keys() -> None:
|
||||
# Arrange
|
||||
runtime = _FakeRuntime(
|
||||
output={"embedding": np.zeros((1, 512), dtype=np.float16)}
|
||||
)
|
||||
preprocessor = _FakePreprocessor(hw=(224, 224))
|
||||
|
||||
# Act
|
||||
result = assert_engine_output_dim(
|
||||
runtime, _DUMMY_HANDLE, preprocessor, descriptor_dim=512
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
assert runtime.infer_call_count == 1
|
||||
assert runtime.last_input_key == "input"
|
||||
assert runtime.last_input_shape == (1, 3, 224, 224)
|
||||
assert runtime.last_input_dtype == np.dtype(np.float16)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: Wrong shape raises ConfigError naming both expected and actual dims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"actual_shape,expected_dim",
|
||||
[
|
||||
((1, 999), 512),
|
||||
((1, 4096), 2048),
|
||||
((1, 100), 8448),
|
||||
],
|
||||
)
|
||||
def test_ac2_wrong_descriptor_width_raises_config_error(
|
||||
actual_shape: tuple[int, int], expected_dim: int
|
||||
) -> None:
|
||||
# Arrange
|
||||
runtime = _FakeRuntime(
|
||||
output={"embedding": np.zeros(actual_shape, dtype=np.float16)}
|
||||
)
|
||||
preprocessor = _FakePreprocessor(hw=(224, 224))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
assert_engine_output_dim(
|
||||
runtime, _DUMMY_HANDLE, preprocessor, descriptor_dim=expected_dim
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert f"expected (1, {expected_dim})" in msg
|
||||
assert f"got {actual_shape!r}".replace("'", "") in msg or str(actual_shape) in msg
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"actual_shape", [(512,), (2, 1, 512), (1, 1, 512), (1, 512, 1)]
|
||||
)
|
||||
def test_ac2_wrong_ndim_or_batch_raises_config_error(
|
||||
actual_shape: tuple[int, ...],
|
||||
) -> None:
|
||||
# Arrange
|
||||
runtime = _FakeRuntime(
|
||||
output={"embedding": np.zeros(actual_shape, dtype=np.float16)}
|
||||
)
|
||||
preprocessor = _FakePreprocessor()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError, match=r"engine output shape mismatch"):
|
||||
assert_engine_output_dim(
|
||||
runtime, _DUMMY_HANDLE, preprocessor, descriptor_dim=512
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: Missing output key raises ConfigError naming the missing key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac3_missing_default_output_key_raises_config_error() -> None:
|
||||
# Arrange
|
||||
runtime = _FakeRuntime(
|
||||
output={"wrong_key": np.zeros((1, 512), dtype=np.float16)}
|
||||
)
|
||||
preprocessor = _FakePreprocessor()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
assert_engine_output_dim(
|
||||
runtime, _DUMMY_HANDLE, preprocessor, descriptor_dim=512
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "'embedding' key absent" in msg
|
||||
assert "wrong_key" in msg
|
||||
|
||||
|
||||
def test_ac3_missing_overridden_output_key_raises_config_error() -> None:
|
||||
# Arrange — NetVLAD-style override
|
||||
runtime = _FakeRuntime(
|
||||
output={"embedding": np.zeros((1, 4096), dtype=np.float16)}
|
||||
)
|
||||
preprocessor = _FakePreprocessor(hw=(480, 640))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError, match=r"'vlad_descriptor' key absent"):
|
||||
assert_engine_output_dim(
|
||||
runtime,
|
||||
_DUMMY_HANDLE,
|
||||
preprocessor,
|
||||
descriptor_dim=4096,
|
||||
output_key="vlad_descriptor",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Non-default keys (covers NetVLAD's overrides + future strategies)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_helper_accepts_non_default_output_and_input_keys() -> None:
|
||||
# Arrange — NetVLAD-style: vlad_descriptor + custom input key
|
||||
runtime = _FakeRuntime(
|
||||
output={"vlad_descriptor": np.zeros((1, 4096), dtype=np.float16)}
|
||||
)
|
||||
preprocessor = _FakePreprocessor(hw=(480, 640))
|
||||
|
||||
# Act
|
||||
assert_engine_output_dim(
|
||||
runtime,
|
||||
_DUMMY_HANDLE,
|
||||
preprocessor,
|
||||
descriptor_dim=4096,
|
||||
output_key="vlad_descriptor",
|
||||
input_key="image",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert runtime.last_input_key == "image"
|
||||
assert runtime.last_input_shape == (1, 3, 480, 640)
|
||||
|
||||
|
||||
def test_helper_propagates_preprocessor_input_shape_to_probe_tensor() -> None:
|
||||
# Arrange — SALAD-style 322x322 + non-square shape coverage
|
||||
for hw in [(224, 224), (322, 322), (480, 480), (480, 640)]:
|
||||
runtime = _FakeRuntime(
|
||||
output={"embedding": np.zeros((1, 8448), dtype=np.float16)}
|
||||
)
|
||||
preprocessor = _FakePreprocessor(hw=hw)
|
||||
|
||||
# Act
|
||||
assert_engine_output_dim(
|
||||
runtime, _DUMMY_HANDLE, preprocessor, descriptor_dim=8448
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert runtime.last_input_shape == (1, 3, hw[0], hw[1]), hw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: Regression guard — no stray definitions outside the helper module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac4_no_stray_engine_dim_assertion_definitions_outside_helper() -> None:
|
||||
"""AC-4 (AZ-527): a `def assert_engine_output_dim` /
|
||||
`def _assert_engine_output_dim` MUST exist only inside
|
||||
`_engine_dim_assertion.py`. Any other definition under `src/`
|
||||
means a c2_vpr strategy author slipped a copy back in.
|
||||
|
||||
Modeled on the AZ-508 / AZ-526 `test_no_local_iso_ts_*_definitions_remain`
|
||||
pattern.
|
||||
"""
|
||||
# Arrange
|
||||
src_root = _REPO_ROOT / "src"
|
||||
offenders: list[tuple[Path, str]] = []
|
||||
target_names = {"assert_engine_output_dim", "_assert_engine_output_dim"}
|
||||
|
||||
# Act
|
||||
for path in src_root.rglob("*.py"):
|
||||
if path == _HELPER_PATH:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name in target_names:
|
||||
offenders.append((path.relative_to(_REPO_ROOT), node.name))
|
||||
|
||||
# Assert
|
||||
assert offenders == [], (
|
||||
"Found stray `assert_engine_output_dim` definitions outside the "
|
||||
f"c2_vpr helper module: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac4_seven_strategy_modules_import_the_helper() -> None:
|
||||
"""The 7 migrated strategy modules must import from the helper so
|
||||
future hygiene cycles can rely on a single source of truth.
|
||||
"""
|
||||
# Arrange
|
||||
c2_dir = _REPO_ROOT / "src" / "gps_denied_onboard" / "components" / "c2_vpr"
|
||||
strategy_files = [
|
||||
c2_dir / "ultra_vpr.py",
|
||||
c2_dir / "net_vlad.py",
|
||||
c2_dir / "mega_loc.py",
|
||||
c2_dir / "mix_vpr.py",
|
||||
c2_dir / "sela_vpr.py",
|
||||
c2_dir / "eigen_places.py",
|
||||
c2_dir / "salad.py",
|
||||
]
|
||||
expected_module = "gps_denied_onboard.components.c2_vpr._engine_dim_assertion"
|
||||
missing: list[Path] = []
|
||||
|
||||
# Act
|
||||
for path in strategy_files:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if expected_module not in text or "assert_engine_output_dim" not in text:
|
||||
missing.append(path.relative_to(_REPO_ROOT))
|
||||
|
||||
# Assert
|
||||
assert missing == [], (
|
||||
f"Strategy modules missing the helper import: {missing}"
|
||||
)
|
||||
Reference in New Issue
Block a user