WanderLust / tests /test_orienteering.py
Tristan Leduc
Wanderlust: taste-aware Paris discovery routing
f00d669
Raw
History Blame Contribute Delete
6.79 kB
"""Brick 2 tests: scoring, submodular reward, and the orienteering solver.
Uses a planar Euclidean ``time_fn`` (treating coords as a flat plane) so optima
are hand-computable and deterministic — no graph required.
"""
from __future__ import annotations
import math
from discoverroute.routing import orienteering as ot
from discoverroute.routing import scoring
class FakePOI:
"""Minimal POI with identity equality (so `p in selected` works)."""
def __init__(self, lat, lon, category, score):
self.lat, self.lon, self.category, self.score = lat, lon, category, score
def planar_time(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
START, END = (0.0, 0.0), (10.0, 0.0) # direct distance = 10
# --- scoring / reward --------------------------------------------------------
def test_submodular_reward_diminishes():
a = FakePOI(0, 0, "cafe", 1.0)
b = FakePOI(0, 0, "cafe", 1.0)
c = FakePOI(0, 0, "park", 1.0)
# two cafes: 1 + 0.5 = 1.5 ; cafe + park: 1 + 1 = 2.0 (diversity wins)
assert scoring.set_reward([a, b]) == 1.5
assert scoring.set_reward([a, c]) == 2.0
def test_marginal_gain_accounts_for_demotion():
low = FakePOI(0, 0, "cafe", 1.0)
high = FakePOI(0, 0, "cafe", 10.0)
# adding the high-scoring cafe demotes the low one (1 -> 0.5): delta = 9.5
assert scoring.marginal_gain([low], high) == 9.5
# --- solver ------------------------------------------------------------------
def test_budget_zero_gives_no_detour():
pois = [FakePOI(5, 1.0, "x", 5.0), FakePOI(5, 2.0, "y", 5.0)]
res = ot.solve(START, END, pois, budget_s=10.0, time_fn=planar_time)
assert res.ordered_pois == [] # any off-line POI would exceed the direct time
def test_known_optimal_selection():
# A,B sit on the direct line (free). C is a high-value off-line detour.
# Hand-computed optimum within budget 15 is {C, one-of-A/B} with reward 13:
# {A,B}=6 (cost 0) ; {C}=10 (cost 4.14) ; {A,C}=13 (cost ~4.9, feasible) ;
# {A,B,C}=16 needs cost ~5.66 -> infeasible at 15.
# A pure ratio-greedy grabs the free A,B and gets stuck at reward 6; the
# better-of-two solver must find the reward-13 optimum.
A = FakePOI(2.0, 0.0, "a", 3.0)
B = FakePOI(8.0, 0.0, "b", 3.0)
C = FakePOI(5.0, 5.0, "c", 10.0)
res = ot.solve(START, END, [A, B, C], budget_s=15.0, time_fn=planar_time)
chosen = set(res.ordered_pois)
assert C in chosen and len(chosen) == 2 # C plus exactly one of A/B
assert abs(res.reward - 13.0) < 1e-9 # the known optimum
assert res.approx_time_s <= 15.0 + 1e-9 # budget respected
def test_diversity_preferred_over_repetition():
cafes = [FakePOI(5, 0.2, "cafe", 1.0) for _ in range(5)]
park = FakePOI(3, 0.2, "park", 0.95)
view = FakePOI(7, 0.2, "view", 0.95)
res = ot.solve(START, END, cafes + [park, view],
budget_s=100.0, time_fn=planar_time, max_pois=3)
cats = [p.category for p in res.ordered_pois]
assert len(res.ordered_pois) == 3
assert "park" in cats and "view" in cats # diversity beat 3 cafes
assert cats.count("cafe") <= 1
def test_budget_is_never_exceeded():
pois = [FakePOI(5, d, f"c{d}", 3.0) for d in (0.5, 1.0, 1.5, 2.0, 2.5)]
res = ot.solve(START, END, pois, budget_s=12.0, time_fn=planar_time)
assert res.approx_time_s <= 12.0 + 1e-9
# --- Brick 7: adventurousness serendipity (P1-3) ---
def test_adventurousness_injects_low_confidence():
from discoverroute.routing import scoring
class P:
def __init__(self, conf):
self.category, self.greenness, self.quietness, self.confidence = \
"cafe", 0.0, 0.0, conf
w = scoring.Weights(category_affinity={"cafe": 1.0}, w_category=1.0)
low, high = P(0.1), P(1.0)
# conservative: well-documented place scores higher than the sparse one
assert scoring.base_score(low, w, 0.0) < scoring.base_score(high, w, 0.0)
# adventurous: the under-documented place is boosted above the safe one
assert scoring.base_score(low, w, 1.0) > scoring.base_score(high, w, 1.0)
# --- P1-2: Dual budget tests ---
def test_backward_compat_no_dual_budget():
"""Test that old API (no dual budget params) still works."""
pois = [FakePOI(5, 1.0, "cafe", 5.0), FakePOI(5, 2.0, "park", 5.0)]
res = ot.solve(START, END, pois, budget_s=10.0, time_fn=planar_time)
assert res.ordered_pois == [] # direct time is 10, no room for detours
assert hasattr(res, 'dwell_time_s')
assert hasattr(res, 'detour_distance_m')
def test_dual_budget_respects_dwell_constraint():
"""Test that dwell budget is enforced separately from travel budget."""
# Create high-value café (stop, expensive dwell) and cheap park (pass)
cafe = FakePOI(5.0, 1.0, "cafe", 10.0)
park = FakePOI(5.0, 2.0, "park_garden", 3.0)
def posture_fn(poi):
# Café: 600 sec dwell; park: 0 sec (pass-by)
return 600.0 if poi.category == "cafe" else 0.0
# Travel budget: 30 sec (enough for a detour)
# Dwell budget: 5 sec (NOT enough for café's 600 sec)
# → café should be rejected despite high value
res = ot.solve(
START, END, [cafe, park], budget_s=30.0, time_fn=planar_time,
dwell_budget_s=5.0, posture_fn=posture_fn
)
# Café should NOT be selected (exceeds dwell budget)
cafe_selected = any(p.category == "cafe" for p in res.ordered_pois)
assert not cafe_selected, "Café should not be selected (exceeds dwell budget)"
def test_pass_by_unaffected_by_dwell_budget():
"""Test that pass-by POIs bypass the dwell budget constraint."""
park1 = FakePOI(3.0, 0.5, "park_garden", 5.0)
park2 = FakePOI(7.0, 0.5, "park_garden", 5.0)
def posture_fn(poi):
# All parks are pass-by (0 dwell)
return 0.0
# Zero dwell budget should not prevent parks from being selected
res = ot.solve(
START, END, [park1, park2], budget_s=15.0, time_fn=planar_time,
dwell_budget_s=0.0, posture_fn=posture_fn
)
# Should select parks since they don't consume dwell budget
assert len(res.ordered_pois) > 0
def test_dwell_tracking():
"""Test that dwell_time_s and detour_distance_m are returned."""
cafe = FakePOI(5.0, 1.0, "cafe", 10.0)
def posture_fn(poi):
return 600.0 if poi.category == "cafe" else 0.0
# Generous budgets to allow selection
res = ot.solve(
START, END, [cafe], budget_s=20.0, time_fn=planar_time,
dwell_budget_s=700.0, posture_fn=posture_fn
)
assert hasattr(res, 'dwell_time_s')
assert hasattr(res, 'detour_distance_m')
# If café was selected, dwell should reflect its cost
if any(p.category == "cafe" for p in res.ordered_pois):
assert res.dwell_time_s > 0