Spaces:
Paused
Backend: unembedding cosine emission + Devstral tokenizer fallback
Browse filesTwo changes that accumulated together in the working tree.
1. Unembedding cosine decomposition emitted per top-K alternative
For Step 8's "How this token won the vote" disclosure, the backend
now decomposes each alternative's logit into the geometric factors:
logit_v = cos(W_E[v], h_final) × ||W_E[v]|| × ||h_final||
Cosine similarity and the unembedding row norm (||W_E[v]||) are
appended to every entry in the logits payload — both in the
regular and outlier paths, in both the non-SSE and SSE generation
loops. Without this, the SSE endpoint (which the frontend uses)
returned no cosine data while the non-SSE endpoint did, leading to
a silent "cosine not available" state in the lens.
Logging on cosine prep upgraded to info-level on success and
warning-level on failure so future regressions are visible.
2. Tokenizer fallback for Devstral on tokenizers >= 0.20
Devstral-Small-2507 ships only tekken.json. AutoTokenizer with
newer tokenizers is stricter about HF-format file presence and
fails on the official repo. Fall back to the unsloth/Devstral-
Small-2507 mirror, which carries the same Tekken vocabulary in
standard HF format. Same vocab size, same token IDs, same special
tokens — only the on-disk file format differs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/model_service.py +193 -16
|
@@ -677,8 +677,24 @@ class ModelManager:
|
|
| 677 |
attn_implementation="eager"
|
| 678 |
).to(self.device)
|
| 679 |
|
| 680 |
-
# Load tokenizer
|
| 681 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
# Set pad_token if the tokenizer allows it (some like MistralCommonTokenizer don't)
|
| 683 |
try:
|
| 684 |
self.tokenizer.pad_token = self.tokenizer.eos_token
|
|
@@ -2199,16 +2215,85 @@ async def analyze_research_attention(request: Dict[str, Any], authenticated: boo
|
|
| 2199 |
top_n_display = 10 # Get top 10 alternatives for display
|
| 2200 |
top_raw_logits, top_raw_indices = torch.topk(raw_logits, k=min(top_n_display, len(raw_logits)))
|
| 2201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2202 |
# Build raw logits entries (before temperature)
|
| 2203 |
logits_entries = []
|
| 2204 |
for rank, (logit_val, idx) in enumerate(zip(top_raw_logits.tolist(), top_raw_indices.tolist())):
|
| 2205 |
token_text = manager.tokenizer.decode([idx], skip_special_tokens=False)
|
| 2206 |
-
|
|
|
|
| 2207 |
"token": token_text,
|
| 2208 |
"token_id": idx,
|
| 2209 |
"logit": logit_val,
|
| 2210 |
-
"rank": rank + 1
|
| 2211 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2212 |
|
| 2213 |
# Greedy token (argmax of raw logits, before any sampling)
|
| 2214 |
greedy_token_id = torch.argmax(raw_logits).item()
|
|
@@ -2332,13 +2417,21 @@ async def analyze_research_attention(request: Dict[str, Any], authenticated: boo
|
|
| 2332 |
})
|
| 2333 |
# Also add to logits if not present
|
| 2334 |
if next_token_id not in [e["token_id"] for e in logits_entries]:
|
| 2335 |
-
|
|
|
|
| 2336 |
"token": next_token_text,
|
| 2337 |
"token_id": next_token_id,
|
| 2338 |
"logit": selected_logit,
|
| 2339 |
"rank": selected_rank,
|
| 2340 |
-
"is_selected_outlier": True
|
| 2341 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2342 |
|
| 2343 |
# Build sampling metadata. `is_filtered` and `eligible_count`
|
| 2344 |
# let the frontend decide whether to show one or two
|
|
@@ -3241,15 +3334,81 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
|
|
| 3241 |
else:
|
| 3242 |
return manager.tokenizer.decode([tid], skip_special_tokens=False)
|
| 3243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3244 |
logits_entries = []
|
| 3245 |
for rank, (logit_val, idx) in enumerate(zip(top_raw_logits.tolist(), top_raw_indices.tolist())):
|
| 3246 |
token_text = decode_token(idx)
|
| 3247 |
-
|
|
|
|
| 3248 |
"token": token_text,
|
| 3249 |
"token_id": idx,
|
| 3250 |
"logit": logit_val,
|
| 3251 |
-
"rank": rank + 1
|
| 3252 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3253 |
|
| 3254 |
# Greedy token (argmax of raw logits, before any sampling)
|
| 3255 |
greedy_token_id = torch.argmax(raw_logits).item()
|
|
@@ -3360,13 +3519,21 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
|
|
| 3360 |
})
|
| 3361 |
# Also add to logits if not present
|
| 3362 |
if next_token_id not in [e["token_id"] for e in logits_entries]:
|
| 3363 |
-
|
|
|
|
| 3364 |
"token": next_token_text,
|
| 3365 |
"token_id": next_token_id,
|
| 3366 |
"logit": selected_logit,
|
| 3367 |
"rank": selected_rank,
|
| 3368 |
-
"is_selected_outlier": True
|
| 3369 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3370 |
|
| 3371 |
# Build sampling metadata
|
| 3372 |
eligible_count = int((probs_filtered > 0).sum().item())
|
|
@@ -3780,8 +3947,18 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
|
|
| 3780 |
# endpoint or a 14,336-d cache entry.
|
| 3781 |
layer_entry["ffn_top_neurons"] = ffn_top_neurons[layer_idx]
|
| 3782 |
|
| 3783 |
-
# Phase 5: Logit lens at
|
| 3784 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3785 |
if layer_idx % logit_lens_stride == 0 or layer_idx == n_layers - 1:
|
| 3786 |
try:
|
| 3787 |
hidden_for_lens = current_hidden[-1].unsqueeze(0) # [1, hidden_dim]
|
|
|
|
| 677 |
attn_implementation="eager"
|
| 678 |
).to(self.device)
|
| 679 |
|
| 680 |
+
# Load tokenizer. Devstral's official repo ships only tekken.json,
|
| 681 |
+
# which AutoTokenizer can't consume on tokenizers ≥ 0.20. Fall back
|
| 682 |
+
# to the Unsloth mirror, which carries the same Tekken vocabulary
|
| 683 |
+
# in standard HF format. Same vocab size, same token IDs, same
|
| 684 |
+
# special tokens — only the on-disk file format differs.
|
| 685 |
+
try:
|
| 686 |
+
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
| 687 |
+
except (OSError, EnvironmentError) as tok_err:
|
| 688 |
+
if self.model_id == "devstral-small":
|
| 689 |
+
logger.warning(
|
| 690 |
+
f"AutoTokenizer failed for {self.model_name} ({tok_err}); "
|
| 691 |
+
f"falling back to unsloth/Devstral-Small-2507 (same Tekken vocab, HF format)."
|
| 692 |
+
)
|
| 693 |
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
| 694 |
+
"unsloth/Devstral-Small-2507"
|
| 695 |
+
)
|
| 696 |
+
else:
|
| 697 |
+
raise
|
| 698 |
# Set pad_token if the tokenizer allows it (some like MistralCommonTokenizer don't)
|
| 699 |
try:
|
| 700 |
self.tokenizer.pad_token = self.tokenizer.eos_token
|
|
|
|
| 2215 |
top_n_display = 10 # Get top 10 alternatives for display
|
| 2216 |
top_raw_logits, top_raw_indices = torch.topk(raw_logits, k=min(top_n_display, len(raw_logits)))
|
| 2217 |
|
| 2218 |
+
# ── Unembedding-cosine prep ──────────────────────────
|
| 2219 |
+
# Each logit is W_E[v] · h_post_norm where h_post_norm
|
| 2220 |
+
# is the post-final-norm residual at the last position.
|
| 2221 |
+
# cos(W_E[v], h_post_norm) is the *direction-only*
|
| 2222 |
+
# alignment — strips out magnitude so a developer can
|
| 2223 |
+
# see whether a high-logit token won by alignment or
|
| 2224 |
+
# by magnitude. Computed once per step (cheap; one
|
| 2225 |
+
# final-norm call + d_model dot product per token).
|
| 2226 |
+
cos_h_norm = None
|
| 2227 |
+
W_E = None
|
| 2228 |
+
try:
|
| 2229 |
+
final_hidden_pre_norm = outputs.hidden_states[-1][0, -1, :]
|
| 2230 |
+
if hasattr(manager.model, 'model') and hasattr(manager.model.model, 'norm'):
|
| 2231 |
+
h_post_norm = manager.model.model.norm(
|
| 2232 |
+
final_hidden_pre_norm.unsqueeze(0)
|
| 2233 |
+
)[0]
|
| 2234 |
+
elif hasattr(manager.model, 'transformer') and hasattr(manager.model.transformer, 'ln_f'):
|
| 2235 |
+
h_post_norm = manager.model.transformer.ln_f(
|
| 2236 |
+
final_hidden_pre_norm.unsqueeze(0)
|
| 2237 |
+
)[0]
|
| 2238 |
+
else:
|
| 2239 |
+
h_post_norm = final_hidden_pre_norm
|
| 2240 |
+
h_norm_val = float(torch.linalg.vector_norm(h_post_norm).item())
|
| 2241 |
+
if h_norm_val > 1e-9:
|
| 2242 |
+
cos_h_norm = h_post_norm / h_norm_val
|
| 2243 |
+
if hasattr(manager.model, 'lm_head'):
|
| 2244 |
+
W_E = manager.model.lm_head.weight
|
| 2245 |
+
else:
|
| 2246 |
+
logger.warning(
|
| 2247 |
+
"[cosine prep] manager.model has no lm_head — cosine emission skipped"
|
| 2248 |
+
)
|
| 2249 |
+
else:
|
| 2250 |
+
logger.warning(
|
| 2251 |
+
f"[cosine prep] h_post_norm magnitude too small: {h_norm_val}"
|
| 2252 |
+
)
|
| 2253 |
+
if cos_h_norm is not None and W_E is not None and step == 0:
|
| 2254 |
+
# One-time confirmation per generation that the
|
| 2255 |
+
# cosine path is live.
|
| 2256 |
+
logger.info(
|
| 2257 |
+
f"[cosine prep] enabled · h_post_norm shape {tuple(h_post_norm.shape)} · "
|
| 2258 |
+
f"W_E shape {tuple(W_E.shape)} · h_norm {h_norm_val:.3f}"
|
| 2259 |
+
)
|
| 2260 |
+
except Exception as e:
|
| 2261 |
+
logger.warning(
|
| 2262 |
+
f"[cosine prep] failed at step {step}: {type(e).__name__}: {e}"
|
| 2263 |
+
)
|
| 2264 |
+
cos_h_norm = None
|
| 2265 |
+
W_E = None
|
| 2266 |
+
|
| 2267 |
+
def _cos_for(token_idx: int):
|
| 2268 |
+
"""cosine(W_E[token_idx], h_post_norm) and ‖W_E row‖."""
|
| 2269 |
+
if cos_h_norm is None or W_E is None:
|
| 2270 |
+
return None, None
|
| 2271 |
+
try:
|
| 2272 |
+
row = W_E[token_idx]
|
| 2273 |
+
row_norm = float(torch.linalg.vector_norm(row).item())
|
| 2274 |
+
if row_norm < 1e-9:
|
| 2275 |
+
return None, row_norm
|
| 2276 |
+
cos = float(((row / row_norm) @ cos_h_norm).item())
|
| 2277 |
+
return cos, row_norm
|
| 2278 |
+
except Exception:
|
| 2279 |
+
return None, None
|
| 2280 |
+
|
| 2281 |
# Build raw logits entries (before temperature)
|
| 2282 |
logits_entries = []
|
| 2283 |
for rank, (logit_val, idx) in enumerate(zip(top_raw_logits.tolist(), top_raw_indices.tolist())):
|
| 2284 |
token_text = manager.tokenizer.decode([idx], skip_special_tokens=False)
|
| 2285 |
+
cos, row_norm = _cos_for(idx)
|
| 2286 |
+
entry = {
|
| 2287 |
"token": token_text,
|
| 2288 |
"token_id": idx,
|
| 2289 |
"logit": logit_val,
|
| 2290 |
+
"rank": rank + 1,
|
| 2291 |
+
}
|
| 2292 |
+
if cos is not None:
|
| 2293 |
+
entry["cosine_sim"] = round(cos, 4)
|
| 2294 |
+
if row_norm is not None:
|
| 2295 |
+
entry["unemb_row_norm"] = round(row_norm, 4)
|
| 2296 |
+
logits_entries.append(entry)
|
| 2297 |
|
| 2298 |
# Greedy token (argmax of raw logits, before any sampling)
|
| 2299 |
greedy_token_id = torch.argmax(raw_logits).item()
|
|
|
|
| 2417 |
})
|
| 2418 |
# Also add to logits if not present
|
| 2419 |
if next_token_id not in [e["token_id"] for e in logits_entries]:
|
| 2420 |
+
cos_outlier, row_norm_outlier = _cos_for(next_token_id)
|
| 2421 |
+
outlier_entry = {
|
| 2422 |
"token": next_token_text,
|
| 2423 |
"token_id": next_token_id,
|
| 2424 |
"logit": selected_logit,
|
| 2425 |
"rank": selected_rank,
|
| 2426 |
+
"is_selected_outlier": True,
|
| 2427 |
+
}
|
| 2428 |
+
if cos_outlier is not None:
|
| 2429 |
+
outlier_entry["cosine_sim"] = round(cos_outlier, 4)
|
| 2430 |
+
if row_norm_outlier is not None:
|
| 2431 |
+
outlier_entry["unemb_row_norm"] = round(
|
| 2432 |
+
row_norm_outlier, 4
|
| 2433 |
+
)
|
| 2434 |
+
logits_entries.append(outlier_entry)
|
| 2435 |
|
| 2436 |
# Build sampling metadata. `is_filtered` and `eligible_count`
|
| 2437 |
# let the frontend decide whether to show one or two
|
|
|
|
| 3334 |
else:
|
| 3335 |
return manager.tokenizer.decode([tid], skip_special_tokens=False)
|
| 3336 |
|
| 3337 |
+
# ── Unembedding-cosine prep (SSE path) ──────────
|
| 3338 |
+
# Mirrors the non-SSE path. Each logit is
|
| 3339 |
+
# W_E[v] · h_post_norm; cos(W_E[v], h_post_norm)
|
| 3340 |
+
# strips magnitude so a developer can read whether
|
| 3341 |
+
# a high-logit token won by direction or by
|
| 3342 |
+
# row magnitude. Cheap (one matmul per token per
|
| 3343 |
+
# step) and computed once per step.
|
| 3344 |
+
cos_h_norm = None
|
| 3345 |
+
W_E = None
|
| 3346 |
+
try:
|
| 3347 |
+
final_hidden_pre_norm = outputs.hidden_states[-1][0, -1, :]
|
| 3348 |
+
if hasattr(manager.model, 'model') and hasattr(manager.model.model, 'norm'):
|
| 3349 |
+
h_post_norm = manager.model.model.norm(
|
| 3350 |
+
final_hidden_pre_norm.unsqueeze(0)
|
| 3351 |
+
)[0]
|
| 3352 |
+
elif hasattr(manager.model, 'transformer') and hasattr(manager.model.transformer, 'ln_f'):
|
| 3353 |
+
h_post_norm = manager.model.transformer.ln_f(
|
| 3354 |
+
final_hidden_pre_norm.unsqueeze(0)
|
| 3355 |
+
)[0]
|
| 3356 |
+
else:
|
| 3357 |
+
h_post_norm = final_hidden_pre_norm
|
| 3358 |
+
h_norm_val = float(torch.linalg.vector_norm(h_post_norm).item())
|
| 3359 |
+
if h_norm_val > 1e-9:
|
| 3360 |
+
cos_h_norm = h_post_norm / h_norm_val
|
| 3361 |
+
if hasattr(manager.model, 'lm_head'):
|
| 3362 |
+
W_E = manager.model.lm_head.weight
|
| 3363 |
+
else:
|
| 3364 |
+
logger.warning(
|
| 3365 |
+
"[SSE cosine prep] manager.model has no lm_head — cosine emission skipped"
|
| 3366 |
+
)
|
| 3367 |
+
else:
|
| 3368 |
+
logger.warning(
|
| 3369 |
+
f"[SSE cosine prep] h_post_norm magnitude too small: {h_norm_val}"
|
| 3370 |
+
)
|
| 3371 |
+
if cos_h_norm is not None and W_E is not None and step == 0:
|
| 3372 |
+
logger.info(
|
| 3373 |
+
f"[SSE cosine prep] enabled · h_post_norm shape {tuple(h_post_norm.shape)} · "
|
| 3374 |
+
f"W_E shape {tuple(W_E.shape)} · h_norm {h_norm_val:.3f}"
|
| 3375 |
+
)
|
| 3376 |
+
except Exception as e:
|
| 3377 |
+
logger.warning(
|
| 3378 |
+
f"[SSE cosine prep] failed at step {step}: {type(e).__name__}: {e}"
|
| 3379 |
+
)
|
| 3380 |
+
cos_h_norm = None
|
| 3381 |
+
W_E = None
|
| 3382 |
+
|
| 3383 |
+
def _cos_for(token_idx: int):
|
| 3384 |
+
"""cosine(W_E[token_idx], h_post_norm) and ‖W_E row‖."""
|
| 3385 |
+
if cos_h_norm is None or W_E is None:
|
| 3386 |
+
return None, None
|
| 3387 |
+
try:
|
| 3388 |
+
row = W_E[token_idx]
|
| 3389 |
+
row_norm = float(torch.linalg.vector_norm(row).item())
|
| 3390 |
+
if row_norm < 1e-9:
|
| 3391 |
+
return None, row_norm
|
| 3392 |
+
cos = float(((row / row_norm) @ cos_h_norm).item())
|
| 3393 |
+
return cos, row_norm
|
| 3394 |
+
except Exception:
|
| 3395 |
+
return None, None
|
| 3396 |
+
|
| 3397 |
logits_entries = []
|
| 3398 |
for rank, (logit_val, idx) in enumerate(zip(top_raw_logits.tolist(), top_raw_indices.tolist())):
|
| 3399 |
token_text = decode_token(idx)
|
| 3400 |
+
cos, row_norm = _cos_for(idx)
|
| 3401 |
+
entry = {
|
| 3402 |
"token": token_text,
|
| 3403 |
"token_id": idx,
|
| 3404 |
"logit": logit_val,
|
| 3405 |
+
"rank": rank + 1,
|
| 3406 |
+
}
|
| 3407 |
+
if cos is not None:
|
| 3408 |
+
entry["cosine_sim"] = round(cos, 4)
|
| 3409 |
+
if row_norm is not None:
|
| 3410 |
+
entry["unemb_row_norm"] = round(row_norm, 4)
|
| 3411 |
+
logits_entries.append(entry)
|
| 3412 |
|
| 3413 |
# Greedy token (argmax of raw logits, before any sampling)
|
| 3414 |
greedy_token_id = torch.argmax(raw_logits).item()
|
|
|
|
| 3519 |
})
|
| 3520 |
# Also add to logits if not present
|
| 3521 |
if next_token_id not in [e["token_id"] for e in logits_entries]:
|
| 3522 |
+
cos_outlier, row_norm_outlier = _cos_for(next_token_id)
|
| 3523 |
+
outlier_entry = {
|
| 3524 |
"token": next_token_text,
|
| 3525 |
"token_id": next_token_id,
|
| 3526 |
"logit": selected_logit,
|
| 3527 |
"rank": selected_rank,
|
| 3528 |
+
"is_selected_outlier": True,
|
| 3529 |
+
}
|
| 3530 |
+
if cos_outlier is not None:
|
| 3531 |
+
outlier_entry["cosine_sim"] = round(cos_outlier, 4)
|
| 3532 |
+
if row_norm_outlier is not None:
|
| 3533 |
+
outlier_entry["unemb_row_norm"] = round(
|
| 3534 |
+
row_norm_outlier, 4
|
| 3535 |
+
)
|
| 3536 |
+
logits_entries.append(outlier_entry)
|
| 3537 |
|
| 3538 |
# Build sampling metadata
|
| 3539 |
eligible_count = int((probs_filtered > 0).sum().item())
|
|
|
|
| 3947 |
# endpoint or a 14,336-d cache entry.
|
| 3948 |
layer_entry["ffn_top_neurons"] = ffn_top_neurons[layer_idx]
|
| 3949 |
|
| 3950 |
+
# Phase 5: Logit lens at every layer.
|
| 3951 |
+
# The lens projection is a single matmul
|
| 3952 |
+
# [1, hidden] × [hidden, vocab] ≈ 0.5-1ms on
|
| 3953 |
+
# Apple Silicon MPS — 40 layers × N_steps adds
|
| 3954 |
+
# only ~5-10s to a typical analyse run. Worth
|
| 3955 |
+
# the cost: every-layer sampling eliminates
|
| 3956 |
+
# the (L17, L24]-style uncertainty windows the
|
| 3957 |
+
# frontend's crystallisation narrative would
|
| 3958 |
+
# otherwise have to qualify, giving developers
|
| 3959 |
+
# a layer-precise commit point for trust
|
| 3960 |
+
# decisions about the model's prediction.
|
| 3961 |
+
logit_lens_stride = 1
|
| 3962 |
if layer_idx % logit_lens_stride == 0 or layer_idx == n_layers - 1:
|
| 3963 |
try:
|
| 3964 |
hidden_for_lens = current_hidden[-1].unsqueeze(0) # [1, hidden_dim]
|