gary-boon commited on
Commit
a1b2ad2
·
unverified ·
2 Parent(s): e11c2773a10cf3

Merge pull request #6 from gary-boon/feature/rq1-ffn-intermediate

Browse files

Backend: emit per-token embedding norms, top-K SwiGLU neurons, unembedding cosine + Devstral tokenizer fallback

Files changed (1) hide show
  1. backend/model_service.py +471 -42
backend/model_service.py CHANGED
@@ -283,6 +283,56 @@ class MatrixCache:
283
  # Default to mean for unknown modes
284
  return np.mean(arr, axis=0).tolist()
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
  # Global matrix cache instance
288
  matrix_cache = MatrixCache(ttl_seconds=3600) # 60 min TTL
@@ -300,6 +350,52 @@ def _classify_stability(margin: float) -> str:
300
  return "fragile"
301
 
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  class HiddenStateCache:
304
  """
305
  Cache for hidden states and logits per (request_id, step).
@@ -581,8 +677,24 @@ class ModelManager:
581
  attn_implementation="eager"
582
  ).to(self.device)
583
 
584
- # Load tokenizer
585
- self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  # Set pad_token if the tokenizer allows it (some like MistralCommonTokenizer don't)
587
  try:
588
  self.tokenizer.pad_token = self.tokenizer.eos_token
@@ -2103,16 +2215,85 @@ async def analyze_research_attention(request: Dict[str, Any], authenticated: boo
2103
  top_n_display = 10 # Get top 10 alternatives for display
2104
  top_raw_logits, top_raw_indices = torch.topk(raw_logits, k=min(top_n_display, len(raw_logits)))
2105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2106
  # Build raw logits entries (before temperature)
2107
  logits_entries = []
2108
  for rank, (logit_val, idx) in enumerate(zip(top_raw_logits.tolist(), top_raw_indices.tolist())):
2109
  token_text = manager.tokenizer.decode([idx], skip_special_tokens=False)
2110
- logits_entries.append({
 
2111
  "token": token_text,
2112
  "token_id": idx,
2113
  "logit": logit_val,
2114
- "rank": rank + 1
2115
- })
 
 
 
 
 
2116
 
2117
  # Greedy token (argmax of raw logits, before any sampling)
2118
  greedy_token_id = torch.argmax(raw_logits).item()
@@ -2236,13 +2417,21 @@ async def analyze_research_attention(request: Dict[str, Any], authenticated: boo
2236
  })
2237
  # Also add to logits if not present
2238
  if next_token_id not in [e["token_id"] for e in logits_entries]:
2239
- logits_entries.append({
 
2240
  "token": next_token_text,
2241
  "token_id": next_token_id,
2242
  "logit": selected_logit,
2243
  "rank": selected_rank,
2244
- "is_selected_outlier": True
2245
- })
 
 
 
 
 
 
 
2246
 
2247
  # Build sampling metadata. `is_filtered` and `eligible_count`
2248
  # let the frontend decide whether to show one or two
@@ -2654,6 +2843,16 @@ async def analyze_research_attention(request: Dict[str, Any], authenticated: boo
2654
  })
2655
  return result
2656
 
 
 
 
 
 
 
 
 
 
 
2657
  # Build response
2658
  response = {
2659
  "requestId": request_id, # For lazy-loading matrices via /matrix endpoint
@@ -2672,6 +2871,7 @@ async def analyze_research_attention(request: Dict[str, Any], authenticated: boo
2672
  "headDim": head_dim,
2673
  "vocabSize": manager.model.config.vocab_size
2674
  },
 
2675
  "generationTime": generation_time,
2676
  "numTokensGenerated": len(generated_tokens)
2677
  }
@@ -2921,6 +3121,16 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
2921
  attn_output_norms = {}
2922
  mlp_output_norms = {}
2923
  gate_activation_stats = {}
 
 
 
 
 
 
 
 
 
 
2924
 
2925
  def make_attn_output_hook(layer_idx):
2926
  def hook(module, input, output):
@@ -2965,6 +3175,63 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
2965
  pass
2966
  return hook
2967
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2968
  # Cache for decoded token texts (reused across heads within a step)
2969
  step_token_texts_cache: Dict[str, Any] = {}
2970
 
@@ -2996,6 +3263,14 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
2996
  hooks.append(hook)
2997
  if layer_idx == 0:
2998
  ffn_type = "swiglu"
 
 
 
 
 
 
 
 
2999
  logger.info(f"Registered attn/MLP output hooks for contribution tracking (ffn_type={ffn_type})")
3000
  except Exception as hook_error:
3001
  logger.warning(f"Could not register attn/MLP hooks: {hook_error}")
@@ -3017,6 +3292,7 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3017
  attn_output_norms.clear()
3018
  mlp_output_norms.clear()
3019
  gate_activation_stats.clear()
 
3020
 
3021
  # Forward pass with full outputs
3022
  outputs = manager.model(
@@ -3058,15 +3334,81 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3058
  else:
3059
  return manager.tokenizer.decode([tid], skip_special_tokens=False)
3060
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3061
  logits_entries = []
3062
  for rank, (logit_val, idx) in enumerate(zip(top_raw_logits.tolist(), top_raw_indices.tolist())):
3063
  token_text = decode_token(idx)
3064
- logits_entries.append({
 
3065
  "token": token_text,
3066
  "token_id": idx,
3067
  "logit": logit_val,
3068
- "rank": rank + 1
3069
- })
 
 
 
 
 
3070
 
3071
  # Greedy token (argmax of raw logits, before any sampling)
3072
  greedy_token_id = torch.argmax(raw_logits).item()
@@ -3177,13 +3519,21 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3177
  })
3178
  # Also add to logits if not present
3179
  if next_token_id not in [e["token_id"] for e in logits_entries]:
3180
- logits_entries.append({
 
3181
  "token": next_token_text,
3182
  "token_id": next_token_id,
3183
  "logit": selected_logit,
3184
  "rank": selected_rank,
3185
- "is_selected_outlier": True
3186
- })
 
 
 
 
 
 
 
3187
 
3188
  # Build sampling metadata
3189
  eligible_count = int((probs_filtered > 0).sum().item())
@@ -3585,9 +3935,30 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3585
  layer_entry["ffn_contribution"] = round(mlp_n / total, 4)
3586
  if layer_idx in gate_activation_stats:
3587
  layer_entry["gate_stats"] = gate_activation_stats[layer_idx]
3588
-
3589
- # Phase 5: Logit lens at sampled layers (every 8th layer)
3590
- logit_lens_stride = max(1, n_layers // 5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3591
  if layer_idx % logit_lens_stride == 0 or layer_idx == n_layers - 1:
3592
  try:
3593
  hidden_for_lens = current_hidden[-1].unsqueeze(0) # [1, hidden_dim]
@@ -3866,6 +4237,22 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3866
  "flip_count": tuned_flip_count,
3867
  }
3868
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3869
  # Build response
3870
  response = {
3871
  "requestId": request_id, # For lazy-loading matrices via /matrix endpoint
@@ -3887,6 +4274,7 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3887
  "ffnType": ffn_type,
3888
  "intermediateSize": getattr(manager.model.config, 'intermediate_size', None),
3889
  },
 
3890
  "generationTime": generation_time,
3891
  "numTokensGenerated": len(generated_tokens),
3892
  "marginStats": margin_stats,
@@ -3934,46 +4322,87 @@ async def get_attention_matrix(
3934
  request_id: str,
3935
  step: int,
3936
  layer: int,
3937
- head: int,
3938
- authenticated: bool = Depends(verify_api_key)
 
3939
  ):
3940
  """
