[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:
Oleksandr Bezdieniezhnykh
2026-05-23 15:08:34 +03:00
parent 0ed1a5d988
commit bfcac2cb9f
7 changed files with 1544 additions and 17 deletions
+474
View File
@@ -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
View File
@@ -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"
)