Spaces:
Running on Zero
Call-1 vibe weights: reasoning pass + anti-zero prompt + degenerate guard
Browse filesLive traces showed MiniCPM5-1B returning an all-zero weighting for
"quiet green wander" that passed validation (used_fallback=False), so the
router got no taste signal and defaulted to nearby well-tagged POIs
(public art) — a green vibe came out "arty".
Root causes + fixes:
- Prompt showed `"cafe": 0.0-1.0`; the 1B parroted `0.0` as the value.
Rewrite states 0..1 in words, forbids all-zero/all-equal, gives one
worked example on an unrelated vibe (shape, not value).
- run_inference now supports enable_thinking (MiniCPM5-1B is hybrid-
reasoning, verified on the model card); vibe->weights runs a reasoning
pass and the <think> block is stripped before JSON parse.
- _is_degenerate() rejects all-zero/all-equal output so the dispatcher
falls through to the bge-small embed tier instead of routing tasteless.
Test: test_llm_rejects_degenerate_weights (from the real all-zero trace).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@@ -15,15 +15,28 @@ import time
|
|
| 15 |
|
| 16 |
from discoverroute.interpret import mapping
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
SYSTEM_PROMPT = (
|
| 19 |
-
"You
|
| 20 |
-
"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
REQUIRED_KEYS = (
|
|
@@ -71,6 +84,22 @@ def _validate(obj: dict | None) -> dict | None:
|
|
| 71 |
return out
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
@functools.lru_cache(maxsize=256)
|
| 75 |
def extract(vibe: str) -> dict | None:
|
| 76 |
"""Return ``{"affinity", "budget_multiplier", "raw"}`` or ``None``.
|
|
@@ -90,12 +119,18 @@ def extract(vibe: str) -> dict | None:
|
|
| 90 |
|
| 91 |
messages = [
|
| 92 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 93 |
-
{"role": "user", "content": f"
|
| 94 |
]
|
| 95 |
t0 = time.time()
|
| 96 |
try:
|
| 97 |
from discoverroute.narrate.llm import run_inference
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
except Exception as exc: # noqa: BLE001 - never break interpretation
|
| 100 |
latency = int((time.time() - t0) * 1000)
|
| 101 |
trace.log_trace("vibe_extraction", {"vibe": vibe},
|
|
@@ -105,9 +140,12 @@ def extract(vibe: str) -> dict | None:
|
|
| 105 |
|
| 106 |
latency = int((time.time() - t0) * 1000)
|
| 107 |
parsed = _validate(_extract_json(raw_text))
|
| 108 |
-
|
|
|
|
|
|
|
| 109 |
trace.log_trace("vibe_extraction", {"vibe": vibe},
|
| 110 |
-
{"raw": raw_text
|
|
|
|
| 111 |
return None
|
| 112 |
|
| 113 |
affinity = mapping.brief_scores_to_affinity(parsed)
|
|
|
|
| 15 |
|
| 16 |
from discoverroute.interpret import mapping
|
| 17 |
|
| 18 |
+
# NOTE on prompt design: the previous schema literally showed each key as
|
| 19 |
+
# `"cafe": 0.0-1.0`, and the 1B model parroted the `0.0` back as the value —
|
| 20 |
+
# returning an all-zero weighting that the router can't act on (a "quiet green
|
| 21 |
+
# wander" came out tasteless). We now state the 0..1 meaning in words, forbid the
|
| 22 |
+
# all-zero / all-equal degenerate answers explicitly, and give ONE worked example
|
| 23 |
+
# (a vibe unrelated to any preset) so the model copies the *shape*, not a value.
|
| 24 |
SYSTEM_PROMPT = (
|
| 25 |
+
"You convert a walk/ride 'vibe' into place-type preference weights for a "
|
| 26 |
+
"routing engine.\n"
|
| 27 |
+
"For each place type, score how strongly the vibe calls for it: 0 means "
|
| 28 |
+
"irrelevant, 1 means central to the vibe. Most vibes strongly want only two "
|
| 29 |
+
"to four types — give those 0.7-1.0 and keep the rest low. Never set every "
|
| 30 |
+
"value to 0, and never give every type the same value.\n"
|
| 31 |
+
"detour_budget_multiplier is how far off the direct line the vibe justifies: "
|
| 32 |
+
"0.5 = stay direct, 2.0 = big detours welcome.\n"
|
| 33 |
+
"Reply with ONLY a JSON object (no prose, no markdown) using EXACTLY these "
|
| 34 |
+
"keys: cafe, park, bookshop, museum, bakery, restaurant, bar, viewpoint, "
|
| 35 |
+
"market, quiet, green, historic, busy, detour_budget_multiplier.\n"
|
| 36 |
+
"Example — for the vibe \"sunny riverside picnic\":\n"
|
| 37 |
+
'{"cafe":0.4,"park":0.9,"bookshop":0.1,"museum":0.1,"bakery":0.6,'
|
| 38 |
+
'"restaurant":0.2,"bar":0.1,"viewpoint":0.7,"market":0.5,"quiet":0.6,'
|
| 39 |
+
'"green":0.9,"historic":0.2,"busy":0.1,"detour_budget_multiplier":1.2}'
|
| 40 |
)
|
| 41 |
|
| 42 |
REQUIRED_KEYS = (
|
|
|
|
| 84 |
return out
|
| 85 |
|
| 86 |
|
| 87 |
+
def _is_degenerate(weights: dict[str, float]) -> bool:
|
| 88 |
+
"""A weighting carries no usable taste signal if every place-type score is
|
| 89 |
+
zero, or they're all equal (the model emitted a flat default). Such output
|
| 90 |
+
passed ``_validate`` but tells the router nothing — so we reject it and let
|
| 91 |
+
the dispatcher fall through to the embedding tier, which *does* read the vibe.
|
| 92 |
+
(``detour_budget_multiplier`` is excluded — it isn't a place-type score.)"""
|
| 93 |
+
cats = [v for k, v in weights.items() if k != "detour_budget_multiplier"]
|
| 94 |
+
if not cats:
|
| 95 |
+
return True
|
| 96 |
+
if max(cats) <= 0.0: # all-zero
|
| 97 |
+
return True
|
| 98 |
+
if max(cats) - min(cats) < 1e-9: # all-equal → no differentiation
|
| 99 |
+
return True
|
| 100 |
+
return False
|
| 101 |
+
|
| 102 |
+
|
| 103 |
@functools.lru_cache(maxsize=256)
|
| 104 |
def extract(vibe: str) -> dict | None:
|
| 105 |
"""Return ``{"affinity", "budget_multiplier", "raw"}`` or ``None``.
|
|
|
|
| 119 |
|
| 120 |
messages = [
|
| 121 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 122 |
+
{"role": "user", "content": f"Vibe: {vibe}\nReturn the JSON weights now."},
|
| 123 |
]
|
| 124 |
t0 = time.time()
|
| 125 |
try:
|
| 126 |
from discoverroute.narrate.llm import run_inference
|
| 127 |
+
# Reasoning pass: MiniCPM5-1B is hybrid-reasoning, and scoring a fuzzy vibe
|
| 128 |
+
# across 14 types is exactly the kind of short deliberation a 1B does better
|
| 129 |
+
# with than off-the-cuff. enable_thinking=True lets it reason, then emit the
|
| 130 |
+
# JSON; run_inference strips the <think> block and returns only the JSON. The
|
| 131 |
+
# budget covers the reasoning + the ~120-token object (truncated reasoning →
|
| 132 |
+
# empty answer → clean fallback).
|
| 133 |
+
raw_text = run_inference(messages, max_new_tokens=512, enable_thinking=True)
|
| 134 |
except Exception as exc: # noqa: BLE001 - never break interpretation
|
| 135 |
latency = int((time.time() - t0) * 1000)
|
| 136 |
trace.log_trace("vibe_extraction", {"vibe": vibe},
|
|
|
|
| 140 |
|
| 141 |
latency = int((time.time() - t0) * 1000)
|
| 142 |
parsed = _validate(_extract_json(raw_text))
|
| 143 |
+
# Reject unparseable OR degenerate (all-zero / all-equal) output: both leave the
|
| 144 |
+
# router with no taste signal, so fall through to the embedding tier instead.
|
| 145 |
+
if parsed is None or _is_degenerate(parsed):
|
| 146 |
trace.log_trace("vibe_extraction", {"vibe": vibe},
|
| 147 |
+
{"raw": raw_text, "degenerate": parsed is not None},
|
| 148 |
+
latency, used_fallback=True)
|
| 149 |
return None
|
| 150 |
|
| 151 |
affinity = mapping.brief_scores_to_affinity(parsed)
|
|
@@ -46,21 +46,40 @@ def _load():
|
|
| 46 |
|
| 47 |
@_gpu
|
| 48 |
def run_inference(messages: list[dict], max_new_tokens: int = 320,
|
| 49 |
-
temperature: float
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
tok, model = _load()
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
# budget and leave nothing usable. We want the fast direct answer, so disable
|
| 55 |
-
# it explicitly (the kwarg is ignored harmlessly by templates that lack it).
|
| 56 |
text = tok.apply_chat_template(
|
| 57 |
-
messages, tokenize=False, add_generation_prompt=True,
|
|
|
|
| 58 |
)
|
| 59 |
inputs = tok([text], return_tensors="pt").to(model.device)
|
| 60 |
-
# MiniCPM5 "no-think" recommended sampling: temperature 0.7, top_p 0.95.
|
| 61 |
out = model.generate(
|
| 62 |
**inputs, max_new_tokens=max_new_tokens,
|
| 63 |
temperature=temperature, top_p=0.95, top_k=20, do_sample=True,
|
| 64 |
)
|
| 65 |
gen = out[0][inputs.input_ids.shape[1]:]
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
@_gpu
|
| 48 |
def run_inference(messages: list[dict], max_new_tokens: int = 320,
|
| 49 |
+
temperature: float | None = None,
|
| 50 |
+
enable_thinking: bool = False) -> str:
|
| 51 |
+
"""Run a chat completion. ``messages`` = [{"role","content"}, ...].
|
| 52 |
+
|
| 53 |
+
MiniCPM5-1B is a hybrid-reasoning model (verified on the model card: built-in
|
| 54 |
+
``<think>`` template, switched by ``enable_thinking``). With thinking ON it
|
| 55 |
+
first emits a ``<think>…</think>`` reasoning block and *then* the answer; we
|
| 56 |
+
strip the block and return only the answer, so callers parse clean output.
|
| 57 |
+
With thinking OFF the template injects an empty block and there's nothing to
|
| 58 |
+
strip — the fast direct path.
|
| 59 |
+
|
| 60 |
+
``temperature`` defaults to the model card's recommended sampling for the
|
| 61 |
+
chosen mode (Think 0.9 / No-Think 0.7), both with top_p 0.95.
|
| 62 |
+
"""
|
| 63 |
tok, model = _load()
|
| 64 |
+
if temperature is None:
|
| 65 |
+
temperature = 0.9 if enable_thinking else 0.7
|
|
|
|
|
|
|
| 66 |
text = tok.apply_chat_template(
|
| 67 |
+
messages, tokenize=False, add_generation_prompt=True,
|
| 68 |
+
enable_thinking=enable_thinking,
|
| 69 |
)
|
| 70 |
inputs = tok([text], return_tensors="pt").to(model.device)
|
|
|
|
| 71 |
out = model.generate(
|
| 72 |
**inputs, max_new_tokens=max_new_tokens,
|
| 73 |
temperature=temperature, top_p=0.95, top_k=20, do_sample=True,
|
| 74 |
)
|
| 75 |
gen = out[0][inputs.input_ids.shape[1]:]
|
| 76 |
+
decoded = tok.decode(gen, skip_special_tokens=True).strip()
|
| 77 |
+
if enable_thinking:
|
| 78 |
+
# Keep only what follows the reasoning block. If the model never closed
|
| 79 |
+
# </think> (reasoning ran to the token budget), there's no usable answer —
|
| 80 |
+
# return "" so the caller falls back rather than parsing half a thought.
|
| 81 |
+
if "</think>" in decoded:
|
| 82 |
+
decoded = decoded.split("</think>")[-1].strip()
|
| 83 |
+
else:
|
| 84 |
+
decoded = ""
|
| 85 |
+
return decoded
|
|
@@ -47,6 +47,23 @@ def test_llm_json_extract_and_validate():
|
|
| 47 |
assert llm_vibe._extract_json("sorry, I cannot do that") is None
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def test_resolve_affinity_neutral_on_empty():
|
| 51 |
aff, src = affinity.resolve_affinity("")
|
| 52 |
assert src == "neutral"
|
|
|
|
| 47 |
assert llm_vibe._extract_json("sorry, I cannot do that") is None
|
| 48 |
|
| 49 |
|
| 50 |
+
def test_llm_rejects_degenerate_weights():
|
| 51 |
+
"""All-zero / all-equal extractions pass _validate but carry no taste signal,
|
| 52 |
+
so _is_degenerate must flag them (the live trace showed a real all-zero row for
|
| 53 |
+
'quiet green wander' that the router then ignored)."""
|
| 54 |
+
keys = llm_vibe.REQUIRED_KEYS
|
| 55 |
+
all_zero = {k: 0.0 for k in keys}
|
| 56 |
+
all_zero["detour_budget_multiplier"] = 0.5
|
| 57 |
+
assert llm_vibe._is_degenerate(all_zero) is True
|
| 58 |
+
all_equal = {k: 0.5 for k in keys}
|
| 59 |
+
all_equal["detour_budget_multiplier"] = 1.0
|
| 60 |
+
assert llm_vibe._is_degenerate(all_equal) is True
|
| 61 |
+
# a real, differentiated weighting is kept
|
| 62 |
+
good = {k: 0.1 for k in keys}
|
| 63 |
+
good.update(park=0.9, green=0.9, quiet=0.7, detour_budget_multiplier=1.2)
|
| 64 |
+
assert llm_vibe._is_degenerate(good) is False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
def test_resolve_affinity_neutral_on_empty():
|
| 68 |
aff, src = affinity.resolve_affinity("")
|
| 69 |
assert src == "neutral"
|