Spaces:
Paused
Paused
Emit per-token embedding L2 norms for RQ1 Step 3
Browse filesAdds 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>
- 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 |
-
|
|
|
|
| 4026 |
):
|
| 4027 |
"""
|
| 4028 |
-
Retrieve cached attention/QKV matrices for a specific head
|
|
|
|
| 4029 |
|
| 4030 |
-
Used for lazy-loading matrix data when user
|
| 4031 |
-
|
|
|
|
|
|
|
| 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]
|
| 4042 |
-
|
| 4043 |
-
-
|
|
|
|
|
|
|
| 4044 |
"""
|
| 4045 |
-
|
| 4046 |
-
|
| 4047 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4054 |
-
|
| 4055 |
-
|
| 4056 |
-
|
| 4057 |
-
|
| 4058 |
-
|
| 4059 |
-
|
| 4060 |
-
|
| 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")
|