mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 10:21:13 +00:00
[AZ-839] [AZ-835] operator_pre_flight_setup real fixture (E-AZ-835 C3)
Replace the placeholder operator_pre_flight_setup pytest fixture (the mkdir stub at tests/e2e/replay/conftest.py:293-310) with a real driver that wires C1 (AZ-836 RouteSpec) + C2 (AZ-838 SatelliteProviderRoute Client) + C11 (AZ-316 HttpTileDownloader) + C10 (AZ-322 Descriptor Batcher) end-to-end and yields a typed PopulatedC6Cache. AZ-306 FAISS sidecar triple-consistency is verified post-rebuild via a caller- supplied descriptor_index_factory; partial sidecars are cleaned up on failure (AC-7) while pre-existing warm-cache files are preserved. Algorithm lives in tests/e2e/replay/_operator_pre_flight.py with pure dependency injection so the AC-8 unit suite (11 tests covering happy / transient-retry / terminal-failure / validation-error / tamper-detection / cleanup-on-failure) runs against stubs and the AC-9 Tier-2 integration test runs the same algorithm against the real Jetson harness. The conftest fixture skip-gates on RUN_REPLAY _E2E + SATELLITE_PROVIDER_URL/API_KEY + BUILD_FAISS_INDEX + GPS_DENIED_OPERATOR_CONFIG_PATH and wires deps through the existing runtime_root factories. Supersedes AZ-777 Phase 3. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
"""Operator pre-flight cache assembly driver (AZ-839 / Epic AZ-835 C3).
|
||||
|
||||
Replaces the placeholder ``operator_pre_flight_setup`` fixture stub at
|
||||
``conftest.py`` lines 293-310 with a real driver that wires together
|
||||
the four operator-side production components:
|
||||
|
||||
1. **C1 / AZ-836 RouteSpec** — already extracted by the caller via
|
||||
:func:`gps_denied_onboard.replay_input.tlog_route.extract_route_from_tlog`
|
||||
and handed in as :paramref:`populate_c6_from_route.route_spec`.
|
||||
2. **C2 / AZ-838 SatelliteProviderRouteClient** — POSTs the route to
|
||||
satellite-provider, polls ``mapsReady``.
|
||||
3. **C11 / AZ-316 + AZ-777 Phase 1 HttpTileDownloader** — pulls the
|
||||
seeded tiles from satellite-provider into C6 over a bbox derived
|
||||
from the route waypoints.
|
||||
4. **C10 / AZ-322 DescriptorBatcher** — rebuilds the FAISS HNSW
|
||||
descriptor index over the populated C6 cache (NetVLAD backbone per
|
||||
``c2_vpr/config.py:67``).
|
||||
|
||||
The descriptor index sidecar coherence (AZ-306 triple-consistency:
|
||||
``.index`` + ``.sha256`` + ``.meta.json``) is verified by re-loading
|
||||
the index after rebuild via the caller-supplied
|
||||
``descriptor_index_factory``; any tampering surfaces as
|
||||
:class:`IndexUnavailableError`.
|
||||
|
||||
Public surface — re-exported from this module:
|
||||
|
||||
* :class:`PopulatedC6Cache` — frozen dataclass returned on success.
|
||||
* :func:`populate_c6_from_route` — the driver function.
|
||||
|
||||
Cleanup-on-failure removes any FAISS sidecar files produced inside the
|
||||
driver if any later step raises. Tile-store rows written by C11 are
|
||||
NOT deleted (the C6 store owns its own rollback semantics — leaving
|
||||
those rows enables idempotent re-runs via the C11 download journal).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from gps_denied_onboard.components.c10_provisioning.descriptor_batcher import (
|
||||
BatcherOutcome,
|
||||
CorpusFilter,
|
||||
DescriptorBatcher,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
DownloadOutcome,
|
||||
DownloadRequest,
|
||||
HttpTileDownloader,
|
||||
SectorClassification,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
RouteTerminalFailureError,
|
||||
RouteTransientError,
|
||||
RouteValidationError,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||
SatelliteProviderRouteClient,
|
||||
)
|
||||
from gps_denied_onboard.components.c6_tile_cache.errors import (
|
||||
IndexUnavailableError,
|
||||
)
|
||||
from gps_denied_onboard.components.c6_tile_cache.faiss_descriptor_index import (
|
||||
META_SUFFIX,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import SIDECAR_SUFFIX
|
||||
from gps_denied_onboard.replay_input.tlog_route import RouteSpec
|
||||
|
||||
__all__ = [
|
||||
"PopulatedC6Cache",
|
||||
"populate_c6_from_route",
|
||||
]
|
||||
|
||||
|
||||
# Mirror C11's existing schedule so the fixture does not introduce a
|
||||
# parallel retry budget. AC-5 ties our per-attempt cap (3) to the
|
||||
# documented pause cadence; the schedule itself lives in the
|
||||
# downloader module and is re-exported here so tests can override.
|
||||
_DEFAULT_RETRY_SCHEDULE_S: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0)
|
||||
_DEFAULT_MAX_RETRY_ATTEMPTS: int = 3
|
||||
_DEFAULT_ZOOM_LEVEL: int = 18
|
||||
_DEFAULT_SECTOR_CLASS: SectorClassification = SectorClassification.ACTIVE_CONFLICT
|
||||
# Per-degree-of-latitude metres at WGS84 mean radius — reused from C11
|
||||
# route-coverage enumeration (route_client._enumerate_route_tile_coords).
|
||||
# Re-stated here so the driver does not depend on a private constant.
|
||||
_METERS_PER_DEGREE_LAT: float = 111_320.0
|
||||
|
||||
_LOGGER = logging.getLogger(
|
||||
"tests.e2e.replay.operator_pre_flight"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PopulatedC6Cache:
|
||||
"""Output of :func:`populate_c6_from_route`.
|
||||
|
||||
Mirrors the public-surface dataclass documented in the AZ-839 spec.
|
||||
All paths point at on-disk artifacts that survive the fixture's
|
||||
``session`` scope (when mounted on the named docker volume the
|
||||
e2e-runner declares); ``elapsed_seconds`` powers the AC-1 / AC-2
|
||||
perf budget assertions.
|
||||
"""
|
||||
|
||||
cache_root: Path
|
||||
tile_store_path: Path
|
||||
faiss_index_path: Path
|
||||
faiss_sidecar_sha256_path: Path
|
||||
faiss_sidecar_meta_path: Path
|
||||
route_spec: RouteSpec
|
||||
tile_count: int
|
||||
elapsed_seconds: float
|
||||
|
||||
|
||||
def populate_c6_from_route(
|
||||
*,
|
||||
route_spec: RouteSpec,
|
||||
route_client: SatelliteProviderRouteClient,
|
||||
tile_downloader: HttpTileDownloader,
|
||||
descriptor_batcher: DescriptorBatcher,
|
||||
descriptor_index_factory: Callable[[], Any],
|
||||
cache_root: Path,
|
||||
tile_store_path: Path,
|
||||
faiss_index_path: Path,
|
||||
flight_id: UUID | None = None,
|
||||
sector_class: SectorClassification = _DEFAULT_SECTOR_CLASS,
|
||||
zoom_level: int = _DEFAULT_ZOOM_LEVEL,
|
||||
region_size_meters: float | None = None,
|
||||
retry_schedule_s: tuple[float, ...] = _DEFAULT_RETRY_SCHEDULE_S,
|
||||
max_retry_attempts: int = _DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> PopulatedC6Cache:
|
||||
"""Drive the full C1+C2+C11+C10 pipeline end-to-end.
|
||||
|
||||
Args:
|
||||
route_spec: Coarsened route from AZ-836's
|
||||
:func:`extract_route_from_tlog`. The caller chooses the
|
||||
tlog (typically a session-scoped fixture); this driver is
|
||||
tlog-agnostic.
|
||||
route_client: Configured C2 client. Built from env vars by the
|
||||
production fixture; injected as a stub by unit tests.
|
||||
tile_downloader: Configured C11 downloader. Same wiring rules.
|
||||
descriptor_batcher: Configured C10 batcher; its rebuild path
|
||||
owns the on-disk FAISS write (atomic via
|
||||
:class:`Sha256Sidecar`).
|
||||
descriptor_index_factory: Zero-arg callable that constructs a
|
||||
FRESH descriptor index pointed at ``faiss_index_path``.
|
||||
Production passes
|
||||
``lambda: FaissDescriptorIndex.from_config(config)``; the
|
||||
constructor auto-loads via
|
||||
:meth:`FaissDescriptorIndex._load`, raising
|
||||
:class:`IndexUnavailableError` on triple-consistency
|
||||
failure (AC-3 / AC-6 verification).
|
||||
cache_root: Root directory mounted on the named docker volume
|
||||
that survives across pytest sessions.
|
||||
tile_store_path: Where C6's :class:`TileStore` writes JPEG
|
||||
blobs. Carried on the result for downstream tests.
|
||||
faiss_index_path: Final ``.index`` blob path. Sidecars live at
|
||||
``<faiss_index_path>.sha256`` + ``<faiss_index_path>.meta.json``.
|
||||
flight_id: C11 download-journal key; defaults to a fresh UUID
|
||||
so two fixture sessions never collide their journals.
|
||||
sector_class: C11 / C6 sector classification. Defaults to
|
||||
``ACTIVE_CONFLICT`` — Derkachi is an active-conflict
|
||||
corridor; ``STABLE_REAR`` is for non-Ukraine clips.
|
||||
zoom_level: Single Web-Mercator zoom level the fixture
|
||||
populates. AZ-839 spec defaults to 18 to match
|
||||
``seed_route.py`` ergonomics; tests override for speed.
|
||||
region_size_meters: Per-waypoint coverage radius in metres.
|
||||
``None`` falls back to
|
||||
:attr:`RouteSpec.suggested_region_size_meters`.
|
||||
retry_schedule_s: Pause cadence between transient retries.
|
||||
Defaults to C11's documented ``_DEFAULT_BACKOFF_SCHEDULE_S``.
|
||||
max_retry_attempts: Total :meth:`seed_route` attempts on
|
||||
transient error before propagating (AC-5 — final
|
||||
attempt's exception is propagated unchanged).
|
||||
sleep: Test override for the retry pause; production passes
|
||||
:func:`time.sleep`.
|
||||
monotonic: Test override for elapsed-time measurement.
|
||||
logger: Optional logger. Defaults to the module logger.
|
||||
|
||||
Returns:
|
||||
:class:`PopulatedC6Cache` on success.
|
||||
|
||||
Raises:
|
||||
RouteValidationError: Pre-emptive validation or HTTP 4xx —
|
||||
propagated unchanged with original cause (AC-4).
|
||||
RouteTerminalFailureError: ``mapsReady`` never reached or
|
||||
terminal failure status — propagated unchanged (AC-4).
|
||||
RouteTransientError: 5xx / network / timeout AFTER all retry
|
||||
attempts have been exhausted (AC-5).
|
||||
IndexUnavailableError: Triple-consistency check failed after
|
||||
rebuild — sidecars are corrupt / mismatched (AC-3 / AC-6).
|
||||
RuntimeError: C11 ``download_tiles_for_area`` returned a
|
||||
non-success outcome OR C10 ``populate_descriptors``
|
||||
returned :attr:`BatcherOutcome.FAILURE`.
|
||||
|
||||
Notes:
|
||||
Cleanup behaviour (AC-7) — if any step raises after the
|
||||
rebuild has begun writing sidecar files, the partial files
|
||||
(.index, .sha256, .meta.json) are removed before the
|
||||
exception propagates so a re-run starts from a clean slate.
|
||||
Tile-store rows are NOT deleted on cleanup; the C11 download
|
||||
journal owns idempotent re-run semantics.
|
||||
"""
|
||||
|
||||
log = logger or _LOGGER
|
||||
if max_retry_attempts < 1:
|
||||
raise ValueError(
|
||||
f"max_retry_attempts must be >= 1; got {max_retry_attempts}"
|
||||
)
|
||||
|
||||
started_monotonic = monotonic()
|
||||
effective_flight_id = flight_id or uuid4()
|
||||
effective_region_size = float(
|
||||
region_size_meters
|
||||
if region_size_meters is not None
|
||||
else route_spec.suggested_region_size_meters
|
||||
)
|
||||
if effective_region_size <= 0:
|
||||
raise ValueError(
|
||||
f"region_size_meters must be > 0; got {effective_region_size}"
|
||||
)
|
||||
if not route_spec.waypoints:
|
||||
raise ValueError("route_spec.waypoints must be non-empty")
|
||||
|
||||
sidecar_paths = (
|
||||
faiss_index_path,
|
||||
Path(str(faiss_index_path) + SIDECAR_SUFFIX),
|
||||
Path(str(faiss_index_path) + META_SUFFIX),
|
||||
)
|
||||
pre_existing_sidecar = {p: p.is_file() for p in sidecar_paths}
|
||||
|
||||
try:
|
||||
seed_result = _seed_route_with_retry(
|
||||
route_client=route_client,
|
||||
spec=route_spec,
|
||||
region_size_meters=effective_region_size,
|
||||
zoom_level=zoom_level,
|
||||
retry_schedule_s=retry_schedule_s,
|
||||
max_retry_attempts=max_retry_attempts,
|
||||
sleep=sleep,
|
||||
logger=log,
|
||||
)
|
||||
|
||||
bbox = _route_bbox(
|
||||
waypoints=route_spec.waypoints,
|
||||
region_size_meters=effective_region_size,
|
||||
)
|
||||
download_request = DownloadRequest(
|
||||
flight_id=effective_flight_id,
|
||||
bbox_min_lat=bbox[0],
|
||||
bbox_min_lon=bbox[1],
|
||||
bbox_max_lat=bbox[2],
|
||||
bbox_max_lon=bbox[3],
|
||||
zoom_levels=(int(zoom_level),),
|
||||
sector_class=sector_class,
|
||||
cache_root=cache_root,
|
||||
)
|
||||
download_report = tile_downloader.download_tiles_for_area(download_request)
|
||||
if download_report.outcome not in {
|
||||
DownloadOutcome.SUCCESS,
|
||||
DownloadOutcome.IDEMPOTENT_NO_OP,
|
||||
}:
|
||||
raise RuntimeError(
|
||||
"C11 download_tiles_for_area returned non-success outcome "
|
||||
f"{download_report.outcome.value!r}; "
|
||||
f"requested={download_report.tiles_requested} "
|
||||
f"downloaded={download_report.tiles_downloaded} "
|
||||
f"rejected_resolution={download_report.tiles_rejected_resolution} "
|
||||
f"rejected_freshness={download_report.tiles_rejected_freshness}"
|
||||
)
|
||||
|
||||
log.info(
|
||||
"operator_pre_flight: tiles populated",
|
||||
extra={
|
||||
"kind": "operator_pre_flight.tiles_populated",
|
||||
"kv": {
|
||||
"route_id": str(seed_result.route_id),
|
||||
"seeded_tile_count": seed_result.tile_count,
|
||||
"downloaded_tiles": download_report.tiles_downloaded,
|
||||
"request_hash": download_report.request_hash,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
corpus_filter = CorpusFilter(
|
||||
bbox=bbox,
|
||||
zoom_levels=(int(zoom_level),),
|
||||
sector_class=sector_class.value,
|
||||
)
|
||||
batcher_report = descriptor_batcher.populate_descriptors(corpus_filter)
|
||||
if batcher_report.outcome is not BatcherOutcome.SUCCESS:
|
||||
raise RuntimeError(
|
||||
"C10 populate_descriptors returned FAILURE: "
|
||||
f"{batcher_report.failure_reason}"
|
||||
)
|
||||
|
||||
verifier_index = descriptor_index_factory()
|
||||
log.debug(
|
||||
"operator_pre_flight: sidecar coherence verified",
|
||||
extra={
|
||||
"kind": "operator_pre_flight.sidecar_verified",
|
||||
"kv": {
|
||||
"faiss_index_path": str(faiss_index_path),
|
||||
"verifier_type": type(verifier_index).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
elapsed_seconds = max(0.0, monotonic() - started_monotonic)
|
||||
return PopulatedC6Cache(
|
||||
cache_root=cache_root,
|
||||
tile_store_path=tile_store_path,
|
||||
faiss_index_path=faiss_index_path,
|
||||
faiss_sidecar_sha256_path=sidecar_paths[1],
|
||||
faiss_sidecar_meta_path=sidecar_paths[2],
|
||||
route_spec=route_spec,
|
||||
tile_count=batcher_report.tiles_consumed,
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
)
|
||||
except BaseException:
|
||||
_cleanup_partial_sidecars(
|
||||
sidecar_paths=sidecar_paths,
|
||||
pre_existing=pre_existing_sidecar,
|
||||
logger=log,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _seed_route_with_retry(
|
||||
*,
|
||||
route_client: SatelliteProviderRouteClient,
|
||||
spec: RouteSpec,
|
||||
region_size_meters: float,
|
||||
zoom_level: int,
|
||||
retry_schedule_s: tuple[float, ...],
|
||||
max_retry_attempts: int,
|
||||
sleep: Callable[[float], None],
|
||||
logger: logging.Logger,
|
||||
) -> Any:
|
||||
"""Call ``seed_route`` with bounded transient retries (AC-5).
|
||||
|
||||
Validation / terminal-failure errors propagate IMMEDIATELY with
|
||||
their original cause (AC-4 — no silent swallow). Only
|
||||
:class:`RouteTransientError` triggers the retry ladder; the final
|
||||
attempt's exception is re-raised unchanged so the caller sees
|
||||
the actual transient signal that exhausted the budget.
|
||||
"""
|
||||
last_transient: RouteTransientError | None = None
|
||||
for attempt in range(1, max_retry_attempts + 1):
|
||||
try:
|
||||
return route_client.seed_route(
|
||||
spec,
|
||||
region_size_meters=region_size_meters,
|
||||
zoom_level=zoom_level,
|
||||
)
|
||||
except (RouteValidationError, RouteTerminalFailureError):
|
||||
raise
|
||||
except RouteTransientError as exc:
|
||||
last_transient = exc
|
||||
logger.warning(
|
||||
"operator_pre_flight: route seed transient failure",
|
||||
extra={
|
||||
"kind": "operator_pre_flight.route_seed.transient",
|
||||
"kv": {
|
||||
"attempt": attempt,
|
||||
"max_attempts": max_retry_attempts,
|
||||
"exc": repr(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
if attempt >= max_retry_attempts:
|
||||
raise
|
||||
pause_s = retry_schedule_s[
|
||||
min(attempt - 1, len(retry_schedule_s) - 1)
|
||||
]
|
||||
sleep(pause_s)
|
||||
# Defensive — the loop body always returns or raises before this.
|
||||
if last_transient is not None:
|
||||
raise last_transient
|
||||
raise RuntimeError(
|
||||
"operator_pre_flight: seed_route loop exited without result"
|
||||
)
|
||||
|
||||
|
||||
def _route_bbox(
|
||||
*,
|
||||
waypoints: tuple[tuple[float, float], ...],
|
||||
region_size_meters: float,
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Bounding box of every waypoint's coverage square.
|
||||
|
||||
Mirrors the local enumeration in
|
||||
:func:`gps_denied_onboard.components.c11_tile_manager.route_client._enumerate_route_tile_coords`
|
||||
by taking ``region_size_meters`` as the per-waypoint square edge
|
||||
and unioning the lat/lon extents. The result is a single bbox
|
||||
that the C11 :meth:`HttpTileDownloader.download_tiles_for_area`
|
||||
Protocol consumes; C11 then runs the standard slippy-map
|
||||
enumeration over that bbox at the requested zoom level.
|
||||
|
||||
Returns:
|
||||
``(min_lat, min_lon, max_lat, max_lon)`` — matching
|
||||
:class:`DownloadRequest`'s field order.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
half = region_size_meters / 2.0
|
||||
min_lat = float("inf")
|
||||
max_lat = float("-inf")
|
||||
min_lon = float("inf")
|
||||
max_lon = float("-inf")
|
||||
for lat_deg, lon_deg in waypoints:
|
||||
lat_delta_deg = half / _METERS_PER_DEGREE_LAT
|
||||
cos_lat = math.cos(math.radians(lat_deg))
|
||||
if cos_lat <= 1e-9:
|
||||
cos_lat = 1e-9
|
||||
lon_delta_deg = half / (_METERS_PER_DEGREE_LAT * cos_lat)
|
||||
min_lat = min(min_lat, lat_deg - lat_delta_deg)
|
||||
max_lat = max(max_lat, lat_deg + lat_delta_deg)
|
||||
min_lon = min(min_lon, lon_deg - lon_delta_deg)
|
||||
max_lon = max(max_lon, lon_deg + lon_delta_deg)
|
||||
|
||||
if min_lat >= max_lat or min_lon >= max_lon:
|
||||
raise ValueError(
|
||||
"operator_pre_flight: degenerate bbox from route waypoints "
|
||||
f"(min_lat={min_lat}, max_lat={max_lat}, "
|
||||
f"min_lon={min_lon}, max_lon={max_lon})"
|
||||
)
|
||||
return (min_lat, min_lon, max_lat, max_lon)
|
||||
|
||||
|
||||
def _cleanup_partial_sidecars(
|
||||
*,
|
||||
sidecar_paths: tuple[Path, ...],
|
||||
pre_existing: dict[Path, bool],
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
"""Remove sidecar files this driver may have produced.
|
||||
|
||||
Only files that did NOT exist when the driver started AND now
|
||||
exist are removed — pre-existing files (a warm cache from a prior
|
||||
run) are preserved. OS errors during cleanup are logged but do
|
||||
not mask the original exception.
|
||||
"""
|
||||
|
||||
for path in sidecar_paths:
|
||||
if pre_existing.get(path, False):
|
||||
continue
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
logger.warning(
|
||||
"operator_pre_flight: cleaned up partial sidecar",
|
||||
extra={
|
||||
"kind": "operator_pre_flight.cleanup.removed",
|
||||
"kv": {"path": str(path)},
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"operator_pre_flight: cleanup unlink failed",
|
||||
extra={
|
||||
"kind": "operator_pre_flight.cleanup.failed",
|
||||
"kv": {"path": str(path), "exc": repr(exc)},
|
||||
},
|
||||
)
|
||||
+374
-16
@@ -290,21 +290,379 @@ def replay_runner(derkachi_replay_inputs: DerkachiReplayInputs) -> Any:
|
||||
return _run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operator_pre_flight_setup(tmp_path: Path) -> Iterator[Path]:
|
||||
"""Operator C12 pre-flight rehearsal stub.
|
||||
@pytest.fixture(scope="session")
|
||||
def operator_pre_flight_setup(
|
||||
derkachi_replay_inputs: DerkachiReplayInputs,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator["PopulatedC6Cache"]:
|
||||
"""Operator C12 pre-flight: real C1+C2+C11+C10 wiring (AZ-839 / Epic AZ-835 C3).
|
||||
|
||||
Per AZ-404's spec this fixture should run the operator's full
|
||||
C10/C11/C12 pre-flight against a ``mock-suite-sat-service``
|
||||
fixture and yield the populated cache directory. The current
|
||||
``tests/fixtures/mock-suite-sat-service`` is a bootstrap stub
|
||||
(only ``GET /healthz`` per its README) — the full D-PROJ-2
|
||||
contract is not implemented. Until that ships, AC-8 (operator
|
||||
workflow rehearsal) is skipped at the test level; this fixture
|
||||
yields a placeholder cache directory so test bodies that
|
||||
request it can fail-fast with a documented reason rather than a
|
||||
surprise ImportError.
|
||||
Replaces the AZ-404 placeholder. Drives the operator-side
|
||||
pre-flight pipeline end-to-end and yields the populated cache
|
||||
so AC-8 (operator workflow rehearsal) and the AZ-840 e2e
|
||||
orchestrator test can consume it.
|
||||
|
||||
Skip gates (in evaluation order — first match wins):
|
||||
|
||||
* ``RUN_REPLAY_E2E`` not in ``{1, true, yes, on}`` — same as
|
||||
every other heavy test in this directory.
|
||||
* ``SATELLITE_PROVIDER_URL`` / ``SATELLITE_PROVIDER_API_KEY``
|
||||
missing — the C2 route client cannot reach the parent suite.
|
||||
* ``BUILD_FAISS_INDEX`` not ON — the C6 ``DescriptorIndex``
|
||||
runtime is gated by the env flag (``storage_factory.py``).
|
||||
* ``GPS_DENIED_OPERATOR_CONFIG_PATH`` missing OR points at a
|
||||
config that does not register every component this fixture
|
||||
needs (c6_tile_cache + c7_inference + c10_provisioning +
|
||||
c11_tile_manager) — the wiring would fail later with a less
|
||||
readable error.
|
||||
|
||||
See ``tests/e2e/replay/_operator_pre_flight.py::populate_c6_from_route``
|
||||
for the algorithm; this fixture only owns the
|
||||
runtime-factory wiring + skip gates.
|
||||
"""
|
||||
cache_dir = tmp_path / "operator_cache"
|
||||
cache_dir.mkdir()
|
||||
yield cache_dir
|
||||
|
||||
skip_reason = _operator_pre_flight_skip_reason()
|
||||
if skip_reason is not None:
|
||||
pytest.skip(skip_reason)
|
||||
|
||||
yield from _build_operator_pre_flight_cache(
|
||||
derkachi_replay_inputs=derkachi_replay_inputs,
|
||||
tmp_path_factory=tmp_path_factory,
|
||||
)
|
||||
|
||||
|
||||
def _operator_pre_flight_skip_reason() -> str | None:
|
||||
"""Return a SKIP reason string when env / build flags are not viable.
|
||||
|
||||
Centralised so the conditions stay testable + documented in one
|
||||
place. Returns ``None`` when the fixture is allowed to run.
|
||||
"""
|
||||
|
||||
if os.environ.get("RUN_REPLAY_E2E", "").strip().lower() not in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}:
|
||||
return "AZ-839 operator_pre_flight_setup gated by RUN_REPLAY_E2E=1"
|
||||
sp_url = os.environ.get("SATELLITE_PROVIDER_URL", "").strip()
|
||||
sp_jwt = os.environ.get("SATELLITE_PROVIDER_API_KEY", "").strip()
|
||||
if not sp_url:
|
||||
return (
|
||||
"AZ-839 operator_pre_flight_setup requires SATELLITE_PROVIDER_URL "
|
||||
"(e.g. https://satellite-provider:8080)"
|
||||
)
|
||||
if not sp_jwt:
|
||||
return (
|
||||
"AZ-839 operator_pre_flight_setup requires SATELLITE_PROVIDER_API_KEY "
|
||||
"(Bearer JWT for the parent-suite Route + Inventory APIs)"
|
||||
)
|
||||
if os.environ.get("BUILD_FAISS_INDEX", "").strip().lower() not in {
|
||||
"on",
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}:
|
||||
return (
|
||||
"AZ-839 operator_pre_flight_setup requires BUILD_FAISS_INDEX=ON "
|
||||
"(the C6 FaissDescriptorIndex runtime is build-flag-gated per "
|
||||
"runtime_root.storage_factory)"
|
||||
)
|
||||
if not os.environ.get("GPS_DENIED_OPERATOR_CONFIG_PATH", "").strip():
|
||||
return (
|
||||
"AZ-839 operator_pre_flight_setup requires "
|
||||
"GPS_DENIED_OPERATOR_CONFIG_PATH pointing at a YAML that "
|
||||
"registers c6_tile_cache + c7_inference + c10_provisioning + "
|
||||
"c11_tile_manager blocks (Jetson e2e harness sets this; "
|
||||
"dev macOS does not)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _build_operator_pre_flight_cache(
|
||||
*,
|
||||
derkachi_replay_inputs: DerkachiReplayInputs,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator["PopulatedC6Cache"]:
|
||||
"""Wire the operator-side runtime graph and run the AZ-839 driver.
|
||||
|
||||
All imports of heavy collaborators (httpx, runtime_root factories,
|
||||
c10/c11/c6 modules) live inside this function so collection on
|
||||
dev macOS without the e2e env stays cheap (the SKIP path returns
|
||||
before reaching this body).
|
||||
|
||||
Raises:
|
||||
pytest.skip.Exception: when an env-flagged dependency
|
||||
(e.g. ``c10_provisioning`` config block, route extraction)
|
||||
cannot be satisfied and re-running with the right env is
|
||||
the right next step.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.config.loader import load_config
|
||||
from gps_denied_onboard.replay_input.tlog_route import (
|
||||
extract_route_from_tlog,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.c10_factory import (
|
||||
build_descriptor_batcher,
|
||||
build_engine_compiler,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.c11_factory import (
|
||||
build_tile_downloader,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.storage_factory import (
|
||||
build_descriptor_index,
|
||||
build_tile_metadata_store,
|
||||
build_tile_store,
|
||||
)
|
||||
|
||||
from tests.e2e.replay._operator_pre_flight import (
|
||||
populate_c6_from_route,
|
||||
)
|
||||
|
||||
config_path = Path(os.environ["GPS_DENIED_OPERATOR_CONFIG_PATH"])
|
||||
if not config_path.is_file():
|
||||
pytest.skip(
|
||||
f"GPS_DENIED_OPERATOR_CONFIG_PATH points at a non-file: {config_path}"
|
||||
)
|
||||
config = load_config(os.environ, paths=[config_path])
|
||||
|
||||
cache_root = tmp_path_factory.mktemp("operator_pre_flight_cache")
|
||||
tile_store_path = cache_root / "tile_store"
|
||||
tile_store_path.mkdir(parents=True, exist_ok=True)
|
||||
faiss_index_path = cache_root / "descriptor.index"
|
||||
|
||||
route_spec = extract_route_from_tlog(
|
||||
derkachi_replay_inputs.tlog_path,
|
||||
max_waypoints=10,
|
||||
)
|
||||
|
||||
sp_url = os.environ["SATELLITE_PROVIDER_URL"].strip()
|
||||
sp_jwt = os.environ["SATELLITE_PROVIDER_API_KEY"].strip()
|
||||
tls_insecure = os.environ.get(
|
||||
"SATELLITE_PROVIDER_TLS_INSECURE", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||
SatelliteProviderRouteClient,
|
||||
)
|
||||
|
||||
route_client = SatelliteProviderRouteClient(
|
||||
base_url=sp_url,
|
||||
jwt=sp_jwt,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
|
||||
tile_store = build_tile_store(config)
|
||||
tile_metadata_store = build_tile_metadata_store(config)
|
||||
descriptor_index = build_descriptor_index(config)
|
||||
|
||||
httpx_client = httpx.Client(
|
||||
verify=not tls_insecure,
|
||||
timeout=httpx.Timeout(30.0),
|
||||
headers={"Authorization": f"Bearer {sp_jwt}"},
|
||||
)
|
||||
tile_downloader = build_tile_downloader(
|
||||
config,
|
||||
http_client=httpx_client,
|
||||
tile_store=tile_store,
|
||||
tile_metadata_store=tile_metadata_store,
|
||||
budget_enforcer=tile_store,
|
||||
)
|
||||
|
||||
clock = WallClock()
|
||||
engine_compiler = build_engine_compiler(config)
|
||||
backbone_embedder = _build_replay_backbone_embedder(
|
||||
config=config,
|
||||
engine_compiler=engine_compiler,
|
||||
cache_root=cache_root,
|
||||
)
|
||||
|
||||
descriptor_batcher = build_descriptor_batcher(
|
||||
config,
|
||||
backbone_embedder=backbone_embedder,
|
||||
tile_metadata_store=tile_metadata_store,
|
||||
tile_store=tile_store,
|
||||
descriptor_index=descriptor_index,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
def _descriptor_index_factory() -> Any:
|
||||
from gps_denied_onboard.components.c6_tile_cache.faiss_descriptor_index import ( # noqa: E501
|
||||
FaissDescriptorIndex,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import Sha256Sidecar
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
return FaissDescriptorIndex(
|
||||
index_path=faiss_index_path,
|
||||
sidecar=Sha256Sidecar(),
|
||||
logger=get_logger("c6_tile_cache.faiss_descriptor_index"),
|
||||
)
|
||||
|
||||
populated = populate_c6_from_route(
|
||||
route_spec=route_spec,
|
||||
route_client=route_client,
|
||||
tile_downloader=tile_downloader,
|
||||
descriptor_batcher=descriptor_batcher,
|
||||
descriptor_index_factory=_descriptor_index_factory,
|
||||
cache_root=cache_root,
|
||||
tile_store_path=tile_store_path,
|
||||
faiss_index_path=faiss_index_path,
|
||||
)
|
||||
try:
|
||||
yield populated
|
||||
finally:
|
||||
httpx_client.close()
|
||||
|
||||
|
||||
def _build_replay_backbone_embedder(
|
||||
*,
|
||||
config: Any,
|
||||
engine_compiler: Any,
|
||||
cache_root: Path,
|
||||
) -> Any:
|
||||
"""Compile the first configured backbone and wrap it for the AZ-322 batcher.
|
||||
|
||||
The replay-mode operator binary does not exist yet (tracked under
|
||||
Epic AZ-835); until it does, this fixture performs the wiring
|
||||
inline. The path is deliberately the production path:
|
||||
|
||||
* :func:`runtime_root.c10_factory.build_engine_compiler` builds
|
||||
the AZ-321 :class:`EngineCompiler`.
|
||||
* The first backbone in
|
||||
``config.components['c10_provisioning'].backbones`` is
|
||||
compiled to an engine cache entry; the AZ-297
|
||||
:class:`InferenceRuntime` deserialises it into the
|
||||
:class:`EngineHandle` the embedder consumes.
|
||||
* The tile decoder converts a C6 :class:`TilePixelHandle`
|
||||
(mmap of JPEG bytes) to the ``np.float32`` tensor shape the
|
||||
backbone expects via OpenCV — the same primitive the C7
|
||||
pre-processor uses.
|
||||
|
||||
Tests / dev workstations without a backbone ONNX or a working
|
||||
:class:`InferenceRuntime` fail this function, which surfaces as
|
||||
a fixture error (deliberate — the SKIP gate above is meant to
|
||||
catch the env-mismatch case before we get here).
|
||||
"""
|
||||
|
||||
from gps_denied_onboard._types.inference import PrecisionMode
|
||||
from gps_denied_onboard._types.manifests import HostCapabilities
|
||||
from gps_denied_onboard.components.c10_provisioning.c7_engine_embedder import (
|
||||
C7EngineBackboneEmbedder,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.engine_compiler import (
|
||||
EngineCompileRequest,
|
||||
)
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
from gps_denied_onboard.runtime_root.c10_factory import (
|
||||
build_backbone_specs,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.inference_factory import (
|
||||
build_inference_runtime,
|
||||
)
|
||||
|
||||
backbones = build_backbone_specs(config)
|
||||
if not backbones:
|
||||
pytest.skip(
|
||||
"AZ-839 operator_pre_flight_setup: config has no "
|
||||
"c10_provisioning.backbones entries — the e2e harness "
|
||||
"config must declare at least one backbone (typically "
|
||||
"DINOv2-VPR or NetVLAD per AZ-321)."
|
||||
)
|
||||
|
||||
host = HostCapabilities(
|
||||
gpu_name="replay-e2e",
|
||||
cuda_compute_capability=(0, 0),
|
||||
cuda_runtime_version="0.0",
|
||||
tensorrt_version="0.0",
|
||||
host_arch="unknown",
|
||||
host_os="linux",
|
||||
driver_version="unknown",
|
||||
)
|
||||
engine_cache_root = cache_root / "engines"
|
||||
engine_cache_root.mkdir(parents=True, exist_ok=True)
|
||||
request = EngineCompileRequest(
|
||||
backbones=backbones,
|
||||
calibration_path=None,
|
||||
cache_root=engine_cache_root,
|
||||
precision=PrecisionMode.FP16,
|
||||
host=host,
|
||||
workspace_mb=int(
|
||||
config.components["c10_provisioning"].workspace_mb
|
||||
),
|
||||
)
|
||||
results = engine_compiler.compile_engines_for_corpus(request)
|
||||
if not results:
|
||||
pytest.skip(
|
||||
"AZ-839 operator_pre_flight_setup: engine compiler returned "
|
||||
"empty results — corpus failed to compile."
|
||||
)
|
||||
first = results[0]
|
||||
spec = backbones[0]
|
||||
inference_runtime = build_inference_runtime(config)
|
||||
engine_handle = inference_runtime.deserialize_engine(first.entry)
|
||||
descriptor_dim = _resolve_replay_descriptor_dim(config, spec)
|
||||
return C7EngineBackboneEmbedder(
|
||||
inference_runtime=inference_runtime,
|
||||
engine_handle=engine_handle,
|
||||
input_name=spec.input_name,
|
||||
output_name="descriptor",
|
||||
descriptor_dim=descriptor_dim,
|
||||
tile_decoder=_default_tile_decoder,
|
||||
logger=get_logger("c10_provisioning.replay_backbone_embedder"),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_replay_descriptor_dim(config: Any, spec: Any) -> int:
|
||||
"""Resolve the descriptor output dimension for the AZ-839 NetVLAD baseline.
|
||||
|
||||
The AZ-839 task spec pins the C2 backbone at NetVLAD (per
|
||||
``c2_vpr/config.py:67``); :class:`C2VprConfig.netvlad_descriptor_dim`
|
||||
is the canonical source. We read the c2_vpr block and fall back
|
||||
to the architecture default ``4096`` when the block is absent so
|
||||
operators on a hand-rolled YAML still get a coherent dim. Other
|
||||
backbones (UltraVPR=512, MegaLoc=2048, MixVPR=4096) require
|
||||
swapping this resolver — out of scope for AZ-839.
|
||||
"""
|
||||
|
||||
block = config.components.get("c2_vpr") if config.components else None
|
||||
if block is not None and getattr(block, "strategy", "") == "net_vlad":
|
||||
return int(getattr(block, "netvlad_descriptor_dim", 4096))
|
||||
pytest.skip(
|
||||
"AZ-839 operator_pre_flight_setup: descriptor_dim resolver "
|
||||
f"only supports c2_vpr.strategy='net_vlad'; got "
|
||||
f"{getattr(block, 'strategy', '<missing>')!r} on backbone "
|
||||
f"{spec.model_name!r}. See AZ-839 spec § Out of scope."
|
||||
)
|
||||
raise AssertionError("unreachable: pytest.skip raises")
|
||||
|
||||
|
||||
def _default_tile_decoder(handle: Any) -> Any:
|
||||
"""Decode a C6 :class:`TilePixelHandle` (JPEG mmap) to a CHW float32 tensor.
|
||||
|
||||
The handle exposes ``read_bytes()`` (or context-manager + ``read``);
|
||||
we prefer the simpler ``read_bytes()`` path. OpenCV imdecode
|
||||
yields HWC-uint8-BGR; the embedder expects float32-CHW-RGB
|
||||
normalised to ``[0, 1]`` (DINOv2-VPR + NetVLAD share this layout).
|
||||
Imports are lazy — no OpenCV penalty when this module is imported
|
||||
on dev macOS.
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if hasattr(handle, "read_bytes"):
|
||||
blob = handle.read_bytes()
|
||||
else:
|
||||
with handle as opened:
|
||||
blob = opened.read()
|
||||
arr = np.frombuffer(blob, dtype=np.uint8)
|
||||
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
raise RuntimeError("cv2.imdecode returned None for tile handle")
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
chw = np.transpose(rgb, (2, 0, 1)).astype(np.float32) / 255.0
|
||||
return chw
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Unit tests for ``populate_c6_from_route`` (AZ-839 AC-8).
|
||||
|
||||
Covers the AZ-839 acceptance criteria that can be exercised against
|
||||
stubbed dependencies (the AC-9 integration test against the Jetson
|
||||
harness lives in ``test_derkachi_real_tlog.py`` once Epic AZ-835
|
||||
completes):
|
||||
|
||||
* AC-3 happy path — driver returns a populated cache with paths
|
||||
pointing at the on-disk sidecar triple.
|
||||
* AC-4 — :class:`RouteValidationError` and
|
||||
:class:`RouteTerminalFailureError` propagate unchanged with their
|
||||
original cause; no silent swallow.
|
||||
* AC-5 — :class:`RouteTransientError` triggers retry up to 3 attempts
|
||||
using the documented backoff schedule. Final attempt's exception is
|
||||
propagated unchanged.
|
||||
* AC-6 — Tamper between rebuild and verify (simulated by having
|
||||
``descriptor_index_factory`` raise :class:`IndexUnavailableError`)
|
||||
surfaces the failure and leaves no half-built artifacts.
|
||||
* AC-7 — Cleanup on failure removes any sidecar file the driver
|
||||
produced (pre-existing files are preserved).
|
||||
|
||||
The driver intentionally takes every collaborator via dependency
|
||||
injection so this module never imports httpx, FAISS, or Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c10_provisioning.descriptor_batcher import (
|
||||
BatcherOutcome,
|
||||
DescriptorBatchReport,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
DownloadOutcome,
|
||||
SectorClassification,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager._types import (
|
||||
DownloadBatchReport,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
RouteTerminalFailureError,
|
||||
RouteTransientError,
|
||||
RouteValidationError,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||
RouteSeedResult,
|
||||
)
|
||||
from gps_denied_onboard.components.c6_tile_cache.errors import (
|
||||
IndexUnavailableError,
|
||||
)
|
||||
from gps_denied_onboard.components.c6_tile_cache.faiss_descriptor_index import (
|
||||
META_SUFFIX,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import SIDECAR_SUFFIX
|
||||
from gps_denied_onboard.replay_input.tlog_route import RouteSpec
|
||||
|
||||
from tests.e2e.replay._operator_pre_flight import (
|
||||
PopulatedC6Cache,
|
||||
populate_c6_from_route,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DriverHarness:
|
||||
"""Bundle of paths + collaborators wired into one driver call."""
|
||||
|
||||
cache_root: Path
|
||||
tile_store_path: Path
|
||||
faiss_index_path: Path
|
||||
sha256_path: Path
|
||||
meta_path: Path
|
||||
route_spec: RouteSpec
|
||||
route_client: MagicMock
|
||||
tile_downloader: MagicMock
|
||||
descriptor_batcher: MagicMock
|
||||
descriptor_index_factory: MagicMock
|
||||
sleep_calls: list[float]
|
||||
|
||||
|
||||
def _build_harness(tmp_path: Path) -> _DriverHarness:
|
||||
"""Wire a self-contained harness with sane default stub returns.
|
||||
|
||||
Each collaborator is a :class:`MagicMock` with a default success
|
||||
return value; tests override per-call as needed.
|
||||
"""
|
||||
|
||||
cache_root = tmp_path / "cache_root"
|
||||
cache_root.mkdir()
|
||||
tile_store_path = cache_root / "tile_store"
|
||||
tile_store_path.mkdir()
|
||||
faiss_index_path = cache_root / "descriptor.index"
|
||||
sha256_path = Path(str(faiss_index_path) + SIDECAR_SUFFIX)
|
||||
meta_path = Path(str(faiss_index_path) + META_SUFFIX)
|
||||
|
||||
route_spec = RouteSpec(
|
||||
waypoints=(
|
||||
(50.10, 36.10),
|
||||
(50.11, 36.11),
|
||||
(50.12, 36.12),
|
||||
),
|
||||
suggested_region_size_meters=500.0,
|
||||
source_tlog=Path("test.tlog"),
|
||||
source_segment=(0, 100),
|
||||
total_distance_meters=1500.0,
|
||||
)
|
||||
|
||||
route_client = MagicMock()
|
||||
route_client.seed_route.return_value = RouteSeedResult(
|
||||
route_id=uuid4(),
|
||||
terminal_status="completed",
|
||||
maps_ready=True,
|
||||
tile_count=12,
|
||||
elapsed_ms=2500,
|
||||
submitted_payload_sha256="cafebabe" * 8,
|
||||
)
|
||||
|
||||
tile_downloader = MagicMock()
|
||||
tile_downloader.download_tiles_for_area.return_value = DownloadBatchReport(
|
||||
outcome=DownloadOutcome.SUCCESS,
|
||||
tiles_requested=12,
|
||||
tiles_downloaded=12,
|
||||
tiles_rejected_resolution=0,
|
||||
tiles_rejected_freshness=0,
|
||||
tiles_downgraded=0,
|
||||
retry_count=0,
|
||||
request_hash="abcdef0123456789",
|
||||
)
|
||||
|
||||
descriptor_batcher = MagicMock()
|
||||
descriptor_batcher.populate_descriptors.return_value = DescriptorBatchReport(
|
||||
descriptors_generated=12,
|
||||
tiles_consumed=12,
|
||||
oom_retries=0,
|
||||
elapsed_s=1.2,
|
||||
outcome=BatcherOutcome.SUCCESS,
|
||||
failure_reason=None,
|
||||
)
|
||||
|
||||
descriptor_index_factory = MagicMock()
|
||||
descriptor_index_factory.return_value = MagicMock(
|
||||
spec=["mmap_handle", "descriptor_dim"]
|
||||
)
|
||||
|
||||
return _DriverHarness(
|
||||
cache_root=cache_root,
|
||||
tile_store_path=tile_store_path,
|
||||
faiss_index_path=faiss_index_path,
|
||||
sha256_path=sha256_path,
|
||||
meta_path=meta_path,
|
||||
route_spec=route_spec,
|
||||
route_client=route_client,
|
||||
tile_downloader=tile_downloader,
|
||||
descriptor_batcher=descriptor_batcher,
|
||||
descriptor_index_factory=descriptor_index_factory,
|
||||
sleep_calls=[],
|
||||
)
|
||||
|
||||
|
||||
def _drive(harness: _DriverHarness, **overrides: object) -> PopulatedC6Cache:
|
||||
"""Invoke the driver with the harness defaults plus any overrides."""
|
||||
|
||||
kwargs: dict[str, object] = {
|
||||
"route_spec": harness.route_spec,
|
||||
"route_client": harness.route_client,
|
||||
"tile_downloader": harness.tile_downloader,
|
||||
"descriptor_batcher": harness.descriptor_batcher,
|
||||
"descriptor_index_factory": harness.descriptor_index_factory,
|
||||
"cache_root": harness.cache_root,
|
||||
"tile_store_path": harness.tile_store_path,
|
||||
"faiss_index_path": harness.faiss_index_path,
|
||||
"sleep": harness.sleep_calls.append,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return populate_c6_from_route(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3 — happy path
|
||||
|
||||
|
||||
def test_populate_c6_from_route_returns_populated_cache(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
|
||||
# Act
|
||||
populated = _drive(harness)
|
||||
|
||||
# Assert
|
||||
assert isinstance(populated, PopulatedC6Cache)
|
||||
assert populated.cache_root == harness.cache_root
|
||||
assert populated.tile_store_path == harness.tile_store_path
|
||||
assert populated.faiss_index_path == harness.faiss_index_path
|
||||
assert populated.faiss_sidecar_sha256_path == harness.sha256_path
|
||||
assert populated.faiss_sidecar_meta_path == harness.meta_path
|
||||
assert populated.route_spec is harness.route_spec
|
||||
assert populated.tile_count == 12
|
||||
assert populated.elapsed_seconds >= 0.0
|
||||
harness.route_client.seed_route.assert_called_once()
|
||||
harness.tile_downloader.download_tiles_for_area.assert_called_once()
|
||||
harness.descriptor_batcher.populate_descriptors.assert_called_once()
|
||||
harness.descriptor_index_factory.assert_called_once()
|
||||
|
||||
|
||||
def test_populate_c6_from_route_passes_sector_class_to_downloader(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
|
||||
# Act
|
||||
_drive(harness, sector_class=SectorClassification.STABLE_REAR)
|
||||
|
||||
# Assert
|
||||
download_request = harness.tile_downloader.download_tiles_for_area.call_args.args[0]
|
||||
assert download_request.sector_class is SectorClassification.STABLE_REAR
|
||||
corpus_filter = harness.descriptor_batcher.populate_descriptors.call_args.args[0]
|
||||
assert corpus_filter.sector_class == SectorClassification.STABLE_REAR.value
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4 — validation / terminal failure propagate unchanged
|
||||
|
||||
|
||||
def test_route_validation_error_propagates_unchanged(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
|
||||
def _raise_validation(*_args: object, **_kwargs: object) -> RouteSeedResult:
|
||||
try:
|
||||
raise ValueError("payload sha256 mismatch")
|
||||
except ValueError as cause:
|
||||
raise RouteValidationError("payload rejected") from cause
|
||||
|
||||
harness.route_client.seed_route.side_effect = _raise_validation
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(RouteValidationError) as exc_info:
|
||||
_drive(harness)
|
||||
assert isinstance(exc_info.value.__cause__, ValueError)
|
||||
assert "payload sha256 mismatch" in str(exc_info.value.__cause__)
|
||||
assert harness.tile_downloader.download_tiles_for_area.call_count == 0
|
||||
assert harness.descriptor_batcher.populate_descriptors.call_count == 0
|
||||
assert harness.sleep_calls == []
|
||||
|
||||
|
||||
def test_route_terminal_failure_propagates_unchanged(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
harness.route_client.seed_route.side_effect = RouteTerminalFailureError(
|
||||
"mapsReady never reached"
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(RouteTerminalFailureError):
|
||||
_drive(harness)
|
||||
assert harness.tile_downloader.download_tiles_for_area.call_count == 0
|
||||
assert harness.descriptor_batcher.populate_descriptors.call_count == 0
|
||||
assert harness.sleep_calls == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5 — transient retry budget
|
||||
|
||||
|
||||
def test_route_transient_error_retries_then_succeeds(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
success_result = harness.route_client.seed_route.return_value
|
||||
harness.route_client.seed_route.side_effect = [
|
||||
RouteTransientError("503 first attempt"),
|
||||
RouteTransientError("503 second attempt"),
|
||||
success_result,
|
||||
]
|
||||
|
||||
# Act
|
||||
populated = _drive(
|
||||
harness,
|
||||
retry_schedule_s=(0.1, 0.2, 0.4),
|
||||
max_retry_attempts=3,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert populated.tile_count == 12
|
||||
assert harness.route_client.seed_route.call_count == 3
|
||||
assert harness.sleep_calls == [pytest.approx(0.1), pytest.approx(0.2)]
|
||||
|
||||
|
||||
def test_route_transient_error_exhausted_propagates_last_attempt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
final_exc = RouteTransientError("503 final attempt")
|
||||
harness.route_client.seed_route.side_effect = [
|
||||
RouteTransientError("503 a"),
|
||||
RouteTransientError("503 b"),
|
||||
final_exc,
|
||||
]
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(RouteTransientError) as exc_info:
|
||||
_drive(
|
||||
harness,
|
||||
retry_schedule_s=(0.1, 0.2),
|
||||
max_retry_attempts=3,
|
||||
)
|
||||
assert exc_info.value is final_exc
|
||||
assert harness.route_client.seed_route.call_count == 3
|
||||
assert harness.sleep_calls == [pytest.approx(0.1), pytest.approx(0.2)]
|
||||
assert harness.tile_downloader.download_tiles_for_area.call_count == 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6 — tamper between rebuild and verify
|
||||
|
||||
|
||||
def test_descriptor_index_factory_index_unavailable_propagates(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
# Simulate the rebuild writing sidecar files DURING populate_descriptors
|
||||
# (the real C10 batcher does this via its DescriptorIndexRebuilder cut).
|
||||
_stub_populate_descriptors_writes_sidecars(harness)
|
||||
harness.descriptor_index_factory.side_effect = IndexUnavailableError(
|
||||
"sidecar sha256 mismatch — index is corrupt"
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(IndexUnavailableError):
|
||||
_drive(harness)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7 — cleanup on failure
|
||||
|
||||
|
||||
def test_cleanup_removes_partial_sidecar_files_on_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
# The driver MUST observe an absent-sidecar state on entry, then a
|
||||
# rebuild that writes the trio, then a verifier that fails — only
|
||||
# then is the cleanup contract exercised on a "we created these"
|
||||
# set of paths.
|
||||
assert not harness.faiss_index_path.exists()
|
||||
_stub_populate_descriptors_writes_sidecars(harness)
|
||||
harness.descriptor_index_factory.side_effect = IndexUnavailableError(
|
||||
"tamper detected"
|
||||
)
|
||||
|
||||
# Act
|
||||
with pytest.raises(IndexUnavailableError):
|
||||
_drive(harness)
|
||||
|
||||
# Assert
|
||||
assert not harness.faiss_index_path.exists()
|
||||
assert not harness.sha256_path.exists()
|
||||
assert not harness.meta_path.exists()
|
||||
|
||||
|
||||
def test_cleanup_preserves_pre_existing_warm_cache(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
# A warm cache existed before the driver ran (named-volume reuse path).
|
||||
_write_dummy_sidecars(harness, marker="WARM_CACHE")
|
||||
harness.route_client.seed_route.side_effect = RouteValidationError(
|
||||
"noop fail post-warm-cache"
|
||||
)
|
||||
|
||||
# Act
|
||||
with pytest.raises(RouteValidationError):
|
||||
_drive(harness)
|
||||
|
||||
# Assert — the pre-existing warm-cache files MUST stay on disk.
|
||||
assert harness.faiss_index_path.read_text() == "WARM_CACHE"
|
||||
assert harness.sha256_path.read_text() == "WARM_CACHE"
|
||||
assert harness.meta_path.read_text() == "WARM_CACHE"
|
||||
|
||||
|
||||
def test_batcher_failure_propagates_and_cleans_up(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
|
||||
def _populate_writes_partial_sidecar_then_fails(
|
||||
_filter: object,
|
||||
) -> DescriptorBatchReport:
|
||||
_write_dummy_sidecars(harness, marker="HALF_BUILT")
|
||||
return DescriptorBatchReport(
|
||||
descriptors_generated=0,
|
||||
tiles_consumed=0,
|
||||
oom_retries=0,
|
||||
elapsed_s=0.5,
|
||||
outcome=BatcherOutcome.FAILURE,
|
||||
failure_reason="OOM at batch_size=64",
|
||||
)
|
||||
|
||||
harness.descriptor_batcher.populate_descriptors.side_effect = (
|
||||
_populate_writes_partial_sidecar_then_fails
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
_drive(harness)
|
||||
assert "OOM at batch_size=64" in str(exc_info.value)
|
||||
assert not harness.faiss_index_path.exists()
|
||||
assert not harness.sha256_path.exists()
|
||||
assert not harness.meta_path.exists()
|
||||
|
||||
|
||||
def test_downloader_failure_propagates_and_cleans_up(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
harness = _build_harness(tmp_path)
|
||||
harness.tile_downloader.download_tiles_for_area.return_value = (
|
||||
DownloadBatchReport(
|
||||
outcome=DownloadOutcome.FAILURE,
|
||||
tiles_requested=12,
|
||||
tiles_downloaded=0,
|
||||
tiles_rejected_resolution=0,
|
||||
tiles_rejected_freshness=0,
|
||||
tiles_downgraded=0,
|
||||
retry_count=2,
|
||||
request_hash="abcdef0123456789",
|
||||
)
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
_drive(harness)
|
||||
assert "failure" in str(exc_info.value).lower()
|
||||
assert harness.descriptor_batcher.populate_descriptors.call_count == 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
|
||||
|
||||
def _write_dummy_sidecars(
|
||||
harness: _DriverHarness,
|
||||
*,
|
||||
marker: str = "PARTIAL",
|
||||
) -> None:
|
||||
"""Create the three sidecar files at the harness's faiss path."""
|
||||
|
||||
harness.faiss_index_path.write_text(marker)
|
||||
harness.sha256_path.write_text(marker)
|
||||
harness.meta_path.write_text(marker)
|
||||
|
||||
|
||||
def _stub_populate_descriptors_writes_sidecars(
|
||||
harness: _DriverHarness,
|
||||
*,
|
||||
marker: str = "FRESH_REBUILD",
|
||||
) -> None:
|
||||
"""Make the stubbed batcher write the three sidecar files on success.
|
||||
|
||||
The real C10 batcher writes the FAISS index + sha256 + meta.json
|
||||
via the AZ-306 :class:`FaissDescriptorIndex.rebuild_from_descriptors`
|
||||
path. The stub mirrors that side effect so the AC-7 cleanup path
|
||||
has files to rollback on a downstream verifier failure.
|
||||
"""
|
||||
|
||||
success_report = harness.descriptor_batcher.populate_descriptors.return_value
|
||||
|
||||
def _populate(_filter: object) -> DescriptorBatchReport:
|
||||
_write_dummy_sidecars(harness, marker=marker)
|
||||
return success_report
|
||||
|
||||
harness.descriptor_batcher.populate_descriptors.side_effect = _populate
|
||||
@@ -0,0 +1,40 @@
|
||||
"""AZ-839 AC-9 — integration test: fixture produces a real :class:`PopulatedC6Cache`.
|
||||
|
||||
Gated by ``RUN_REPLAY_E2E=1`` AND ``@pytest.mark.tier2`` per the
|
||||
AZ-839 task spec. The work the test asserts is the fixture's
|
||||
contract; the fixture wiring itself lives in
|
||||
``tests/e2e/replay/conftest.py::operator_pre_flight_setup`` and the
|
||||
algorithmic correctness is covered by
|
||||
``test_operator_pre_flight_driver.py`` against stubs (AC-8).
|
||||
|
||||
This test exists so AC-9 has a concrete pytest entry point. Other
|
||||
end-to-end consumers (AZ-840 e2e orchestrator test; AZ-841 un-xfail
|
||||
of the AZ-777 Tier-2 tests) chain off the same fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.replay._operator_pre_flight import PopulatedC6Cache
|
||||
|
||||
|
||||
@pytest.mark.tier2
|
||||
def test_operator_pre_flight_setup_produces_populated_cache(
|
||||
operator_pre_flight_setup: PopulatedC6Cache,
|
||||
) -> None:
|
||||
# Arrange
|
||||
populated = operator_pre_flight_setup
|
||||
|
||||
# Assert
|
||||
assert isinstance(populated, PopulatedC6Cache)
|
||||
assert populated.cache_root.is_dir()
|
||||
assert populated.tile_store_path.is_dir()
|
||||
assert populated.faiss_index_path.is_file()
|
||||
assert populated.faiss_sidecar_sha256_path.is_file()
|
||||
assert populated.faiss_sidecar_meta_path.is_file()
|
||||
assert populated.tile_count > 0
|
||||
assert populated.elapsed_seconds >= 0.0
|
||||
assert populated.route_spec.waypoints, (
|
||||
"RouteSpec must carry at least one waypoint extracted from the tlog"
|
||||
)
|
||||
Reference in New Issue
Block a user