navi-offroute: tighten hybrid gates + fix hybrid render

PROBLEM 1 (latency): short in-town Auto routes (Twin Falls->Filer ~7 mi) took 60+
seconds because urban OSM-parking density exploded hybrid candidate evaluation.
Fixes in router.py:
- MIN_HYBRID_DISTANCE_KM 8.0 -> 24.0 (~15 mi): in-town trips never enter hybrid eval
  at all -- this alone eliminates the reported latency on Matt's routes.
- HYBRID_MAX_TRAILHEADS 20 -> 8: fewer candidates even on long trips.
- HYBRID_OVERALL_TIMEOUT_S = 6.0: a wall-clock check inside the candidate loop bails
  hybrid eval past 6 s (logger.warning) and keeps the single-mode / best-so-far winner.
- HYBRID_EARLY_ABORT_MIN = 30.0: once a candidate beats the single-mode winner by 30+
  min, stop probing the rest and ship it.
- Per-stage timing logs (logger.info) in _route_auto / _try_hybrid_auto:
  "single-mode probing took Xs", "hybrid candidate gathering: N candidates in Xs",
  "hybrid probing took Xs across N tested candidates".

PROBLEM 2 (hybrid render): investigated the missing network polyline. The stated
hypothesis (a hybrid drive leg using a non-"network" segment_type) is DISPROVEN --
_build_hybrid_response emits segment_type=="network" for BOTH the drive and offroad
legs (verified against the live response), the OFFROUTE_NETWORK_LAYER filter matches
it, MODE_COLORS is fully defined, and the store passes data.route correctly. The one
real fragility is the MapLibre color match: if network_mode is ever null the whole
layer can fail to paint (wilderness still draws via its static color -- matching the
exact symptom). Hardened it with ["to-string", ["get","network_mode"]] so a
missing/unknown mode falls through to the blue fallback and the layer always paints.
I could not reproduce the exact missing-leg render headlessly (all backend shapes +
frontend filters are correct), so a Chrome MCP repro is recommended to confirm #2 is
resolved; if a render issue remains it should be diagnosed in-browser.

Tests: hybrid synthetic-trip distances bumped 20 -> 30 km (past the new 24 km gate);
new test_hybrid_early_abort_stops_probing; surface-change integration savings lowered
into the 15-30 min band so both candidate sources are still probed (not early-aborted).
Full offroute suite: 84 passed. npm run build: clean.

PROBLEM 3 (off-road wilderness timeout) is out of scope -- separate follow-up; the
wilderness pathfinder is untouched here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-26 23:26:04 +00:00
commit 4760c8e4a3
5 changed files with 74 additions and 11 deletions

View file

