coreprinciple Claude Opus 4.8 commited on
Commit
128e312
·
1 Parent(s): 6b41d01

Call-1 vibe weights: reasoning pass + anti-zero prompt + degenerate guard

Browse files

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

src/discoverroute/interpret/llm_vibe.py CHANGED
@@ -15,15 +15,28 @@ import time
15
 
16
  from discoverroute.interpret import mapping
17
 
 
 
 
 
 
 
18
  SYSTEM_PROMPT = (
19
- "You are a routing preference extractor.\n"
20
- "Output ONLY valid JSON. No prose, no explanation, no markdown.\n"
21
- 'Schema: {"cafe": 0.0-1.0, "park": 0.0-1.0, "bookshop": 0.0-1.0, '
22
- '"museum": 0.0-1.0, "bakery": 0.0-1.0, "restaurant": 0.0-1.0, '
23
- '"bar": 0.0-1.0, "viewpoint": 0.0-1.0, "market": 0.0-1.0, '
24
- '"quiet": 0.0-1.0, "green": 0.0-1.0, "historic": 0.0-1.0, '
25
- '"busy": 0.0-1.0, "detour_budget_multiplier": 0.5-2.0}\n'
26
- "All keys required. Values reflect how strongly the vibe matches each."
 
 
 
 
 
 
 
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"Extract routing weights for this vibe: {vibe}"},
94
  ]
95
  t0 = time.time()
96
  try:
97
  from discoverroute.narrate.llm import run_inference
98
- raw_text = run_inference(messages, max_new_tokens=160, temperature=0.2)
 
 
 
 
 
 
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
- if parsed is None:
 
 
109
  trace.log_trace("vibe_extraction", {"vibe": vibe},
110
- {"raw": raw_text}, latency, used_fallback=True)
 
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)
src/discoverroute/narrate/llm.py CHANGED
@@ -46,21 +46,40 @@ def _load():
46
 
47
  @_gpu
48
  def run_inference(messages: list[dict], max_new_tokens: int = 320,
49
- temperature: float = 0.7) -> str:
50
- """Run a chat completion. ``messages`` = [{"role","content"}, ...]."""
 
 
 
 
 
 
 
 
 
 
 
 
51
  tok, model = _load()
52
- # MiniCPM5 is a hybrid-reasoning model: with thinking on it emits a
53
- # <think>...</think> block first, which would devour the JSON/narration token
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, enable_thinking=False
 
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
- return tok.decode(gen, skip_special_tokens=True).strip()
 
 
 
 
 
 
 
 
 
 
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
tests/test_interpret_fallback.py CHANGED
@@ -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"