diff --git a/backend/services/navi_offroute/hpa_manifest.py b/backend/services/navi_offroute/hpa_manifest.py
new file mode 100644
index 0000000..19d732a
--- /dev/null
+++ b/backend/services/navi_offroute/hpa_manifest.py
@@ -0,0 +1,144 @@
+"""HPA tile-DB manifest pattern (H6a-pre). Replaces the single-file
+``NAVI_OFFROUTE_HPA_DB`` env var with a directory-based manifest
+(``NAVI_OFFROUTE_HPA_DIR``) so one deployment can carry multiple regional tile
+DBs and look up which one(s) cover a route bbox. The two-level HPA* kernel
+(``astar_hpa_multimode``) is unchanged; ``router.py`` asks this module for
+matching tile DBs and forwards the path. Schema is encoded in the loaders
+below; backward-compat: legacy ``HPA_DB`` alone -> single unbounded entry,
+neither env var set -> HPA disabled (same as today).
+"""
+import json
+import logging
+import os
+import sqlite3
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+MANIFEST_VERSION = 1
+ENV_DIR = "NAVI_OFFROUTE_HPA_DIR"
+ENV_LEGACY = "NAVI_OFFROUTE_HPA_DB"
+
+
+@dataclass(frozen=True)
+class ChunkBounds:
+ """Inclusive chunk-index rectangle."""
+ min_x: int
+ max_x: int
+ min_y: int
+ max_y: int
+
+ def intersects(self, cx_min: int, cx_max: int, cy_min: int, cy_max: int) -> bool:
+ return not (cx_max < self.min_x or cx_min > self.max_x
+ or cy_max < self.min_y or cy_min > self.max_y)
+
+
+@dataclass(frozen=True)
+class TileDBEntry:
+ name: str
+ abs_path: str
+ chunk_bounds: Optional[ChunkBounds] # None -> unbounded
+
+
+@dataclass
+class HPAManifest:
+ """Registry of regional tile DBs + lazy per-path sqlite connection cache.
+ The cache is process-local mutable state; entries are immutable after load."""
+ entries: List[TileDBEntry] = field(default_factory=list)
+ _conns: Dict[str, sqlite3.Connection] = field(default_factory=dict)
+
+ def enabled(self) -> bool:
+ return len(self.entries) > 0
+
+ def dbs_for_chunks(self, cx_min: int, cx_max: int,
+ cy_min: int, cy_max: int) -> List[str]:
+ """Tile DBs whose chunk_bounds intersect the chunk rectangle. Unbounded
+ entries always match. Preserves declaration order."""
+ out = []
+ for e in self.entries:
+ if e.chunk_bounds is None or e.chunk_bounds.intersects(
+ cx_min, cx_max, cy_min, cy_max):
+ out.append(e.abs_path)
+ return out
+
+ def dbs_for_route_bbox(self, south: float, north: float,
+ west: float, east: float) -> List[str]:
+ """Resolve a route's degree bbox to its chunk-index rectangle and look up
+ matching DB paths. Local import keeps this module independent of the
+ build pipeline's import graph."""
+ from .hpa_build import chunk_coords
+ cx0, cy0 = chunk_coords(south, west)
+ cx1, cy1 = chunk_coords(north, east)
+ return self.dbs_for_chunks(min(cx0, cx1), max(cx0, cx1),
+ min(cy0, cy1), max(cy0, cy1))
+
+ def get_connection(self, abs_path: str) -> sqlite3.Connection:
+ """Read-only sqlite connection cached for the life of the process. v1
+ dispatch (astar_hpa_multimode) opens its own connection per call and does
+ not yet use this cache; multi-region UNION + admin endpoints will."""
+ conn = self._conns.get(abs_path)
+ if conn is None:
+ conn = sqlite3.connect(f"file:{abs_path}?mode=ro", uri=True)
+ self._conns[abs_path] = conn
+ return conn
+
+
+# ── loaders ──────────────────────────────────────────────────────────────────
+
+def _load_from_dir(dir_path: str) -> HPAManifest:
+ """Parse ``
/manifest.json``. Fails fast on bad version, missing file,
+ or any entry whose resolved path is absent."""
+ mpath = os.path.join(dir_path, "manifest.json")
+ if not os.path.isfile(mpath):
+ raise FileNotFoundError(f"HPA manifest not found at {mpath}")
+ with open(mpath) as f:
+ data = json.load(f)
+ if data.get("version") != MANIFEST_VERSION:
+ raise ValueError(
+ f"HPA manifest at {mpath}: unsupported version "
+ f"{data.get('version')!r} (expected {MANIFEST_VERSION})")
+ entries: List[TileDBEntry] = []
+ for raw in data.get("tile_dbs") or []:
+ name = raw["name"]
+ rel = raw["path"]
+ abs_path = rel if os.path.isabs(rel) else os.path.join(dir_path, rel)
+ if not os.path.isfile(abs_path):
+ raise FileNotFoundError(
+ f"HPA manifest at {mpath}: entry {name!r} -> {abs_path} not found")
+ cb_raw = raw.get("chunk_bounds")
+ cb = (ChunkBounds(min_x=int(cb_raw["min_x"]), max_x=int(cb_raw["max_x"]),
+ min_y=int(cb_raw["min_y"]), max_y=int(cb_raw["max_y"]))
+ if cb_raw is not None else None)
+ entries.append(TileDBEntry(name=name, abs_path=abs_path, chunk_bounds=cb))
+ return HPAManifest(entries=entries)
+
+
+def _load_from_legacy(legacy_path: str) -> HPAManifest:
+ """Synthesize a single unbounded entry from ``NAVI_OFFROUTE_HPA_DB``. Always
+ warns; missing file -> empty (HPA disabled, same as today)."""
+ if not os.path.isfile(legacy_path):
+ logger.warning("%s=%s does not exist; HPA disabled", ENV_LEGACY, legacy_path)
+ return HPAManifest(entries=[])
+ logger.warning(
+ "%s is deprecated; migrate to %s + manifest.json. "
+ "Treating %s as an unbounded single-entry manifest.",
+ ENV_LEGACY, ENV_DIR, legacy_path)
+ return HPAManifest(entries=[TileDBEntry(
+ name="legacy", abs_path=legacy_path, chunk_bounds=None)])
+
+
+def load() -> HPAManifest:
+ """Load the active manifest from env. ``HPA_DIR`` wins over ``HPA_DB`` if
+ both are set (warns); neither -> empty (HPA disabled)."""
+ dir_env = os.environ.get(ENV_DIR)
+ legacy_env = os.environ.get(ENV_LEGACY)
+ if dir_env:
+ if legacy_env:
+ logger.warning(
+ "Both %s and %s are set; %s takes precedence",
+ ENV_DIR, ENV_LEGACY, ENV_DIR)
+ return _load_from_dir(dir_env)
+ if legacy_env:
+ return _load_from_legacy(legacy_env)
+ return HPAManifest(entries=[])
diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py
index 7a6b126..510e932 100755
--- a/backend/services/navi_offroute/router.py
+++ b/backend/services/navi_offroute/router.py
@@ -48,6 +48,7 @@ from .trails import TrailReader
from .mvum import get_mvum_access_grid, get_mvum_access_grids_all_modes
from .mvum_annotate import annotate_network_edges
from .mvum_exclude import build_exclude_polygons
+from . import hpa_manifest
logger = logging.getLogger("navi_offroute.router")
@@ -61,10 +62,11 @@ POSTGIS_DSN = os.environ.get("NAVI_OFFROUTE_POSTGIS_DSN", "dbname=padus")
# Valhalla endpoint (recon-side network router, HTTP)
VALHALLA_URL = os.environ.get("NAVI_OFFROUTE_VALHALLA_URL", "http://localhost:8002")
-# HPA* cost-tile DB (HPA-SPEC.md §8/§9, Phase H3). Unset (None) -> HPA* never engages and
-# Auto routing is byte-identical to the unified-graph path. Set to a tile DB (built by
-# hpa_build) to enable the two-level fast path for covered, pragmatic, no-affinity routes.
-HPA_TILE_DB = os.environ.get("NAVI_OFFROUTE_HPA_DB")
+# HPA* cost-tile manifest (HPA-SPEC.md §8/§9, Phase H3 + H6a-pre). Empty manifest ->
+# HPA* never engages; Auto routing is byte-identical to the unified-graph path. Populated
+# from NAVI_OFFROUTE_HPA_DIR (manifest.json) or the legacy NAVI_OFFROUTE_HPA_DB (treated as
+# a single unbounded entry). See hpa_manifest.py.
+_HPA_MANIFEST = hpa_manifest.load()
# Search radius for entry points (km)
DEFAULT_SEARCH_RADIUS_KM = 50
@@ -916,24 +918,31 @@ class OffrouteRouter:
origin_modes = np.array(sorted(MODE_INDEX[m] for m in start_eligible), dtype=np.int64)
goal_modes = np.array(sorted(MODE_INDEX[m] for m in end_eligible), dtype=np.int64)
- # HPA* fast path (Phase H3): when a tile DB is configured + covers the route, search
- # the precomputed abstract chunk graph instead of flooding the full bbox. Whole-route
- # fallback to the unified kernel below on any miss (HPA-SPEC.md §8/§9). When
- # NAVI_OFFROUTE_HPA_DB is unset this block is skipped entirely (behaviour unchanged).
+ # HPA* fast path (Phase H3 + H6a-pre): consult the manifest for any tile DB(s) that
+ # cover this route's chunk range; with exactly one match, dispatch to the precomputed
+ # abstract graph instead of flooding the full bbox. Multi-region UNION across DBs is
+ # a future PR (needs astar_hpa_multimode signature change); for now multi-match logs
+ # the reason and falls through to the unified kernel. Whole-route fallback on any
+ # miss (HPA-SPEC.md §8/§9). With an empty manifest this block is skipped entirely.
if self._hpa_eligible(boundary_mode, network_affinity):
- _h0 = time.perf_counter()
- _cache = {"raster": {}, "layer": {}}
- hidx, hpath, hcost, hreason = astar_hpa_multimode(
- HPA_TILE_DB, meta, start_lat, start_lon, end_lat, end_lon,
- origin_modes, goal_modes, boundary_mode, network_affinity,
- chunk_layer=lambda cx, cy, mi: self._hpa_chunk_layer(cx, cy, mi, _cache),
- dem_reader=self.dem_reader)
- if hidx >= 0 and hpath.shape[0] > 0:
- logger.info("auto: HPA* (chunks=%d) in %.2fs",
- len(_cache["raster"]), time.perf_counter() - _h0)
- return self._render_unified_path(hpath, hcost, meta, boundary_mode)
- logger.info("auto: HPA fallback reason=%s -> unified A*", hreason)
- elif HPA_TILE_DB and os.path.exists(HPA_TILE_DB):
+ db_paths = _HPA_MANIFEST.dbs_for_route_bbox(*meta["bounds"])
+ if len(db_paths) == 1:
+ _h0 = time.perf_counter()
+ _cache = {"raster": {}, "layer": {}}
+ hidx, hpath, hcost, hreason = astar_hpa_multimode(
+ db_paths[0], meta, start_lat, start_lon, end_lat, end_lon,
+ origin_modes, goal_modes, boundary_mode, network_affinity,
+ chunk_layer=lambda cx, cy, mi: self._hpa_chunk_layer(cx, cy, mi, _cache),
+ dem_reader=self.dem_reader)
+ if hidx >= 0 and hpath.shape[0] > 0:
+ logger.info("auto: HPA* (chunks=%d) in %.2fs",
+ len(_cache["raster"]), time.perf_counter() - _h0)
+ return self._render_unified_path(hpath, hcost, meta, boundary_mode)
+ logger.info("auto: HPA fallback reason=%s -> unified A*", hreason)
+ else:
+ _r = "multi_region" if len(db_paths) > 1 else "no_coverage"
+ logger.info("auto: HPA fallback reason=%s -> unified A*", _r)
+ elif _HPA_MANIFEST.enabled():
_r = "boundary_mode" if boundary_mode != "pragmatic" else "affinity"
logger.info("auto: HPA fallback reason=%s -> unified A*", _r)
@@ -1112,10 +1121,10 @@ class OffrouteRouter:
}
def _hpa_eligible(self, boundary_mode, network_affinity):
- """HPA* engages only with a configured + existing tile DB, the default boundary mode,
+ """HPA* engages only with a non-empty tile-DB manifest, the default boundary mode,
and no network_affinity — the tiles are pure-terrain/pragmatic (HPA-SPEC.md §8), so
other configs would change the answer and must use the unified fallback."""
- if not (HPA_TILE_DB and os.path.exists(HPA_TILE_DB)):
+ if not _HPA_MANIFEST.enabled():
return False
if boundary_mode != "pragmatic":
return False
diff --git a/backend/services/navi_offroute/tests/test_hpa_manifest.py b/backend/services/navi_offroute/tests/test_hpa_manifest.py
new file mode 100644
index 0000000..85d17a4
--- /dev/null
+++ b/backend/services/navi_offroute/tests/test_hpa_manifest.py
@@ -0,0 +1,117 @@
+"""HPA tile-DB manifest pattern (hpa_manifest.py) unit tests."""
+import json
+import logging
+import os
+
+import pytest
+
+from services.navi_offroute import hpa_manifest as hm
+
+
+def _write(dirpath, payload):
+ with open(os.path.join(dirpath, "manifest.json"), "w") as f:
+ json.dump(payload, f)
+
+
+def _touch(dirpath, name):
+ p = os.path.join(dirpath, name)
+ open(p, "w").close()
+ return p
+
+
+def test_chunk_bounds_intersects():
+ cb = hm.ChunkBounds(min_x=-10, max_x=10, min_y=0, max_y=100)
+ assert cb.intersects(0, 0, 50, 50) # point inside
+ assert cb.intersects(-20, -5, 50, 50) # overlapping west edge
+ assert cb.intersects(-10, 10, 0, 100) # exact match (inclusive)
+ assert not cb.intersects(11, 20, 50, 50) # entirely east
+ assert not cb.intersects(0, 5, 101, 200) # entirely north
+
+
+def test_load_from_dir_roundtrip_and_unbounded(tmp_path):
+ a = _touch(str(tmp_path), "a.db"); _touch(str(tmp_path), "world.db")
+ _write(str(tmp_path), {"version": 1, "tile_dbs": [
+ {"name": "a", "path": "a.db",
+ "chunk_bounds": {"min_x": -8550, "max_x": -8430, "min_y": 3100, "max_y": 3260}},
+ {"name": "world", "path": "world.db", "chunk_bounds": None},
+ ]})
+ m = hm._load_from_dir(str(tmp_path))
+ assert m.enabled() and len(m.entries) == 2
+ assert m.entries[0].abs_path == a
+ assert m.entries[0].chunk_bounds == hm.ChunkBounds(-8550, -8430, 3100, 3260)
+ assert m.entries[1].chunk_bounds is None
+
+
+def test_load_from_dir_fails_fast(tmp_path):
+ # missing manifest -> FileNotFoundError
+ with pytest.raises(FileNotFoundError, match="manifest not found"):
+ hm._load_from_dir(str(tmp_path))
+ # bad version -> ValueError
+ _write(str(tmp_path), {"version": 99, "tile_dbs": []})
+ with pytest.raises(ValueError, match="unsupported version"):
+ hm._load_from_dir(str(tmp_path))
+ # entry pointing at missing file -> FileNotFoundError
+ _write(str(tmp_path), {"version": 1, "tile_dbs": [
+ {"name": "ghost", "path": "ghost.db", "chunk_bounds": None}]})
+ with pytest.raises(FileNotFoundError, match="ghost.db"):
+ hm._load_from_dir(str(tmp_path))
+
+
+def test_dbs_for_chunks_lookup(tmp_path):
+ a = _touch(str(tmp_path), "a.db"); b = _touch(str(tmp_path), "b.db")
+ w = _touch(str(tmp_path), "world.db")
+ _write(str(tmp_path), {"version": 1, "tile_dbs": [
+ {"name": "a", "path": "a.db",
+ "chunk_bounds": {"min_x": 0, "max_x": 10, "min_y": 0, "max_y": 10}},
+ {"name": "b", "path": "b.db",
+ "chunk_bounds": {"min_x": 20, "max_x": 30, "min_y": 0, "max_y": 10}},
+ {"name": "world", "path": "world.db", "chunk_bounds": None},
+ ]})
+ m = hm._load_from_dir(str(tmp_path))
+ assert m.dbs_for_chunks(5, 5, 5, 5) == [a, w] # hits a + unbounded
+ assert m.dbs_for_chunks(25, 25, 5, 5) == [b, w] # hits b + unbounded
+ assert m.dbs_for_chunks(5, 25, 5, 5) == [a, b, w] # straddles seam
+ assert m.dbs_for_chunks(100, 200, 0, 0) == [w] # only unbounded
+
+
+def test_load_env_dir_wins_over_legacy(tmp_path, monkeypatch, caplog):
+ _touch(str(tmp_path), "world.db")
+ _write(str(tmp_path), {"version": 1, "tile_dbs": [
+ {"name": "w", "path": "world.db", "chunk_bounds": None}]})
+ legacy = _touch(str(tmp_path), "legacy.db")
+ monkeypatch.setenv(hm.ENV_DIR, str(tmp_path))
+ monkeypatch.setenv(hm.ENV_LEGACY, legacy)
+ with caplog.at_level(logging.WARNING, logger="services.navi_offroute.hpa_manifest"):
+ m = hm.load()
+ assert [e.name for e in m.entries] == ["w"]
+ assert "takes precedence" in caplog.text
+
+
+def test_load_env_legacy_synthesizes_unbounded(tmp_path, monkeypatch, caplog):
+ legacy = _touch(str(tmp_path), "legacy.db")
+ monkeypatch.delenv(hm.ENV_DIR, raising=False)
+ monkeypatch.setenv(hm.ENV_LEGACY, legacy)
+ with caplog.at_level(logging.WARNING, logger="services.navi_offroute.hpa_manifest"):
+ m = hm.load()
+ assert len(m.entries) == 1 and m.entries[0].chunk_bounds is None
+ assert m.entries[0].abs_path == legacy
+ assert "deprecated" in caplog.text
+
+
+def test_load_env_disabled_paths(tmp_path, monkeypatch):
+ # legacy points at missing file -> empty
+ monkeypatch.delenv(hm.ENV_DIR, raising=False)
+ monkeypatch.setenv(hm.ENV_LEGACY, str(tmp_path / "nope.db"))
+ assert not hm.load().enabled()
+ # neither var set -> empty
+ monkeypatch.delenv(hm.ENV_LEGACY, raising=False)
+ assert not hm.load().enabled()
+
+
+def test_get_connection_caches_per_path(tmp_path):
+ import sqlite3
+ p = _touch(str(tmp_path), "x.db")
+ with sqlite3.connect(p) as c:
+ c.execute("CREATE TABLE t (x INTEGER)")
+ m = hm.HPAManifest(entries=[hm.TileDBEntry("x", p, None)])
+ assert m.get_connection(p) is m.get_connection(p)
diff --git a/backend/services/navi_offroute/tests/test_hpa_runtime.py b/backend/services/navi_offroute/tests/test_hpa_runtime.py
index b081990..2ee5922 100644
--- a/backend/services/navi_offroute/tests/test_hpa_runtime.py
+++ b/backend/services/navi_offroute/tests/test_hpa_runtime.py
@@ -9,6 +9,7 @@ import pytest
from services.navi_offroute import astar, hpa_build as hb
import services.navi_offroute.router as router_mod
+from services.navi_offroute import hpa_manifest
import services.navi_offroute.transitions as p_trans
from services.navi_offroute.router import OffrouteRouter
from services.navi_offroute.transitions import _latlon_to_pixel as _ll2px, _pixel_to_latlon as _px2ll
@@ -130,7 +131,8 @@ def _stub_router(monkeypatch):
def test_hpa_dispatcher_uses_hpa_when_tile_db_set(tmp_path, monkeypatch, caplog):
db = str(tmp_path / "tiles.db")
_make_tile_db(db, _base_rows()) # a real file so os.path.exists passes
- monkeypatch.setattr(router_mod, "HPA_TILE_DB", db)
+ monkeypatch.setattr(router_mod, "_HPA_MANIFEST", hpa_manifest.HPAManifest(
+ entries=[hpa_manifest.TileDBEntry(name="test", abs_path=db, chunk_bounds=None)]))
calls = []