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

Emit per-token embedding L2 norms for RQ1 Step 3

Browse files

Adds a `_compute_embedding_norms` helper that bounds memory by
unique token IDs (`torch.unique` + `weight.index_select`) so long
contexts don't allocate `[1, seq_len, hidden_size]` × 2 unnecessarily.

Both analyse endpoints now return `embeddingNorms: List[float]`
covering the prompt + generated tokens, used by the new code-first
Mechanism lens to surface real per-token embedding magnitudes
instead of synthetic illustrative values.

Also extends MatrixCache with `get_aggregate_attention_matrix()`
(mean / max pooling across heads) so the Step 4 attention view can
fetch the aggregate matrix from a single endpoint instead of
synthesising it on the client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. backend/model_service.py +190 -25
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).
@@ -2654,6 +2750,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 +2778,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
  }
@@ -3953,6 +4060,22 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3953
  "flip_count": tuned_flip_count,
3954
  }
3955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3956
  # Build response
3957
  response = {
3958
  "requestId": request_id, # For lazy-loading matrices via /matrix endpoint
@@ -3974,6 +4097,7 @@ async def analyze_research_attention_stream(request: Dict[str, Any], authenticat
3974
  "ffnType": ffn_type,
3975
  "intermediateSize": getattr(manager.model.config, 'intermediate_size', None),
3976
  },
 
3977
  "generationTime": generation_time,
3978
  "numTokensGenerated": len(generated_tokens),
3979
  "marginStats": margin_stats,
@@ -4021,46 +4145,87 @@ async def get_attention_matrix(
4021
  request_id: str,
4022
  step: int,
4023
  layer: int,
4024
- head: int,
4025
- authenticated: bool = Depends(verify_api_key)
 
4026
  ):
4027
  """
4028
- Retrieve cached attention/QKV matrices for a specific head.
 
4029
 
4030
- Used for lazy-loading matrix data when user clicks "View Matrix" in the frontend.
4031
- Matrices are cached during the initial analysis and available for 60 minutes.
 
 
4032
 
4033
  Parameters:
4034
  - request_id: UUID from the original analysis response
4035
  - step: Generation step (0 = first generated token)
4036
  - layer: Layer index (0-based)
4037
- - head: Head index (0-based)
 
 
 
 
4038
 
4039
  Returns:
4040
  - attention_weights: [seq_len, seq_len] attention matrix
4041
- - q_matrix: [seq_len, head_dim] query projections
4042
- - k_matrix: [seq_len, head_dim] key projections
4043
- - v_matrix: [seq_len, head_dim] value projections
 
 
4044
  """
4045
- data = matrix_cache.get(request_id, step, layer, head)
4046
- if data is None:
4047
- logger.warning(f"Matrix cache miss: request_id={request_id}, step={step}, layer={layer}, head={head}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4048
  raise HTTPException(
4049
  status_code=404,
4050
- detail="Matrix data not found. Cache may have expired (60 min TTL). Please re-analyze."
4051
  )
4052
-
4053
- logger.info(f"Matrix cache hit: request_id={request_id}, step={step}, layer={layer}, head={head}")
4054
-
4055
- # Convert numpy arrays to lists for JSON serialization
4056
- # Arrays are stored as numpy for memory efficiency, converted on-demand here
4057
- response = {}
4058
- for key, value in data.items():
4059
- if value is not None and hasattr(value, 'tolist'):
4060
- response[key] = value.tolist()
4061
- else:
4062
- response[key] = value
4063
- return response
4064
 
4065
 
4066
  @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).
 
2750
  })
2751
  return result
2752
 
2753
+ # Embedding L2 norms — see streaming endpoint for the full
2754
+ # rationale (RQ1 layer-0 anchor for the residual-norm trace).
2755
+ # Covers both prompt tokens and generated tokens so the
2756
+ # Mechanism lens can render bars for the full context window
2757
+ # (prompt + previously-generated tokens) at any step.
2758
+ embedding_norms: List[float] = _compute_embedding_norms(
2759
+ manager,
2760
+ list(prompt_token_ids) + list(generated_token_ids),
2761
+ )
2762
+
2763
  # Build response
2764
  response = {
2765
  "requestId": request_id, # For lazy-loading matrices via /matrix endpoint
 
2778
  "headDim": head_dim,
2779
  "vocabSize": manager.model.config.vocab_size
2780
  },
2781
+ "embeddingNorms": embedding_norms,
2782
  "generationTime": generation_time,
2783
  "numTokensGenerated": len(generated_tokens)
2784
  }
 
4060
  "flip_count": tuned_flip_count,
4061
  }
4062
 
