Spaces:
Running on Zero
Round-4 fixes: drop overclaiming match-tag, dedup stops, hidden excludes famous
Browse filesAdversarial round 4 (NOT-CONVERGED) β root-caused to 3 issues:
1. The per-stop "a match for your vibe" tag overclaimed: any route that
backfills a lower-ranked category (bakery on wine, church on jazz, statue
on street-food/photography) falsely labelled those stops a match. Removed
the tag entirely β each stop's reason text + the interpretation panel carry
the honest framing; the model can't guarantee per-stop relevance.
2. No within-route dedup β the same place ("UP /SIDE / DOWN / TOWN") appeared
twice in one route. Dedup the shortlist by osm_id AND name.
3. "hidden gems" still surfaced Notre Dame β the attraction-zeroing missed it
(it's viewpoint). Discovery cues now drop famous, well-documented POIs by
tag-richness (confidence >= FAMOUS_CONFIDENCE 0.85), category-independent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@@ -111,6 +111,10 @@ TOP_AFFINITY_CATEGORIES = 6
|
|
| 111 |
# architecture" 0.51, nonsense ~0.49). We still route, but the narration says so
|
| 112 |
# honestly instead of claiming "a match for your vibe".
|
| 113 |
WEAK_MATCH_SIMILARITY = 0.55
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
# Below this cosine-similarity span across categories, a vibe is treated as
|
| 115 |
# off-domain/neutral rather than amplified into false preferences. Measured
|
| 116 |
# (bge-small, 16-vibe battery): gibberish "asdfqwer" spans 0.081; the LOWEST
|
|
|
|
| 111 |
# architecture" 0.51, nonsense ~0.49). We still route, but the narration says so
|
| 112 |
# honestly instead of claiming "a match for your vibe".
|
| 113 |
WEAK_MATCH_SIMILARITY = 0.55
|
| 114 |
+
# For "hidden gems"-style vibes, exclude well-documented (famous) POIs: a place
|
| 115 |
+
# this richly tagged isn't off the beaten path. Confidence is the tag-richness
|
| 116 |
+
# proxy; Notre Dame etc. sit at ~1.0. (Only applied when a discovery cue fires.)
|
| 117 |
+
FAMOUS_CONFIDENCE = 0.85
|
| 118 |
# Below this cosine-similarity span across categories, a vibe is treated as
|
| 119 |
# off-domain/neutral rather than amplified into false preferences. Measured
|
| 120 |
# (bge-small, 16-vibe battery): gibberish "asdfqwer" spans 0.081; the LOWEST
|
|
@@ -48,6 +48,7 @@ class Interpretation:
|
|
| 48 |
confidence: float = 1.0 # best raw cosine to a category gloss
|
| 49 |
weak: bool = False # True => out-of-vocabulary / weak match
|
| 50 |
adventurousness: float = config.DEFAULT_ADVENTUROUSNESS # possibly cue-boosted
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def _contains(text: str, cues) -> bool:
|
|
@@ -63,7 +64,8 @@ def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS
|
|
| 63 |
# Discovery intent: drop "famous attraction" (opposite of "hidden") and lift
|
| 64 |
# adventurousness so the route favours under-documented places. Copy first β
|
| 65 |
# resolve_affinity is cached and returns a shared dict.
|
| 66 |
-
|
|
|
|
| 67 |
affinity = {**affinity, "attraction": 0.0}
|
| 68 |
adventurousness = max(adventurousness, 0.8)
|
| 69 |
weights = Weights(category_affinity=affinity, w_category=1.0)
|
|
@@ -95,7 +97,7 @@ def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS
|
|
| 95 |
explanation = _explain(vibe, top, affinity, posture, budget_hint, weak)
|
| 96 |
return Interpretation(affinity, weights, posture, budget_hint, explanation, top,
|
| 97 |
confidence=confidence, weak=weak,
|
| 98 |
-
adventurousness=adventurousness)
|
| 99 |
|
| 100 |
|
| 101 |
def _explain(vibe, top, affinity, posture, budget_hint, weak=False) -> str:
|
|
|
|
| 48 |
confidence: float = 1.0 # best raw cosine to a category gloss
|
| 49 |
weak: bool = False # True => out-of-vocabulary / weak match
|
| 50 |
adventurousness: float = config.DEFAULT_ADVENTUROUSNESS # possibly cue-boosted
|
| 51 |
+
exclude_famous: bool = False # discovery cue => drop well-documented sights
|
| 52 |
|
| 53 |
|
| 54 |
def _contains(text: str, cues) -> bool:
|
|
|
|
| 64 |
# Discovery intent: drop "famous attraction" (opposite of "hidden") and lift
|
| 65 |
# adventurousness so the route favours under-documented places. Copy first β
|
| 66 |
# resolve_affinity is cached and returns a shared dict.
|
| 67 |
+
hidden = _contains(text, _HIDDEN_CUES)
|
| 68 |
+
if hidden:
|
| 69 |
affinity = {**affinity, "attraction": 0.0}
|
| 70 |
adventurousness = max(adventurousness, 0.8)
|
| 71 |
weights = Weights(category_affinity=affinity, w_category=1.0)
|
|
|
|
| 97 |
explanation = _explain(vibe, top, affinity, posture, budget_hint, weak)
|
| 98 |
return Interpretation(affinity, weights, posture, budget_hint, explanation, top,
|
| 99 |
confidence=confidence, weak=weak,
|
| 100 |
+
adventurousness=adventurousness, exclude_famous=hidden)
|
| 101 |
|
| 102 |
|
| 103 |
def _explain(vibe, top, affinity, posture, budget_hint, weak=False) -> str:
|
|
@@ -79,13 +79,12 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
|
| 79 |
f"**{n} {place_word}** between {start_label or 'the start'} and "
|
| 80 |
f"{end_label or 'the destination'}:\n"
|
| 81 |
)
|
| 82 |
-
#
|
| 83 |
-
#
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
lines = [lead]
|
| 90 |
prev_cat = None
|
| 91 |
for i, p in enumerate(pois, 1):
|
|
@@ -96,7 +95,7 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
|
| 96 |
reason = _REASON.get(p.category, "a spot worth a look")
|
| 97 |
prev_cat = p.category
|
| 98 |
verb = _verb(posture.get(p.category, "pass"))
|
| 99 |
-
tie = "
|
| 100 |
badge = _hours_badge(p, posture.get(p.category, "pass"))
|
| 101 |
lines.append(f"{i}. **{label}** β {verb.lower()} for {reason}{tie}.{badge}")
|
| 102 |
lines.append(
|
|
|
|
| 79 |
f"**{n} {place_word}** between {start_label or 'the start'} and "
|
| 80 |
f"{end_label or 'the destination'}:\n"
|
| 81 |
)
|
| 82 |
+
# NOTE: we deliberately do NOT tag stops "a match for your vibe". Adversarial
|
| 83 |
+
# review showed that claim overreaches whenever a route backfills with a
|
| 84 |
+
# lower-ranked category (a bakery on a wine vibe, a church on a jazz vibe) β
|
| 85 |
+
# the affinity model can't guarantee per-stop relevance, so the blanket claim
|
| 86 |
+
# reads as a lie. Each stop's own reason text conveys its appeal honestly; the
|
| 87 |
+
# interpretation panel already shows how the vibe was read.
|
|
|
|
| 88 |
lines = [lead]
|
| 89 |
prev_cat = None
|
| 90 |
for i, p in enumerate(pois, 1):
|
|
|
|
| 95 |
reason = _REASON.get(p.category, "a spot worth a look")
|
| 96 |
prev_cat = p.category
|
| 97 |
verb = _verb(posture.get(p.category, "pass"))
|
| 98 |
+
tie = ""
|
| 99 |
badge = _hours_badge(p, posture.get(p.category, "pass"))
|
| 100 |
lines.append(f"{i}. **{label}** β {verb.lower()} for {reason}{tie}.{badge}")
|
| 101 |
lines.append(
|
|
@@ -115,6 +115,7 @@ def _plan_route_impl(
|
|
| 115 |
from discoverroute.data import taxonomy
|
| 116 |
interp_md = ""
|
| 117 |
weak_match = False
|
|
|
|
| 118 |
top_requested = None # the #1 category the vibe asked for (for sparse feedback)
|
| 119 |
posture = {c: taxonomy.posture(c) for c in taxonomy.CATEGORIES}
|
| 120 |
has_vibe = bool((vibe or "").strip())
|
|
@@ -131,6 +132,7 @@ def _plan_route_impl(
|
|
| 131 |
interp_md = interp.explanation
|
| 132 |
weak_match = interp.weak
|
| 133 |
adventurousness = interp.adventurousness # may be cue-boosted (e.g. "hidden gems")
|
|
|
|
| 134 |
if not weak_match and interp.top_categories:
|
| 135 |
top_requested = interp.top_categories[0]
|
| 136 |
# Use the interpreter's OWN affinity (it carries discovery-cue
|
|
@@ -168,7 +170,7 @@ def _plan_route_impl(
|
|
| 168 |
try:
|
| 169 |
shortlist, matrix, time_fn = _prepare_discovery(
|
| 170 |
graph, start, end, plain, mode, budget, weights, adventurousness,
|
| 171 |
-
posture=posture)
|
| 172 |
for _ in range(max(1, n_alternatives)):
|
| 173 |
if shortlist is None:
|
| 174 |
break
|
|
@@ -227,7 +229,7 @@ def _plan_route_impl(
|
|
| 227 |
|
| 228 |
|
| 229 |
def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventurousness,
|
| 230 |
-
posture=None):
|
| 231 |
"""Corridor β score β shortlist β real travel matrix. Done ONCE per request.
|
| 232 |
|
| 233 |
The expensive step is the matrix (cutoff-bounded multi-source Dijkstra), so we
|
|
@@ -238,13 +240,34 @@ def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventur
|
|
| 238 |
candidates = poimod.corridor_pois(plain.coords, budget)
|
| 239 |
if not candidates:
|
| 240 |
return None, None, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
scoring.score_pois(candidates, weights, adventurousness)
|
| 242 |
# Open-now awareness: demote places that are closed right now (heavily for
|
| 243 |
# stop-at categories, mildly for pass-by; unknown hours left untouched).
|
| 244 |
from discoverroute.routing import hours
|
| 245 |
hours.apply_open_now(candidates, posture)
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
if not shortlist:
|
| 249 |
return None, None, None
|
| 250 |
|
|
|
|
| 115 |
from discoverroute.data import taxonomy
|
| 116 |
interp_md = ""
|
| 117 |
weak_match = False
|
| 118 |
+
exclude_famous = False # discovery-cue vibes drop well-documented famous sights
|
| 119 |
top_requested = None # the #1 category the vibe asked for (for sparse feedback)
|
| 120 |
posture = {c: taxonomy.posture(c) for c in taxonomy.CATEGORIES}
|
| 121 |
has_vibe = bool((vibe or "").strip())
|
|
|
|
| 132 |
interp_md = interp.explanation
|
| 133 |
weak_match = interp.weak
|
| 134 |
adventurousness = interp.adventurousness # may be cue-boosted (e.g. "hidden gems")
|
| 135 |
+
exclude_famous = interp.exclude_famous
|
| 136 |
if not weak_match and interp.top_categories:
|
| 137 |
top_requested = interp.top_categories[0]
|
| 138 |
# Use the interpreter's OWN affinity (it carries discovery-cue
|
|
|
|
| 170 |
try:
|
| 171 |
shortlist, matrix, time_fn = _prepare_discovery(
|
| 172 |
graph, start, end, plain, mode, budget, weights, adventurousness,
|
| 173 |
+
posture=posture, exclude_famous=exclude_famous)
|
| 174 |
for _ in range(max(1, n_alternatives)):
|
| 175 |
if shortlist is None:
|
| 176 |
break
|
|
|
|
| 229 |
|
| 230 |
|
| 231 |
def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventurousness,
|
| 232 |
+
posture=None, exclude_famous=False):
|
| 233 |
"""Corridor β score β shortlist β real travel matrix. Done ONCE per request.
|
| 234 |
|
| 235 |
The expensive step is the matrix (cutoff-bounded multi-source Dijkstra), so we
|
|
|
|
| 240 |
candidates = poimod.corridor_pois(plain.coords, budget)
|
| 241 |
if not candidates:
|
| 242 |
return None, None, None
|
| 243 |
+
# Discovery vibes ("hidden gems"): drop famous, well-documented sights so the
|
| 244 |
+
# route stays genuinely off the beaten path (Notre Dame etc. enter via several
|
| 245 |
+
# categories, so filter by tag-richness, not category).
|
| 246 |
+
if exclude_famous:
|
| 247 |
+
kept = [p for p in candidates if p.confidence < config.FAMOUS_CONFIDENCE]
|
| 248 |
+
if kept:
|
| 249 |
+
candidates = kept
|
| 250 |
scoring.score_pois(candidates, weights, adventurousness)
|
| 251 |
# Open-now awareness: demote places that are closed right now (heavily for
|
| 252 |
# stop-at categories, mildly for pass-by; unknown hours left untouched).
|
| 253 |
from discoverroute.routing import hours
|
| 254 |
hours.apply_open_now(candidates, posture)
|
| 255 |
+
ranked = sorted((p for p in candidates if p.score > 0),
|
| 256 |
+
key=lambda p: p.score, reverse=True)
|
| 257 |
+
# Dedup within a route: the same OSM place can appear as multiple rows
|
| 258 |
+
# (multipolygon centroids) or two distinct ids can share a name β either way
|
| 259 |
+
# a route must never tell you to visit the same spot twice. Keep first (best).
|
| 260 |
+
seen_id, seen_name, shortlist = set(), set(), []
|
| 261 |
+
for p in ranked:
|
| 262 |
+
nkey = (p.name or "").strip().lower()
|
| 263 |
+
if p.osm_id in seen_id or (nkey and nkey in seen_name):
|
| 264 |
+
continue
|
| 265 |
+
seen_id.add(p.osm_id)
|
| 266 |
+
if nkey:
|
| 267 |
+
seen_name.add(nkey)
|
| 268 |
+
shortlist.append(p)
|
| 269 |
+
if len(shortlist) >= config.SOLVER_CANDIDATES:
|
| 270 |
+
break
|
| 271 |
if not shortlist:
|
| 272 |
return None, None, None
|
| 273 |
|