Spaces:
Paused
Paused
Merge pull request #6 from gary-boon/feature/rq1-ffn-intermediate
Browse filesBackend: emit per-token embedding norms, top-K SwiGLU neurons, unembedding cosine + Devstral tokenizer fallback
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 3590 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 3939 |
):
|
| 3940 |
"""
|
| 3941 |
-
Retrieve cached attention/QKV matrices for a specific head
|
|
|
|
| 3942 |
|
| 3943 |
-
Used for lazy-loading matrix data when user
|
| 3944 |
-
|
|
|
|
|
|
|
| 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]
|
| 3955 |
-
|
| 3956 |
-
-
|
|
|
|
|
|
|
| 3957 |
"""
|
| 3958 |
-
|
| 3959 |
-
|
| 3960 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3967 |
-
|
| 3968 |
-
|
| 3969 |
-
|
| 3970 |
-
|
| 3971 |
-
|
| 3972 |
-
|
| 3973 |
-
|
| 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")
|