3941
- Retrieve cached attention/QKV matrices for a specific head.
 
3942
 
3943
- Used for lazy-loading matrix data when user clicks "View Matrix" in the frontend.
3944
- Matrices are cached during the initial analysis and available for 60 minutes.
 
 
3945
 
3946
  Parameters:
3947
  - request_id: UUID from the original analysis response
3948
  - step: Generation step (0 = first generated token)
3949
  - layer: Layer index (0-based)
3950
- - head: Head index (0-based)
 
 
 
 
3951
 
3952
  Returns:
3953
  - attention_weights: [seq_len, seq_len] attention matrix
3954
- - q_matrix: [seq_len, head_dim] query projections
3955
- - k_matrix: [seq_len, head_dim] key projections
3956
- - v_matrix: [seq_len, head_dim] value projections
 
 
3957
  """
3958
- data = matrix_cache.get(request_id, step, layer, head)
3959
- if data is None:
3960
- logger.warning(f"Matrix cache miss: request_id={request_id}, step={step}, layer={layer}, head={head}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3961
  raise HTTPException(
3962
  status_code=404,
3963
- detail="Matrix data not found. Cache may have expired (60 min TTL). Please re-analyze."
3964
  )
3965
-
3966
- logger.info(f"Matrix cache hit: request_id={request_id}, step={step}, layer={layer}, head={head}")
3967
-
3968
- # Convert numpy arrays to lists for JSON serialization
3969
- # Arrays are stored as numpy for memory efficiency, converted on-demand here
3970
- response = {}
3971
- for key, value in data.items():
3972
- if value is not None and hasattr(value, 'tolist'):
3973
- response[key] = value.tolist()
3974
- else:
3975
- response[key] = value
3976
- return response
3977
 
3978
 
3979
  @app.get("/analyze/research/attention/matrix/stats")
 
283
  # Default to mean for unknown modes
284
  return np.mean(arr, axis=0).tolist()
285
 
286
+ def get_aggregate_attention_matrix(
287
+ self,
288
+ request_id: str,
289
+ step: int,
290
+ layer: int,
291
+ mode: str = "mean",
292
+ num_heads: Optional[int] = None,
293
+ ) -> Optional[list]:
294
+ """
295
+ Compute the aggregated attention MATRIX across all heads for a
296
+ layer. Mirrors get_aggregate_row but returns the full
297
+ [seq_len, seq_len] matrix instead of one row.
298
+
299
+ Used by the Mechanism lens's Step 4 (Attention) to render the
300
+ per-step matrix in its "all heads (mean)" default mode without
301
+ forcing the frontend to fetch + aggregate N matrices itself.
302
+
303
+ Args:
304
+ request_id: UUID from analysis
305
+ step: Generation step
306
+ layer: Layer index
307
+ mode: Aggregation mode - "mean" or "max"
308
+ num_heads: Optional override; otherwise derived from request meta
309
+
310
+ Returns:
311
+ Aggregated 2-D matrix as a list of lists, or None if data
312
+ unavailable or request metadata is missing.
313
+ """
314
+ if num_heads is None:
315
+ meta = self.get_request_metadata(request_id)
316
+ if meta is None or meta.get("num_heads") is None:
317
+ return None
318
+ num_heads = meta["num_heads"]
319
+ matrices = []
320
+ for h in range(num_heads):
321
+ data = self.get(request_id, step, layer, h)
322
+ if data is None:
323
+ continue
324
+ attn = data.get("attention_weights")
325
+ if attn is None:
326
+ continue
327
+ matrices.append(np.asarray(attn))
328
+ if not matrices:
329
+ return None
330
+ arr = np.stack(matrices, axis=0) # (n_heads, seq, seq)
331
+ if mode == "max":
332
+ return np.max(arr, axis=0).tolist()
333
+ # Default to mean for "mean" or unknown modes.
334
+ return np.mean(arr, axis=0).tolist()
335
+
336
 
337
  # Global matrix cache instance
338
  matrix_cache = MatrixCache(ttl_seconds=3600) # 60 min TTL
 
350
  return "fragile"
351
 
352
 
353
+ def _compute_embedding_norms(manager, token_ids: List[int]) -> List[float]:
354
+ """
355
+ Per-token L2 norms of the layer-0 embedding vectors, indexed by the
356
+ token ids supplied. Used by the analyse endpoints to surface a
357
+ real-data anchor for the residual-norm trace shown in the
358
+ Mechanism lens's Microscope rail (RQ1).
359
+
360
+ Memory is bounded by the number of *unique* token ids in the
361
+ sequence rather than the sequence length: a long prompt with
362
+ repeated tokens reuses the same embedding row, so we gather rows
363
+ once for unique ids and scatter the resulting scalar back to the
364
+ sequence positions. This avoids materialising a full
365
+ [1, seq_len, hidden_size] activation tensor — important for the
366
+ long-context models this service supports (e.g. devstral-small at
367
+ 131,072 tokens, where the naive path would allocate multi-GB at
368
+ end-of-request and risk OOM).
369
+
370
+ Failures (missing module, unexpected layout) downgrade gracefully
371
+ to an empty list so the rest of the trace is unaffected.
372
+ """
373
+ if not token_ids:
374
+ return []
375
+ try:
376
+ with torch.no_grad():
377
+ embed_layer = manager.model.get_input_embeddings()
378
+ ids_tensor = torch.tensor(
379
+ token_ids, dtype=torch.long, device=manager.device
380
+ )
381
+ unique_ids, inverse = torch.unique(
382
+ ids_tensor, return_inverse=True
383
+ )
384
+ # Index the embedding weight matrix directly. Allocation is
385
+ # (n_unique, hidden_size) instead of (seq_len, hidden_size)
386
+ # — typically a few hundred rows for code-generation
387
+ # prompts regardless of how long the prompt is.
388
+ unique_rows = embed_layer.weight.index_select(0, unique_ids)
389
+ unique_norms = torch.linalg.vector_norm(
390
+ unique_rows.float(), dim=-1
391
+ )
392
+ seq_norms = unique_norms[inverse]
393
+ return [float(v) for v in seq_norms.cpu().tolist()]
394
+ except Exception as e: # pragma: no cover — defensive
395
+ logger.warning(f"Failed to compute embedding norms: {e}")
396
+ return []
397
+
398
+
399
  class HiddenStateCache:
400
  """
