garyboon Claude Opus 4.7 (1M context) commited on
Commit
3a10cf3
·
1 Parent(s): 336085f

Backend: unembedding cosine emission + Devstral tokenizer fallback

Browse files

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

Files changed (1) hide show
  1. backend/model_service.py +193 -16
backend/model_service.py CHANGED
@@ -677,8 +677,24 @@ class ModelManager:
677
  attn_implementation="eager"
678
  ).to(self.device)
679
 
680
- # Load tokenizer
681
- self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- logits_entries.append({
 
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
- logits_entries.append({
 
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
- logits_entries.append({
 
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
- logits_entries.append({
 
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 sampled layers (every 8th layer)
3784
- logit_lens_stride = max(1, n_layers // 5)
 
 
 
 
 
 
 
 
 
 
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]