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