401
  Cache for hidden states and logits per (request_id, step).
 
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
 
2843
  })
2844
  return result
2845
 
2846
+ # Embedding L2 norms — see streaming endpoint for the full
2847
+ # rationale (RQ1 layer-0 anchor for the residual-norm trace).
2848
+ # Covers both prompt tokens and generated tokens so the
2849
+ # Mechanism lens can render bars for the full context window
2850
+ # (prompt + previously-generated tokens) at any step.
2851
+ embedding_norms: List[float] = _compute_embedding_norms(
2852
+ manager,
2853
+ list(prompt_token_ids) + list(generated_token_ids),
2854
+ )
2855
+
2856
  # Build response
2857
  response = {
2858
  "requestId": request_id, # For lazy-loading matrices via /matrix endpoint
 
2871
  "headDim": head_dim,
2872
  "vocabSize": manager.model.config.vocab_size
2873
  },
2874
+ "embeddingNorms": embedding_norms,
2875
  "generationTime": generation_time,
2876
  "numTokensGenerated": len(generated_tokens)
2877
  }
 
3121
  attn_output_norms = {}
3122
  mlp_output_norms = {}
3123
  gate_activation_stats = {}
3124
+ # Per-layer top-K SwiGLU intermediate activations at the
3125
+ # predicting position (last token of the current forward
3126
+ # pass). Each entry is a list of {idx, value} for the
3127
+ # top-K neurons by |silu(W_g·x) ⊙ (W_u·x)| at that layer
3128
+ # for that step. Powers RQ1 Step 5's "Behind the SwiGLU"
3129
+ # per-neuron view without storing the full 14,336-d
3130
+ # vector — bounded memory, full transparency for the
3131
+ # most-loaded neurons.
3132
+ ffn_top_neurons = {}
3133
+ FFN_TOPK = 32
3134
 
