5 Commits

Author SHA1 Message Date
Oleksandr Bezdieniezhnykh 2ce300ddb1 [AZ-527] Archive AZ-527 + batch 52 report + state bump
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:51:19 +03:00
Oleksandr Bezdieniezhnykh 235eb4549e [AZ-527] Consolidate _assert_engine_output_dim into c2-internal helper
Closes cumulative review batches 49-51 Finding F1 (Medium /
Maintainability) -- the 7-way duplication of _assert_engine_output_dim
across c2_vpr secondary VPR strategy modules.

Add c2-internal helper assert_engine_output_dim(inference_runtime,
handle, preprocessor, descriptor_dim, *, output_key='embedding',
input_key='input') in src/gps_denied_onboard/components/c2_vpr/
_engine_dim_assertion.py. The helper runs a zero-init dry-run
inference at preprocessor.input_shape() and asserts the engine output
dict carries (1, descriptor_dim) under output_key. Raises
gps_denied_onboard.config.schema.ConfigError on mismatch (preserving
the prior error envelope and message wording byte-identically).

Migrate 7 strategy modules (ultra_vpr, net_vlad, mega_loc, mix_vpr,
sela_vpr, eigen_places, salad) to import the helper and delete the
local _assert_engine_output_dim definitions + their inline
'AZ-527 (planned)' comments. NetVLAD is the only call site that
overrides output_key='vlad_descriptor'; the other 6 explicitly pass
output_key=_OUTPUT_KEY + input_key=_ENGINE_INPUT_KEY (matching helper
defaults but documenting strategy contract at the call site).

Add tests/unit/c2_vpr/test_az527_engine_dim_assertion.py (14 tests,
AAA pattern, Protocol-conforming fakes) covering AC-1..AC-4: helper
signature; wrong shape raises ConfigError naming both dims; missing
output key raises ConfigError naming the missing key; AST-walk
regression guard for stray definitions outside the helper module
(modeled on AZ-526's test_ac4_az526_no_module_level_iso_ts_from_clock_outside_helper);
import-grep regression guard verifying all 7 strategy modules import
the helper.

AC-5 (existing AZ-337/338/339/340 AC-6 sub-tests pass unmodified) is
exercised transitively: c2_vpr/ full directory 230/230 PASS, no test
file modified outside the new test_az527_*. AC-6 (AZ-270 + AZ-507
layer lints) verified by tests/unit/test_az270_compose_root.py
8/8 PASS.

Code-review verdict: PASS (zero findings). Ruff clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:50:17 +03:00
Oleksandr Bezdieniezhnykh f6a180e5df [AZ-340] [AZ-527] Archive AZ-340 + batch 51 report + cumulative review 49-51
Bookkeeping for batch 51 close:

- Archive AZ-340 spec todo/ -> done/
- Add _docs/03_implementation/batch_51_cycle1_report.md
- Add _docs/03_implementation/cumulative_review_batches_49-51_cycle1_report.md
  Verdict: PASS_WITH_WARNINGS. F1 (Medium) escalates the 2-way
  _assert_engine_output_dim near-duplicate from cumulative-46-48 to a
  7-way duplication after AZ-339 + AZ-340; new hygiene PBI AZ-527
  formally created. F2 (Low) carries the AC-10 ConfigError vs literal
  ConfigurationError spec drift (documentation only).
- File AZ-527 hygiene PBI (Hygiene -- consolidate
  _assert_engine_output_dim into a c2-internal helper, 2pt, AZ-255
  E-C2). Add the spec stub at _docs/02_tasks/todo/AZ-527_*.md.
- Refresh _docs/02_tasks/_dependencies_table.md: +AZ-527 row, totals
  bumped to 148 tasks / 491 points.
- Bump _docs/_autodev_state.md: last_completed_batch=51,
  last_cumulative_review=batches_49-51.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:39:29 +03:00
Oleksandr Bezdieniezhnykh 87909cce9f [AZ-340] C2 SelaVPR + EigenPlaces + SALAD secondary VPR backbones
Three new VprStrategy implementations for IT-12 comparative-study
(research binary only, gated OFF for airborne / operator-tooling per
ADR-002). All run via the C7 TensorRT runtime (or ONNX-RT fallback)
with their own concrete BackbonePreprocessor, single-stage L2
normalisation, and FaissBridge-delegated retrieval — same pattern as
AZ-339 (MegaLoc + MixVPR), parametrised in tests for compactness.

  * SelaVprStrategy   — D=512,  input 224x224
  * EigenPlacesStrategy — D=2048, input 480x480
  * SaladStrategy     — D=8448, input 322x322 (DINOv2-Large backbone;
                        heaviest in the C2 family — NFR-perf budget
                        relaxed to 120 ms p95 / 1200 MB GPU per task
                        spec)

The composition-root factory tables and KNOWN_STRATEGIES set were
already pre-wired at AZ-336 land time; module-layout.md already names
all three Internal entries and BUILD_VPR_* rows. No CMake change
required (env-flag gating).

54 unit tests (3 strategies * 18 cases) cover AC-1..AC-11 plus extras
(single-stage L2, NCHW FP16, constructor validation, FDR emission).
All pass; sibling c2_vpr suite + composition-root regression + AZ-526
iso-ts regression all green.

Code review verdict: PASS_WITH_WARNINGS. Two Low findings logged in
batch_51_review.md: F1 escalates `_assert_engine_output_dim`
duplication from 4-way to 7-way (already tracked by AZ-527 hygiene
PBI; will surface in cumulative review batches 49-51); F2 mirrors the
AZ-337 / 338 / 339 AC-10 spec-drift precedent (literal
ConfigurationError vs implemented ConfigError / StrategyNotAvailable).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:32:38 +03:00
Oleksandr Bezdieniezhnykh e81616a09d [meta] Refresh D-CROSS-CVE-1 leftover replay timestamp
Bootstrap-time replay check confirmed gtsam==4.2.1 still pins
numpy<2.0.0; opencv-python>=4.12 pin remains deferred.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:19:06 +03:00
23 changed files with 3893 additions and 110 deletions
+4 -3
View File
@@ -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 4951 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 4951 (last cumulative review covered batches 46-48). The cumulative review for batches 4951 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 4951 (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 4951 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 4951.
- **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.
+2 -2
View File
@@ -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}"
)