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=<picked mode> 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) <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-27 05:34:52 +00:00
commit 91711f5f3f
2 changed files with 58 additions and 4 deletions

View file

@ -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.

View file

@ -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):