3135
  def make_attn_output_hook(layer_idx):
3136
  def hook(module, input, output):
 
3175
  pass
3176
  return hook
3177
 
3178
+ def make_ffn_top_neurons_hook(layer_idx):
3179
+ """
3180
+ Capture the top-K of the SwiGLU intermediate activation
3181
+ vector at the predicting (last) position for this layer.
3182
+
3183
+ Computes silu(W_gate · x) ⊙ (W_up · x) — the same
3184
+ 14,336-d intermediate that gets fed to W_down. Storing
3185
+ only the top-K (=32) by |value| keeps memory bounded:
3186
+ 32 × {idx:int, value:float} ≈ 256 bytes per layer per
3187
+ step, vs 14,336 × 4 bytes = 57KB per layer per step
3188
+ if we stored the full vector. The top-K is what a
3189
+ developer reads anyway (the rest are noise) and the
3190
+ stored neuron index lets the frontend label "neuron
3191
+ 8421 dominated this token at L37".
3192
+
3193
+ Only fires for SwiGLU layers (gate_proj + up_proj
3194
+ present); other architectures are skipped at
3195
+ registration time.
3196
+ """
3197
+ def hook(module, input, output):
3198
+ try:
3199
+ inp = input[0] if isinstance(input, tuple) else input
3200
+ if inp.dim() == 3:
3201
+ inp = inp[0, -1] # last (predicting) token
3202
+ elif inp.dim() == 2:
3203
+ inp = inp[-1]
3204
+ if hasattr(module, 'gate_proj') and hasattr(module, 'up_proj'):
3205
+ gate_out = torch.nn.functional.silu(
3206
+ module.gate_proj(inp)
3207
+ )
3208
+ up_out = module.up_proj(inp)
3209
+ intermediate = gate_out * up_out
3210
+ abs_inter = intermediate.abs()
3211
+ k = min(FFN_TOPK, intermediate.shape[-1])
3212
+ top_vals, top_idx = torch.topk(abs_inter, k=k)
3213
+ # Use the signed values (not abs) so the
3214
+ # frontend can render direction; abs only
3215
+ # drove the ranking.
3216
+ signed = intermediate[top_idx]
3217
+ ffn_top_neurons[layer_idx] = {
3218
+ "k": k,
3219
+ "intermediate_size": int(intermediate.shape[-1]),
3220
+ "neurons": [
3221
+ {
3222
+ "idx": int(i),
3223
+ "value": round(float(v), 4),
3224
+ }
3225
+ for i, v in zip(
3226
+ top_idx.cpu().tolist(),
3227
+ signed.cpu().tolist(),
3228
+ )
3229
+ ],
3230
+ }
3231
+ except Exception:
3232
+ pass
3233
+ return hook
3234
+
3235
  # Cache for decoded token texts (reused across heads within a step)
