Spaces:
Running on Zero
Round-2 route-fit: top-N category cap + weak-match honesty
Browse filesAdversarial round 2 (NOT-CONVERGED) found two systemic issues, both fixed:
- AFFINITY_FLOOR let all 17 categories keep weight, so ranks 5-17 silently
backfilled routes with off-vibe filler (same statues/churches bled into
10+ unrelated routes). Now cap to TOP_AFFINITY_CATEGORIES=6; the tail is
zeroed (sparse routes end honestly short, not confabulated). Applied in
embed + centrally in resolve_affinity (covers llm/keyword tiers too).
- min-max forces a top category to 1.00 even for weak/out-of-vocabulary
vibes, so "brutalist architecture" got churches confidently labelled "a
match for your vibe". Added raw-cosine confidence (WEAK_MATCH_SIMILARITY
=0.55; real vibes 0.66-0.85, brutalist 0.51, nonsense ~0.49); weak vibes
now get honest framing ("no strong match β here's my closest guess") and
drop the per-stop "a match for your vibe" tag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@@ -101,6 +101,16 @@ TRACE_REPO = os.environ.get(
|
|
| 101 |
# Affinity floor: the least-matching category still keeps this much interest so
|
| 102 |
# the route can explore a little; the best-matching category maps to 1.0.
|
| 103 |
AFFINITY_FLOOR = 0.15
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
# Below this cosine-similarity span across categories, a vibe is treated as
|
| 105 |
# off-domain/neutral rather than amplified into false preferences. Measured
|
| 106 |
# (bge-small, 16-vibe battery): gibberish "asdfqwer" spans 0.081; the LOWEST
|
|
|
|
| 101 |
# Affinity floor: the least-matching category still keeps this much interest so
|
| 102 |
# the route can explore a little; the best-matching category maps to 1.0.
|
| 103 |
AFFINITY_FLOOR = 0.15
|
| 104 |
+
# Only the top-N matched categories drive a vibe route; the rest are zeroed so
|
| 105 |
+
# the long tail (ranks N+1..17) can't silently backfill stops with off-vibe
|
| 106 |
+
# filler (the adversarial review found the same statues/churches bleeding into
|
| 107 |
+
# 10+ unrelated routes via the floor). Sparse routes then end honestly short.
|
| 108 |
+
TOP_AFFINITY_CATEGORIES = 6
|
| 109 |
+
# A vibe whose BEST raw cosine to any category gloss is below this is a weak/
|
| 110 |
+
# out-of-vocabulary match (measured: real vibes peak 0.66-0.85; "brutalist
|
| 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
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
| 15 |
|
| 16 |
import functools
|
| 17 |
|
|
|
|
| 18 |
from discoverroute.data import taxonomy
|
| 19 |
|
| 20 |
|
|
@@ -22,6 +23,16 @@ def _neutral() -> dict[str, float]:
|
|
| 22 |
return {c: 1.0 for c in taxonomy.CATEGORIES}
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
@functools.lru_cache(maxsize=256)
|
| 26 |
def resolve_affinity(vibe: str) -> tuple[dict[str, float], str]:
|
| 27 |
"""Return ``(affinity, source)`` where source β {llm, embed, keyword, neutral}."""
|
|
@@ -33,9 +44,9 @@ def resolve_affinity(vibe: str) -> tuple[dict[str, float], str]:
|
|
| 33 |
from discoverroute.interpret import llm_vibe
|
| 34 |
result = llm_vibe.extract(vibe)
|
| 35 |
if result:
|
| 36 |
-
return result["affinity"], "llm"
|
| 37 |
|
| 38 |
-
# tier 2 β sentence embeddings (CPU)
|
| 39 |
try:
|
| 40 |
from discoverroute.interpret import embed
|
| 41 |
return embed.vibe_to_affinity(vibe), "embed"
|
|
@@ -46,7 +57,7 @@ def resolve_affinity(vibe: str) -> tuple[dict[str, float], str]:
|
|
| 46 |
from discoverroute.interpret import keywords
|
| 47 |
kw = keywords.keyword_affinity(vibe)
|
| 48 |
if kw:
|
| 49 |
-
return kw, "keyword"
|
| 50 |
|
| 51 |
return _neutral(), "neutral"
|
| 52 |
|
|
|
|
| 15 |
|
| 16 |
import functools
|
| 17 |
|
| 18 |
+
from discoverroute import config
|
| 19 |
from discoverroute.data import taxonomy
|
| 20 |
|
| 21 |
|
|
|
|
| 23 |
return {c: 1.0 for c in taxonomy.CATEGORIES}
|
| 24 |
|
| 25 |
|
| 26 |
+
def _cap_top_n(aff: dict[str, float]) -> dict[str, float]:
|
| 27 |
+
"""Zero all but the top-N categories so the long tail can't backfill routes
|
| 28 |
+
with off-vibe filler. (Embed self-caps; this also covers the LLM/keyword
|
| 29 |
+
tiers so the guarantee holds regardless of which one ran.)"""
|
| 30 |
+
if not aff:
|
| 31 |
+
return aff
|
| 32 |
+
keep = set(sorted(aff, key=aff.get, reverse=True)[: config.TOP_AFFINITY_CATEGORIES])
|
| 33 |
+
return {c: (v if c in keep else 0.0) for c, v in aff.items()}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
@functools.lru_cache(maxsize=256)
|
| 37 |
def resolve_affinity(vibe: str) -> tuple[dict[str, float], str]:
|
| 38 |
"""Return ``(affinity, source)`` where source β {llm, embed, keyword, neutral}."""
|
|
|
|
| 44 |
from discoverroute.interpret import llm_vibe
|
| 45 |
result = llm_vibe.extract(vibe)
|
| 46 |
if result:
|
| 47 |
+
return _cap_top_n(result["affinity"]), "llm"
|
| 48 |
|
| 49 |
+
# tier 2 β sentence embeddings (CPU); embed.vibe_to_affinity self-caps
|
| 50 |
try:
|
| 51 |
from discoverroute.interpret import embed
|
| 52 |
return embed.vibe_to_affinity(vibe), "embed"
|
|
|
|
| 57 |
from discoverroute.interpret import keywords
|
| 58 |
kw = keywords.keyword_affinity(vibe)
|
| 59 |
if kw:
|
| 60 |
+
return _cap_top_n(kw), "keyword"
|
| 61 |
|
| 62 |
return _neutral(), "neutral"
|
| 63 |
|
|
@@ -82,4 +82,21 @@ def vibe_to_affinity(vibe: str) -> dict[str, float]:
|
|
| 82 |
if span < config.MIN_AFFINITY_SPAN:
|
| 83 |
return {c: 1.0 for c in cats}
|
| 84 |
floor = config.AFFINITY_FLOOR
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
if span < config.MIN_AFFINITY_SPAN:
|
| 83 |
return {c: 1.0 for c in cats}
|
| 84 |
floor = config.AFFINITY_FLOOR
|
| 85 |
+
aff = {c: floor + (1.0 - floor) * (float(s) - lo) / span for c, s in zip(cats, sims)}
|
| 86 |
+
# Keep only the top-N categories; zero the long tail so off-vibe categories
|
| 87 |
+
# can't backfill route slots once on-vibe candidates run out.
|
| 88 |
+
keep = set(sorted(aff, key=aff.get, reverse=True)[: config.TOP_AFFINITY_CATEGORIES])
|
| 89 |
+
return {c: (v if c in keep else 0.0) for c, v in aff.items()}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@functools.lru_cache(maxsize=256)
|
| 93 |
+
def raw_top_similarity(vibe: str) -> float:
|
| 94 |
+
"""Best raw cosine of the vibe to any category gloss β a match-confidence
|
| 95 |
+
signal (independent of the min-max rescale, which always forces a 1.0)."""
|
| 96 |
+
vibe = (vibe or "").strip()
|
| 97 |
+
if not vibe:
|
| 98 |
+
return 0.0
|
| 99 |
+
_, gloss_emb = _gloss_matrix()
|
| 100 |
+
_, encode = _encoder()
|
| 101 |
+
q = encode([config.EMBED_QUERY_INSTRUCTION + vibe])[0]
|
| 102 |
+
return float((gloss_emb @ q).max())
|
|
@@ -37,6 +37,8 @@ class Interpretation:
|
|
| 37 |
budget_hint: float | None # suggested budget, or None if not implied
|
| 38 |
explanation: str # human-readable, inspectable
|
| 39 |
top_categories: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def _contains(text: str, cues) -> bool:
|
|
@@ -68,11 +70,19 @@ def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS
|
|
| 68 |
budget_hint = 0.2
|
| 69 |
|
| 70 |
top = sorted(affinity, key=affinity.get, reverse=True)[:4]
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
if not (vibe or "").strip():
|
| 77 |
return "_No vibe given β every kind of place is weighted equally._"
|
| 78 |
# Off-domain / unreadable vibe: the interpreter degraded to neutral (all
|
|
@@ -81,7 +91,9 @@ def _explain(vibe, top, affinity, posture, budget_hint) -> str:
|
|
| 81 |
if vals and (max(vals) - min(vals)) < 1e-6:
|
| 82 |
return (f"_I couldn't read a clear taste from β{vibe.strip()}β β "
|
| 83 |
f"weighting every kind of place equally._")
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
for c in top:
|
| 86 |
nice = c.replace("_", " ")
|
| 87 |
lines.append(f"- {nice} (affinity {affinity[c]:.2f}, "
|
|
|
|
| 37 |
budget_hint: float | None # suggested budget, or None if not implied
|
| 38 |
explanation: str # human-readable, inspectable
|
| 39 |
top_categories: list[str] = field(default_factory=list)
|
| 40 |
+
confidence: float = 1.0 # best raw cosine to a category gloss
|
| 41 |
+
weak: bool = False # True => out-of-vocabulary / weak match
|
| 42 |
|
| 43 |
|
| 44 |
def _contains(text: str, cues) -> bool:
|
|
|
|
| 70 |
budget_hint = 0.2
|
| 71 |
|
| 72 |
top = sorted(affinity, key=affinity.get, reverse=True)[:4]
|
| 73 |
+
# Match confidence from the raw embedding cosine (independent of rescaling).
|
| 74 |
+
try:
|
| 75 |
+
from discoverroute.interpret import embed
|
| 76 |
+
confidence = embed.raw_top_similarity(vibe)
|
| 77 |
+
except Exception: # noqa: BLE001 - encoder unavailable
|
| 78 |
+
confidence = 1.0
|
| 79 |
+
weak = bool(text) and confidence < config.WEAK_MATCH_SIMILARITY
|
| 80 |
+
explanation = _explain(vibe, top, affinity, posture, budget_hint, weak)
|
| 81 |
+
return Interpretation(affinity, weights, posture, budget_hint, explanation, top,
|
| 82 |
+
confidence=confidence, weak=weak)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _explain(vibe, top, affinity, posture, budget_hint, weak=False) -> str:
|
| 86 |
if not (vibe or "").strip():
|
| 87 |
return "_No vibe given β every kind of place is weighted equally._"
|
| 88 |
# Off-domain / unreadable vibe: the interpreter degraded to neutral (all
|
|
|
|
| 91 |
if vals and (max(vals) - min(vals)) < 1e-6:
|
| 92 |
return (f"_I couldn't read a clear taste from β{vibe.strip()}β β "
|
| 93 |
f"weighting every kind of place equally._")
|
| 94 |
+
header = (f"**No strong match for β{vibe.strip()}β β here's my closest guess:**"
|
| 95 |
+
if weak else f"**Reading β{vibe.strip()}β as:**")
|
| 96 |
+
lines = [header]
|
| 97 |
for c in top:
|
| 98 |
nice = c.replace("_", " ")
|
| 99 |
lines.append(f"- {nice} (affinity {affinity[c]:.2f}, "
|
|
@@ -57,7 +57,7 @@ def _hours_badge(poi, posture_val: str) -> str:
|
|
| 57 |
|
| 58 |
|
| 59 |
def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
| 60 |
-
end_label="", posture=None, weights=None) -> str:
|
| 61 |
posture = posture or {}
|
| 62 |
n = len(pois)
|
| 63 |
extra = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0
|
|
@@ -65,7 +65,14 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
|
| 65 |
unit = "minute" if extra == 1 else "minutes"
|
| 66 |
place_word = "place" if n == 1 else "places"
|
| 67 |
v = (vibe or "").strip()
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
lead = "### Why this route\n"
|
| 70 |
lead += (
|
| 71 |
f"Spending **{extra} extra {unit}**{vibe_clause}, your {mode} threads "
|
|
@@ -73,9 +80,10 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
|
| 73 |
f"{end_label or 'the destination'}:\n"
|
| 74 |
)
|
| 75 |
# The categories the vibe leans on most β used to tie a stop back to the vibe.
|
|
|
|
| 76 |
top_cats: set[str] = set()
|
| 77 |
aff = getattr(weights, "category_affinity", None)
|
| 78 |
-
if v and aff:
|
| 79 |
top_cats = set(sorted(aff, key=aff.get, reverse=True)[:3])
|
| 80 |
|
| 81 |
lines = [lead]
|
|
@@ -128,10 +136,10 @@ def llm_available() -> bool:
|
|
| 128 |
|
| 129 |
|
| 130 |
def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
|
| 131 |
-
end_label="", posture=None, weights=None) -> tuple[str, bool]:
|
| 132 |
"""Return (markdown, used_llm). Output is guaranteed grounded."""
|
| 133 |
template = template_narration(
|
| 134 |
-
plain, discovery, pois, vibe, mode, start_label, end_label, posture, weights
|
| 135 |
)
|
| 136 |
if not llm_available():
|
| 137 |
return template, False
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
| 60 |
+
end_label="", posture=None, weights=None, weak=False) -> str:
|
| 61 |
posture = posture or {}
|
| 62 |
n = len(pois)
|
| 63 |
extra = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0
|
|
|
|
| 65 |
unit = "minute" if extra == 1 else "minutes"
|
| 66 |
place_word = "place" if n == 1 else "places"
|
| 67 |
v = (vibe or "").strip()
|
| 68 |
+
# Honest framing for a weak / out-of-vocabulary vibe: don't pretend these are
|
| 69 |
+
# tailored matches (the review found "brutalist architecture" β churches
|
| 70 |
+
# confidently labelled "a match for your vibe").
|
| 71 |
+
if v and weak:
|
| 72 |
+
vibe_clause = (f" β I didn't find a strong match for *{v}*, so here's a "
|
| 73 |
+
f"varied walk worth taking")
|
| 74 |
+
else:
|
| 75 |
+
vibe_clause = f" to match your *{v}* mood" if v else ""
|
| 76 |
lead = "### Why this route\n"
|
| 77 |
lead += (
|
| 78 |
f"Spending **{extra} extra {unit}**{vibe_clause}, your {mode} threads "
|
|
|
|
| 80 |
f"{end_label or 'the destination'}:\n"
|
| 81 |
)
|
| 82 |
# The categories the vibe leans on most β used to tie a stop back to the vibe.
|
| 83 |
+
# A weak vibe has no real matches, so don't tag stops "a match for your vibe".
|
| 84 |
top_cats: set[str] = set()
|
| 85 |
aff = getattr(weights, "category_affinity", None)
|
| 86 |
+
if v and aff and not weak:
|
| 87 |
top_cats = set(sorted(aff, key=aff.get, reverse=True)[:3])
|
| 88 |
|
| 89 |
lines = [lead]
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
|
| 139 |
+
end_label="", posture=None, weights=None, weak=False) -> tuple[str, bool]:
|
| 140 |
"""Return (markdown, used_llm). Output is guaranteed grounded."""
|
| 141 |
template = template_narration(
|
| 142 |
+
plain, discovery, pois, vibe, mode, start_label, end_label, posture, weights, weak
|
| 143 |
)
|
| 144 |
if not llm_available():
|
| 145 |
return template, False
|
|
@@ -114,6 +114,7 @@ def _plan_route_impl(
|
|
| 114 |
# Taste resolution priority: (persistent profile β trip vibe) > manual sliders.
|
| 115 |
from discoverroute.data import taxonomy
|
| 116 |
interp_md = ""
|
|
|
|
| 117 |
posture = {c: taxonomy.posture(c) for c in taxonomy.CATEGORIES}
|
| 118 |
has_vibe = bool((vibe or "").strip())
|
| 119 |
has_profile = bool(
|
|
@@ -128,6 +129,7 @@ def _plan_route_impl(
|
|
| 128 |
interp = interpret(vibe, adventurousness, budget)
|
| 129 |
posture = interp.posture
|
| 130 |
interp_md = interp.explanation
|
|
|
|
| 131 |
# An explicit pace word in the vibe ("quick", "all day") nudges the
|
| 132 |
# budget β BUT never resurrects a detour the user explicitly disabled
|
| 133 |
# by zeroing the slider (P0-3: budget 0 == plain route, slider wins).
|
|
@@ -169,7 +171,7 @@ def _plan_route_impl(
|
|
| 169 |
itinerary_md, _ = narrate(
|
| 170 |
plain, discovery, selected, vibe=vibe, mode=mode,
|
| 171 |
start_label=start_query.strip(), end_label=dest_query.strip(),
|
| 172 |
-
posture=posture, weights=weights,
|
| 173 |
)
|
| 174 |
alternatives.append(Alternative(
|
| 175 |
discovery=discovery, pois=selected,
|
|
|
|
| 114 |
# Taste resolution priority: (persistent profile β trip vibe) > manual sliders.
|
| 115 |
from discoverroute.data import taxonomy
|
| 116 |
interp_md = ""
|
| 117 |
+
weak_match = False
|
| 118 |
posture = {c: taxonomy.posture(c) for c in taxonomy.CATEGORIES}
|
| 119 |
has_vibe = bool((vibe or "").strip())
|
| 120 |
has_profile = bool(
|
|
|
|
| 129 |
interp = interpret(vibe, adventurousness, budget)
|
| 130 |
posture = interp.posture
|
| 131 |
interp_md = interp.explanation
|
| 132 |
+
weak_match = interp.weak
|
| 133 |
# An explicit pace word in the vibe ("quick", "all day") nudges the
|
| 134 |
# budget β BUT never resurrects a detour the user explicitly disabled
|
| 135 |
# by zeroing the slider (P0-3: budget 0 == plain route, slider wins).
|
|
|
|
| 171 |
itinerary_md, _ = narrate(
|
| 172 |
plain, discovery, selected, vibe=vibe, mode=mode,
|
| 173 |
start_label=start_query.strip(), end_label=dest_query.strip(),
|
| 174 |
+
posture=posture, weights=weights, weak=weak_match,
|
| 175 |
)
|
| 176 |
alternatives.append(Alternative(
|
| 177 |
discovery=discovery, pois=selected,
|