Spaces:
Running on Zero
Running on Zero
| """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 | |