3236
  step_token_texts_cache: Dict[str, Any] = {}
3237
 
 
3263
  hooks.append(hook)
3264
  if layer_idx == 0:
3265
  ffn_type = "swiglu"
3266
+ # FFN top-K intermediate hook — needs both
3267
+ # gate_proj and up_proj to compute the
3268
+ # SwiGLU intermediate vector.
3269
+ if hasattr(layer.mlp, 'gate_proj') and hasattr(layer.mlp, 'up_proj'):
3270
+ hook = layer.mlp.register_forward_hook(
3271
+ make_ffn_top_neurons_hook(layer_idx)
3272
+ )
3273
+ hooks.append(hook)
3274
  logger.info(f"Registered attn/MLP output hooks for contribution tracking (ffn_type={ffn_type})")
3275
  except Exception as hook_error:
3276
  logger.warning(f"Could not register attn/MLP hooks: {hook_error}")
 
3292
  attn_output_norms.clear()
3293
  mlp_output_norms.clear()
3294
  gate_activation_stats.clear()
3295
+ ffn_top_neurons.clear()
3296
 
3297
  # Forward pass with full outputs
3298
  outputs = manager.model(
 
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())
 
3935
  layer_entry["ffn_contribution"] = round(mlp_n / total, 4)
3936
  if layer_idx in gate_activation_stats:
3937
  layer_entry["gate_stats"] = gate_activation_stats[layer_idx]
3938
+ if layer_idx in ffn_top_neurons:
3939
+ # `ffn_top_neurons` holds top-K SwiGLU
3940
+ # intermediate activations at the
3941
+ # predicting position — see
3942
+ # make_ffn_top_neurons_hook for the
3943
+ # per-neuron value semantics. The frontend
3944
+ # uses this to render Step 5's per-neuron
3945
+ # bar chart inside "Behind the SwiGLU"
3946
+ # without the backend needing a separate
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]
 
4237
  "flip_count": tuned_flip_count,
4238
  }
4239
 
4240
+ # === Embedding L2 norms — per-input-token layer-0 anchor ===
4241
+ # One float per token in the FULL context (prompt +
4242
+ # generated): ‖e_i‖ where e_i is the row the embedding
4243
+ # matrix returns for token i. The Mechanism lens uses this
4244
+ # as the real-data anchor for the residual-norm trace
4245
+ # shown in the Microscope rail (RQ1: developer-
4246
+ # interpretable architectural signal at the embedding
4247
+ # stage). Generated tokens are included so the chart
4248
+ # reflects the actual input the model sees at every
4249
+ # selected step. Memory is bounded by the number of
4250
+ # *unique* token ids — see _compute_embedding_norms.
4251
+ embedding_norms: List[float] = _compute_embedding_norms(
4252
+ manager,
4253
+ list(prompt_token_ids) + list(generated_token_ids),
4254
+ )
4255
+
4256
  # Build response
4257
  response = {
4258
  "requestId": request_id, # For lazy-loading matrices via /matrix endpoint
 
4274
  "ffnType": ffn_type,
4275
  "intermediateSize": getattr(manager.model.config, 'intermediate_size', None),
4276
  },
