Tristan Leduc Claude Fable 5 commited on
Commit
a4c45c1
Β·
1 Parent(s): 6c11e60

Round-2 route-fit: top-N category cap + weak-match honesty

Browse files

Adversarial 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>

src/discoverroute/config.py CHANGED
@@ -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
src/discoverroute/interpret/affinity.py CHANGED
@@ -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
 
src/discoverroute/interpret/embed.py CHANGED
@@ -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
- return {c: floor + (1.0 - floor) * (float(s) - lo) / span for c, s in zip(cats, sims)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())
src/discoverroute/interpret/vibe.py CHANGED
@@ -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
- explanation = _explain(vibe, top, affinity, posture, budget_hint)
72
- return Interpretation(affinity, weights, posture, budget_hint, explanation, top)
73
-
74
-
75
- def _explain(vibe, top, affinity, posture, budget_hint) -> str:
 
 
 
 
 
 
 
 
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
- lines = [f"**Reading β€œ{vibe.strip()}” as:**"]
 
 
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}, "
src/discoverroute/narrate/narrate.py CHANGED
@@ -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
- vibe_clause = f" to match your *{v}* mood" if v else ""
 
 
 
 
 
 
 
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
src/discoverroute/pipeline.py CHANGED
@@ -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,