Spaces:
Running on Zero
Running on Zero
| """POI scoring + submodular set reward. | |
| Scoring is a transparent weighted sum over precomputed POI features (category | |
| affinity, greenness, quietness), modulated by confidence and adventurousness — | |
| fully debuggable, no black box (spec §9.1, §9.2). The set reward is *submodular*: | |
| within a category each additional similar POI is worth less, which is the | |
| structural mechanism that produces diversity (spec §9.2). | |
| Weights are produced manually for Bricks 2-3 (sliders) and by the vibe | |
| interpreter for Brick 4 — same structure either way. | |
| """ | |
| from __future__ import annotations | |
| from collections import defaultdict | |
| from dataclasses import dataclass, field | |
| from discoverroute.data import taxonomy | |
| # Each additional POI in the same category is worth this factor more times less: | |
| # ranks contribute score * decay**rank (rank 0 = full). 0.5 => 1, 0.5, 0.25, ... | |
| DIVERSITY_DECAY = 0.5 | |
| # Anonymous POIs (no OSM name) are demoted by this factor — applied BEFORE the | |
| # adventurousness serendipity boost, so high adventurousness surfaces *named* | |
| # hidden gems instead of flooding the route with un-findable "a piece of public | |
| # art" entries. (Parks/fountains/art are 40-83% unnamed in OSM.) | |
| UNNAMED_SCORE_FACTOR = 0.45 | |
| class Weights: | |
| """Interpretable scoring weights. category_affinity maps category -> [0,1]. | |
| Greenness/quietness are not separate weight terms: they enter affinity | |
| directly — folded in by ``manual_weights`` for the sliders, and carried by the | |
| category glosses for vibe/profile embeddings — so scoring stays a single | |
| transparent term (affinity) with no always-zero dead weights. | |
| """ | |
| category_affinity: dict[str, float] = field(default_factory=dict) | |
| w_category: float = 1.0 | |
| def uniform(cls, **kw) -> "Weights": | |
| """Equal affinity for every category (a neutral baseline).""" | |
| return cls(category_affinity={c: 1.0 for c in taxonomy.CATEGORIES}, **kw) | |
| def manual_weights(prefer_green: float = 0.0, prefer_quiet: float = 0.0, | |
| base: float = 0.15) -> Weights: | |
| """Brick 2-3 manual sliders → per-category affinity. | |
| The green/quiet sliders are folded directly into each category's affinity via | |
| its feature priors (plus a small ``base`` so nothing is ever fully excluded), | |
| so raising "prefer green" actually pulls parks/viewpoints into the route | |
| rather than being a negligible tilt on a uniform interest. This mirrors how | |
| Brick 4 will emit category affinity from a free-text vibe (same structure). | |
| """ | |
| affinity = { | |
| c: base | |
| + prefer_green * taxonomy.greenness(c) | |
| + prefer_quiet * taxonomy.quietness(c) | |
| for c in taxonomy.CATEGORIES | |
| } | |
| return Weights(category_affinity=affinity, w_category=1.0) | |
| def base_score(poi, weights: Weights, adventurousness: float) -> float: | |
| """Score one POI: weighted feature sum modulated by confidence & adventurousness. | |
| Two effects of adventurousness (spec §9.4, P1-3): | |
| 1. the confidence penalty fades: raw * confidence**(1 - adv) | |
| (adv=0 → low-confidence places heavily penalised; adv=1 → no penalty); | |
| 2. a serendipity *injection* actively boosts under-documented places: | |
| × (1 + adv * (1 - confidence)) | |
| So low adventurousness sticks to well-known, well-documented spots, while | |
| high adventurousness deliberately surfaces sparse/hidden-gem POIs. | |
| """ | |
| affinity = weights.category_affinity.get(poi.category, 0.0) | |
| raw = weights.w_category * affinity | |
| if raw <= 0: | |
| return 0.0 | |
| adv = min(1.0, max(0.0, adventurousness)) | |
| confidence_factor = poi.confidence ** (1.0 - adv) | |
| serendipity = 1.0 + adv * (1.0 - poi.confidence) | |
| # Name-aware demotion (adv-independent): only penalise POIs that explicitly | |
| # carry an empty/None name. POIs with no ``name`` attribute at all (synthetic | |
| # test objects) are treated as named, so this never perturbs unit tests. | |
| name_factor = 1.0 | |
| if hasattr(poi, "name"): | |
| _name = getattr(poi, "name") | |
| # Empty OR a bare ref code ("PA_1570") → unfindable → demote like unnamed. | |
| if not taxonomy.is_real_name(_name): | |
| name_factor = UNNAMED_SCORE_FACTOR | |
| return raw * name_factor * confidence_factor * serendipity | |
| def score_pois(pois: list, weights: Weights, adventurousness: float) -> list: | |
| """Assign ``.score`` to each POI in place and return the list.""" | |
| for p in pois: | |
| p.score = base_score(p, weights, adventurousness) | |
| return pois | |
| def set_reward(pois: list, decay: float = DIVERSITY_DECAY) -> float: | |
| """Submodular reward of a *set* of scored POIs (diminishing within category).""" | |
| by_cat: dict[str, list[float]] = defaultdict(list) | |
| for p in pois: | |
| by_cat[p.category].append(p.score) | |
| total = 0.0 | |
| for scores in by_cat.values(): | |
| for rank, s in enumerate(sorted(scores, reverse=True)): | |
| total += s * (decay ** rank) | |
| return total | |
| def _cat_reward(scores: list[float], decay: float) -> float: | |
| return sum(s * (decay ** rank) for rank, s in enumerate(sorted(scores, reverse=True))) | |
| def marginal_gain(current: list, candidate, decay: float = DIVERSITY_DECAY) -> float: | |
| """Exact submodular delta of adding ``candidate`` to ``current``. | |
| Only the candidate's category re-ranks, so we recompute that category's | |
| contribution before/after — this correctly accounts for lower-scoring | |
| same-category POIs being demoted (which the naive rank formula misses). | |
| """ | |
| same = [p.score for p in current if p.category == candidate.category] | |
| return _cat_reward(same + [candidate.score], decay) - _cat_reward(same, decay) | |