4277
+ "embeddingNorms": embedding_norms,
4278
  "generationTime": generation_time,
4279
  "numTokensGenerated": len(generated_tokens),
4280
  "marginStats": margin_stats,
 
4322
  request_id: str,
4323
  step: int,
4324
  layer: int,
4325
+ head: Optional[int] = None,
4326
+ aggregate_mode: str = "mean",
4327
+ authenticated: bool = Depends(verify_api_key),
4328
  ):
4329
  """
4330
+ Retrieve cached attention/QKV matrices for a specific head, OR an
4331
+ aggregated attention matrix across all heads when `head` is omitted.
4332
 
4333
+ Used for lazy-loading matrix data when the user opens an inline
4334
+ head panel (per-head Q/K/V) or the Mechanism lens's Step 4
4335
+ attention matrix view (aggregate by default). Matrices are cached
4336
+ during the initial analysis and available for 60 minutes.
4337
 
4338
  Parameters:
4339
  - request_id: UUID from the original analysis response
4340
  - step: Generation step (0 = first generated token)
4341
  - layer: Layer index (0-based)
4342
+ - head: Head index (0-based). Omit for an aggregate matrix across
4343
+ all heads — the returned payload then contains only
4344
+ `attention_weights` (the Q/K/V projections are per-head and
4345
+ have no canonical aggregate form).
4346
+ - aggregate_mode: "mean" or "max" when `head` is omitted.
4347
 
4348
  Returns:
4349
  - attention_weights: [seq_len, seq_len] attention matrix
4350
+ - q_matrix / k_matrix / v_matrix: [seq_len, head_dim] projections
4351
+ (only when `head` is specified)
4352
+ - layer: Layer index
4353
+ - head: Head index, or null when aggregated
4354
+ - aggregate_mode: Aggregation mode used, or null for per-head
4355
  """
4356
+ if head is not None:
4357
+ data = matrix_cache.get(request_id, step, layer, head)
4358
+ if data is None:
4359
+ logger.warning(
4360
+ f"Matrix cache miss: request_id={request_id}, step={step}, layer={layer}, head={head}"
4361
+ )
4362
+ raise HTTPException(
4363
+ status_code=404,
4364
+ detail="Matrix data not found. Cache may have expired (60 min TTL). Please re-analyze.",
4365
+ )
4366
+ logger.info(
4367
+ f"Matrix cache hit: request_id={request_id}, step={step}, layer={layer}, head={head}"
4368
+ )
4369
+ # Convert numpy arrays to lists for JSON serialization. Arrays
4370
+ # are stored as numpy for memory efficiency, converted on-
4371
+ # demand here.
4372
+ response = {}
4373
+ for key, value in data.items():
4374
+ if value is not None and hasattr(value, "tolist"):
4375
+ response[key] = value.tolist()
4376
+ else:
4377
+ response[key] = value
4378
+ response["layer"] = layer
4379
+ response["head"] = head
4380
+ response["aggregate_mode"] = None
4381
+ return response
4382
+
4383
+ # Aggregate path — averages or max-pools attention matrices across
4384
+ # heads. Q/K/V projections are intentionally omitted; they're
4385
+ # per-head quantities and don't have a canonical aggregate.
4386
+ aggregate = matrix_cache.get_aggregate_attention_matrix(
4387
+ request_id, step, layer, aggregate_mode
4388
+ )
4389
+ if aggregate is None:
4390
+ logger.warning(
4391
+ f"Aggregate matrix cache miss: request_id={request_id}, step={step}, layer={layer}, mode={aggregate_mode}"
4392
+ )
4393
  raise HTTPException(
4394
  status_code=404,
4395
+ detail="Matrix data not found. Cache may have expired (60 min TTL). Please re-analyze.",
4396
  )
4397
+ logger.info(
4398
+ f"Aggregate matrix cache hit: request_id={request_id}, step={step}, layer={layer}, mode={aggregate_mode}"
4399
+ )
4400
+ return {
4401
+ "attention_weights": aggregate,
4402
+ "layer": layer,
4403
+ "head": None,
4404
+ "aggregate_mode": aggregate_mode,
4405
+ }
 
 
 
4406
 
4407
 
4408
  @app.get("/analyze/research/attention/matrix/stats")