4063
+ # === Embedding L2 norms — per-input-token layer-0 anchor ===
4064
+ # One float per token in the FULL context (prompt +
4065
+ # generated): ‖e_i‖ where e_i is the row the embedding
4066
+ # matrix returns for token i. The Mechanism lens uses this
4067
+ # as the real-data anchor for the residual-norm trace
4068
+ # shown in the Microscope rail (RQ1: developer-
4069
+ # interpretable architectural signal at the embedding
4070
+ # stage). Generated tokens are included so the chart
4071
+ # reflects the actual input the model sees at every
4072
+ # selected step. Memory is bounded by the number of
4073
+ # *unique* token ids — see _compute_embedding_norms.
4074
+ embedding_norms: List[float] = _compute_embedding_norms(
4075
+ manager,
4076
+ list(prompt_token_ids) + list(generated_token_ids),
4077
+ )
4078
+
4079
  # Build response
4080
  response = {
4081
  "requestId": request_id, # For lazy-loading matrices via /matrix endpoint
 
4097
  "ffnType": ffn_type,
4098
  "intermediateSize": getattr(manager.model.config, 'intermediate_size', None),
4099
  },
4100
+ "embeddingNorms": embedding_norms,
4101
  "generationTime": generation_time,
4102
  "numTokensGenerated": len(generated_tokens),
4103
  "marginStats": margin_stats,
 
4145
  request_id: str,
4146
  step: int,
4147
  layer: int,
4148
+ head: Optional[int] = None,
4149
+ aggregate_mode: str = "mean",
4150
+ authenticated: bool = Depends(verify_api_key),
4151
  ):
4152
  """
4153
+ Retrieve cached attention/QKV matrices for a specific head, OR an
4154
+ aggregated attention matrix across all heads when `head` is omitted.
4155
 
4156
+ Used for lazy-loading matrix data when the user opens an inline
4157
+ head panel (per-head Q/K/V) or the Mechanism lens's Step 4
4158
+ attention matrix view (aggregate by default). Matrices are cached
4159
+ during the initial analysis and available for 60 minutes.
4160
 
4161
  Parameters:
4162
  - request_id: UUID from the original analysis response
4163
  - step: Generation step (0 = first generated token)
4164
  - layer: Layer index (0-based)
4165
+ - head: Head index (0-based). Omit for an aggregate matrix across
4166
+ all heads — the returned payload then contains only
4167
+ `attention_weights` (the Q/K/V projections are per-head and
4168
+ have no canonical aggregate form).
4169
+ - aggregate_mode: "mean" or "max" when `head` is omitted.
4170
 
4171
  Returns:
4172
  - attention_weights: [seq_len, seq_len] attention matrix
4173
+ - q_matrix / k_matrix / v_matrix: [seq_len, head_dim] projections
4174
+ (only when `head` is specified)
4175
+ - layer: Layer index
4176
+ - head: Head index, or null when aggregated
4177
+ - aggregate_mode: Aggregation mode used, or null for per-head
4178
  """
4179
+ if head is not None:
4180
+ data = matrix_cache.get(request_id, step, layer, head)
4181
+ if data is None:
4182
+ logger.warning(
4183
+ f"Matrix cache miss: request_id={request_id}, step={step}, layer={layer}, head={head}"
4184
+ )
4185
+ raise HTTPException(
4186
+ status_code=404,
4187
+ detail="Matrix data not found. Cache may have expired (60 min TTL). Please re-analyze.",
4188
+ )
4189
+ logger.info(
4190
+ f"Matrix cache hit: request_id={request_id}, step={step}, layer={layer}, head={head}"
4191
+ )
4192
+ # Convert numpy arrays to lists for JSON serialization. Arrays
4193
+ # are stored as numpy for memory efficiency, converted on-
4194
+ # demand here.
4195
+ response = {}
4196
+ for key, value in data.items():
4197
+ if value is not None and hasattr(value, "tolist"):
4198
+ response[key] = value.tolist()
4199
+ else:
4200
+ response[key] = value
4201
+ response["layer"] = layer
4202
+ response["head"] = head
4203
+ response["aggregate_mode"] = None
4204
+ return response
4205
+
4206
+ # Aggregate path — averages or max-pools attention matrices across
4207
+ # heads. Q/K/V projections are intentionally omitted; they're
4208
+ # per-head quantities and don't have a canonical aggregate.
4209
+ aggregate = matrix_cache.get_aggregate_attention_matrix(
4210
+ request_id, step, layer, aggregate_mode
4211
+ )
4212
+ if aggregate is None:
4213
+ logger.warning(
4214
+ f"Aggregate matrix cache miss: request_id={request_id}, step={step}, layer={layer}, mode={aggregate_mode}"
4215
+ )
4216
  raise HTTPException(
4217
  status_code=404,
4218
+ detail="Matrix data not found. Cache may have expired (60 min TTL). Please re-analyze.",
4219
  )
4220
+ logger.info(
4221
+ f"Aggregate matrix cache hit: request_id={request_id}, step={step}, layer={layer}, mode={aggregate_mode}"
4222
+ )
4223
+ return {
4224
+ "attention_weights": aggregate,
4225
+ "layer": layer,
4226
+ "head": None,
4227
+ "aggregate_mode": aggregate_mode,
4228
+ }
 
 
 
4229
 
4230
 
4231
  @app.get("/analyze/research/attention/matrix/stats")