@ -104,10 +104,12 @@ AUTO_MODE_PRIORITY = ["vehicle", "4w", "2w", "foot"]
# switch vehicles, continue offroad" can beat the single-mode winner; Auto picks that
# hybrid plan when it does. Leg times are summed with NO transition penalty, and the
# minimums below keep the suggestions sensible (no short trips / trivial detours).
MIN_HYBRID_DISTANCE_KM = 8.0 # ~5 mi: shorter single-mode wins stay as-is
MIN_HYBRID_DISTANCE_KM = 24.0 # ~15 mi: in-town trips never enter hybrid eval
HYBRID_MIN_TIME_SAVINGS_MIN = 15.0 # a hybrid must beat the winner by at least this
HYBRID_MIN_OFFROAD_KM = 0.8 # ~0.5 mi: reject trivial offroad detours
HYBRID_MAX_TRAILHEADS = 20 # cap candidates (closest to the route first)
HYBRID_MAX_TRAILHEADS = 8 # cap candidates (closest to the route first)
HYBRID_OVERALL_TIMEOUT_S = 6.0 # bail hybrid eval past this, keep single-mode/best-so-far
HYBRID_EARLY_ABORT_MIN = 30.0 # a candidate beating the winner by this much ends probing
HYBRID_TRAILHEAD_BUFFER_M = 2000 # candidate trailheads within 2 km of the route
# (drive_mode, offroad_mode) transition pairs, tried at each candidate trailhead.
HYBRID_PAIRS = [("vehicle", "4w"), ("vehicle", "2w"), ("vehicle", "foot"), ("4w", "foot")]
@ -863,6 +865,7 @@ class OffrouteRouter:
best_result = None
best_minutes = None
last_error = None
_probe_t0 = time.perf_counter()
for candidate in priority:
result = self.route(
start_lat, start_lon, end_lat, end_lon,
@ -877,6 +880,8 @@ class OffrouteRouter:
best_result["selected_mode"] = candidate
else:
last_error = result
logger.info("single-mode probing took %.2fs (%d candidate modes)",
time.perf_counter() - _probe_t0, len(priority))
if best_result is not None:
# MVUM Layer 3a: a "drive to a trailhead, switch, continue offroad" plan may
@ -931,6 +936,8 @@ class OffrouteRouter:
if len(coords) < 2:
return None
_hybrid_t0 = time.perf_counter()
_gather_t0 = _hybrid_t0
# Gather transition candidates from every available source; each yields the
# same {lat, lon, name, road_class, ...} record shape, so they mix freely and
# share the closest-first sort + cap below.
@ -954,6 +961,8 @@ class OffrouteRouter:
line = LineString([(lon, lat) for (lat, lon) in coords])
candidates.sort(key=lambda th: line.distance(Point(th["lon"], th["lat"])))
candidates = candidates[:HYBRID_MAX_TRAILHEADS]
logger.info("hybrid candidate gathering: %d candidates in %.2fs",
len(candidates), time.perf_counter() - _gather_t0)
# Per trailhead, leg1 depends only on drive_mode and leg2 only on offroad_mode,
# so route each distinct mode once and recombine across pairs.
@ -961,9 +970,19 @@ class OffrouteRouter:
offroad_modes = sorted({o for _, o in HYBRID_PAIRS})
threshold = best_minutes - HYBRID_MIN_TIME_SAVINGS_MIN
early_abort_at = best_minutes - HYBRID_EARLY_ABORT_MIN
winner = None
winner_minutes = None
_probe_t0 = time.perf_counter()
tested = 0
for th in candidates:
if time.perf_counter() - _hybrid_t0 > HYBRID_OVERALL_TIMEOUT_S:
logger.warning(
"hybrid eval exceeded %.1fs after %d/%d candidates; using %s",
HYBRID_OVERALL_TIMEOUT_S, tested, len(candidates),
"best hybrid so far" if winner is not None else "single-mode winner")
break
tested += 1
leg1_by_mode = {}
for dm in drive_modes:
r = self.route(start_lat, start_lon, th["lat"], th["lon"],
@ -990,6 +1009,14 @@ class OffrouteRouter:
if total < threshold and (winner_minutes is None or total < winner_minutes):
winner_minutes = total
winner = (leg1, leg2, dm, om, th)
# Early abort: a candidate that beats the single-mode winner by a wide
# margin is good enough -- stop probing the rest and ship it.
if winner_minutes is not None and winner_minutes <= early_abort_at:
logger.info("hybrid early-abort: candidate beats single-mode by >=%.0f min "
"after %d candidates", HYBRID_EARLY_ABORT_MIN, tested)
break
logger.info("hybrid probing took %.2fs across %d tested candidates",
time.perf_counter() - _probe_t0, tested)
if winner is None:
return None

View file

@ -155,9 +155,11 @@ def test_integration_with_hybrid(monkeypatch):
r.spatial_index = None
r.trailhead_index = _FakeTrailheads([trailhead])
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
# 70-min single-mode vs ~50-min hybrid = ~20 min savings: qualifies (>15) but is
# below the early-abort margin (30), so BOTH candidate sources are probed.
best = _winning_single_mode(distance_km=30.0, minutes=70.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
best, 70.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is not None
assert out["selected_mode"] == "hybrid"
# BOTH candidate types were probed as leg-1 destinations (drive-to-transition).

View file

@ -151,7 +151,7 @@ def test_hybrid_wins_with_big_savings(monkeypatch):
return _ok_leg(4.0, 30.0) # 4w/2w/foot offroad legs (>= 0.8 km)
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
best = _winning_single_mode(distance_km=30.0, minutes=120.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is not None
@ -181,7 +181,7 @@ def test_hybrid_skips_trivial_offroad_detour(monkeypatch):
return _ok_leg(0.3, 5.0) # < 0.8 km offroad
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
best = _winning_single_mode(distance_km=30.0, minutes=120.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is None
@ -191,7 +191,7 @@ def test_no_trailheads_falls_back_to_single_mode(monkeypatch):
r = _bare_router(_FakeTrailheads([])) # no candidates near the line
monkeypatch.setattr(OffrouteRouter, "route",
lambda self, *a, **k: _ok_leg(5.0, 10.0))
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
best = _winning_single_mode(distance_km=30.0, minutes=120.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is None
@ -210,7 +210,38 @@ def test_hybrid_not_taken_when_savings_below_threshold(monkeypatch):
return _ok_leg(4.0, 20.0)
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
best = _winning_single_mode(distance_km=20.0, minutes=50.0)
best = _winning_single_mode(distance_km=30.0, minutes=50.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 50.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is None
def test_hybrid_early_abort_stops_probing(monkeypatch):
# Three trailheads, each yielding a hybrid that beats the single-mode winner by
# ~70 min (>= HYBRID_EARLY_ABORT_MIN). The first qualifying candidate must end
# probing, so not all three are routed as leg-1 destinations.
ths = [
{"lat": 44.0, "lon": -114.20, "name": "TH1", "road_class": "track"},
{"lat": 44.0, "lon": -114.22, "name": "TH2", "road_class": "track"},
{"lat": 44.0, "lon": -114.24, "name": "TH3", "road_class": "track"},
]
r = _bare_router(_FakeTrailheads(ths))
seen = []
def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot",
boundary_mode="pragmatic", annotate_mvum=True, **k):
seen.append((round(e_lat, 4), round(e_lon, 4)))
if mode == "vehicle":
return _ok_leg(12.0, 20.0)
return _ok_leg(4.0, 30.0)
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
best = _winning_single_mode(distance_km=30.0, minutes=120.0) # hybrids save ~70 min
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is not None and out["selected_mode"] == "hybrid"
# early-abort: probing stopped before all three trailheads were evaluated
probed_ths = {(d for d in seen)} # noqa: F841 (readability)
th_dests = {(round(t["lat"], 4), round(t["lon"], 4)) for t in ths}
seen_th_dests = th_dests & set(seen)
assert len(seen_th_dests) < 3 # did not probe every candidate

View file

@ -949,7 +949,7 @@ def test_hybrid_consumes_parking_candidates(monkeypatch):
r.trailhead_index = None # no trailheads -> parking must still be gathered
r.parking_index = _FakeParking([parking])
best = _hybrid_winning_single_mode(distance_km=20.0, minutes=120.0)
best = _hybrid_winning_single_mode(distance_km=30.0, minutes=120.0)
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
assert out is not None

View file

@ -1415,11 +1415,14 @@ function updateRouteDisplay(map, routeGeojson) {
layout: { "line-join": "round", "line-cap": "round" },
paint: {
// Layer 3a: color each network leg by its travel mode (hybrid trips mix modes).
// to-string coerces a missing/null network_mode to "" so the match always hits
// its fallback instead of failing to paint the whole layer (which would drop
// the network polyline while the static-colored wilderness layer still drew).
"line-color": [
"match", ["get", "network_mode"],
"match", ["to-string", ["get", "network_mode"]],
"vehicle", MODE_COLORS.vehicle, "auto", MODE_COLORS.auto,
"4w", MODE_COLORS["4w"], "2w", MODE_COLORS["2w"], "foot", MODE_COLORS.foot,
"#3b82f6", // default (single-mode legacy blue)
"#3b82f6", // default (single-mode legacy blue / unknown mode)
],
"line-width": 5,
"line-opacity": 0.85,