From 91711f5f3f4652280060ac799a6adeca14fff460 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 27 May 2026 05:34:52 +0000 Subject: [PATCH] Auto: foot-as-last-resort fallback when picked mode fails to route If the capability-picked mode cannot route, retry foot ONCE (foot always routes modulo bbox limits) instead of surfacing a wall to the user. On success, ship the foot route tagged with auto_fallback_from= for the UI; on foot failure, return the original error. No fallback when the picked mode is already foot. selected_mode_set still reflects the original capability intersection. Tests: no_fallthrough -> falls_back_to_foot_on_error; + both-fail returns error; + no-fallback-when-picked-is-foot. Full offroute suite: 86 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/services/navi_offroute/router.py | 23 +++++++++++ .../navi_offroute/tests/test_offroute.py | 39 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 813f539..68543e8 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -882,6 +882,29 @@ class OffrouteRouter: logger.info("auto: classified mode=%s, routed once in %.2fs", mode, time.perf_counter() - _probe_t0) + # Foot-as-last-resort: foot always routes (modulo bbox limits), so if the + # capability-picked mode failed, fall back to foot ONCE rather than surface a + # wall to the user. selected_mode_set still reflects the original eligibility. + if result.get("status") != "ok" and mode != "foot": + _foot_t0 = time.perf_counter() + foot_result = self.route( + start_lat, start_lon, end_lat, end_lon, + mode="foot", boundary_mode=boundary_mode, annotate_mvum=False + ) + if foot_result.get("status") == "ok": + best_result = foot_result + best_result["selected_mode"] = "foot" + best_result["auto_fallback_from"] = mode # surface to client/UI + best_minutes = (foot_result.get("summary") or {}).get( + "total_effort_minutes", float("inf")) + last_error = None + logger.info("auto: %s failed, foot fallback succeeded in %.2fs", + mode, time.perf_counter() - _foot_t0) + else: + # foot also failed -- keep the original last_error (return original error) + logger.info("auto: %s failed, foot fallback also failed in %.2fs", + mode, time.perf_counter() - _foot_t0) + if best_result is not None: # MVUM Layer 3a: a "drive to a trailhead, switch, continue offroad" plan may # beat the single-mode winner on long trips. If so, return it instead. diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index 84751b9..a37d106 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -326,9 +326,26 @@ def test_route_auto_picks_capability_mode(monkeypatch): assert calls == ["vehicle"] # ONE route call, not four -def test_route_auto_no_fallthrough_on_route_error(monkeypatch): - # Route-once: if the capability-picked mode (vehicle) cannot route, the error is - # returned -- classify-once does NOT fall through to other modes (PR1 trade-off). +def test_route_auto_falls_back_to_foot_on_error(monkeypatch): + # Foot-as-last-resort: the capability-picked mode (vehicle) fails, so Auto retries + # foot ONCE and ships it, tagging auto_fallback_from for the UI. + _typed_all(monkeypatch) + calls = [] + per_mode = {m: {"status": "error", "message": f"{m} failed"} for m in AUTO_MODE_PRIORITY} + per_mode["foot"] = {"status": "ok"} + monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) + r = object.__new__(OffrouteRouter) + out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") + assert out["status"] == "ok" + assert out["selected_mode"] == "foot" + assert out["auto_fallback_from"] == "vehicle" + assert out["selected_mode_set"] == sorted(ALL_MODES) # original eligibility, not foot-only + assert calls == ["vehicle", "foot"] + + +def test_route_auto_returns_error_when_picked_and_foot_both_fail(monkeypatch): + # Picked mode AND the foot fallback both fail -> original error surfaces, exactly + # two attempts (picked, then foot). _typed_all(monkeypatch) calls = [] per_mode = {m: {"status": "error", "message": f"{m} failed"} for m in AUTO_MODE_PRIORITY} @@ -338,7 +355,21 @@ def test_route_auto_no_fallthrough_on_route_error(monkeypatch): assert out["status"] == "error" assert "selected_mode" not in out assert out["selected_mode_set"] == sorted(ALL_MODES) - assert calls == ["vehicle"] # only the picked mode is attempted + assert calls == ["vehicle", "foot"] + + +def test_route_auto_no_fallback_when_picked_is_foot(monkeypatch): + # When the picked mode is already foot (foot-only intersection), there is no second + # attempt -- foot cannot fall back to itself. + calls = [] + per_mode = {"foot": {"status": "error", "message": "foot failed"}} + monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) + r = object.__new__(OffrouteRouter) + out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic", + start_category="building:house", end_category="natural:peak") # -> {foot} + assert out["status"] == "error" + assert out["selected_mode_set"] == ["foot"] + assert calls == ["foot"] # no fallback attempt def test_route_auto_tagged_road_to_road_no_spatial_probe(monkeypatch):