[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 00:32:38 +03:00
parent e81616a09d
commit 87909cce9f
8 changed files with 2970 additions and 0 deletions
@@ -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