Spaces:
Running on Zero
Running on Zero
File size: 6,789 Bytes
f00d669 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | """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
|