rongyuan commited on
Commit
89280a9
·
1 Parent(s): 665f965

Update 1st version of UI.

Browse files
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install system dependencies
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy requirements first for better caching
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy application code
16
+ COPY . .
17
+
18
+ # HuggingFace Spaces expects port 7860
19
+ EXPOSE 7860
20
+
21
+ # Run the FastAPI application
22
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,14 +1,31 @@
1
  ---
2
- title: CAIW
3
- emoji:
4
  colorFrom: indigo
5
  colorTo: blue
6
- sdk: gradio
7
- sdk_version: 6.11.0
8
- app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
- short_description: An example demo for CAIW project
 
12
  ---
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: CAIW - NeuralPostmortem
3
+ emoji: 🔍
4
  colorFrom: indigo
5
  colorTo: blue
6
+ sdk: docker
 
 
7
  pinned: false
8
  license: apache-2.0
9
+ short_description: An Interactive Framework for LLM Failure Analysis
10
+ suggested_hardware: t4-small
11
  ---
12
 
13
+ # NeuralPostmortem
14
+
15
+ An Interactive Framework for LLM Failure Analysis using Attribution Methods (Attn-LRP, CP-LRP, Gradient).
16
+
17
+ ## Features
18
+
19
+ - **Model Loading**: Load HuggingFace models with optional 4-bit quantization
20
+ - **Error Token Localization**: Identify erroneous tokens via LLM validators or manual truncation
21
+ - **Input Attribution**: Visualize token-level attribution using Attn-LRP, CP-LRP, or vanilla gradients
22
+ - **Perturbation Evaluation**: Evaluate attribution quality by zeroing out top-attributed tokens
23
+ - **Circuit Visualization**: Build and visualize attribution graphs across model layers
24
+
25
+ ## Environment Variables (Optional)
26
+
27
+ - `OPENAI_API_KEY`: OpenAI API key for LLM-based error token localization
28
+ - `OPENAI_BASE_URL`: Custom OpenAI-compatible API base URL
29
+ - `LLM_MODELS`: Comma-separated list of model names for validators (default: `gpt-4o-mini`)
30
+
31
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from pydantic import BaseModel
3
+ from typing import List, Optional, Dict, Any
4
+ import uvicorn
5
+ import os
6
+ import sys
7
+ import torch
8
+ import json
9
+ import logging
10
+ import networkx as nx
11
+ from networkx.readwrite import json_graph
12
+ import numpy as np
13
+
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+ class NumpyEncoder(json.JSONEncoder):
18
+ def default(self, obj):
19
+ if isinstance(obj, np.integer):
20
+ return int(obj)
21
+ if isinstance(obj, np.floating):
22
+ # Safe handle checking for finite
23
+ f = float(obj)
24
+ return f if np.isfinite(f) else 0.0
25
+ if isinstance(obj, np.ndarray):
26
+ return obj.tolist()
27
+ return super(NumpyEncoder, self).default(obj)
28
+
29
+ # Ensure backend can be imported
30
+ PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))
31
+ sys.path.insert(0, PROJECT_ROOT)
32
+
33
+ from backend.models import ModelManager
34
+ from backend.core import AttributionEngine
35
+ from backend.circuit import CircuitAnalyzer
36
+ from backend.error_token_location import ErrorTokenLocator
37
+ from fastapi.middleware.cors import CORSMiddleware
38
+ from fastapi.staticfiles import StaticFiles
39
+ from fastapi.responses import RedirectResponse, StreamingResponse
40
+ from huggingface_hub import list_models, list_repo_refs
41
+
42
+ app = FastAPI(title="NeuralPostmortem - Evaluation Backend (Attribution Comparison & Perturbation)")
43
+
44
+ app.add_middleware(
45
+ CORSMiddleware,
46
+ allow_origins=["*"],
47
+ allow_credentials=True,
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ )
51
+
52
+ # Mount Frontend (Static Files)
53
+ frontend_path = os.path.join(PROJECT_ROOT, 'frontend')
54
+ if os.path.exists(frontend_path):
55
+ app.mount("/ui", StaticFiles(directory=frontend_path), name="ui")
56
+
57
+ @app.get("/")
58
+ async def read_root():
59
+ return RedirectResponse(url="/ui/index.html")
60
+
61
+ # Global instances
62
+ model_manager = ModelManager()
63
+ attribution_engine = None # Initialize after model load
64
+ error_token_locator = None # Initialize after model load
65
+
66
+ # Caching for connection matrices to speed up slider interactions
67
+ CACHED_CONNECTION_DATA = {
68
+ "config_hash": None,
69
+ "data": None
70
+ }
71
+
72
+ def get_config_hash(bp_config, layers):
73
+ try:
74
+ # Create a deterministic hash string
75
+ return json.dumps({
76
+ "bp": bp_config,
77
+ "layers": sorted(layers)
78
+ }, sort_keys=True)
79
+ except:
80
+ return None
81
+
82
+ def unescape_string(text: str) -> str:
83
+ """
84
+ Safely unescape string with escape sequences like \\n, \\t, \\r, etc.
85
+
86
+ Args:
87
+ text: Input text that may contain escape sequences
88
+
89
+ Returns:
90
+ Text with escape sequences converted to actual characters
91
+ """
92
+ if not text:
93
+ return text
94
+
95
+ try:
96
+ # Try to decode escape sequences using unicode_escape
97
+ # This handles \n, \t, \r, \", \', \\, etc.
98
+ return text.encode('utf-8').decode('unicode_escape')
99
+ except Exception as e:
100
+ # Fallback to manual replacement if unicode_escape fails
101
+ logging.warning(f"unicode_escape failed, using manual replacement: {e}")
102
+ result = text
103
+ result = result.replace('\\n', '\n')
104
+ result = result.replace('\\t', '\t')
105
+ result = result.replace('\\r', '\r')
106
+ result = result.replace('\\"', '"')
107
+ result = result.replace("\\'", "'")
108
+ result = result.replace('\\\\', '\\')
109
+ return result
110
+
111
+
112
+ # Pydantic models for inputs
113
+ class LoadModelRequest(BaseModel):
114
+ model_path: str = "Qwen/Qwen3-0.6B"
115
+ quantization_4bit: bool = False # Default to False to avoid bitsandbytes requirement
116
+ dtype: str = "float16" # float16, bfloat16, float32, auto
117
+ revision: Optional[str] = None
118
+ # LRP is no longer loaded at model load time
119
+
120
+ class ComputeLogitsRequest(BaseModel):
121
+ prompt: str
122
+ is_append_bos: bool = True
123
+ topk: int = 10
124
+ extra_token_ids: Optional[List[int]] = None
125
+ extra_token_strs: Optional[List[str]] = None
126
+ capture_mid: bool = False # Fine-grained attribution separation
127
+
128
+ class BackpropConfig(BaseModel):
129
+ mode: str = "max_logit" # "max_logit" or "logit_diff"
130
+ strategy: Optional[str] = "by_topk_avg" # "demean", "by_topk_avg", "by_ref_token"
131
+ ref_token_id: Optional[int] = None
132
+ contrast_rank: Optional[int] = 2
133
+ k: Optional[int] = 10
134
+ node_threshold: Optional[float] = 0.01 # Threshold for computing node inter-connections
135
+
136
+ class ComputeCircuitRequest(BaseModel):
137
+ # Configurations for backprop
138
+ backprop_config: BackpropConfig
139
+
140
+ # New Multi-Layer Field
141
+ layers: List[int]
142
+
143
+ # Pruning Params
144
+ pruning_mode: str = "by_per_layer_cum_mass_percentile"
145
+ top_p: float = 0.9
146
+ edge_threshold: float = 0.01 # Used if by_global_threshold
147
+
148
+
149
+ class ComputeInputAttributionRequest(BaseModel):
150
+ target_token_id: int
151
+ contrast_token_id: Optional[int] = None
152
+ backprop_config: BackpropConfig
153
+
154
+ class GenerateRequest(BaseModel):
155
+ prompt: str
156
+ max_new_tokens: int = 30
157
+ append_token_id: Optional[int] = None
158
+
159
+ class LocateErrorTokenRequest(BaseModel):
160
+ prompt: str
161
+ completion: str
162
+ ground_truth: Optional[str] = None
163
+ validators: Optional[List[str]] = None
164
+ use_llm: bool = True
165
+ manual_chunks: Optional[List[str]] = None
166
+
167
+ class EnableLRPRequest(BaseModel):
168
+ lrp_rule: str = "Attn-LRP" # "Attn-LRP", "CP-LRP", or "Gradient"
169
+ capture_mid: bool = False
170
+
171
+ class ComputePerturbationRequest(BaseModel):
172
+ attribution_scores: List[float]
173
+ k_values: List[int] = [1, 3, 5, 10]
174
+ target_token_id: int
175
+
176
+ class ComputePerturbationManualRequest(BaseModel):
177
+ perturb_indices: List[int]
178
+ target_token_id: int
179
+
180
+ @app.get("/api/list_hf_models")
181
+ async def list_hf_models(series: str = "Qwen2"):
182
+ """
183
+ List models from HuggingFace Hub filtered by series/author.
184
+ """
185
+ try:
186
+ if series.lower() == "qwen2":
187
+ models = list(list_models(author="Qwen", search="Qwen2", filter="text-generation", sort="downloads", direction=-1, limit=50))
188
+ return {"models": [m.id for m in models]}
189
+
190
+ elif series.lower() == "qwen3":
191
+ models = list(list_models(author="Qwen", search="Qwen3", filter="text-generation", sort="downloads", direction=-1, limit=50))
192
+ return {"models": [m.id for m in models]}
193
+
194
+ elif series.lower() == "olmo3":
195
+ models = list(list_models(author="allenai", search="Olmo-3", filter="text-generation", sort="downloads", direction=-1, limit=50))
196
+ return {"models": [m.id for m in models]}
197
+
198
+ elif series.lower() == "olmo":
199
+ models = list(list_models(author="allenai", search="OLMo", filter="text-generation", sort="downloads", direction=-1, limit=50))
200
+ return {"models": [m.id for m in models]}
201
+
202
+ elif series.lower() == "qwen":
203
+ models = list(list_models(author="Qwen", filter="text-generation", sort="downloads", direction=-1, limit=50))
204
+ return {"models": [m.id for m in models]}
205
+
206
+ # Generic fallback
207
+ models = list(list_models(search=series, filter="text-generation", sort="downloads", direction=-1, limit=20))
208
+ return {"models": [m.id for m in models]}
209
+
210
+ except Exception as e:
211
+ print(f"Error listing models: {e}")
212
+ # Return fallback/hardcoded list if offline
213
+ if series.lower() == "qwen2":
214
+ return {"models": ["Qwen/Qwen2.5-0.5B-Instruct", "Qwen/Qwen2.5-1.5B-Instruct", "Qwen/Qwen2.5-3B-Instruct", "Qwen/Qwen2.5-7B-Instruct", "Qwen/Qwen2-0.5B", "Qwen/Qwen2-1.5B", "Qwen/Qwen2-7B"]}
215
+ elif series.lower() == "qwen3":
216
+ return {"models": ["Qwen/Qwen3-0.6B"]}
217
+ elif series.lower() == "qwen":
218
+ return {"models": ["Qwen/Qwen2.5-0.5B-Instruct", "Qwen/Qwen3-0.6B"]}
219
+ elif series.lower() == "olmo3":
220
+ return {"models": ["allenai/Olmo-3-7B-Think"]}
221
+ elif series.lower() == "olmo":
222
+ return {"models": ["allenai/OLMo-7B", "allenai/OLMo-1B-0724", "allenai/Olmo-3-7B-Think"]}
223
+ return {"models": [], "error": str(e)}
224
+
225
+ @app.get("/api/list_model_revisions")
226
+ async def list_model_revisions(model_id: str):
227
+ """
228
+ List git branches/refs for a model.
229
+ """
230
+ try:
231
+ refs = list_repo_refs(model_id)
232
+ branches = [b.name for b in refs.branches]
233
+ tags = [t.name for t in refs.tags]
234
+ return {"branches": branches, "tags": tags}
235
+ except Exception as e:
236
+ print(f"Error listing revisions for {model_id}: {e}")
237
+ return {"branches": [], "tags": [], "error": str(e)}
238
+
239
+ @app.post("/api/cleanup")
240
+ async def cleanup_memory():
241
+ global attribution_engine
242
+ if attribution_engine:
243
+ attribution_engine.reset()
244
+ else:
245
+ # even if no engine, try to clear cache
246
+ torch.cuda.empty_cache()
247
+
248
+ import gc
249
+ gc.collect()
250
+
251
+ return {"status": "success", "message": "Memory cleanup complete"}
252
+
253
+ @app.post("/api/generate")
254
+ async def generate_continuation(request: GenerateRequest):
255
+ if not model_manager.model:
256
+ raise HTTPException(status_code=400, detail="Model not loaded")
257
+
258
+ tokenizer = model_manager.tokenizer
259
+ model = model_manager.model
260
+ device = model_manager.device
261
+
262
+ try:
263
+ # Unescape special characters in prompt
264
+ prompt = unescape_string(request.prompt)
265
+
266
+ # Switch to eval for generation
267
+ was_training = model.training
268
+ model.eval()
269
+
270
+ # Encode prompt
271
+ input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
272
+
273
+ # Append token if requested
274
+ if request.append_token_id is not None:
275
+ token_tensor = torch.tensor([[request.append_token_id]], device=device)
276
+ input_ids = torch.cat([input_ids, token_tensor], dim=1)
277
+
278
+ with torch.no_grad():
279
+ output_ids = model.generate(
280
+ input_ids,
281
+ max_new_tokens=request.max_new_tokens,
282
+ do_sample=False,
283
+ pad_token_id=tokenizer.eos_token_id
284
+ )
285
+
286
+ new_token_ids = output_ids[0][input_ids.shape[1]:]
287
+ generated_text = tokenizer.decode(new_token_ids, skip_special_tokens=True)
288
+
289
+ # Restore training mode
290
+ if was_training:
291
+ model.train()
292
+
293
+ return {"generated_text": generated_text}
294
+
295
+ except Exception as e:
296
+ if model_manager.model and was_training:
297
+ model_manager.model.train()
298
+ raise HTTPException(status_code=500, detail=str(e))
299
+
300
+ @app.post("/api/locate_err_token")
301
+ async def locate_error_token_endpoint(request: LocateErrorTokenRequest):
302
+ """
303
+ Locate the error token in a completion using multiple LLM validators
304
+ """
305
+ global error_token_locator
306
+
307
+ if not error_token_locator:
308
+ raise HTTPException(status_code=400, detail="Model not loaded. Please call /api/load_model first.")
309
+
310
+ try:
311
+ # Unescape special characters in prompt, completion, and ground_truth
312
+ prompt = unescape_string(request.prompt)
313
+ completion = unescape_string(request.completion)
314
+ ground_truth = unescape_string(request.ground_truth) if request.ground_truth else None
315
+
316
+ # Call the error token locator
317
+ result = error_token_locator.locate_error_token(
318
+ prompt=prompt,
319
+ completion=completion,
320
+ ground_truth=ground_truth,
321
+ validators=request.validators,
322
+ use_llm=request.use_llm,
323
+ manual_chunks=request.manual_chunks
324
+ )
325
+
326
+ if result["status"] == "error":
327
+ raise HTTPException(status_code=500, detail=result.get("message", "Unknown error"))
328
+
329
+ return {
330
+ "status": "success",
331
+ "truncated_text": result["truncated_text"],
332
+ "explanation": result["explanation"],
333
+ "error_token_index": result.get("error_token_index", -1),
334
+ "vote_details": result.get("vote_details", {})
335
+ }
336
+
337
+ except HTTPException:
338
+ raise
339
+ except Exception as e:
340
+ import traceback
341
+ traceback.print_exc()
342
+ raise HTTPException(status_code=500, detail=str(e))
343
+
344
+ @app.post("/api/load_model")
345
+ async def load_model(request: LoadModelRequest):
346
+ global attribution_engine
347
+ global error_token_locator
348
+ try:
349
+ # Load model without LRP (LRP will be enabled when needed)
350
+ model_name = model_manager.load_model(
351
+ request.model_path,
352
+ request.quantization_4bit,
353
+ dtype=request.dtype,
354
+ revision=request.revision,
355
+ lrp_rule=None # Don't load LRP yet
356
+ )
357
+ attribution_engine = AttributionEngine(model_manager)
358
+ error_token_locator = ErrorTokenLocator(model_manager.model, model_manager.tokenizer)
359
+
360
+ # Get Num Layers
361
+ n_layers = 28 # Default for Qwen 0.5B
362
+ try:
363
+ # Try access config
364
+ if hasattr(model_manager.model, 'config'):
365
+ n_layers = getattr(model_manager.model.config, 'num_hidden_layers', 28)
366
+ except:
367
+ pass
368
+
369
+ # Get vocabulary size
370
+ vocab_size = len(model_manager.tokenizer)
371
+
372
+ return {
373
+ "status": "success",
374
+ "message": f"Model {model_name} loaded successfully",
375
+ "num_layers": n_layers,
376
+ "vocab_size": vocab_size
377
+ }
378
+ except Exception as e:
379
+ raise HTTPException(status_code=500, detail=str(e))
380
+
381
+ @app.post("/api/enable_lrp")
382
+ async def enable_lrp(request: EnableLRPRequest):
383
+ """
384
+ Enable LRP functionality on the loaded model.
385
+ This should be called before computing attribution or circuits.
386
+ For "Gradient" rule, the model is loaded WITHOUT LRP patches (vanilla gradient).
387
+ """
388
+ if not model_manager.model:
389
+ raise HTTPException(status_code=400, detail="Model not loaded. Please call /api/load_model first.")
390
+
391
+ try:
392
+ # For Gradient method, load model without LRP patches
393
+ lrp_rule_for_model = None if request.lrp_rule == "Gradient" else request.lrp_rule
394
+
395
+ # Reload model with appropriate LRP setting
396
+ model_name = model_manager.load_model(
397
+ model_path=model_manager.current_model_path,
398
+ quantization_4bit=model_manager.current_quantization,
399
+ dtype=model_manager.current_dtype,
400
+ revision=model_manager.current_revision,
401
+ lrp_rule=lrp_rule_for_model
402
+ )
403
+
404
+ # Reinitialize attribution engine
405
+ global attribution_engine
406
+ attribution_engine = AttributionEngine(model_manager)
407
+
408
+ return {
409
+ "status": "success",
410
+ "message": f"Attribution method ({request.lrp_rule}) enabled successfully",
411
+ "lrp_rule": request.lrp_rule,
412
+ "capture_mid": request.capture_mid
413
+ }
414
+ except Exception as e:
415
+ import traceback
416
+ traceback.print_exc()
417
+ raise HTTPException(status_code=500, detail=str(e))
418
+
419
+ @app.post("/api/compute_logits")
420
+ async def compute_logits(request: ComputeLogitsRequest):
421
+ global attribution_engine
422
+ if not attribution_engine:
423
+ raise HTTPException(status_code=400, detail="Model not loaded. Please call /api/load_model first.")
424
+
425
+ try:
426
+ # Unescape special characters in prompt
427
+ prompt = unescape_string(request.prompt)
428
+
429
+ topk_data, _, input_tokens = attribution_engine.compute_logits(
430
+ prompt=prompt,
431
+ is_append_bos=request.is_append_bos,
432
+ topk=request.topk,
433
+ extra_token_ids=request.extra_token_ids,
434
+ extra_token_strs=request.extra_token_strs,
435
+ capture_mid=request.capture_mid
436
+ )
437
+
438
+ # Convert simple string list input_tokens to list of objects for frontend consistency
439
+ token_objs = [{"token_str": t, "token_id": i} for i, t in enumerate(input_tokens)]
440
+
441
+ return {"data": topk_data, "tokens": token_objs}
442
+ except Exception as e:
443
+ raise HTTPException(status_code=500, detail=str(e))
444
+
445
+ @app.post("/api/compute_input_attribution")
446
+ async def compute_input_attribution_endpoint(request: ComputeInputAttributionRequest):
447
+ global attribution_engine
448
+ if not attribution_engine:
449
+ raise HTTPException(status_code=400, detail="Model not loaded.")
450
+
451
+ # Auto-enable LRP if not already enabled
452
+ if not model_manager.current_lrp_rule:
453
+ logger.info("LRP not enabled yet - auto-enabling with default rule 'Attn-LRP'...")
454
+ try:
455
+ model_name = model_manager.load_model(
456
+ model_path=model_manager.current_model_path,
457
+ quantization_4bit=model_manager.current_quantization,
458
+ dtype=model_manager.current_dtype,
459
+ revision=model_manager.current_revision,
460
+ lrp_rule="Attn-LRP"
461
+ )
462
+ attribution_engine = AttributionEngine(model_manager)
463
+ logger.info(f"Auto-enabled LRP with Attn-LRP rule on {model_name}")
464
+ except Exception as e:
465
+ logger.error(f"Failed to auto-enable LRP: {e}")
466
+ raise HTTPException(
467
+ status_code=400,
468
+ detail="LRP not enabled and auto-enable failed. Please call /api/enable_lrp before computing attribution."
469
+ )
470
+
471
+ try:
472
+ if attribution_engine.outputs is None:
473
+ raise HTTPException(status_code=400, detail="No forward pass found. Run compute_logits first.")
474
+
475
+ # Inject target token ID into backprop config
476
+ bp_config = request.backprop_config.dict()
477
+ bp_config["target_token_id"] = request.target_token_id
478
+
479
+ relevance = attribution_engine.compute_input_attribution(bp_config)
480
+ return {"relevance": relevance}
481
+ except Exception as e:
482
+ import traceback
483
+ traceback.print_exc()
484
+ raise HTTPException(status_code=500, detail=str(e))
485
+
486
+ @app.post("/api/compute_input_attribution_gradient")
487
+ async def compute_input_attribution_gradient_endpoint(request: ComputeInputAttributionRequest):
488
+ """
489
+ Compute input attribution using vanilla gradient method (Input * Gradient).
490
+ Does NOT require LRP to be enabled - uses standard PyTorch autograd.
491
+ """
492
+ global attribution_engine
493
+ if not attribution_engine:
494
+ raise HTTPException(status_code=400, detail="Model not loaded.")
495
+
496
+ try:
497
+ if attribution_engine.outputs is None:
498
+ raise HTTPException(status_code=400, detail="No forward pass found. Run compute_logits first.")
499
+
500
+ # Inject target token ID into backprop config
501
+ bp_config = request.backprop_config.dict()
502
+ bp_config["target_token_id"] = request.target_token_id
503
+
504
+ relevance = attribution_engine.compute_input_attribution_gradient(bp_config)
505
+ return {"relevance": relevance}
506
+ except Exception as e:
507
+ import traceback
508
+ traceback.print_exc()
509
+ raise HTTPException(status_code=500, detail=str(e))
510
+
511
+ @app.post("/api/compute_perturbation")
512
+ async def compute_perturbation_endpoint(request: ComputePerturbationRequest):
513
+ """
514
+ Evaluate attribution quality by perturbing top-attributed tokens.
515
+ Zero out top-k most attributed tokens and check if the error is fixed.
516
+ """
517
+ global attribution_engine
518
+ if not attribution_engine:
519
+ raise HTTPException(status_code=400, detail="Model not loaded.")
520
+
521
+ try:
522
+ if attribution_engine.input_ids is None or attribution_engine.input_embeddings is None:
523
+ raise HTTPException(status_code=400, detail="No forward pass found. Run compute_logits first.")
524
+
525
+ results = attribution_engine.compute_perturbation_eval(
526
+ attribution_scores=request.attribution_scores,
527
+ k_values=request.k_values,
528
+ target_token_id=request.target_token_id
529
+ )
530
+ return {"results": results}
531
+ except Exception as e:
532
+ import traceback
533
+ traceback.print_exc()
534
+ raise HTTPException(status_code=500, detail=str(e))
535
+
536
+ @app.post("/api/compute_perturbation_manual")
537
+ async def compute_perturbation_manual_endpoint(request: ComputePerturbationManualRequest):
538
+ """
539
+ Evaluate attribution by perturbing manually selected token positions.
540
+ Zero out the specified token embeddings and check if the error is fixed.
541
+ """
542
+ global attribution_engine
543
+ if not attribution_engine:
544
+ raise HTTPException(status_code=400, detail="Model not loaded.")
545
+
546
+ try:
547
+ if attribution_engine.input_ids is None or attribution_engine.input_embeddings is None:
548
+ raise HTTPException(status_code=400, detail="No forward pass found. Run compute_logits first.")
549
+
550
+ result = attribution_engine.compute_perturbation_manual(
551
+ perturb_indices=request.perturb_indices,
552
+ target_token_id=request.target_token_id
553
+ )
554
+ return {"result": result}
555
+ except Exception as e:
556
+ import traceback
557
+ traceback.print_exc()
558
+ raise HTTPException(status_code=500, detail=str(e))
559
+
560
+ @app.post("/api/compute_circuit")
561
+ async def compute_circuit(request: ComputeCircuitRequest):
562
+ global attribution_engine
563
+ if not attribution_engine:
564
+ raise HTTPException(status_code=400, detail="Model not loaded.")
565
+
566
+ # Auto-enable LRP if not already enabled
567
+ if not model_manager.current_lrp_rule:
568
+ logger.info("LRP not enabled yet - auto-enabling with default rule 'Attn-LRP' for circuit analysis...")
569
+ try:
570
+ model_name = model_manager.load_model(
571
+ model_path=model_manager.current_model_path,
572
+ quantization_4bit=model_manager.current_quantization,
573
+ dtype=model_manager.current_dtype,
574
+ revision=model_manager.current_revision,
575
+ lrp_rule="Attn-LRP"
576
+ )
577
+ attribution_engine = AttributionEngine(model_manager)
578
+ logger.info(f"Auto-enabled LRP with Attn-LRP rule on {model_name}")
579
+ except Exception as e:
580
+ logger.error(f"Failed to auto-enable LRP: {e}")
581
+ raise HTTPException(
582
+ status_code=400,
583
+ detail="LRP not enabled and auto-enable failed. Please call /api/enable_lrp before computing circuits."
584
+ )
585
+
586
+ if attribution_engine.outputs is None:
587
+ raise HTTPException(status_code=400, detail="No forward pass found. Run compute_logits first.")
588
+
589
+ async def generate_response():
590
+ try:
591
+ # Step 1: Run Backward Pass
592
+ yield json.dumps({"type": "progress", "msg": "Initiating Backward Pass...", "percent": 0}) + "\n"
593
+
594
+ # Use CircuitAnalyzer
595
+ analyzer = CircuitAnalyzer(attribution_engine)
596
+
597
+ bp_config = request.backprop_config.dict()
598
+
599
+ # We explicitly run backward pass first (though build_graph does it, we want to emit progress)
600
+ # Check Cache
601
+ current_hash = get_config_hash(bp_config, request.layers)
602
+ connection_data = None
603
+
604
+ if CACHED_CONNECTION_DATA["config_hash"] == current_hash and CACHED_CONNECTION_DATA["data"] is not None:
605
+ yield json.dumps({"type": "progress", "msg": "Using Cached Matrices (Fast)...", "percent": 50}) + "\n"
606
+ connection_data = CACHED_CONNECTION_DATA["data"]
607
+ else:
608
+ yield json.dumps({"type": "progress", "msg": "Computing Circuit (This may take a moment)...", "percent": 20}) + "\n"
609
+ # Run the heavy lifting
610
+ connection_data = analyzer.compute_connection_matrices(bp_config, sorted(request.layers))
611
+
612
+ # Update Cache
613
+ CACHED_CONNECTION_DATA["config_hash"] = current_hash
614
+ CACHED_CONNECTION_DATA["data"] = connection_data
615
+
616
+ yield json.dumps({"type": "progress", "msg": "Pruning & Building Graph...", "percent": 80}) + "\n"
617
+
618
+ G, pruning_details = analyzer.build_graph_from_matrices(
619
+ connection_data,
620
+ edge_rel_threshold=request.edge_threshold,
621
+ pruning_mode=request.pruning_mode,
622
+ top_p=request.top_p
623
+ )
624
+
625
+ yield json.dumps({"type": "progress", "msg": "Graph Constructed. Serializing...", "percent": 90}) + "\n"
626
+
627
+ # Serialize Graph
628
+ graph_data = nx.node_link_data(G)
629
+
630
+ yield json.dumps({
631
+ "type": "graph_data",
632
+ "graph": graph_data,
633
+ "pruning_details": pruning_details
634
+ }, cls=NumpyEncoder) + "\n"
635
+
636
+ yield json.dumps({"type": "progress", "msg": "Complete!", "percent": 100}) + "\n"
637
+ yield json.dumps({"type": "complete"}) + "\n"
638
+
639
+ except Exception as e:
640
+ import traceback
641
+ traceback.print_exc()
642
+ yield json.dumps({"type": "error", "msg": str(e)}) + "\n"
643
+
644
+ return StreamingResponse(generate_response(), media_type="application/x-ndjson")
645
+
646
+ # Return empty datasets (dataset loading removed for HuggingFace Space deployment)
647
+ @app.get("/api/datasets")
648
+ async def get_datasets():
649
+ return {"datasets": []}
650
+
651
+ if __name__ == "__main__":
652
+ port = int(os.environ.get("PORT", 7860))
653
+ uvicorn.run(app, host="0.0.0.0", port=port)
backend/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .models import ModelManager
2
+ from .core import AttributionEngine
backend/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (229 Bytes). View file
 
backend/__pycache__/batch_config.cpython-312.pyc ADDED
Binary file (1.21 kB). View file
 
backend/__pycache__/circuit.cpython-312.pyc ADDED
Binary file (10.3 kB). View file
 
backend/__pycache__/core.cpython-312.pyc ADDED
Binary file (34 kB). View file
 
backend/__pycache__/error_token_location.cpython-312.pyc ADDED
Binary file (12.4 kB). View file
 
backend/__pycache__/graph_metrics.cpython-312.pyc ADDED
Binary file (15.4 kB). View file
 
backend/__pycache__/metrics.cpython-312.pyc ADDED
Binary file (5.4 kB). View file
 
backend/batch_config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ # Configuration for Batch Chunk Sizes based on Model Size and Dtype
4
+ # Reference: 4B model with bf16 uses batch_chunk_size = 64
5
+
6
+ # Mapping: (Min_Params_Billions, Max_Params_Billions) -> Recommended Batch Size (BF16/FP16)
7
+ BATCH_CHUNK_MAPPING = {
8
+ (0.0, 1.0): 256, # For 0.6B and similar
9
+ (1.0, 3.0): 128, # For 1.7B, 2B, 3B
10
+ (3.0, 6.0): 8, # For 4B, 6B
11
+ (6.0, 12.0): 32, # For 7B, 8B, 10B
12
+ (12.0, 25.0): 16, # For 14B, 20B
13
+ (25.0, 1000.0): 8 # For 32B+, 70B
14
+ }
15
+
16
+ def get_batch_chunk_size(model_params_count, model_dtype):
17
+ """
18
+ Determine appropriate batch chunk size based on parameter count and dtype.
19
+
20
+ Args:
21
+ model_params_count (int): Total number of parameters in the model.
22
+ model_dtype (torch.dtype): The data type used for computation (activations).
23
+
24
+ Returns:
25
+ int: Recommended batch chunk size.
26
+ """
27
+ # Convert to Billions
28
+ params_billions = model_params_count / 1e9
29
+
30
+ # Default fallback
31
+ chunk_size = 32
32
+
33
+ # Lookup in mapping
34
+ for (min_b, max_b), size in BATCH_CHUNK_MAPPING.items():
35
+ if min_b <= params_billions < max_b:
36
+ chunk_size = size
37
+ break
38
+
39
+ # Scale by Dtype
40
+ # If using float32, activations take 2x memory compared to bf16/fp16
41
+ if model_dtype == torch.float32:
42
+ chunk_size = max(1, chunk_size // 2)
43
+
44
+ return chunk_size
backend/circuit.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import networkx as nx
2
+ import torch
3
+ import gc
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ class CircuitAnalyzer:
8
+ """
9
+ A class to build and analyze attribution circuits using an AttributionEngine.
10
+ """
11
+ def __init__(self, attribution_engine):
12
+ """
13
+ Args:
14
+ attribution_engine: An instance of AttributionEngine.
15
+ """
16
+ self.engine = attribution_engine
17
+
18
+ def compute_connection_matrices(self, backprop_config, layers=None):
19
+ """
20
+ Step 1: Compute dense connection matrices for all layer transitions.
21
+ Returns a list of dictionaries containing matrix data for each transition.
22
+ """
23
+ model = self.engine.manager.get_model()
24
+ model_layers = model.model.layers
25
+ n_layers = len(model_layers)
26
+
27
+ if layers is None:
28
+ # Auto-detect if mid activations are available (captured in compute_logits)
29
+ check_layer = model_layers[0]
30
+ has_mid = False
31
+ # Check for mid_activation attribute on the normative layer where we hook
32
+ if hasattr(check_layer, 'post_attention_layernorm') and hasattr(check_layer.post_attention_layernorm, 'mid_activation'):
33
+ has_mid = True
34
+ print("Auto-detected mid activations: Using fine-grained circuit (Attn/MLP separation).")
35
+
36
+ nodes = [-1]
37
+ for i in range(n_layers):
38
+ if has_mid:
39
+ nodes.append((i, 'mid'))
40
+ nodes.append(i) # integers imply 'post'
41
+
42
+ # Sort/Sequence is crucial.
43
+ # [-1, (0,'mid'), 0, (1,'mid'), 1, ...]
44
+ layer_pairs = list(zip(nodes[:-1], nodes[1:]))
45
+ else:
46
+ # Use provided layers list
47
+ # Ensure it's sorted? User responsibility if custom list.
48
+ nodes = layers
49
+ layer_pairs = list(zip(nodes[:-1], nodes[1:]))
50
+
51
+ # 1. Run Backward Pass
52
+ print("Running backward pass...")
53
+ self.engine.run_backward_pass(backprop_config)
54
+
55
+ connection_data = []
56
+
57
+ print(f"Computing matrices for {len(nodes)} nodes ({len(layer_pairs)} transitions)...")
58
+
59
+ for i, (src_layer, tgt_layer) in enumerate(layer_pairs):
60
+ print(f"Computing transition: {src_layer} -> {tgt_layer}...")
61
+
62
+ gen = self.engine.compute_connection_matrix_gen(
63
+ source=src_layer,
64
+ target=tgt_layer
65
+ )
66
+
67
+ final_res = None
68
+ for item in gen:
69
+ if item['type'] == 'result':
70
+ final_res = item['payload']
71
+
72
+ if final_res:
73
+ # Store the dense matrix and relevance vectors
74
+ connection_data.append({
75
+ 'src_layer': src_layer,
76
+ 'tgt_layer': tgt_layer,
77
+ 'matrix': final_res['matrix'], # Dense numpy array
78
+ 'real_source_rel': final_res['real_source_rel'],
79
+ 'real_target_rel': final_res['real_target_rel']
80
+ })
81
+
82
+ # Basic cleanup of engine internals, but we keep the matrix in connection_data
83
+ torch.cuda.empty_cache()
84
+
85
+ return connection_data
86
+
87
+ def build_graph_from_matrices(self, connection_data, edge_rel_threshold=0.01, pruning_mode="by_global_threshold", top_p=0.9):
88
+ """
89
+ Step 2: Prune based on threshold and build NetworkX graph.
90
+
91
+ Args:
92
+ connection_data: List of dictionaries containing matrix data.
93
+ edge_rel_threshold: Threshold for 'by_global_threshold' mode.
94
+ pruning_mode: Pruning strategy. Options:
95
+ 1. "by_global_threshold" (default): Prune globally using edge_rel_threshold.
96
+ 2. "by_per_layer_cum_mass_percentile": Prune per layer to keep top_p mass.
97
+ top_p: The cumulative mass percentile (0.0-1.0) for "by_per_layer_cum_mass_percentile". Default 0.9.
98
+
99
+ Returns:
100
+ G: The built NetworkX graph.
101
+ pruning_details: A list of dictionaries containing pruning stats per layer pair.
102
+ """
103
+ G = nx.DiGraph()
104
+ print(f"Building graph from {len(connection_data)} transitions. Mode: {pruning_mode}...")
105
+
106
+ pruning_details = []
107
+
108
+ for data in connection_data:
109
+ src_layer = data['src_layer']
110
+ tgt_layer = data['tgt_layer']
111
+ matrix = data['matrix']
112
+ real_source_rel = data['real_source_rel']
113
+ real_target_rel = data['real_target_rel']
114
+ abs_matrix = np.abs(matrix)
115
+
116
+ # Count only active edges (non-zero) as total_elements for percentage calculation
117
+ nonzero_mask = abs_matrix > 1e-9
118
+ total_elements = np.sum(nonzero_mask)
119
+
120
+ used_threshold = edge_rel_threshold
121
+
122
+ # Determine mask for edges to keep based on mode
123
+ if pruning_mode == "by_per_layer_cum_mass_percentile":
124
+ # Dynamic thresholding per layer
125
+ flattened = np.sort(abs_matrix.flatten())[::-1]
126
+ total_mass = flattened.sum()
127
+
128
+ dynamic_threshold = 1.0 # Default High if empty
129
+
130
+ if total_mass > 1e-12:
131
+ cumsum = np.cumsum(flattened)
132
+ cutoff_mass = total_mass * top_p
133
+ # Find first index where cumsum >= cutoff_mass
134
+ # searchsorted returns insertion point index i such that a[i-1] < v <= a[i]
135
+ # If we want elements up to index k such sum(0..k) >= target
136
+ cutoff_idx = np.searchsorted(cumsum, cutoff_mass)
137
+
138
+ if cutoff_idx >= len(flattened):
139
+ cutoff_idx = len(flattened) - 1
140
+
141
+ dynamic_threshold = flattened[cutoff_idx]
142
+ # Ensure we do not include effective zeros
143
+ if dynamic_threshold < 1e-9:
144
+ dynamic_threshold = 1e-9
145
+
146
+ used_threshold = dynamic_threshold
147
+ rows, cols = np.where(abs_matrix >= dynamic_threshold)
148
+ else:
149
+ # "by_global_threshold"
150
+ rows, cols = np.where(abs_matrix > edge_rel_threshold)
151
+
152
+ # Record pruning details
153
+ num_kept = len(rows)
154
+ percentage = (num_kept / total_elements * 100) if total_elements > 0 else 0
155
+
156
+ pruning_details.append({
157
+ 'src_layer': src_layer,
158
+ 'tgt_layer': tgt_layer,
159
+ 'threshold': float(used_threshold),
160
+ 'kept_edges': int(num_kept),
161
+ 'total_edges': int(total_elements),
162
+ 'kept_percentage': percentage
163
+ })
164
+
165
+ # Add Source Nodes
166
+ for t_idx in np.where(np.abs(real_source_rel) > 0)[0]:
167
+ src_node_id = (src_layer, t_idx)
168
+ if not G.has_node(src_node_id):
169
+ G.add_node(src_node_id, layer=src_layer, token=t_idx, relevance=real_source_rel[t_idx])
170
+
171
+ # Add Target Nodes
172
+ for t_idx in np.where(np.abs(real_target_rel) > 0)[0]:
173
+ tgt_node_id = (tgt_layer, t_idx)
174
+ if not G.has_node(tgt_node_id):
175
+ G.add_node(tgt_node_id, layer=tgt_layer, token=t_idx, relevance=real_target_rel[t_idx])
176
+
177
+ # Add Edges (Sparse)
178
+ # rows, cols calculated above
179
+ weights = matrix[rows, cols]
180
+
181
+ edges_to_add = []
182
+ for r, c, w in zip(rows, cols, weights):
183
+ # Edge direction: Source (col/c) -> Target (row/r)
184
+ u = (src_layer, c)
185
+ v = (tgt_layer, r)
186
+ # Ensure nodes exist (redundant safety check)
187
+ if not G.has_node(u): G.add_node(u, layer=src_layer, token=c, relevance=real_source_rel[c])
188
+ if not G.has_node(v): G.add_node(v, layer=tgt_layer, token=r, relevance=real_target_rel[r])
189
+
190
+ edges_to_add.append((u, v, {'weight': float(w)}))
191
+
192
+ G.add_edges_from(edges_to_add)
193
+
194
+ print(f"Graph construction complete. Nodes: {G.number_of_nodes()}, Edges: {G.number_of_edges()}")
195
+ return G, pruning_details
196
+
197
+ def build_graph(self, backprop_config, layers=None, edge_rel_threshold=0.01, pruning_mode="by_global_threshold", top_p=0.9):
198
+ """
199
+ Wrapper that performs both steps: compute matrices and build graph.
200
+ """
201
+ connection_data = self.compute_connection_matrices(backprop_config, layers)
202
+ return self.build_graph_from_matrices(connection_data, edge_rel_threshold, pruning_mode=pruning_mode, top_p=top_p)
203
+
204
+ def get_connected_subgraph(self, G, target_node=None):
205
+ """
206
+ Extracts the subgraph connected to the target node (backward reachability).
207
+ If target_node is None, it tries to infer the last token at the last layer.
208
+
209
+ Args:
210
+ G: The full attribution graph
211
+ target_node: tuple (layer, token_idx) of the target.
212
+
213
+ Returns:
214
+ (subgraph, target_node): The connected subgraph and the resolved target node.
215
+ """
216
+ # 1. Identify Target Node
217
+ if target_node is None:
218
+ if G.number_of_nodes() == 0:
219
+ print("Graph is empty.")
220
+ return None, None
221
+
222
+ # Infer: Max layer
223
+ try:
224
+ max_layer = max([n[0] for n in G.nodes()])
225
+ except ValueError:
226
+ print("Error finding max layer.")
227
+ return None, None
228
+
229
+ # Check nodes in max_layer
230
+ nodes_in_last = [n for n in G.nodes() if n[0] == max_layer]
231
+ if not nodes_in_last:
232
+ print(f"No nodes found in layer {max_layer}.")
233
+ return None, None
234
+
235
+ # Max token index
236
+ max_token = max([n[1] for n in nodes_in_last])
237
+ target_node = (max_layer, max_token)
238
+
239
+ print(f"Extracting connected component for Target Node: {target_node}")
240
+
241
+ if not G.has_node(target_node):
242
+ print(f"Target node {target_node} not found in graph (maybe thresholded out?).")
243
+ return None, target_node
244
+
245
+ # 2. Get Ancestors (Backward Reachability)
246
+ ancestors = nx.ancestors(G, target_node)
247
+ ancestors.add(target_node) # Include self
248
+
249
+ subgraph = G.subgraph(ancestors).copy()
250
+ print(f"Connected Subgraph: {subgraph.number_of_nodes()} nodes, {subgraph.number_of_edges()} edges")
251
+
252
+ return subgraph, target_node
backend/core.py ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import numpy as np
3
+ from .batch_config import get_batch_chunk_size
4
+
5
+ class AttributionEngine:
6
+ def __init__(self, model_manager):
7
+ self.manager = model_manager
8
+ self.hook_handles = []
9
+ self.outputs = None
10
+ self.input_ids = None
11
+
12
+ def _parse_node(self, node):
13
+ if isinstance(node, int):
14
+ return node, 'post'
15
+ if isinstance(node, (tuple, list)) and len(node) == 2:
16
+ return node[0], node[1]
17
+ raise ValueError(f"Invalid node format: {node}")
18
+
19
+ def _forward_part1(self, layer_module, hidden_states, position_embeddings=None, attention_mask=None):
20
+ """
21
+ Executes: Norm -> Attn -> Residual Add
22
+ Returns: resid_mid
23
+ """
24
+ return self.manager.decomposer.forward_part1(layer_module, hidden_states, position_embeddings, attention_mask)
25
+
26
+ def _forward_part2(self, layer_module, hidden_states):
27
+ """
28
+ Executes: Norm -> MLP -> Residual Add
29
+ Returns: resid_post
30
+ """
31
+ return self.manager.decomposer.forward_part2(layer_module, hidden_states)
32
+
33
+ def _hook_hidden_activation(self, module, input, output):
34
+ """
35
+ Hook to save activation and enable gradient retention.
36
+ """
37
+ if isinstance(output, tuple):
38
+ output = output[0]
39
+
40
+ # Save output to the module for later access
41
+ module.output = output
42
+ if module.output.requires_grad:
43
+ module.output.retain_grad()
44
+
45
+ def _hook_mid_activation(self, module, input, output):
46
+ """
47
+ Hook to capture resid_mid at the input of post_attention_layernorm.
48
+ """
49
+ # input is a tuple (tensor,)
50
+ val = input[0]
51
+ # Attach to the module (which is the Norm layer)
52
+ module.mid_activation = val
53
+ if module.mid_activation.requires_grad:
54
+ module.mid_activation.retain_grad()
55
+
56
+ def register_hooks(self, capture_mid=False):
57
+ """
58
+ Register forward hooks on all model layers.
59
+ """
60
+ self.remove_hooks() # Clear existing
61
+ model = self.manager.get_model()
62
+ if not model:
63
+ raise ValueError("Model not loaded yet.")
64
+
65
+ # Assuming generic structure model.model.layers (common in HF Qwen, Llama, etc.)
66
+ if hasattr(model, "model") and hasattr(model.model, "layers"):
67
+ layers = model.model.layers
68
+ elif hasattr(model, "layers"): # Some archs
69
+ layers = model.layers
70
+ else:
71
+ layers = []
72
+
73
+ for layer in layers:
74
+ # Hook Output (Resid Post)
75
+ handle = layer.register_forward_hook(self._hook_hidden_activation)
76
+ self.hook_handles.append(handle)
77
+
78
+ # Hook Mid (Resid Mid) - Pre-hook on Post-Attn Norm
79
+ if capture_mid:
80
+ # Use decomposer to find the correct module for mid activation
81
+ mid_module = self.manager.decomposer.get_mid_activation_module(layer)
82
+
83
+ if mid_module:
84
+ # Use forward hook to get input?
85
+ # register_forward_hook receives (module, input, output)
86
+ # input is (resid_mid,)
87
+ handle_mid = mid_module.register_forward_hook(self._hook_mid_activation)
88
+ self.hook_handles.append(handle_mid)
89
+ else:
90
+ print(f"Warning: Could not identify mid-activation module for layer {layer}. Skipping mid hook.")
91
+
92
+ def remove_hooks(self):
93
+ for handle in self.hook_handles:
94
+ handle.remove()
95
+ self.hook_handles = []
96
+
97
+ def reset(self):
98
+ """
99
+ Clears all internal state and specific temporary data from model layers.
100
+ """
101
+ self.remove_hooks()
102
+ self.outputs = None
103
+ self.input_ids = None
104
+
105
+ # Manually clear output tensors attached to layers to free graph
106
+ model = self.manager.get_model()
107
+ if model:
108
+ layers = []
109
+ if hasattr(model, "model") and hasattr(model.model, "layers"):
110
+ layers = model.model.layers
111
+ elif hasattr(model, "layers"):
112
+ layers = model.layers
113
+
114
+ for layer in layers:
115
+ if hasattr(layer, 'output'):
116
+ del layer.output
117
+ if hasattr(layer, 'post_attention_layernorm') and hasattr(layer.post_attention_layernorm, 'mid_activation'):
118
+ del layer.post_attention_layernorm.mid_activation
119
+
120
+ torch.cuda.empty_cache()
121
+
122
+
123
+ def compute_logits(self, prompt, is_append_bos=False, topk=10, extra_token_ids=None, extra_token_strs=None, capture_mid=False):
124
+ """
125
+ Section 1: Forward pass to get logits and top-k predictions.
126
+ """
127
+ model = self.manager.get_model()
128
+ tokenizer = self.manager.get_tokenizer()
129
+
130
+ self.register_hooks(capture_mid=capture_mid)
131
+
132
+ # Prepare input
133
+ # We tokenize with add_special_tokens=False to manually control the BOS/Start token
134
+ inputs = tokenizer(prompt, return_tensors="pt", add_special_tokens=False)
135
+ input_ids = inputs.input_ids.to(model.device)
136
+
137
+ if is_append_bos:
138
+ # 1. Try explicit BOS
139
+ bos_id = tokenizer.bos_token_id
140
+
141
+ # 2. Try CLS (BERT-like)
142
+ if bos_id is None:
143
+ bos_id = tokenizer.cls_token_id
144
+
145
+ # 3. Fallback: EOS (Often used as BOS/Separator in Llama/decoder-only models if BOS is missing)
146
+ if bos_id is None:
147
+ bos_id = tokenizer.eos_token_id
148
+
149
+ if bos_id is not None:
150
+ prefix = torch.tensor([[bos_id]], device=model.device)
151
+ input_ids = torch.cat([prefix, input_ids], dim=1)
152
+ print(f"Appended start token ID: {bos_id}")
153
+ else:
154
+ print("Warning: Append BOS requested but no suitable start token (BOS/CLS/EOS) found.")
155
+
156
+ self.input_ids = input_ids
157
+
158
+ # Embedding with gradients required for LRP base
159
+ # We detach and enable gradients so we can compute attribution w.r.t input embeddings
160
+ # even if the model is frozen/quantized.
161
+ self.input_embeddings = model.get_input_embeddings()(self.input_ids).detach()
162
+ self.input_embeddings.requires_grad_(True)
163
+
164
+ # Forward pass
165
+ # output_hidden_states=True is crucial for some LRP methods,
166
+ # though we use hooks for "efficient" method mostly.
167
+ self.outputs = model(
168
+ inputs_embeds=self.input_embeddings,
169
+ use_cache=False
170
+ )
171
+
172
+ output_logits = self.outputs.logits
173
+ last_logits = output_logits[0, -1, :]
174
+
175
+ # Get Top-K
176
+ sorted_logits, sorted_indices = torch.sort(last_logits, dim=-1, descending=True)
177
+
178
+ # Formatted output
179
+ topk_data = []
180
+ for i in range(topk):
181
+ idx = sorted_indices[i].item()
182
+ token_str = tokenizer.decode([idx])
183
+ logit_val = sorted_logits[i].item()
184
+ topk_data.append({
185
+ "rank": i + 1,
186
+ "token_id": idx,
187
+ "token_str": token_str,
188
+ "logit": logit_val
189
+ })
190
+
191
+ # Handle Extra Tokens (if requested)
192
+ if extra_token_ids or extra_token_strs:
193
+ # Helper to find rank
194
+ def get_rank(val, sorted_vals):
195
+ # tensor search for rank
196
+ # val is float, sorted_vals is tensor
197
+ # find first index where sorted_vals < val is NOT true?
198
+ # sorted_vals is descending
199
+ # we want count of items > val
200
+ return (sorted_vals > val).sum().item() + 1
201
+
202
+ processed_ids = set()
203
+
204
+ # Process IDs
205
+ if extra_token_ids:
206
+ for tid in extra_token_ids:
207
+ if tid < 0 or tid >= len(last_logits): continue
208
+ if tid in processed_ids: continue
209
+
210
+ logit_val = last_logits[tid].item()
211
+ rank = get_rank(logit_val, sorted_logits)
212
+ token_str = tokenizer.decode([tid])
213
+
214
+ topk_data.append({
215
+ "rank": rank,
216
+ "token_id": tid,
217
+ "token_str": token_str,
218
+ "logit": logit_val,
219
+ "is_extra": True
220
+ })
221
+ processed_ids.add(tid)
222
+
223
+ # Process Strings
224
+ if extra_token_strs:
225
+ print(f"DEBUG: Processing extra strs: {extra_token_strs}")
226
+ for tstr in extra_token_strs:
227
+ # Encode
228
+ try:
229
+ # Ensure we get list of ints
230
+ encoded = tokenizer.encode(tstr, add_special_tokens=False)
231
+ print(f"DEBUG: Encoded '{tstr}' -> {encoded} (Type: {type(encoded)})")
232
+
233
+ if hasattr(encoded, 'tolist'): encoded = encoded.tolist()
234
+
235
+ if len(encoded) == 0:
236
+ print(f"DEBUG: Empty encoding for '{tstr}'")
237
+ continue
238
+
239
+ # Take first token
240
+ tid = encoded[0]
241
+ print(f"DEBUG: Using TID {tid} for '{tstr}'")
242
+
243
+ if tid in processed_ids:
244
+ print(f"DEBUG: TID {tid} already processed")
245
+ continue
246
+
247
+ logit_val = last_logits[tid].item()
248
+ rank = get_rank(logit_val, sorted_logits)
249
+ real_str = tokenizer.decode([tid])
250
+
251
+ print(f"DEBUG: Added extra token: {real_str} (ID: {tid}, Rank: {rank})")
252
+
253
+ topk_data.append({
254
+ "rank": rank,
255
+ "token_id": tid,
256
+ "token_str": real_str,
257
+ "logit": logit_val,
258
+ "is_extra": True
259
+ })
260
+ processed_ids.add(tid)
261
+ except Exception as e:
262
+ print(f"DEBUG: Error processing extra str '{tstr}': {e}")
263
+ import traceback
264
+ traceback.print_exc()
265
+
266
+ # Sort combined data by rank for display consistency?
267
+ # Or keep extras at the bottom? User request: "add more tokens in the existing top-50 table"
268
+ # If we sort, they mix in. If they are rank 1000, they go to bottom.
269
+ # But if they are rank 5 (and we showed top 10), they mix in.
270
+ # Let's sort.
271
+ topk_data.sort(key=lambda x: x['rank'])
272
+
273
+ if self.input_ids is None:
274
+ raise ValueError("Input IDs not found. Ensure compute_logits was run.")
275
+
276
+ # Get input tokens for visualization
277
+ # Robust token reconstruction ensuring spaces are preserved
278
+ input_tokens = []
279
+ # convert_ids_to_tokens usually preserves the special characters (like Ġ or )
280
+ raw_tokens = tokenizer.convert_ids_to_tokens(self.input_ids[0])
281
+
282
+ for t in raw_tokens:
283
+ # Handle bytes (common in tiktoken-based tokenizers like Qwen)
284
+ if isinstance(t, bytes):
285
+ try:
286
+ t = t.decode('utf-8')
287
+ except:
288
+ # Fallback for weird bytes behavior
289
+ t = str(t)
290
+
291
+ # If it's a string, it might still have the special whitespace characters
292
+ if isinstance(t, str):
293
+ # Replace SentencePiece underline (U+2581)
294
+ t = t.replace('\u2581', ' ')
295
+ # Replace GPT-2/RoBERTa G-dot (U+0120)
296
+ t = t.replace('\u0120', ' ')
297
+ # Replace Newline char (U+010A)
298
+ t = t.replace('\u010A', '\n')
299
+ # Replace generic replacement char just in case
300
+ t = t.replace('', '')
301
+
302
+ input_tokens.append(t)
303
+
304
+ return topk_data, last_logits, input_tokens
305
+
306
+ def get_target_score(self, backprop_config):
307
+ """
308
+ Calculates the target scalar score (e.g. logit diff) based on config.
309
+ Returns the score tensor (attached to graph).
310
+ """
311
+ if self.outputs is None:
312
+ raise ValueError("Model outputs not computed. Call compute_logits first.")
313
+
314
+ mode = backprop_config.get("mode", "max_logit")
315
+ last_logits = self.outputs.logits[0, -1, :]
316
+ sorted_logits, sorted_indices = torch.sort(last_logits, dim=-1, descending=True)
317
+
318
+ target_token_id = backprop_config.get("target_token_id")
319
+ if target_token_id is not None:
320
+ target_logit = last_logits[target_token_id]
321
+ else:
322
+ target_logit = sorted_logits[0] # Default to Top 1
323
+
324
+ if mode == "max_logit":
325
+ return target_logit
326
+
327
+ elif mode == "logit_diff":
328
+ strategy = backprop_config.get("strategy", "by_topk_avg")
329
+ top_logit = target_logit
330
+
331
+ if strategy == "by_ref_token":
332
+ ref_id = backprop_config.get("ref_token_id")
333
+ if ref_id is None:
334
+ raise ValueError("ref_token_id required for strategy 'by_ref_token'")
335
+ contrast_logit = last_logits[ref_id]
336
+ target_logit = top_logit - contrast_logit
337
+
338
+ elif strategy == "demean":
339
+ target_logit = top_logit - last_logits.mean()
340
+
341
+ elif strategy == "by_topk_avg":
342
+ k = backprop_config.get("k", 10) # default K=10
343
+ k = min(k, len(sorted_logits))
344
+ contrast_logit = sorted_logits[:k].mean()
345
+ target_logit = top_logit - contrast_logit
346
+
347
+ return target_logit
348
+
349
+ def run_backward_pass(self, backprop_config):
350
+ """
351
+ Section 2 Part A: execute backward pass based on configuration.
352
+ """
353
+ target_logit = self.get_target_score(backprop_config)
354
+
355
+ if target_logit is None:
356
+ raise ValueError(f"Invalid backprop configuration: {backprop_config}")
357
+
358
+ # Clear previous gradients
359
+ model = self.manager.get_model()
360
+ model.zero_grad()
361
+
362
+ # Also clear gradients on input embeddings if they exist
363
+ if hasattr(self, 'input_embeddings') and self.input_embeddings is not None:
364
+ if self.input_embeddings.grad is not None:
365
+ self.input_embeddings.grad.zero_()
366
+
367
+ # Run backward
368
+ # We need to retain grad on hidden states often?
369
+ # In notebook: h = outputs.hidden_states[-1]; h.retain_grad(); target_logit.backward()
370
+ # But we act on layer.output.grad which is captured by hook + activation
371
+
372
+ target_logit.backward(retain_graph=True) # retain_graph needed for interactive exploration where we run backward multiple times
373
+
374
+ def compute_input_attribution(self, backprop_config):
375
+ """
376
+ Compute input attribution (Input * Gradient).
377
+ """
378
+ # Ensure correct LRP rule is active
379
+ # The forward pass must have been run with the correct rule.
380
+ # If we detect a mismatch, we must force a reload and ask user to re-run forward.
381
+ # Use the currently loaded LRP rule as default (not hardcoded "Attn-LRP")
382
+ # to avoid false mismatch when the frontend omits lrp_rule from backprop_config.
383
+ default_rule = self.manager.current_lrp_rule or "Attn-LRP"
384
+ required_rule = backprop_config.get("lrp_rule", default_rule)
385
+
386
+ if self.manager.current_lrp_rule and self.manager.current_lrp_rule != required_rule:
387
+ print(f"LRP Rule Mismatch detected (Current: {self.manager.current_lrp_rule}, Requested: {required_rule})")
388
+ print(f"Reloading model {self.manager.current_model_path} with rule={required_rule}...")
389
+
390
+ old_rule = self.manager.current_lrp_rule
391
+
392
+ self.manager.load_model(
393
+ model_path=self.manager.current_model_path,
394
+ dtype=self.manager.current_dtype,
395
+ lrp_rule=required_rule
396
+ )
397
+
398
+ # Since the forward pass graph (self.outputs) was built with the OLD rule,
399
+ # we cannot proceed. The user must re-run compute_logits.
400
+ raise RuntimeError(
401
+ f"LRP rule changed from '{old_rule}' to '{required_rule}'. "
402
+ "The model has been reloaded. You MUST re-run 'compute_logits()' to rebuild the computation graph with the new rule, "
403
+ "then call 'compute_input_attribution()' again."
404
+ )
405
+ # backprop_config['target_token_id'] = 2877
406
+ self.run_backward_pass(backprop_config)
407
+
408
+ # Calculate relevance: (input * grad).sum(-1)
409
+ # self.input_embeddings is [Batch, Seq, Dim]
410
+ if self.input_embeddings.grad is None:
411
+ raise RuntimeError("No gradient found on input embeddings. Ensure compute_logits was run.")
412
+
413
+ relevance = (self.input_embeddings * self.input_embeddings.grad).float().sum(-1).detach().cpu()[0]
414
+ print(f"Computed input attribution with shape: {relevance}")
415
+ # Return raw relevance
416
+ return relevance.tolist()
417
+
418
+ def compute_input_attribution_gradient(self, backprop_config):
419
+ """
420
+ Compute input attribution using vanilla gradient method (Input * Gradient).
421
+ This does NOT require LRP monkey-patching - uses standard PyTorch autograd.
422
+ The gradient flows through normal attention/MLP without LRP decomposition rules.
423
+ """
424
+ if self.outputs is None:
425
+ raise RuntimeError("No forward pass found. Run compute_logits first.")
426
+
427
+ # Run backward pass (works on vanilla model without LRP)
428
+ self.run_backward_pass(backprop_config)
429
+
430
+ # Calculate relevance: (input * grad).sum(-1)
431
+ if self.input_embeddings.grad is None:
432
+ raise RuntimeError("No gradient found on input embeddings. Ensure compute_logits was run.")
433
+
434
+ relevance = (self.input_embeddings * self.input_embeddings.grad).float().sum(-1).detach().cpu()[0]
435
+ print(f"Computed GRADIENT input attribution with shape: {relevance.shape}")
436
+ return relevance.tolist()
437
+
438
+ def compute_perturbation_eval(self, attribution_scores, k_values, target_token_id):
439
+ """
440
+ Evaluate attribution quality by perturbing top-attributed tokens.
441
+
442
+ For each k in k_values:
443
+ 1. Sort tokens by |attribution score| descending
444
+ 2. Take top-k token indices
445
+ 3. Clone input embeddings, zero out those k tokens' embeddings
446
+ 4. Run forward pass with perturbed embeddings
447
+ 5. Check if the error token (target_token_id) is still top-1
448
+
449
+ Args:
450
+ attribution_scores: list of floats (one per input token)
451
+ k_values: list of int (e.g., [1, 3, 5, 10])
452
+ target_token_id: int - the error token ID to check
453
+
454
+ Returns:
455
+ list of result dicts for each k
456
+ """
457
+ if self.input_ids is None or self.input_embeddings is None:
458
+ raise RuntimeError("No forward pass found. Run compute_logits first.")
459
+
460
+ model = self.manager.get_model()
461
+ tokenizer = self.manager.get_tokenizer()
462
+ device = model.device
463
+
464
+ # Get original top-1 prediction for reference
465
+ with torch.no_grad():
466
+ original_logits = model(
467
+ inputs_embeds=self.input_embeddings.detach(),
468
+ use_cache=False
469
+ ).logits[0, -1, :]
470
+ original_top1_id = original_logits.argmax().item()
471
+ original_target_logit = original_logits[target_token_id].item()
472
+
473
+ # Sort tokens by |attribution score| descending
474
+ scores = torch.tensor(attribution_scores, dtype=torch.float32)
475
+ sorted_indices = torch.argsort(scores.abs(), descending=True)
476
+
477
+ seq_len = self.input_embeddings.shape[1]
478
+
479
+ results = []
480
+ for k in k_values:
481
+ k_clamped = min(k, seq_len)
482
+ top_k_indices = sorted_indices[:k_clamped].tolist()
483
+
484
+ # Get the token strings being perturbed
485
+ perturbed_token_strs = []
486
+ for idx in top_k_indices:
487
+ if idx < len(self.input_ids[0]):
488
+ tid = self.input_ids[0][idx].item()
489
+ perturbed_token_strs.append(tokenizer.decode([tid]))
490
+ else:
491
+ perturbed_token_strs.append("?")
492
+
493
+ # Clone embeddings and zero out top-k tokens
494
+ perturbed_embeddings = self.input_embeddings.detach().clone()
495
+ for idx in top_k_indices:
496
+ perturbed_embeddings[0, idx, :] = 0.0
497
+
498
+ # Forward pass with perturbed embeddings
499
+ with torch.no_grad():
500
+ perturbed_logits = model(
501
+ inputs_embeds=perturbed_embeddings,
502
+ use_cache=False
503
+ ).logits[0, -1, :]
504
+
505
+ perturbed_top1_id = perturbed_logits.argmax().item()
506
+ perturbed_top1_str = tokenizer.decode([perturbed_top1_id])
507
+ perturbed_target_logit = perturbed_logits[target_token_id].item()
508
+
509
+ # Error is "fixed" if the target token is no longer top-1
510
+ error_fixed = (perturbed_top1_id != target_token_id)
511
+
512
+ # Compute logit change
513
+ logit_change = perturbed_target_logit - original_target_logit
514
+
515
+ # Compute rank of target token after perturbation
516
+ sorted_perturbed, sorted_perturbed_idx = torch.sort(perturbed_logits, descending=True)
517
+ target_rank_after = (sorted_perturbed_idx == target_token_id).nonzero(as_tuple=True)[0].item() + 1
518
+
519
+ results.append({
520
+ "k": k,
521
+ "perturbed_tokens": perturbed_token_strs,
522
+ "perturbed_indices": top_k_indices,
523
+ "new_top1_token_id": perturbed_top1_id,
524
+ "new_top1_token_str": perturbed_top1_str,
525
+ "error_fixed": error_fixed,
526
+ "original_target_logit": round(original_target_logit, 4),
527
+ "perturbed_target_logit": round(perturbed_target_logit, 4),
528
+ "logit_change": round(logit_change, 4),
529
+ "target_rank_after": target_rank_after
530
+ })
531
+
532
+ print(f"Perturbation k={k}: error_fixed={error_fixed}, "
533
+ f"new_top1='{perturbed_top1_str}' (ID={perturbed_top1_id}), "
534
+ f"logit_change={logit_change:.4f}, target_rank={target_rank_after}")
535
+
536
+ del perturbed_embeddings
537
+
538
+ torch.cuda.empty_cache()
539
+ return results
540
+
541
+ def compute_perturbation_manual(self, perturb_indices, target_token_id):
542
+ """
543
+ Evaluate attribution by perturbing manually selected token positions.
544
+
545
+ Args:
546
+ perturb_indices: list of int - token position indices to zero out
547
+ target_token_id: int - the error token ID to check
548
+
549
+ Returns:
550
+ dict with perturbation result
551
+ """
552
+ if self.input_ids is None or self.input_embeddings is None:
553
+ raise RuntimeError("No forward pass found. Run compute_logits first.")
554
+
555
+ model = self.manager.get_model()
556
+ tokenizer = self.manager.get_tokenizer()
557
+ seq_len = self.input_embeddings.shape[1]
558
+
559
+ # Validate indices
560
+ valid_indices = [idx for idx in perturb_indices if 0 <= idx < seq_len]
561
+ if len(valid_indices) == 0:
562
+ raise ValueError("No valid token indices provided.")
563
+
564
+ # Get original top-1 prediction for reference
565
+ with torch.no_grad():
566
+ original_logits = model(
567
+ inputs_embeds=self.input_embeddings.detach(),
568
+ use_cache=False
569
+ ).logits[0, -1, :]
570
+ original_top1_id = original_logits.argmax().item()
571
+ original_target_logit = original_logits[target_token_id].item()
572
+
573
+ # Get the token strings being perturbed
574
+ perturbed_token_strs = []
575
+ for idx in valid_indices:
576
+ if idx < len(self.input_ids[0]):
577
+ tid = self.input_ids[0][idx].item()
578
+ perturbed_token_strs.append(tokenizer.decode([tid]))
579
+ else:
580
+ perturbed_token_strs.append("?")
581
+
582
+ # Clone embeddings and zero out selected tokens
583
+ perturbed_embeddings = self.input_embeddings.detach().clone()
584
+ for idx in valid_indices:
585
+ perturbed_embeddings[0, idx, :] = 0.0
586
+
587
+ # Forward pass with perturbed embeddings
588
+ with torch.no_grad():
589
+ perturbed_logits = model(
590
+ inputs_embeds=perturbed_embeddings,
591
+ use_cache=False
592
+ ).logits[0, -1, :]
593
+
594
+ perturbed_top1_id = perturbed_logits.argmax().item()
595
+ perturbed_top1_str = tokenizer.decode([perturbed_top1_id])
596
+ perturbed_target_logit = perturbed_logits[target_token_id].item()
597
+
598
+ # Error is "fixed" if the target token is no longer top-1
599
+ error_fixed = (perturbed_top1_id != target_token_id)
600
+
601
+ # Compute logit change
602
+ logit_change = perturbed_target_logit - original_target_logit
603
+
604
+ # Compute rank of target token after perturbation
605
+ sorted_perturbed, sorted_perturbed_idx = torch.sort(perturbed_logits, descending=True)
606
+ target_rank_after = (sorted_perturbed_idx == target_token_id).nonzero(as_tuple=True)[0].item() + 1
607
+
608
+ result = {
609
+ "k": len(valid_indices),
610
+ "perturbed_tokens": perturbed_token_strs,
611
+ "perturbed_indices": valid_indices,
612
+ "new_top1_token_id": perturbed_top1_id,
613
+ "new_top1_token_str": perturbed_top1_str,
614
+ "error_fixed": error_fixed,
615
+ "original_target_logit": round(original_target_logit, 4),
616
+ "perturbed_target_logit": round(perturbed_target_logit, 4),
617
+ "logit_change": round(logit_change, 4),
618
+ "target_rank_after": target_rank_after
619
+ }
620
+
621
+ print(f"Manual Perturbation ({len(valid_indices)} tokens): error_fixed={error_fixed}, "
622
+ f"new_top1='{perturbed_top1_str}' (ID={perturbed_top1_id}), "
623
+ f"logit_change={logit_change:.4f}, target_rank={target_rank_after}")
624
+
625
+ del perturbed_embeddings
626
+ torch.cuda.empty_cache()
627
+ return result
628
+
629
+ def compute_connection_matrix_gen(self, source, target, node_threshold=None):
630
+ """
631
+ Section 2 Part B: Compute Token-to-Token interaction matrix between two nodes.
632
+ Generator version that yields progress.
633
+ source, target: int (layer idx) or tuple (layer_idx, 'mid'/'post')
634
+ """
635
+ source_layer_idx, source_type = self._parse_node(source)
636
+ target_layer_idx, target_type = self._parse_node(target)
637
+
638
+ model = self.manager.get_model()
639
+ layers = model.model.layers
640
+
641
+ target_layer = layers[target_layer_idx]
642
+
643
+ # 1. Identify Source Tensor
644
+ if source_layer_idx == -1:
645
+ source_tensor = self.input_embeddings
646
+ else:
647
+ layer = layers[source_layer_idx]
648
+ if source_type == 'mid':
649
+ # Use Decomposer to get module
650
+ mid_mod = self.manager.decomposer.get_mid_activation_module(layer)
651
+ if not mid_mod:
652
+ raise ValueError(f"Decomposer could not identify mid-activation module for layer {source_layer_idx}")
653
+
654
+ source_tensor = getattr(mid_mod, 'mid_activation', None)
655
+ if source_tensor is None:
656
+ raise ValueError(f"Mid activation for layer {source_layer_idx} not captured. Enable capture_mid in compute_logits.")
657
+ else:
658
+ source_tensor = layer.output
659
+
660
+ # 2. Identify Target Tensor and Gradient
661
+ if target_type == 'mid':
662
+ # We need the gradient at the mid point (input to post_attn_norm)
663
+ mid_mod = self.manager.decomposer.get_mid_activation_module(target_layer)
664
+ if not mid_mod:
665
+ raise ValueError(f"Decomposer could not identify mid-activation module for target {target_layer_idx}")
666
+
667
+ target_tensor = getattr(mid_mod, 'mid_activation', None)
668
+ if target_tensor is None:
669
+ raise ValueError(f"Mid activation for target {target_layer_idx} not captured.")
670
+ else:
671
+ target_tensor = target_layer.output
672
+
673
+ target_grad = target_tensor.grad
674
+
675
+ # Disable gradient checkpointing temporarily
676
+ was_checkpointing = model.is_gradient_checkpointing
677
+ if was_checkpointing:
678
+ model.gradient_checkpointing_disable()
679
+
680
+ try:
681
+ # Prepare Input
682
+ target_layer_input = source_tensor.detach()
683
+ batch_size, seq_len, hidden_dim = target_layer_input.shape
684
+
685
+ # Target Real Relevance
686
+ if target_grad is not None:
687
+ real_target_rel = (target_tensor * target_grad).sum(dim=-1)[0]
688
+ else:
689
+ real_target_rel = torch.zeros(seq_len, device=model.device)
690
+
691
+ # Filter Indices
692
+ total_params = model.num_parameters()
693
+ if node_threshold is None: node_threshold = 0.01
694
+
695
+ if node_threshold > 0:
696
+ indices_to_compute = torch.nonzero(real_target_rel.abs() > node_threshold).squeeze(-1).tolist()
697
+ if isinstance(indices_to_compute, int): indices_to_compute = [indices_to_compute]
698
+ print(f"DEBUG: Node Threshold {node_threshold}. Computing for {len(indices_to_compute)}/{seq_len} nodes.")
699
+ else:
700
+ indices_to_compute = list(range(seq_len))
701
+
702
+ # Fixed Position IDs (for Rotary)
703
+ position_ids = torch.arange(0, seq_len, dtype=torch.long, device=model.device).unsqueeze(0)
704
+
705
+ # Construct Operation Sequence
706
+ ops = []
707
+ if source_layer_idx != -1:
708
+ if source_type == 'mid':
709
+ ops.append(('part2', layers[source_layer_idx]))
710
+
711
+ # Intermediate Layers
712
+ for i in range(source_layer_idx + 1, target_layer_idx):
713
+ ops.append(('part1', layers[i]))
714
+ ops.append(('part2', layers[i]))
715
+
716
+ # Target Layer
717
+ if target_layer_idx > source_layer_idx:
718
+ ops.append(('part1', layers[target_layer_idx]))
719
+ if target_type == 'post':
720
+ ops.append(('part2', layers[target_layer_idx]))
721
+ elif target_layer_idx == source_layer_idx:
722
+ pass # Already handled or identity
723
+ elif source_layer_idx == -1:
724
+ # Special case: source is embeddings, target is 0
725
+ # Range was (0,0) empty.
726
+ # Need to add target 0 parts
727
+ ops.append(('part1', layers[target_layer_idx]))
728
+ if target_type == 'post':
729
+ ops.append(('part2', layers[target_layer_idx]))
730
+
731
+ # Pre-calc Rotary Embedding (using dummy execution or helper)
732
+ # We assume rotary depends only on position_ids and shape
733
+ rotary_emb = None
734
+ if hasattr(model.model, 'rotary_emb'):
735
+ rotary_emb = model.model.rotary_emb(target_layer_input, position_ids)
736
+ elif hasattr(model.model, 'rotary_embs') and 'full_attention' in model.model.rotary_embs:
737
+ rotary_emb = model.model.rotary_embs['full_attention'](target_layer_input, position_ids)
738
+ elif hasattr(model.model, 'rotary_embs') and len(model.model.rotary_embs) > 0:
739
+ rotary_emb = list(model.model.rotary_embs.values())[0](target_layer_input, position_ids)
740
+
741
+ # Chunk Processing
742
+ current_dtype = target_layer_input.dtype
743
+ BATCH_CHUNK_SIZE = get_batch_chunk_size(total_params, current_dtype)
744
+ token_interaction = torch.zeros(seq_len, seq_len, device=model.device)
745
+ target_grad_full = target_grad # Alias
746
+
747
+ total_items = len(indices_to_compute)
748
+ print(f"DEBUG: BATCH_CHUNK_SIZE={BATCH_CHUNK_SIZE}, total_items={total_items}, seq_len={seq_len}, params={total_params/1e9:.2f}B, dtype={current_dtype}")
749
+
750
+ for i in range(0, total_items, BATCH_CHUNK_SIZE):
751
+ yield {"type": "progress", "current": i, "total": total_items}
752
+
753
+ chunk_indices = indices_to_compute[i : i + BATCH_CHUNK_SIZE]
754
+ current_batch_size = len(chunk_indices)
755
+
756
+ expanded_input = target_layer_input.expand(current_batch_size, seq_len, hidden_dim).clone().requires_grad_(True)
757
+
758
+ # Execute Ops
759
+ hidden_states = expanded_input
760
+ for op_type, layer_mod in ops:
761
+ if op_type == 'part1':
762
+ hidden_states = self._forward_part1(layer_mod, hidden_states, position_embeddings=rotary_emb)
763
+ else:
764
+ hidden_states = self._forward_part2(layer_mod, hidden_states)
765
+
766
+ reconstructed_output = hidden_states
767
+
768
+ # Backward
769
+ grad_output_chunk = torch.zeros(current_batch_size, seq_len, hidden_dim, dtype=reconstructed_output.dtype, device=model.device)
770
+
771
+ for batch_idx, global_idx in enumerate(chunk_indices):
772
+ if target_grad_full is not None:
773
+ grad_output_chunk[batch_idx, global_idx, :] = target_grad_full[0, global_idx, :]
774
+
775
+ grad_input = torch.autograd.grad(outputs=reconstructed_output, inputs=expanded_input, grad_outputs=grad_output_chunk, retain_graph=False)[0]
776
+
777
+ chunk_relevance = (grad_input * expanded_input).sum(dim=-1)
778
+ token_interaction[chunk_indices, :] = chunk_relevance.detach().to(token_interaction.dtype)
779
+
780
+ del expanded_input, hidden_states, reconstructed_output, grad_output_chunk, grad_input, chunk_relevance
781
+ torch.cuda.empty_cache()
782
+
783
+ # Source Real Relevance
784
+ if source_layer_idx == -1:
785
+ if self.input_embeddings.grad is not None:
786
+ real_source_rel = (self.input_embeddings * self.input_embeddings.grad).sum(dim=-1)[0]
787
+ else:
788
+ real_source_rel = torch.zeros(seq_len, device=model.device)
789
+ else:
790
+ if source_tensor.grad is not None:
791
+ real_source_rel = (source_tensor * source_tensor.grad).sum(dim=-1)[0]
792
+ else:
793
+ real_source_rel = torch.zeros(seq_len, device=model.device)
794
+
795
+ yield {
796
+ "type": "result",
797
+ "payload": {
798
+ "matrix": token_interaction.detach().float().cpu().numpy(),
799
+ "real_target_rel": real_target_rel.detach().float().cpu().numpy(),
800
+ "real_source_rel": real_source_rel.detach().float().cpu().numpy()
801
+ }
802
+ }
803
+
804
+ finally:
805
+ if was_checkpointing:
806
+ model.gradient_checkpointing_enable()
807
+
808
+ def compute_connection_matrix(self, source, target):
809
+ for item in self.compute_connection_matrix_gen(source, target):
810
+ if item.get("type") == "result":
811
+ return item["payload"]
812
+ return None
backend/error_token_location.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ logging.basicConfig(level=logging.INFO,
3
+ format='%(asctime)s | %(levelname)-6s | %(name)-40s || %(message)s',
4
+ datefmt='%m-%d %H:%M:%S')
5
+ logger = logging.getLogger(__name__)
6
+
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+ import json
13
+ from typing import Optional
14
+ from jinja2 import Template
15
+
16
+ # Use env-var based OpenAI client instead of Azure-specific endpoint
17
+ _openai_client = None
18
+
19
+ def _get_openai_client():
20
+ """Get or create OpenAI client using environment variables."""
21
+ global _openai_client
22
+ if _openai_client is not None:
23
+ return _openai_client
24
+
25
+ try:
26
+ import openai
27
+ except ImportError:
28
+ logger.warning("openai package not installed. LLM-based error token localization will not be available.")
29
+ return None
30
+
31
+ api_key = os.environ.get("OPENAI_API_KEY")
32
+ if not api_key:
33
+ logger.warning("OPENAI_API_KEY not set. LLM-based error token localization will not be available.")
34
+ return None
35
+
36
+ base_url = os.environ.get("OPENAI_BASE_URL", None)
37
+ kwargs = {"api_key": api_key}
38
+ if base_url:
39
+ kwargs["base_url"] = base_url
40
+
41
+ _openai_client = openai.OpenAI(**kwargs)
42
+ return _openai_client
43
+
44
+
45
+ def _get_default_models():
46
+ """Get the list of LLM models to use as validators from env or defaults."""
47
+ models_str = os.environ.get("LLM_MODELS", "gpt-4o-mini")
48
+ return [m.strip() for m in models_str.split(",") if m.strip()]
49
+
50
+
51
+ class ErrorTokenLocator:
52
+ def __init__(self, model, tokenizer, prompt_template_yaml=None):
53
+ """
54
+ Initialize the error token locator
55
+
56
+ Args:
57
+ model: The language model to use
58
+ tokenizer: The corresponding tokenizer for tokenizing text
59
+ prompt_template_yaml (str, optional): Path to the prompt template YAML file, uses default template when None
60
+ """
61
+ self.model = model
62
+ self.tokenizer = tokenizer
63
+
64
+ self.client = _get_openai_client()
65
+
66
+ self.endpoint_list = _get_default_models()
67
+
68
+ if prompt_template_yaml is None:
69
+ self.system_prompt = self.load_general_prompt_template()
70
+ else:
71
+ self.system_prompt = self.load_general_prompt_template(prompt_template_yaml)
72
+
73
+ def prompt_constructor(self, query, completion, ground_truth: str=None):
74
+ """
75
+ Construct prompts for error token location
76
+
77
+ Args:
78
+ query (str): The input query/prompt
79
+ completion (str): The completion text generated by the model
80
+ ground_truth (str, optional): The correct answer/ground truth, defaults to None
81
+
82
+ Returns:
83
+ tuple: (msg, tokens)
84
+ - msg (list): The constructed conversation message list containing system and user messages
85
+ - tokens (list): List of token IDs for the completion
86
+ """
87
+ tokens = self.tokenizer(completion).input_ids
88
+ indexed_completion = ""
89
+ for i, tok in enumerate(tokens):
90
+ indexed_completion += f"{self.tokenizer.decode([tok])}[{i}] "
91
+
92
+ user_msg_content = {
93
+ "prompt": query,
94
+ "completion": completion,
95
+ "indexed_completion": indexed_completion,
96
+ "ground_truth": ground_truth
97
+ }
98
+
99
+ msg = [
100
+ {"role": "system", "content": self.system_prompt},
101
+ {"role": "user", "content": json.dumps(user_msg_content, indent=2)}
102
+ ]
103
+ return msg, tokens
104
+
105
+ def load_general_prompt_template(self, prompt_template_yaml=None):
106
+ """
107
+ Load the general prompt template
108
+
109
+ Args:
110
+ prompt_template_yaml (str, optional): Path to the YAML template file, uses default path when None
111
+
112
+ Returns:
113
+ str: The rendered system prompt template string
114
+ """
115
+ if prompt_template_yaml is None:
116
+ # Use path relative to this file's location
117
+ prompt_template_yaml = os.path.join(
118
+ os.path.dirname(os.path.abspath(__file__)),
119
+ "token_locator_prompts",
120
+ "err_token_localization.yaml"
121
+ )
122
+
123
+ with open(prompt_template_yaml, "r", encoding='utf-8') as f:
124
+ system_prompt_temp = yaml.safe_load(f)
125
+ system_prompt = Template(system_prompt_temp['system']).render(
126
+ dataset_description="No dataset description provided.",
127
+ dataset_specific_instructions="-No dataset specific instructions provided."
128
+ )
129
+
130
+ return system_prompt
131
+
132
+ def call_validator(self, msg, tokens, endpoint_list=None):
133
+ """
134
+ Call the validator for error token location validation
135
+
136
+ Args:
137
+ msg (list): The constructed conversation message list
138
+ tokens (list): List of token IDs
139
+ endpoint_list (list): List of validator endpoint names
140
+
141
+ Returns:
142
+ tuple: (completion_before_err, explanation, vote_details)
143
+ - completion_before_err (str): Completion text truncated before the error token
144
+ - explanation (str): Explanation of why this token is the error
145
+ - vote_details (dict): Detailed voting information for each validator
146
+ """
147
+ if self.client is None:
148
+ raise RuntimeError(
149
+ "OpenAI client not available. Please set OPENAI_API_KEY environment variable "
150
+ "or use manual truncation instead."
151
+ )
152
+
153
+ responses = []
154
+ if endpoint_list is None:
155
+ endpoint_list = self.endpoint_list
156
+ for model_name in endpoint_list:
157
+ response = self.client.chat.completions.create(
158
+ model=model_name,
159
+ messages=msg,
160
+ temperature=0,
161
+ seed=42,
162
+ top_p=1,
163
+ frequency_penalty=0,
164
+ presence_penalty=0,
165
+ response_format={"type": "json_object"}
166
+ )
167
+ responses.append(response)
168
+
169
+ votes = {}
170
+ first_response_for_token = {}
171
+ validator_votes = {} # Track each validator's vote
172
+
173
+ for idx, response in enumerate(responses):
174
+ model_name = endpoint_list[idx]
175
+ try:
176
+ res_json = json.loads(response.choices[0].message.content)
177
+ token_index = int(res_json["token_index"])
178
+ explanation = res_json.get("explanation", "")
179
+
180
+ # Track votes
181
+ votes[token_index] = votes.get(token_index, 0) + 1
182
+ if token_index not in first_response_for_token:
183
+ first_response_for_token[token_index] = (response, idx)
184
+
185
+ # Store each validator's vote details
186
+ validator_votes[model_name] = {
187
+ "token_index": token_index,
188
+ "error_token": self.tokenizer.decode([tokens[token_index]]) if token_index < len(tokens) else "N/A",
189
+ "explanation": explanation
190
+ }
191
+ except Exception as e:
192
+ logger.error(f"Error processing response from {model_name}: {e}")
193
+ validator_votes[model_name] = {
194
+ "token_index": -1,
195
+ "error_token": "Error",
196
+ "explanation": f"Failed to parse response: {str(e)}"
197
+ }
198
+
199
+ if not votes:
200
+ return responses[0] if responses else None, "", {}
201
+
202
+ max_votes = max(votes.values())
203
+ candidates = [t for t, c in votes.items() if c == max_votes]
204
+ # pick the candidate whose first corresponding response appeared earliest
205
+ winner_token = min(candidates, key=lambda t: first_response_for_token[t][1])
206
+
207
+ response = first_response_for_token[winner_token][0].choices[0].message.content
208
+ failure_rca = json.loads(response)
209
+ completion_before_err = ""
210
+ for idx, tok in enumerate(tokens):
211
+ if idx == failure_rca["token_index"]:
212
+ break
213
+ completion_before_err += f"{self.tokenizer.decode([tok])}"
214
+ explanation = failure_rca["explanation"]
215
+
216
+ # Add vote summary to vote_details
217
+ vote_summary = {
218
+ "winner_token_index": winner_token,
219
+ "winner_votes": max_votes,
220
+ "total_validators": len(endpoint_list),
221
+ "vote_distribution": votes
222
+ }
223
+
224
+ return completion_before_err, explanation, {
225
+ "validators": validator_votes,
226
+ "summary": vote_summary
227
+ }
228
+
229
+ def locate_error_token(self, prompt: str, completion: str, ground_truth: str = None,
230
+ validators: Optional[list] = None,
231
+ use_llm: bool = True,
232
+ manual_chunks: Optional[list] = None):
233
+ """
234
+ Main method to locate the error token in a completion
235
+
236
+ Args:
237
+ prompt (str): The input prompt
238
+ completion (str): The completion text to analyze
239
+ ground_truth (str, optional): The correct answer/ground truth, defaults to None
240
+
241
+ Returns:
242
+ dict: Dictionary containing:
243
+ - status (str): "success" or "error"
244
+ - truncated_text (str): Prompt + completion truncated before error token
245
+ - explanation (str): Explanation of the error
246
+ - error_token_index (int): Index of the error token
247
+ """
248
+ try:
249
+ # Construct prompt messages
250
+ msg, tokens = self.prompt_constructor(prompt, completion, ground_truth)
251
+
252
+ # If user requests to skip LLM search, use manual chunks if provided
253
+ if not use_llm:
254
+ if manual_chunks and len(manual_chunks) > 0:
255
+ manual_chunk = manual_chunks[0]
256
+ completion_before_err = manual_chunk
257
+ explanation = "Manual chunk provided by user (LLM search skipped)."
258
+ error_token_index = len(self.tokenizer(manual_chunk).input_ids)
259
+ truncated_text = prompt + completion_before_err
260
+ return {
261
+ "status": "success",
262
+ "truncated_text": truncated_text,
263
+ "explanation": explanation,
264
+ "error_token_index": error_token_index
265
+ }
266
+ else:
267
+ return {
268
+ "status": "error",
269
+ "message": "LLM search disabled but no manual chunk provided.",
270
+ "truncated_text": "",
271
+ "explanation": ""
272
+ }
273
+
274
+ # Check if OpenAI client is available
275
+ if self.client is None:
276
+ return {
277
+ "status": "error",
278
+ "message": "OpenAI API key not configured. Please set OPENAI_API_KEY environment variable or use manual truncation (disable LLM search).",
279
+ "truncated_text": "",
280
+ "explanation": ""
281
+ }
282
+
283
+ # If validators provided, use them for this call
284
+ endpoint_list = validators if (validators and isinstance(validators, list) and len(validators) > 0) else None
285
+
286
+ # Call validator to get error token location with vote details
287
+ completion_before_err, explanation, vote_details = self.call_validator(msg, tokens, endpoint_list)
288
+
289
+ # Combine prompt with truncated completion
290
+ truncated_text = prompt + completion_before_err
291
+
292
+ # Calculate error token index
293
+ error_token_index = len(self.tokenizer(completion_before_err).input_ids)
294
+
295
+ return {
296
+ "status": "success",
297
+ "truncated_text": truncated_text,
298
+ "explanation": explanation,
299
+ "error_token_index": error_token_index,
300
+ "vote_details": vote_details
301
+ }
302
+
303
+ except Exception as e:
304
+ logger.error(f"Error in locate_error_token: {e}")
305
+ import traceback
306
+ traceback.print_exc()
307
+ return {
308
+ "status": "error",
309
+ "message": str(e),
310
+ "truncated_text": "",
311
+ "explanation": ""
312
+ }
backend/graph_metrics.py ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import networkx as nx
2
+ import numpy as np
3
+ import pandas as pd
4
+ from scipy.stats import entropy
5
+
6
+ class GraphMetrics:
7
+ """
8
+ A unified class for calculating metrics on attribution graphs.
9
+ Metrics are organized into three groups:
10
+ 1. Connectivity & Sparsity
11
+ 2. Information Flow & Concentration
12
+ 3. Temporal Dynamics
13
+
14
+ Each metric provides:
15
+ - Global Summary (single scalar)
16
+ - Layer-wise Profile (DataFrame/Series)
17
+ """
18
+ def __init__(self, G, tokens=None, total_seq_len=None):
19
+ self.G = G
20
+ self.tokens = tokens
21
+
22
+ if total_seq_len is None and tokens is not None:
23
+ self.total_seq_len = len(tokens)
24
+ else:
25
+ self.total_seq_len = total_seq_len
26
+
27
+ self.nodes_by_layer = self._group_nodes_by_layer()
28
+ self.sorted_layers = sorted(self.nodes_by_layer.keys())
29
+
30
+ def _get_token_str(self, index):
31
+ if self.tokens and 0 <= index < len(self.tokens):
32
+ return self.tokens[index]
33
+ return str(index)
34
+
35
+ def _group_nodes_by_layer(self):
36
+ groups = {}
37
+ for n in self.G.nodes():
38
+ layer = n[0]
39
+ if layer not in groups:
40
+ groups[layer] = []
41
+ groups[layer].append(n)
42
+ return groups
43
+
44
+ def _calculate_gini(self, array):
45
+ """Auxiliary function to calculate Gini coefficient."""
46
+ array = np.abs(array)
47
+ if np.sum(array) == 0:
48
+ return 0.0
49
+ array = np.sort(array)
50
+ index = np.arange(1, array.shape[0] + 1)
51
+ n = array.shape[0]
52
+ return ((np.sum((2 * index - n - 1) * array)) / (n * np.sum(array)))
53
+
54
+ def _calculate_top_mass(self, values, fraction=0.9):
55
+ """Calculates count and percentage of items needed to reach mass fraction."""
56
+ values = np.abs(values)
57
+ total = values.sum()
58
+ if total == 0:
59
+ return 0, 0.0
60
+
61
+ sorted_vals = np.sort(values)[::-1]
62
+ cumsum = np.cumsum(sorted_vals)
63
+ cutoff = total * fraction
64
+
65
+ # Find index where cumsum >= cutoff
66
+ idx = np.searchsorted(cumsum, cutoff)
67
+ count = idx + 1
68
+ pct = (count / len(values)) * 100.0
69
+ return count, pct
70
+
71
+ def _get_top_nodes_by_mass(self, node_val_pairs, fraction=0.9):
72
+ """
73
+ Calculates top nodes making up mass fraction.
74
+ Args:
75
+ node_val_pairs: list of (node, value)
76
+ Returns:
77
+ count, pct, list of (token_idx, token_str)
78
+ """
79
+ if not node_val_pairs:
80
+ return 0, 0.0, []
81
+
82
+ values = np.array([abs(v) for _, v in node_val_pairs])
83
+ total = values.sum()
84
+ if total == 0:
85
+ return 0, 0.0, []
86
+
87
+ # Sort indices by value descending
88
+ sorted_indices = np.argsort(values)[::-1]
89
+ sorted_vals = values[sorted_indices]
90
+
91
+ cumsum = np.cumsum(sorted_vals)
92
+ cutoff = total * fraction
93
+
94
+ idx_cutoff = np.searchsorted(cumsum, cutoff)
95
+ count = idx_cutoff + 1
96
+ pct = (count / len(values)) * 100.0
97
+
98
+ # Extract top nodes
99
+ top_indices = sorted_indices[:count]
100
+ top_nodes = []
101
+ for i in top_indices:
102
+ node, _ = node_val_pairs[i]
103
+ # node is (layer, token_idx)
104
+ token_idx = int(node[1])
105
+ token_str = self._get_token_str(token_idx)
106
+ top_nodes.append((token_idx, token_str))
107
+
108
+ return count, pct, top_nodes
109
+
110
+ # ==========================================
111
+ # Group 1: Connectivity & Sparsity
112
+ # ==========================================
113
+
114
+ def get_connectivity_stats(self):
115
+ """Calculates Scale, Complexity, and Branching metrics."""
116
+ if self.G.number_of_nodes() == 0:
117
+ return pd.DataFrame()
118
+
119
+ layer_stats = []
120
+
121
+ # --- Layer-wise ---
122
+ for layer in self.sorted_layers:
123
+ nodes = self.nodes_by_layer[layer]
124
+
125
+ # Fan-In (Inputs from prev layers)
126
+ in_degrees = [self.G.in_degree(n) for n in nodes]
127
+ avg_in = np.mean(in_degrees) if in_degrees else 0
128
+ # std_in = np.std(in_degrees) if in_degrees else 0
129
+
130
+ # Fan-Out (Outputs to next layers)
131
+ out_degrees = [self.G.out_degree(n) for n in nodes]
132
+ avg_out = np.mean(out_degrees) if out_degrees else 0
133
+ # std_out = np.std(out_degrees) if out_degrees else 0
134
+
135
+ # Derived Layer Metrics
136
+ node_count = len(nodes)
137
+ edge_count = np.sum(out_degrees)
138
+
139
+ # Edge/Node Ratio
140
+ edge_node_ratio = edge_count / node_count if node_count > 0 else 0
141
+
142
+ stats = {
143
+ "Layer": layer,
144
+ "Node_Count": node_count,
145
+ "Edge_Count": edge_count,
146
+ "Edge_Node_Ratio": edge_node_ratio,
147
+ "Avg_Fan_In": avg_in,
148
+ # "Std_Fan_In": std_in,
149
+ "Avg_Fan_Out": avg_out,
150
+ # "Std_Fan_Out": std_out
151
+ }
152
+
153
+ if self.total_seq_len:
154
+ stats["Sparsity"] = 1.0 - (node_count / self.total_seq_len)
155
+
156
+ layer_stats.append(stats)
157
+
158
+ df_layer = pd.DataFrame(layer_stats)
159
+
160
+ return df_layer
161
+
162
+ # ==========================================
163
+ # Group 2: Information Flow & Concentration
164
+ # ==========================================
165
+
166
+ def get_flow_stats(self):
167
+ """Calculates Effective Branching, Gini, and Verticality."""
168
+ if self.G.number_of_nodes() == 0:
169
+ return pd.DataFrame()
170
+
171
+ layer_stats = []
172
+
173
+ for layer in self.sorted_layers:
174
+ nodes = self.nodes_by_layer[layer]
175
+ # eff_degrees_in = []
176
+ # eff_degrees_out = []
177
+ node_ginis_in = []
178
+ node_ginis_out = []
179
+
180
+ # New Collectors
181
+ layer_node_rels = []
182
+ layer_out_edge_rels = []
183
+
184
+ layer_vertical_mass = 0.0
185
+ layer_total_mass = 0.0
186
+
187
+ for n in nodes:
188
+ # Node Relevance
189
+ rel = abs(self.G.nodes[n].get('relevance', 0.0))
190
+ layer_node_rels.append(rel)
191
+
192
+ # Incoming Edges analysis
193
+ in_edges = self.G.in_edges(n, data=True)
194
+ weights_in = np.array([abs(d.get('weight', 0.0)) for u, v, d in in_edges])
195
+
196
+ # Flow Mass
197
+ if len(weights_in) > 0:
198
+ w_sum = weights_in.sum()
199
+ layer_total_mass += w_sum
200
+
201
+ # Verticality check
202
+ for u, v, d in in_edges:
203
+ if u[1] == v[1]: # Same token index
204
+ layer_vertical_mass += abs(d.get('weight', 0.0))
205
+
206
+
207
+
208
+ # Outgoing Edges analysis
209
+ out_edges = self.G.out_edges(n, data=True)
210
+ weights_out = np.array([abs(d.get('weight', 0.0)) for u, v, d in out_edges])
211
+
212
+ # Collect Out Edge Relevances
213
+ if len(weights_out) > 0:
214
+ layer_out_edge_rels.extend(weights_out)
215
+
216
+
217
+ # Gini (On Inputs)
218
+ if len(weights_in) > 1:
219
+ g = self._calculate_gini(weights_in)
220
+ node_ginis_in.append(g)
221
+ elif len(weights_in) == 1:
222
+ node_ginis_in.append(1.0)
223
+
224
+ # Gini (On Outputs)
225
+ if len(weights_out) > 1:
226
+ g = self._calculate_gini(weights_out)
227
+ node_ginis_out.append(g)
228
+ elif len(weights_out) == 1:
229
+ node_ginis_out.append(1.0)
230
+
231
+ # Node Statistics
232
+ layer_node_rels = np.array(layer_node_rels)
233
+ avg_node_rel = np.mean(layer_node_rels) if len(layer_node_rels) > 0 else 0
234
+ node_rel_gini = self._calculate_gini(layer_node_rels) if len(layer_node_rels) > 0 else 0
235
+
236
+ # Node Mass Fractions (Moved to Group 3)
237
+
238
+ # Out Edge Statistics
239
+ layer_out_edge_rels = np.array(layer_out_edge_rels)
240
+ avg_out_edge_rel = np.mean(layer_out_edge_rels) if len(layer_out_edge_rels) > 0 else 0
241
+ out_edge_rel_gini = self._calculate_gini(layer_out_edge_rels) if len(layer_out_edge_rels) > 0 else 0
242
+
243
+ layer_stats.append({
244
+ "Layer": layer,
245
+
246
+ # Node Relevance Stats
247
+ "Avg_Node_Rel": avg_node_rel,
248
+ "Node_Rel_Gini": node_rel_gini,
249
+
250
+ # Out Edge Relevance Stats
251
+ "Avg_Out_Edge_Rel": avg_out_edge_rel,
252
+ "Gini_Out_Edge_Rel": out_edge_rel_gini,
253
+
254
+ # by node Gini Stats
255
+ "Avg_Edge_Gini_In_by_Node": np.mean(node_ginis_in) if node_ginis_in else 0,
256
+ "Avg_Edge_Gini_Out_by_Node": np.mean(node_ginis_out) if node_ginis_out else 0,
257
+
258
+ "Verticality_Ratio_by_Node": layer_vertical_mass / layer_total_mass if layer_total_mass > 0 else 0
259
+ })
260
+
261
+ df_layer = pd.DataFrame(layer_stats)
262
+
263
+ return df_layer
264
+
265
+ # ==========================================
266
+ # Group 3: Node Hubs & Significant Tokens
267
+ # ==========================================
268
+
269
+ def get_node_hub_stats(self):
270
+ """Calculates Top Mass Nodes, Degree Hubs, and Signed Relevance Hubs."""
271
+ if self.G.number_of_nodes() == 0:
272
+ return pd.DataFrame()
273
+
274
+ layer_stats = []
275
+
276
+ for layer in self.sorted_layers:
277
+ nodes = self.nodes_by_layer[layer]
278
+
279
+ # 1. Gather Data
280
+ node_rels = [] # Pairs of (node, rel)
281
+ in_degrees = [] # Pairs of (node, deg)
282
+ out_degrees = [] # Pairs of (node, deg)
283
+
284
+ pos_node_rels = []
285
+ neg_node_rels = []
286
+
287
+ for n in nodes:
288
+ # Relevance
289
+ rel = self.G.nodes[n].get('relevance', 0.0)
290
+ node_rels.append((n, rel))
291
+
292
+ if rel >= 0:
293
+ pos_node_rels.append((n, rel))
294
+ else:
295
+ neg_node_rels.append((n, rel))
296
+
297
+ # Degree
298
+ in_deg = self.G.in_degree(n)
299
+ in_degrees.append((n, in_deg))
300
+
301
+ out_deg = self.G.out_degree(n)
302
+ out_degrees.append((n, out_deg))
303
+
304
+ # 2. General Top Mass (Abs)
305
+ n_90, pct_90, top_90_nodes = self._get_top_nodes_by_mass(node_rels, fraction=0.9)
306
+
307
+ # 3. Top Mass Positive & Negative
308
+ n_pos, pct_pos, top_pos_nodes = self._get_top_nodes_by_mass(pos_node_rels, fraction=0.9)
309
+ n_neg, pct_neg, top_neg_nodes = self._get_top_nodes_by_mass(neg_node_rels, fraction=0.9)
310
+
311
+ # 4. Degree Hubs (Mean + Std)
312
+ # In-Degree
313
+ in_deg_vals = [d for _, d in in_degrees]
314
+ if len(in_deg_vals) > 0:
315
+ avg_in = np.mean(in_deg_vals)
316
+ std_in = np.std(in_deg_vals)
317
+ thresh_in = avg_in + std_in
318
+ hub_in_nodes = []
319
+ for n, deg in in_degrees:
320
+ if deg > thresh_in:
321
+ token_idx = int(n[1])
322
+ token_str = self._get_token_str(token_idx)
323
+ hub_in_nodes.append((token_idx, token_str))
324
+ else:
325
+ hub_in_nodes = []
326
+
327
+ # Out-Degree
328
+ out_deg_vals = [d for _, d in out_degrees]
329
+ if len(out_deg_vals) > 0:
330
+ avg_out = np.mean(out_deg_vals)
331
+ std_out = np.std(out_deg_vals)
332
+ thresh_out = avg_out + std_out
333
+ hub_out_nodes = []
334
+ for n, deg in out_degrees:
335
+ if deg > thresh_out:
336
+ token_idx = int(n[1])
337
+ token_str = self._get_token_str(token_idx)
338
+ hub_out_nodes.append((token_idx, token_str))
339
+ else:
340
+ hub_out_nodes = []
341
+
342
+ layer_stats.append({
343
+ "Layer": layer,
344
+
345
+ # Abs Relevance Hubs
346
+ "Node_Rel_Top_90_Pct_Count": n_90,
347
+ "Node_Rel_Top_90_Pct": pct_90,
348
+ # "Top_Rel_Nodes": top_90_nodes,
349
+
350
+ # Signed Relevance Hubs
351
+ "Pos_Rel_Top_90_Pct_Count": n_pos,
352
+ "Pos_Rel_Top_90_Pct": pct_pos,
353
+ # "Top_Pos_Nodes": top_pos_nodes,
354
+
355
+ "Neg_Rel_Top_90_Pct_Count": n_neg,
356
+ "Neg_Rel_Top_90_Pct": pct_neg,
357
+ # "Top_Neg_Nodes": top_neg_nodes,
358
+
359
+ # Structural Hubs
360
+ "Hub_In_Count": len(hub_in_nodes),
361
+ # "Hub_In_Nodes": hub_in_nodes,
362
+ "Hub_Out_Count": len(hub_out_nodes),
363
+ # "Hub_Out_Nodes": hub_out_nodes
364
+ })
365
+
366
+ return pd.DataFrame(layer_stats)
367
+
368
+ # ==========================================
369
+ # Group 4: Temporal Dynamics
370
+ # ==========================================
371
+
372
+ def get_temporal_stats(self):
373
+ """Calculates Lookback, Drift, and Locality."""
374
+ if self.G.number_of_nodes() == 0:
375
+ return pd.DataFrame()
376
+
377
+ layer_stats = []
378
+
379
+ for layer in self.sorted_layers:
380
+ nodes = self.nodes_by_layer[layer]
381
+
382
+ # --- Center of Mass (Drift) ---
383
+ positions = []
384
+ relevances = []
385
+
386
+ # For CoM, we look at the nodes THEMSELVES, not edges
387
+ for n in nodes:
388
+ # We need node relevance. If not stored, default to 1
389
+ rel = self.G.nodes[n].get('relevance', 1.0)
390
+ positions.append(n[1]) # Token Index
391
+ relevances.append(abs(rel))
392
+
393
+ positions = np.array(positions)
394
+ relevances = np.array(relevances)
395
+
396
+ if relevances.sum() > 0:
397
+ com = np.average(positions, weights=relevances)
398
+ else:
399
+ com = np.mean(positions) if len(positions) > 0 else 0
400
+
401
+ # --- Lookback & Locality (Edges entering this layer) ---
402
+ layer_lookbacks = []
403
+
404
+ for n in nodes:
405
+ in_edges = self.G.in_edges(n)
406
+ for u, v in in_edges:
407
+ # u is source, v is target (n)
408
+ # Lookback = v_pos - u_pos
409
+ dist = v[1] - u[1]
410
+ layer_lookbacks.append(dist)
411
+
412
+ layer_lookbacks = np.array(layer_lookbacks)
413
+
414
+ layer_stats.append({
415
+ "Layer": layer,
416
+ "Center_Of_Mass_Idx": com,
417
+ "Mean_Lookback": np.mean(layer_lookbacks) if len(layer_lookbacks) > 0 else 0,
418
+ "Local_Processing_Ratio": np.mean(layer_lookbacks == 0) if len(layer_lookbacks) > 0 else 0
419
+ })
420
+
421
+ df_layer = pd.DataFrame(layer_stats)
422
+
423
+ return df_layer
backend/metrics.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from scipy.stats import entropy
3
+
4
+ def calculate_normalized_entropy(relevance):
5
+ """
6
+ Calculates Shannon Entropy and Normalized Entropy of the relevance distribution.
7
+ Use absolute values of relevance to treat as a probability distribution.
8
+
9
+ Returns:
10
+ tuple: (entropy, normalized_entropy)
11
+ """
12
+ rel = np.array(relevance)
13
+ abs_rel = np.abs(rel)
14
+ n = len(abs_rel)
15
+
16
+ if n == 0:
17
+ return 0.0, 0.0
18
+
19
+ total_mass = abs_rel.sum()
20
+ if total_mass > 0:
21
+ probs = abs_rel / total_mass
22
+ else:
23
+ # Uniform if sum is 0
24
+ probs = np.ones_like(abs_rel) / n
25
+
26
+ # Shannon Entropy
27
+ ent = entropy(probs)
28
+
29
+ # Normalized Entropy (0 to 1)
30
+ # max_ent = ln(N)
31
+ max_ent = np.log(n)
32
+ norm_ent = ent / max_ent if max_ent > 0 else 0
33
+
34
+ return ent, norm_ent
35
+
36
+ def calculate_gini_coefficient(relevance):
37
+ """
38
+ Calculates the Gini Coefficient of the relevance absolute values.
39
+ Values near 1 indicate very unequal distribution (high concentration/sparsity).
40
+ Values near 0 indicate uniform distribution.
41
+ """
42
+ rel = np.array(relevance)
43
+ abs_rel = np.abs(rel)
44
+ n = len(abs_rel)
45
+
46
+ if n == 0 or abs_rel.mean() == 0:
47
+ return 0.0
48
+
49
+ # Sort ascending for Gini calculation formula
50
+ sorted_asc = np.sort(abs_rel)
51
+ index = np.arange(1, n + 1)
52
+
53
+ # Gini = (2 * sum(i * xi) - (n + 1) * sum(xi)) / (n * sum(xi))
54
+ gini_coeff = (2 * np.sum(index * sorted_asc) - (n + 1) * np.sum(sorted_asc)) / (n * np.sum(sorted_asc))
55
+
56
+ return gini_coeff
57
+
58
+ def calculate_top_mass_fraction(relevance, fraction=0.9):
59
+ """
60
+ Calculates how many tokens (and percentage) account for a specific fraction of the total attribution mass.
61
+
62
+ Returns:
63
+ tuple: (count, percentage, cdf_array, sorted_indices)
64
+ """
65
+ rel = np.array(relevance)
66
+ abs_rel = np.abs(rel)
67
+ n = len(abs_rel)
68
+
69
+ if n == 0:
70
+ return 0, 0.0, np.array([]), np.array([])
71
+
72
+ total_mass = abs_rel.sum()
73
+ if total_mass == 0:
74
+ # Avoid division by zero
75
+ # Return as if uniform or none
76
+ return n, 100.0, np.linspace(0, 1, n), np.arange(n)
77
+
78
+ # Sort descending
79
+ sorted_indices = np.argsort(abs_rel)[::-1]
80
+ sorted_mass = abs_rel[sorted_indices]
81
+ cumulative_mass = np.cumsum(sorted_mass)
82
+ cdf = cumulative_mass / total_mass
83
+
84
+ # Find how many tokens account for 'fraction' mass
85
+ count = np.searchsorted(cdf, fraction) + 1
86
+ percentage = (count / n) * 100
87
+
88
+ return count, percentage, cdf, sorted_indices, sorted_mass
89
+
90
+ def calculate_center_of_mass(relevance):
91
+ """
92
+ Calculates the center of mass (expected position) of the attribution.
93
+
94
+ Returns:
95
+ tuple: (absolute_center_index, relative_center_0_to_1)
96
+ """
97
+ rel = np.array(relevance)
98
+ abs_rel = np.abs(rel)
99
+ total_mass = abs_rel.sum()
100
+ n = len(abs_rel)
101
+
102
+ if n == 0 or total_mass == 0:
103
+ return 0.0, 0.0
104
+
105
+ indices = np.arange(n)
106
+ # E[index] = sum(p_i * i)
107
+ center_of_mass = np.sum(indices * abs_rel) / total_mass
108
+
109
+ # Relative center: 0 = start, 1 = end
110
+ # Use (n-1) as denominator because indices are 0 to n-1
111
+ relative_center = center_of_mass / (n - 1) if n > 1 else 0.5
112
+
113
+ return center_of_mass, relative_center
114
+
115
+ def calculate_early_late_ratio(relevance, split_ratio=0.5):
116
+ """
117
+ Calculates the ratio of mass in the first part vs the second part of the sequence.
118
+
119
+ Args:
120
+ relevance: The attribution array.
121
+ split_ratio: The point to split early vs late (0.5 = middle).
122
+
123
+ Returns:
124
+ float: Ratio (Early Mass / Late Mass).
125
+ Returns infinity if Late Mass is 0.
126
+ Returns 0 if Early Mass is 0.
127
+ """
128
+ rel = np.array(relevance)
129
+ abs_rel = np.abs(rel)
130
+ n = len(abs_rel)
131
+
132
+ if n == 0:
133
+ return 0.0
134
+
135
+ split_index = int(n * split_ratio)
136
+
137
+ early_mass = np.sum(abs_rel[:split_index])
138
+ late_mass = np.sum(abs_rel[split_index:])
139
+
140
+ if late_mass == 0:
141
+ return float('inf') if early_mass > 0 else 0.0
142
+
143
+ return early_mass / late_mass
backend/models/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .manager import ModelManager
2
+ from .base import LayerDecomposer
3
+ from .qwen import QwenDecomposer
4
+ from .factory import get_decomposer
backend/models/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (326 Bytes). View file
 
backend/models/__pycache__/base.cpython-312.pyc ADDED
Binary file (1.46 kB). View file
 
backend/models/__pycache__/factory.cpython-312.pyc ADDED
Binary file (857 Bytes). View file
 
backend/models/__pycache__/manager.cpython-312.pyc ADDED
Binary file (7.7 kB). View file
 
backend/models/__pycache__/qwen.cpython-312.pyc ADDED
Binary file (2.27 kB). View file
 
backend/models/base.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ import torch.nn as nn
3
+
4
+ class LayerDecomposer(ABC):
5
+ """
6
+ Abstract base class for decomposing Transformer layers into
7
+ Attention (Part 1) and MLP (Part 2) components.
8
+ """
9
+
10
+ @abstractmethod
11
+ def get_mid_activation_module(self, layer_module):
12
+ """
13
+ Returns the module whose input corresponds to 'resid_mid'.
14
+ This is typically the Post-Attention LayerNorm.
15
+ """
16
+ pass
17
+
18
+ @abstractmethod
19
+ def forward_part1(self, layer_module, hidden_states, position_embeddings=None, attention_mask=None):
20
+ """
21
+ Executes: Norm -> Attn -> Residual Add
22
+ Returns: resid_mid
23
+ """
24
+ pass
25
+
26
+ @abstractmethod
27
+ def forward_part2(self, layer_module, hidden_states):
28
+ """
29
+ Executes: Norm -> MLP -> Residual Add
30
+ Returns: resid_post
31
+ """
32
+ pass
backend/models/factory.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .base import LayerDecomposer
2
+ from .qwen import QwenDecomposer
3
+
4
+ # Registry mapping model type string (or class) to Decomposer
5
+ DECOMPOSER_REGISTRY = {
6
+ # Keys should match what we expect in model config or name logic
7
+ "qwen2": QwenDecomposer,
8
+ "qwen3": QwenDecomposer,
9
+ "qwen": QwenDecomposer, # Generic fallback
10
+ "llama": QwenDecomposer, # Llama usually identical structure (PreNorm, RMS, MLP)
11
+ }
12
+
13
+ def get_decomposer(model_name_or_obj) -> LayerDecomposer:
14
+ """
15
+ Factory to return appropriate decomposer.
16
+ """
17
+ # Simple logic based on string for now
18
+ name = str(model_name_or_obj).lower()
19
+
20
+ if "qwen" in name:
21
+ return QwenDecomposer()
22
+ if "llama" in name:
23
+ return QwenDecomposer() # Re-use for now as structure is same
24
+
25
+ # Default fallback (hope compatibility)
26
+ print(f"Warning: No specific decomposer for {name}. Using Qwen/Llama default.")
27
+ return QwenDecomposer()
backend/models/manager.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import importlib
3
+ from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
4
+ from transformers.models.qwen3 import modeling_qwen3
5
+ # Import other models as needed via conditional imports or a mapping
6
+ try:
7
+ from lxt.efficient import monkey_patch
8
+ except ImportError:
9
+ monkey_patch = None
10
+ print("Warning: lxt package not available. LRP attribution methods will be disabled.")
11
+ import gc
12
+ from .factory import get_decomposer
13
+
14
+ class ModelManager:
15
+ """
16
+ Manages model loading, quantization, and patching.
17
+ """
18
+ def __init__(self):
19
+ self.model = None
20
+ self.tokenizer = None
21
+ self.model_name = None
22
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
23
+ self.decomposer = None
24
+
25
+ # Track active configuration for reloading
26
+ self.current_model_path = None
27
+ self.current_dtype = None
28
+ self.current_lrp_rule = None
29
+ self.current_quantization = False
30
+ self.current_revision = None
31
+
32
+ def load_model(self, model_path="Qwen/Qwen3-0.6B", quantization_4bit=False, dtype="auto", revision=None, lrp_rule=None):
33
+ """
34
+ Loads the model and tokenizer, applies monkey patches for LRP if lrp_rule is specified.
35
+ lrp_rule: None (no LRP), "Attn-LRP", or "CP-LRP" (Conservative Propagation)
36
+ """
37
+ if revision == "" or revision == "null":
38
+ revision = None
39
+
40
+ print(f"Loading model from {model_path} with revision={revision} and rule={lrp_rule}...")
41
+
42
+ # Store active configuration
43
+ self.current_model_path = model_path
44
+ self.current_dtype = dtype
45
+ self.current_lrp_rule = lrp_rule
46
+ self.current_quantization = quantization_4bit
47
+ self.current_revision = revision
48
+
49
+ # Free up memory if reloading
50
+ if self.model is not None:
51
+ del self.model
52
+ del self.tokenizer
53
+ torch.cuda.empty_cache()
54
+ gc.collect()
55
+
56
+ self.model_name = model_path.split('/')[-1]
57
+
58
+ # Initialize Decomposer
59
+ self.decomposer = get_decomposer(self.model_name)
60
+
61
+ # Apply Monkey Patch for Efficient LRP (only if lrp_rule is specified)
62
+ if lrp_rule is not None:
63
+ if monkey_patch is None:
64
+ print("Warning: lxt package not available. Cannot apply LRP patches. Loading model without LRP.")
65
+ else:
66
+ target_module = None
67
+ patch_map = None
68
+
69
+ lower_path = model_path.lower()
70
+ if "qwen3" in lower_path:
71
+ importlib.reload(modeling_qwen3) # Reset to original classes to remove previous patches
72
+ target_module = modeling_qwen3
73
+ try:
74
+ import lxt.efficient.models.qwen3 as lxt_qwen3
75
+ importlib.reload(lxt_qwen3) # Reload to update class references from new modeling_qwen3
76
+ patch_map = lxt_qwen3.cp_LRP if lrp_rule == "CP-LRP" else lxt_qwen3.attnLRP
77
+ except ImportError as e:
78
+ print(f"Warning: Could not import lxt.efficient.models.qwen3: {e}")
79
+
80
+ elif "olmo" in lower_path:
81
+ try:
82
+ from transformers.models.olmo3 import modeling_olmo3
83
+ importlib.reload(modeling_olmo3)
84
+ target_module = modeling_olmo3
85
+ import lxt.efficient.models.olmo3 as lxt_olmo3
86
+ importlib.reload(lxt_olmo3)
87
+ patch_map = lxt_olmo3.cp_LRP if lrp_rule == "CP-LRP" else lxt_olmo3.attnLRP
88
+ except ImportError as e:
89
+ print(f"Warning: Could not import modeling_olmo3 or lxt module. LRP might fail. Error: {e}")
90
+
91
+ elif "qwen2" in lower_path:
92
+ try:
93
+ from transformers.models.qwen2 import modeling_qwen2
94
+ importlib.reload(modeling_qwen2)
95
+ target_module = modeling_qwen2
96
+ import lxt.efficient.models.qwen2 as lxt_qwen2
97
+ importlib.reload(lxt_qwen2)
98
+ patch_map = lxt_qwen2.cp_LRP if lrp_rule == "CP-LRP" else lxt_qwen2.attnLRP
99
+ except ImportError as e:
100
+ print(f"Warning: Could not import qwen2 or lxt: {e}")
101
+
102
+ if target_module:
103
+ if patch_map:
104
+ monkey_patch(target_module, patch_map=patch_map, verbose=True)
105
+ print(f"Applied LRP patches with rule: {lrp_rule}")
106
+ else:
107
+ monkey_patch(target_module, verbose=True) # Fallback to default
108
+ print("Applied default LRP patches")
109
+ else:
110
+ # LRP not enabled - reload modules to remove any previous monkey patches
111
+ # so that the model is loaded with vanilla (unpatched) classes.
112
+ lower_path = model_path.lower()
113
+ if "qwen3" in lower_path:
114
+ importlib.reload(modeling_qwen3)
115
+ print("Reloaded modeling_qwen3 to remove LRP patches")
116
+ elif "olmo" in lower_path:
117
+ try:
118
+ from transformers.models.olmo3 import modeling_olmo3
119
+ importlib.reload(modeling_olmo3)
120
+ print("Reloaded modeling_olmo3 to remove LRP patches")
121
+ except ImportError:
122
+ pass
123
+ elif "qwen2" in lower_path:
124
+ try:
125
+ from transformers.models.qwen2 import modeling_qwen2
126
+ importlib.reload(modeling_qwen2)
127
+ print("Reloaded modeling_qwen2 to remove LRP patches")
128
+ except ImportError:
129
+ pass
130
+ print("LRP not enabled - model loaded without attribution patches")
131
+
132
+ # Add else if for other models supported by lxt
133
+
134
+ # Map string dtype to torch dtype
135
+ torch_dtype = "auto"
136
+ bnb_dtype = torch.bfloat16 # Default for 4bit compute
137
+
138
+ if dtype == "float16":
139
+ torch_dtype = torch.float16
140
+ bnb_dtype = torch.float16
141
+ elif dtype == "bfloat16":
142
+ torch_dtype = torch.bfloat16
143
+ bnb_dtype = torch.bfloat16
144
+ elif dtype == "float32":
145
+ torch_dtype = torch.float32
146
+ bnb_dtype = torch.float32
147
+
148
+ # Quantization Config
149
+ quantization_config = None
150
+ if quantization_4bit:
151
+ quantization_config = BitsAndBytesConfig(
152
+ load_in_4bit=True,
153
+ bnb_4bit_compute_dtype=bnb_dtype,
154
+ )
155
+
156
+ # Load Model
157
+ if "qwen3" in model_path.lower():
158
+ self.model = modeling_qwen3.Qwen3ForCausalLM.from_pretrained(
159
+ model_path,
160
+ device_map=self.device,
161
+ torch_dtype=torch_dtype,
162
+ quantization_config=quantization_config,
163
+ revision=revision
164
+ )
165
+ else:
166
+ # Fallback for generic loading if specific class fails
167
+ self.model = AutoModelForCausalLM.from_pretrained(
168
+ model_path,
169
+ device_map=self.device,
170
+ torch_dtype=torch_dtype,
171
+ quantization_config=quantization_config,
172
+ revision=revision
173
+ )
174
+
175
+ self.tokenizer = AutoTokenizer.from_pretrained(model_path, revision=revision)
176
+
177
+ # Prepare model for LRP
178
+ self.model.eval() # Use eval usually, but test.ipynb uses train() + gradients
179
+ # test.ipynb: model.train(), gradient_checkpointing_enable(), requires_grad=False
180
+
181
+ # "model.train()" is often needed for Gradient Checkpointing to work in HF
182
+ self.model.train()
183
+ self.model.gradient_checkpointing_enable()
184
+
185
+ # Deactivate gradients on parameters
186
+ for param in self.model.parameters():
187
+ param.requires_grad = False
188
+
189
+ print(f"Model {self.model_name} loaded successfully on {self.device}")
190
+ return self.model_name
191
+
192
+ def get_model(self):
193
+ return self.model
194
+
195
+ def get_tokenizer(self):
196
+ return self.tokenizer
backend/models/qwen.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn as nn
2
+ from .base import LayerDecomposer
3
+
4
+ class QwenDecomposer(LayerDecomposer):
5
+ """
6
+ Decomposer for Qwen2/Qwen3 models.
7
+ Assumes Pre-Norm architecture:
8
+ Part 1: x -> InputNorm -> Attn -> Add (resid_mid)
9
+ Part 2: resid_mid -> PostAttnNorm -> MLP -> Add (resid_post)
10
+ """
11
+
12
+ def get_mid_activation_module(self, layer_module):
13
+ # Qwen models usually have 'post_attention_layernorm'
14
+ return getattr(layer_module, "post_attention_layernorm", None)
15
+
16
+ def forward_part1(self, layer_module, hidden_states, position_embeddings=None, attention_mask=None):
17
+ norm = getattr(layer_module, "input_layernorm", None)
18
+ # Try both generic 'self_attn' and Qwen specific naming if needed,
19
+ # though HF implementation usually maps to 'self_attn'
20
+ attn = getattr(layer_module, "self_attn", None)
21
+
22
+ if not norm or not attn:
23
+ # Fallback for some versions or wrapped modules
24
+ # Check named children
25
+ children = dict(layer_module.named_modules())
26
+ attn = attn or children.get("self_attn")
27
+
28
+ if not norm or not attn:
29
+ raise AttributeError(f"QwenDecomposer: Layer module {type(layer_module)} missing input_layernorm or self_attn")
30
+
31
+ # Norm
32
+ norm_out = norm(hidden_states)
33
+
34
+ # Attn
35
+ if position_embeddings is not None:
36
+ # Qwen/Llama usually accept position_embeddings
37
+ attn_out = attn(norm_out, position_embeddings=position_embeddings, attention_mask=attention_mask)
38
+ else:
39
+ attn_out = attn(norm_out, attention_mask=attention_mask)
40
+
41
+ if isinstance(attn_out, tuple): attn_out = attn_out[0]
42
+
43
+ return hidden_states + attn_out
44
+
45
+ def forward_part2(self, layer_module, hidden_states):
46
+ norm = getattr(layer_module, "post_attention_layernorm", None)
47
+ mlp = getattr(layer_module, "mlp", None)
48
+
49
+ if not norm or not mlp:
50
+ raise AttributeError("QwenDecomposer: Layer module missing post_attention_layernorm or mlp")
51
+
52
+ norm_out = norm(hidden_states)
53
+ mlp_out = mlp(norm_out)
54
+
55
+ return hidden_states + mlp_out
backend/token_locator_prompts/err_token_localization.yaml ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: v1
2
+ system: |-
3
+ - You are an expert in localizing error tokens in model-generated text completions.
4
+ - You will be provided with a prompt and a model-generated completion, and your task is to localize and output the FIRST token in the completion that is erroneous or causes the error.
5
+ - As the prompt and completion are taken from a particular dataset, refer to Section "Dataset Description" for a brief overview of the dataset.
6
+ - Refer to Section "On the Input Format" for details on how the prompt and completion will be presented to you.
7
+ - Follow the "General Guidance (Dataset Agnostic)" section for general instructions on how to approach the task of error token localization.
8
+ - Refer to the "Dataset Specific Instructions" section for any additional instructions that are specific to this dataset.
9
+ - Finally, adhere to the "On the Output Format" section to ensure your response is structured correctly.
10
+ - Note that the dataset specific sections (i.e., "Dataset Description" and "Dataset Specific Instructions") can be missing for some datasets. In such cases, simply skip those sections and proceed with the rest of the instructions.
11
+
12
+ # Dataset Description
13
+ {{dataset_description}}
14
+
15
+ # On the Input Format
16
+ - The input will be provided in the following JSON format:
17
+ {
18
+ "prompt": "<The prompt text here>",
19
+ "completion": "<The model-generated completion text here>",
20
+ "indexed_completion": "<The model-generated completion text with each token indexed, i.e., `token_1[index_1]token_2[index_2]`>"
21
+ "ground_truth": "<The ground truth answer text here>" (This field may be absent in some cases)
22
+ }
23
+ - An example of the input format is as follows:
24
+ {
25
+ "prompt": "<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\n",
26
+ "completion": "<think> okay, let me see. The capital of France is Lyon. </think>\n\Lyon.",
27
+ "indexed_completion": "<think>[0] okay,[1] let[2] me[3] see.[4] The[5] capital[6] of[7] France[8] is[9] Lyon.[10] </think>[11]\n\n[12] Lyon[13] .[14]",
28
+ "ground_truth": "Paris, the capital of France."
29
+ }
30
+ - The "indexed_completion" field provides a tokenized version of the completion, where each token is followed by its index in square brackets. This will help you localize the position of tokens in the completion.
31
+ - Both the "prompt" and "completion" fields may contain special tokens, e.g., "<|im_start|>", "<|im_end|>", "<think>", "</think>", etc. These tokens are part of the model's output format, specifically,
32
+ - "<|im_start|>" and "<|im_end|>" denote the start and end of a message in a multi-turn conversation, e.g., "<|im_start|>user\n xxx <|im_end|>" indicates the start of a user message, while "<|im_start|>assistant\n xxx <|im_end|>" indicates the start of an assistant message.
33
+ - "<think>" and "</think>" denote the start and end of the model's internal reasoning process.
34
+ - More on the internal thought process of the model:
35
+ - The internal thought process of the model is represented by the reasoning tokens between the "<think>" and "</think>" tokens.
36
+ - This part reflects the model's reasoning steps before arriving at the final answer.
37
+ - The final answer to the prompt is the text that comes AFTER the "</think>" token.
38
+
39
+ # General Guidance (Dataset Agnostic)
40
+ - Refer to the following steps for localizing an erroneous token in the model-generated completion:
41
+ - Step 1: Carefully read dataset description and the provided prompt to understand the context and requirements.
42
+ - Step 2: If "completion" and "indexed_completion" fields contains reasoning tokens (i.e., tokens between "<think>" and "</think>"), seperate the reasoning part from the final answer part.
43
+ - Step 3: Examine the final answer part of the completion first to identify any errors. If no errors are found in this part, skip the rest of the steps and output "<CORRECT>" as the final answer.
44
+ - Step 4: No matther whether the final answer part contains errors, proceed to examine the reasoning part for potential errors.
45
+ - Step 5: Once an erroneous token is identified, use the "indexed_completion" field to find its index.
46
+ - Addiational strict requirements for localizing the erroneous token:
47
+ - Always report the FIRST erroneous token in the "completion" or "indexed_completion" field.
48
+ - The localized erroneous token MUST NOT be the special tokens used for formatting (e.g., "<|im_start|>", "<|im_end|>", "<think>", "</think>").
49
+ - NEVER treat a token is erroneous just because it is a subword or punctuation mark, as the indexed token str does NOT have to be a single word; it can be a subword or punctuation mark as per the tokenization used by the model.
50
+ - On the difference between examining the final answer part and the reasoning part:
51
+ - For the final answer part (i.e., the part AFTER "</think>"):
52
+ - ALWAYS examine whether the tokens in this part fulfill the format/style requirements specified in the prompt. If you identify a token in this part that violates the format/style requirements, you SHOULD consider it as a potential erroneous token.
53
+ - ALSO examine the factual correctness and logical consistency of the tokens in this part. If you identify a token in this part that is factually incorrect or logically inconsistent with the prompt, you SHOULD consider it as a potential erroneous token.
54
+ - For the reasoning part (i.e., the part BETWEEN "<think>" and "</think>"):
55
+ - NEVER examine whether the reasoning tokens have fulfilled the format/style requirements specified in the prompt, as the reasoning tokens between "<think>" and "</think>" are often in a free-form text format. For example, if the prompt requires the model to "respond in a poem format", you MUST NOT consider a token in the reasoning part as erroneous just because the reasoning tokens are not in a poem format.
56
+ - Focus on the factual correctness and logical consistency of the reasoning tokens. If you identify a token in this part that is factually incorrect or logically inconsistent with the prompt, you SHOULD consider it as a potential erroneous token.
57
+
58
+ # Dataset Specific Instructions
59
+ {{dataset_specific_instructions}}
60
+
61
+ # On the Output Format
62
+ - The output MUST be in the following JSON format:
63
+ {
64
+ "error_token": "<The localized erroneous token>",
65
+ "token_index": <The index of the erroneous token in the completion>,
66
+ "explanation": "<A brief explanation of why this token is considered erroneous. Less than 50 words.>"
67
+ }
68
+ - An example of the output format is as follows:
69
+ {
70
+ "error_token": "Lyon",
71
+ "token_index": 10,
72
+ "explanation": "The token 'Lyon' is erroneous because the correct capital of France is Paris, not Lyon. This indicates a factual error in the model's completion."
73
+ }
74
+ - Ensure that the "token_index" corresponds to the index provided in the "indexed_completion" field.
75
+ - The "explanation" should be concise yet informative, providing enough context to justify the localization of the error token.
frontend/css/style.css ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
3
+ margin: 0;
4
+ padding: 0;
5
+ background-color: #f5f7fa;
6
+ color: #333;
7
+ }
8
+
9
+ header {
10
+ background-color: #2c3e50;
11
+ color: white;
12
+ padding: 1rem 2rem;
13
+ }
14
+
15
+ header h1 {
16
+ margin: 0;
17
+ font-size: 1.5rem;
18
+ }
19
+
20
+ main {
21
+ padding: 2rem;
22
+ max-width: 1200px;
23
+ margin: 0 auto;
24
+ }
25
+
26
+ .panel {
27
+ background: white;
28
+ border-radius: 8px;
29
+ padding: 1.5rem;
30
+ margin-bottom: 2rem;
31
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
32
+ }
33
+
34
+ .hidden {
35
+ display: none;
36
+ }
37
+
38
+ .control-group {
39
+ margin-bottom: 1rem;
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 1rem;
43
+ flex-wrap: wrap;
44
+ }
45
+
46
+ label {
47
+ font-weight: 500;
48
+ }
49
+
50
+ input[type="text"], input[type="number"], textarea, select {
51
+ padding: 0.5rem;
52
+ border: 1px solid #ddd;
53
+ border-radius: 4px;
54
+ font-size: 1rem;
55
+ }
56
+
57
+ textarea {
58
+ width: 100%;
59
+ max-width: 600px;
60
+ }
61
+
62
+ button {
63
+ background-color: #3498db;
64
+ color: white;
65
+ border: none;
66
+ padding: 0.5rem 1rem;
67
+ border-radius: 4px;
68
+ cursor: pointer;
69
+ font-size: 1rem;
70
+ transition: background 0.2s;
71
+ }
72
+
73
+ button:hover {
74
+ background-color: #2980b9;
75
+ }
76
+
77
+ button:disabled {
78
+ background-color: #bdc3c7;
79
+ cursor: not-allowed;
80
+ }
81
+
82
+ .status {
83
+ font-style: italic;
84
+ color: #7f8c8d;
85
+ }
86
+
87
+ .status.success {
88
+ color: #27ae60;
89
+ }
90
+ .status.error {
91
+ color: #c0392b;
92
+ }
93
+
94
+ /* Logits Table */
95
+ .scrollable-table {
96
+ max-height: 300px;
97
+ overflow-y: auto;
98
+ border: 1px solid #eee;
99
+ }
100
+
101
+ table {
102
+ width: 100%;
103
+ border-collapse: collapse;
104
+ }
105
+
106
+ th, td {
107
+ padding: 0.35rem 0.75rem; /* Reduced vertical padding */
108
+ text-align: left;
109
+ border-bottom: 1px solid #eee;
110
+ }
111
+
112
+ th {
113
+ background-color: #f8f9fa;
114
+ position: sticky;
115
+ top: 0;
116
+ }
117
+
118
+ tr:hover {
119
+ background-color: #f1f1f1;
120
+ }
121
+
122
+ .btn-select-ref {
123
+ padding: 0.25rem 0.5rem;
124
+ font-size: 0.8rem;
125
+ background-color: #9b59b6;
126
+ }
127
+
128
+ .btn-select-ref.selected {
129
+ background-color: #27ae60;
130
+ }
131
+
132
+ /* Circuit Controls */
133
+ .controls-row {
134
+ display: flex;
135
+ gap: 2rem;
136
+ margin-bottom: 1.5rem;
137
+ border-bottom: 1px solid #eee;
138
+ padding-bottom: 1rem;
139
+ }
140
+
141
+ .control-col {
142
+ flex: 1;
143
+ }
144
+
145
+ .sub-opt {
146
+ margin-left: 1rem;
147
+ margin-top: 0.5rem;
148
+ font-size: 0.9rem;
149
+ }
150
+
151
+ .setting-item {
152
+ margin-bottom: 0.8rem;
153
+ }
154
+
155
+ .input-row {
156
+ display: flex;
157
+ align-items: center;
158
+ gap: 10px;
159
+ margin-top: 4px;
160
+ }
161
+
162
+ .input-row input[type="range"] {
163
+ flex: 1;
164
+ }
165
+
166
+ /* Canvas */
167
+ #canvas-container {
168
+ position: relative;
169
+ border: 1px solid #ddd;
170
+ background: white;
171
+ border-radius: 4px;
172
+ overflow-x: auto;
173
+ }
174
+
175
+ canvas {
176
+ display: block;
177
+ cursor: crosshair;
178
+ }
179
+
180
+ #tooltip {
181
+ position: fixed;
182
+ background: rgba(0, 0, 0, 0.85);
183
+ color: white;
184
+ padding: 8px;
185
+ border-radius: 4px;
186
+ font-size: 12px;
187
+ pointer-events: none;
188
+ opacity: 0;
189
+ transition: opacity 0.2s;
190
+ z-index: 1000;
191
+ }
192
+
193
+ /* Legend */
194
+ #vis-legend {
195
+ margin-top: 1rem;
196
+ font-size: 0.9rem;
197
+ }
198
+ .legend-box {
199
+ display: inline-block;
200
+ width: 15px;
201
+ height: 15px;
202
+ margin-right: 5px;
203
+ margin-left: 10px;
204
+ vertical-align: middle;
205
+ }
206
+ .legend-box.pos { background: rgba(255, 0, 0, 0.6); }
207
+ .legend-box.neg { background: rgba(0, 0, 255, 0.6); }
208
+
209
+ /* Input Attribution Styles */
210
+ .attribution-text-box {
211
+ line-height: 1.6;
212
+ font-size: 1.1em;
213
+ padding: 15px;
214
+ border: 1px solid #ddd;
215
+ border-radius: 4px;
216
+ background-color: #fff;
217
+ white-space: pre-wrap; /* Preserve spacing */
218
+ font-family: monospace;
219
+ }
220
+
221
+ .token-span {
222
+ padding: 2px 0;
223
+ margin: 0;
224
+ border-radius: 2px;
225
+ transition: background-color 0.2s;
226
+ cursor: default;
227
+ }
228
+
229
+ .token-span:hover {
230
+ outline: 1px solid #333;
231
+ position: relative;
232
+ z-index: 10;
233
+ }
234
+
235
+ /* Loading Overlay */
236
+ #loading-overlay {
237
+ position: fixed;
238
+ top: 0;
239
+ left: 0;
240
+ width: 100%;
241
+ height: 100%;
242
+ background: rgba(255, 255, 255, 0.8);
243
+ display: flex;
244
+ flex-direction: column;
245
+ justify-content: center;
246
+ align-items: center;
247
+ z-index: 9999;
248
+ backdrop-filter: blur(2px);
249
+ }
250
+
251
+ #loading-overlay.hidden {
252
+ display: none !important;
253
+ }
254
+
255
+ .spinner {
256
+ border: 6px solid #f3f3f3;
257
+ border-top: 6px solid #3498db;
258
+ border-radius: 50%;
259
+ width: 50px;
260
+ height: 50px;
261
+ animation: spin 1s linear infinite;
262
+ margin-bottom: 15px;
263
+ }
264
+
265
+ @keyframes spin {
266
+ 0% { transform: rotate(0deg); }
267
+ 100% { transform: rotate(360deg); }
268
+ }
269
+
270
+ #loading-message {
271
+ font-size: 1.2rem;
272
+ font-weight: 500;
273
+ color: #2c3e50;
274
+ }
frontend/favicon.ico ADDED
frontend/index.html ADDED
@@ -0,0 +1,702 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>NeuralPostmortem: An Interactive Framework for LLM Failures Analysis</title>
7
+ <link rel="stylesheet" href="css/style.css">
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
10
+ <style>
11
+ /* Additional styles for step-by-step UI */
12
+ .step-container {
13
+ margin-bottom: 30px;
14
+ border: 2px solid #e0e0e0;
15
+ border-radius: 8px;
16
+ overflow: hidden;
17
+ }
18
+
19
+ .step-header {
20
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
21
+ color: white;
22
+ padding: 15px 20px;
23
+ cursor: pointer;
24
+ display: flex;
25
+ justify-content: space-between;
26
+ align-items: center;
27
+ font-weight: bold;
28
+ font-size: 1.1em;
29
+ }
30
+
31
+ .step-header.completed {
32
+ background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
33
+ }
34
+
35
+ .step-header.active {
36
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
37
+ }
38
+
39
+ .step-header.disabled {
40
+ background: #ccc;
41
+ cursor: not-allowed;
42
+ opacity: 0.6;
43
+ }
44
+
45
+ .step-number {
46
+ background: rgba(255,255,255,0.3);
47
+ width: 35px;
48
+ height: 35px;
49
+ border-radius: 50%;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ font-size: 1.2em;
54
+ margin-right: 15px;
55
+ }
56
+
57
+ .step-title {
58
+ flex: 1;
59
+ }
60
+
61
+ .step-status {
62
+ font-size: 0.9em;
63
+ padding: 5px 15px;
64
+ background: rgba(255,255,255,0.2);
65
+ border-radius: 15px;
66
+ }
67
+
68
+ .step-body {
69
+ padding: 20px;
70
+ background: #f9f9f9;
71
+ }
72
+
73
+ .step-body.collapsed {
74
+ display: none;
75
+ }
76
+
77
+ .subsection {
78
+ margin: 15px 0;
79
+ padding: 15px;
80
+ background: white;
81
+ border-radius: 5px;
82
+ border-left: 4px solid #667eea;
83
+ }
84
+
85
+ .subsection h4 {
86
+ margin-top: 0;
87
+ color: #667eea;
88
+ }
89
+
90
+ .action-buttons {
91
+ margin-top: 20px;
92
+ display: flex;
93
+ gap: 10px;
94
+ }
95
+
96
+ .btn-primary {
97
+ background: #667eea;
98
+ color: white;
99
+ border: none;
100
+ padding: 10px 20px;
101
+ border-radius: 5px;
102
+ cursor: pointer;
103
+ font-size: 1em;
104
+ }
105
+
106
+ .btn-primary:hover {
107
+ background: #5568d3;
108
+ }
109
+
110
+ .btn-primary:disabled {
111
+ background: #ccc;
112
+ cursor: not-allowed;
113
+ }
114
+
115
+ .btn-secondary {
116
+ background: #6c757d;
117
+ color: white;
118
+ border: none;
119
+ padding: 10px 20px;
120
+ border-radius: 5px;
121
+ cursor: pointer;
122
+ font-size: 1em;
123
+ }
124
+
125
+ .vote-details {
126
+ margin-top: 10px;
127
+ padding: 10px;
128
+ background: #f0f0f0;
129
+ border-radius: 5px;
130
+ }
131
+
132
+ .vote-item {
133
+ padding: 5px;
134
+ margin: 5px 0;
135
+ background: white;
136
+ border-radius: 3px;
137
+ }
138
+
139
+ /* Method Selector Styles */
140
+ .method-btn {
141
+ background: rgba(255,255,255,0.15);
142
+ color: #ccc;
143
+ border: 1px solid rgba(255,255,255,0.25);
144
+ padding: 6px 16px;
145
+ border-radius: 20px;
146
+ cursor: pointer;
147
+ font-size: 0.9em;
148
+ font-weight: 500;
149
+ transition: all 0.2s ease;
150
+ }
151
+ .method-btn:hover {
152
+ background: rgba(255,255,255,0.25);
153
+ color: white;
154
+ }
155
+ .method-btn.active {
156
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
157
+ color: white;
158
+ border-color: #764ba2;
159
+ box-shadow: 0 2px 8px rgba(102,126,234,0.4);
160
+ }
161
+ .method-btn:disabled {
162
+ opacity: 0.4;
163
+ cursor: not-allowed;
164
+ }
165
+
166
+ /* Method-specific visibility */
167
+ .attnlrp-only { display: block; }
168
+ .nnsight-only { display: none; }
169
+ .pyvene-only { display: none; }
170
+
171
+ body[data-method="nnsight"] .attnlrp-only { display: none; }
172
+ body[data-method="nnsight"] .nnsight-only { display: block; }
173
+
174
+ body[data-method="pyvene"] .attnlrp-only { display: none; }
175
+ body[data-method="pyvene"] .pyvene-only { display: block; }
176
+ </style>
177
+ </head>
178
+ <body>
179
+ <div id="app">
180
+ <header>
181
+ <h1>🔍 NeuralPostmortem</h1>
182
+ <p style="margin: 10px 0; color: #666;">An Interactive Framework for LLM Failure Analysis</p>
183
+ <!-- Method Selector Bar -->
184
+ <div id="method-bar" style="margin-top: 12px; display: flex; align-items: center; gap: 15px;">
185
+ <span style="font-weight: 600; color: #ccc; font-size: 0.95em;">Attribution Method:</span>
186
+ <div id="method-selector" style="display: flex; gap: 6px;">
187
+ <button class="method-btn active" data-method="attnlrp" data-port="8000">Attn-LRP</button>
188
+ <button class="method-btn" data-method="nnsight" data-port="8001">NNsight</button>
189
+ <button class="method-btn" data-method="pyvene" data-port="8002">Pyvene</button>
190
+ </div>
191
+ <span id="method-status" style="font-size: 0.85em; color: #8e8e8e; margin-left: 10px;">
192
+ Backend: <code id="current-api-base" style="color: #38ef7d;">localhost:8000</code>
193
+ </span>
194
+ </div>
195
+ </header>
196
+
197
+ <main style="max-width: 1400px; margin: 0 auto; padding: 20px;">
198
+
199
+ <!-- Step 1: Case Setup & Model Configuration -->
200
+ <div class="step-container" id="step-1">
201
+ <div class="step-header active" onclick="toggleStep(1)">
202
+ <div style="display: flex; align-items: center;">
203
+ <span class="step-number">1</span>
204
+ <span class="step-title">Case Setup & Model Configuration</span>
205
+ </div>
206
+ <span class="step-status" id="status-1">Pending</span>
207
+ </div>
208
+ <div class="step-body" id="body-1">
209
+ <div class="subsection">
210
+ <h4>📁 Load from Trace (Auto-fills all fields)</h4>
211
+ <div class="control-group">
212
+ <label>Dataset:
213
+ <select id="trace-dataset">
214
+ <option value="">-- Select --</option>
215
+ <!-- Populated by JS -->
216
+ </select>
217
+ </label>
218
+ <label>Trace File:
219
+ <select id="trace-file" disabled>
220
+ <option value="">-- Select Dataset First --</option>
221
+ </select>
222
+ </label>
223
+ </div>
224
+ </div>
225
+
226
+ <div class="subsection">
227
+ <h4>✍️ Manual Input</h4>
228
+ <div class="control-group">
229
+ <label for="input-prompt"><strong>Prompt:</strong></label>
230
+ <textarea id="input-prompt" rows="4" placeholder="Enter your prompt here..."></textarea>
231
+ </div>
232
+ <div class="control-group">
233
+ <label for="input-completion"><strong>Completion:</strong></label>
234
+ <textarea id="input-completion" rows="4" placeholder="Enter the completion to analyze..."></textarea>
235
+ </div>
236
+ <div class="control-group">
237
+ <label for="input-ground-truth"><strong>Ground Truth (Optional):</strong></label>
238
+ <textarea id="input-ground-truth" rows="2" placeholder="Expected correct answer..."></textarea>
239
+ </div>
240
+ </div>
241
+
242
+ <div class="subsection">
243
+ <h4>🤖 Model Configuration</h4>
244
+ <div class="control-group">
245
+ <div style="display: flex; gap: 15px; flex-wrap: wrap;">
246
+ <label>Series:
247
+ <select id="model-series">
248
+ <option value="Qwen3">Qwen3</option>
249
+ <option value="Qwen2">Qwen2</option>
250
+ <option value="OLMo3">OLMo3</option>
251
+ </select>
252
+ </label>
253
+ <label>Model:
254
+ <select id="model-select">
255
+ <!-- Populated dynamically -->
256
+ </select>
257
+ </label>
258
+ <label>DType:
259
+ <select id="model-dtype">
260
+ <option value="float16">float16</option>
261
+ <option value="bfloat16" selected>bfloat16</option>
262
+ <option value="float32">float32</option>
263
+ </select>
264
+ </label>
265
+ <label>
266
+ <input type="checkbox" id="quant-4bit"> 4-bit Quantization
267
+ </label>
268
+ </div>
269
+ </div>
270
+ <div class="control-group">
271
+ <label>Model Path:
272
+ <input type="text" id="model-path" value="Qwen/Qwen3-0.6B" style="width: 400px;">
273
+ </label>
274
+ </div>
275
+ </div>
276
+
277
+ <div class="action-buttons">
278
+ <button class="btn-primary" id="btn-load-model">Load Model & Proceed</button>
279
+ <span id="model-load-status" style="margin-left: 15px; line-height: 40px; color: #666;"></span>
280
+ </div>
281
+ </div>
282
+ </div>
283
+
284
+ <!-- Step 2: Contrast Token Pair Identification -->
285
+ <div class="step-container" id="step-2">
286
+ <div class="step-header disabled" onclick="toggleStep(2)">
287
+ <div style="display: flex; align-items: center;">
288
+ <span class="step-number">2</span>
289
+ <span class="step-title">Contrast Token Pair Identification</span>
290
+ </div>
291
+ <span class="step-status" id="status-2">Locked</span>
292
+ </div>
293
+ <div class="step-body collapsed" id="body-2">
294
+
295
+ <!-- Subsection 3.1: Error Token Localization -->
296
+ <div class="subsection">
297
+ <h4>🎯 Target Token Localization & Text Truncation</h4>
298
+ <p style="color: #666; margin-bottom: 15px;">
299
+ Use multiple LLM validators to identify the first erroneous token in the completion.
300
+ </p>
301
+
302
+ <div class="control-group">
303
+ <label style="display: flex; align-items: center; gap: 10px;">
304
+ <input type="checkbox" id="use-llm-search" checked> Use LLM search (validators)
305
+ </label>
306
+ </div>
307
+
308
+ <div class="control-group" style="margin-top: 10px;" id="manual-chunk-group" hidden>
309
+ <label><strong>Manual Localization(s):</strong> (Include both prompt and completion)</label>
310
+ <textarea id="manual-chunks" rows="3" style="width: 100%; font-family: monospace;"></textarea>
311
+ </div>
312
+
313
+ <div class="control-group" style="margin-top: 10px;">
314
+ <label><strong>Validator Models:</strong></label>
315
+ <div style="display: flex; gap: 10px; flex-wrap: wrap; margin: 10px 0;">
316
+ <label><input type="checkbox" id="validator-gpt52" checked> GPT-5.2</label>
317
+ <label><input type="checkbox" id="validator-gptoss" checked> GPT-OSS-120B</label>
318
+ <label><input type="checkbox" id="validator-deepseek" checked> DeepSeek-V3.2</label>
319
+ <label><input type="checkbox" id="validator-kimi"> Kimi-K2-Thinking</label>
320
+ </div>
321
+ </div>
322
+
323
+ <div class="action-buttons">
324
+ <button class="btn-primary" id="btn-locate-error">Locate Target Token</button>
325
+ <span id="error-locate-status" style="margin-left: 15px; line-height: 40px; color: #666;"></span>
326
+ </div>
327
+
328
+ <!-- Results -->
329
+ <div id="error-location-results" class="hidden" style="margin-top: 20px;">
330
+ <h5 style="color: #667eea;">📊 Voting Results:</h5>
331
+ <div id="vote-details" class="vote-details">
332
+ <!-- Populated by JS -->
333
+ </div>
334
+
335
+ <h5 style="color: #667eea; margin-top: 15px;">✂️ Truncated Text:</h5>
336
+ <div class="control-group">
337
+ <textarea id="truncated-text" rows="5" style="width: 100%; font-family: monospace;"></textarea>
338
+ </div>
339
+
340
+ <h5 style="color: #667eea;">💡 Explanation:</h5>
341
+ <div id="error-explanation" style="padding: 10px; background: #fff3cd; border-radius: 5px; white-space: pre-wrap;"></div>
342
+
343
+ <div class="action-buttons" style="margin-top: 15px;">
344
+ <button class="btn-primary" id="btn-accept-truncation">Accept & Use Truncated Text</button>
345
+ <button class="btn-secondary" id="btn-edit-truncation">Edit Truncation</button>
346
+ <span id="accept-status" style="margin-left: 15px; line-height: 40px; color: #28a745; font-weight: bold;"></span>
347
+ </div>
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Subsection 3.2: Contrast Token Selection -->
352
+ <div class="subsection" id="contrast-selection-section" class="hidden">
353
+ <h4>🔄 Contrast Token Selection</h4>
354
+ <p style="color: #666; margin-bottom: 15px;">
355
+ After error token localization, identify the contrast token (correct alternative).
356
+ </p>
357
+
358
+ <div class="control-group">
359
+ <label><strong>Selection Method:</strong></label>
360
+ <div style="margin: 10px 0;">
361
+ <label style="display: block; margin: 5px 0;">
362
+ <input type="radio" name="contrast-method" value="manual" checked>
363
+ Manual Selection (from Top-K predictions)
364
+ </label>
365
+ <label style="display: block; margin: 5px 0;">
366
+ <input type="radio" name="contrast-method" value="beam">
367
+ Beam Search (automatic exploration)
368
+ </label>
369
+ </div>
370
+ </div>
371
+
372
+ <div id="manual-contrast-section">
373
+ <div class="action-buttons">
374
+ <button class="btn-primary" id="btn-compute-topk">Compute Top-K Predictions</button>
375
+ <label style="margin-left: 15px;">
376
+ K = <input type="number" id="topk-value" value="10" min="1" max="100" style="width: 60px;">
377
+ </label>
378
+ </div>
379
+
380
+ <!-- Top-K Table -->
381
+ <div id="topk-results" class="hidden" style="margin-top: 20px;">
382
+ <h5>Top-K Token Predictions:</h5>
383
+ <div class="scrollable-table">
384
+ <table id="topk-table">
385
+ <thead>
386
+ <tr>
387
+ <th>Rank</th>
388
+ <th>Token</th>
389
+ <th>Logit</th>
390
+ <th>Action</th>
391
+ </tr>
392
+ </thead>
393
+ <tbody>
394
+ <!-- Populated by JS -->
395
+ </tbody>
396
+ </table>
397
+ </div>
398
+ </div>
399
+ </div>
400
+
401
+ <div id="beam-contrast-section" class="hidden">
402
+ <p style="color: #999; font-style: italic;">Beam search functionality - Coming soon...</p>
403
+ <div class="action-buttons">
404
+ <button class="btn-primary" disabled>Run Beam Search</button>
405
+ </div>
406
+ </div>
407
+
408
+ <!-- Selected Contrast Token Display -->
409
+ <div id="contrast-selected" class="hidden" style="margin-top: 20px; padding: 15px; background: #d4edda; border-radius: 5px;">
410
+ <h5 style="margin-top: 0; color: #155724;">✓ Contrast Token Pair Identified:</h5>
411
+ <p><strong>Error Token:</strong> <span id="display-error-token">-</span></p>
412
+ <p><strong>Contrast Token:</strong> <span id="display-contrast-token">-</span></p>
413
+ <p><strong>Logit Difference:</strong> <span id="display-logit-diff">-</span></p>
414
+ </div>
415
+ </div>
416
+
417
+ <div class="action-buttons" style="margin-top: 20px;">
418
+ <button class="btn-primary" id="btn-proceed-to-lrp" disabled>Proceed to Input Attribution Analysis</button>
419
+ </div>
420
+ </div>
421
+ </div>
422
+
423
+ <!-- Step 3: Input Attribution -->
424
+ <div class="step-container" id="step-3">
425
+ <div class="step-header disabled" onclick="toggleStep(3)">
426
+ <div style="display: flex; align-items: center;">
427
+ <span class="step-number">3</span>
428
+ <span class="step-title">Input Attribution Analysis</span>
429
+ </div>
430
+ <span class="step-status" id="status-3">Locked</span>
431
+ </div>
432
+ <div class="step-body collapsed" id="body-3">
433
+
434
+ <div class="subsection attnlrp-only">
435
+ <h4>⚙️ Input Attribution Configuration</h4>
436
+ <div class="control-group">
437
+ <label>Rule:
438
+ <select id="lrp-rule">
439
+ <option value="Attn-LRP" selected>Attn-LRP</option>
440
+ <option value="CP-LRP">CP-LRP</option>
441
+ <option value="Gradient">Gradient (Vanilla)</option>
442
+ </select>
443
+ </label>
444
+ <label style="margin-left: 20px;">
445
+ <input type="checkbox" id="capture-mid"> Attn/MLP Split
446
+ </label>
447
+ </div>
448
+ </div>
449
+
450
+ <div class="subsection nnsight-only">
451
+ <h4>⚙️ NNsight Attribution Configuration</h4>
452
+ <p style="color: #666;">NNsight uses neural network surgery to trace attributions through the model's computation graph.</p>
453
+ </div>
454
+
455
+ <div class="subsection pyvene-only">
456
+ <h4>⚙️ Pyvene Attribution Configuration</h4>
457
+ <p style="color: #666;">Pyvene uses causal interventions to identify token attributions.</p>
458
+ </div>
459
+
460
+ <div class="subsection">
461
+ <h4>🎨 Input Attribution Visualization</h4>
462
+ <div class="control-group">
463
+ <label>Mode:
464
+ <select id="attr-mode">
465
+ <option value="max_logit">Max Logit</option>
466
+ <option value="logit_diff" selected>Logit Difference</option>
467
+ </select>
468
+ </label>
469
+ <label style="margin-left: 15px;">Strategy:
470
+ <select id="attr-strategy">
471
+ <option value="by_topk_avg">By Top-K Avg</option>
472
+ <option value="demean">Demean</option>
473
+ <option value="by_ref_token" selected>By Reference Token</option>
474
+ </select>
475
+ </label>
476
+ </div>
477
+
478
+ <div class="action-buttons">
479
+ <button class="btn-primary" id="btn-compute-attribution">Compute Input Attribution</button>
480
+ </div>
481
+
482
+ <div id="attribution-results" class="hidden" style="margin-top: 20px;">
483
+ <h5>Attribution Map:</h5>
484
+ <div id="attribution-display" style="padding: 15px; background: white; border: 1px solid #ddd; border-radius: 5px; font-family: monospace; line-height: 1.8;">
485
+ <!-- Populated by JS -->
486
+ </div>
487
+ <div class="action-buttons" style="margin-top: 10px;">
488
+ <button class="btn-secondary" id="btn-save-attr-png" style="background-color: #2ecc71;">Save PNG</button>
489
+ <button class="btn-secondary" id="btn-save-attr-pdf" style="background-color: #e74c3c;">Save PDF</button>
490
+ </div>
491
+ <!-- Positive Relevance Sorted Indices -->
492
+ <div id="positive-relevance-sorted" class="hidden" style="margin-top: 18px;">
493
+ <h5 style="margin: 0 0 8px 0; color: #667eea;">Positive Relevance Tokens (Sorted by Relevance, Descending)</h5>
494
+ <div id="pos-rel-indices" style="padding: 10px; background: #f8f9fa; border: 1px solid #ddd; border-radius: 4px; font-family: monospace; font-size: 0.95em; word-break: break-all; line-height: 1.8;"></div>
495
+ <h5 style="margin: 12px 0 8px 0; color: #7f8c8d; font-size: 0.9em;">Corresponding Tokens (for verification)</h5>
496
+ <div id="pos-rel-tokens" style="padding: 10px; background: #fefefe; border: 1px solid #eee; border-radius: 4px; font-family: monospace; font-size: 0.9em; word-break: break-all; line-height: 1.8; color: #555;"></div>
497
+ </div>
498
+ </div>
499
+ </div>
500
+
501
+ <!-- Perturbation Evaluation Subsection -->
502
+ <div class="subsection">
503
+ <h4>🧪 Perturbation Evaluation (Ground-Truth Comparison)</h4>
504
+ <p style="color: #666; margin-bottom: 15px;">
505
+ Evaluate attribution quality by zeroing out top-attributed tokens and checking if the model's error is fixed.
506
+ A better attribution method should fix the error at smaller K values.
507
+ </p>
508
+ <div class="control-group">
509
+ <label>K values (comma-separated):
510
+ <input type="text" id="perturbation-k-values" value="1, 3, 5, 10" style="width: 200px;">
511
+ </label>
512
+ </div>
513
+ <div class="action-buttons">
514
+ <button class="btn-primary" id="btn-run-perturbation" disabled>Run Perturbation Evaluation</button>
515
+ <span id="perturbation-status" style="margin-left: 15px; line-height: 40px; color: #666;"></span>
516
+ </div>
517
+ <div id="perturbation-results" class="hidden" style="margin-top: 20px;">
518
+ <h5>Perturbation Results:</h5>
519
+ <div class="scrollable-table">
520
+ <table id="perturbation-table">
521
+ <thead>
522
+ <tr>
523
+ <th>K</th>
524
+ <th>Perturbed Tokens</th>
525
+ <th>New Top-1 Token</th>
526
+ <th>Error Fixed?</th>
527
+ <th>Target Rank After</th>
528
+ <th>Logit Change</th>
529
+ </tr>
530
+ </thead>
531
+ <tbody>
532
+ <!-- Populated by JS -->
533
+ </tbody>
534
+ </table>
535
+ </div>
536
+ </div>
537
+
538
+ <!-- Manual Perturbation Sub-section -->
539
+ <div style="margin-top: 25px; padding-top: 20px; border-top: 2px dashed #dee2e6;">
540
+ <h5 style="color: #667eea; margin-top: 0;">🖱️ Manual Token Perturbation</h5>
541
+ <p style="color: #666; font-size: 0.9em; margin-bottom: 12px;">
542
+ Click tokens in the <strong>Attribution Map</strong> above to select/deselect which tokens to mask.
543
+ Selected tokens will be highlighted with a dashed border.
544
+ </p>
545
+ <div style="margin-bottom: 12px;">
546
+ <strong style="font-size: 0.9em;">Selected Tokens:</strong>
547
+ <div id="manual-perturb-selected" style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; min-height: 32px; padding: 8px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px;">
548
+ <span style="color: #999; font-size: 0.85em; line-height: 24px;" id="manual-perturb-placeholder">No tokens selected</span>
549
+ </div>
550
+ </div>
551
+ <div class="action-buttons">
552
+ <button class="btn-secondary" id="btn-clear-manual-selection" style="background: #95a5a6;">Clear Selection</button>
553
+ <button class="btn-primary" id="btn-run-manual-perturbation" disabled>Run Manual Perturbation</button>
554
+ <span id="manual-perturbation-status" style="margin-left: 15px; line-height: 40px; color: #666;"></span>
555
+ </div>
556
+ <div id="manual-perturbation-result" class="hidden" style="margin-top: 15px;">
557
+ <!-- Populated by JS -->
558
+ </div>
559
+ </div>
560
+ </div>
561
+
562
+ <div class="action-buttons" style="margin-top: 20px;">
563
+ <button class="btn-primary" id="btn-proceed-to-graph">Proceed to Attribution Graph</button>
564
+ </div>
565
+ </div>
566
+ </div>
567
+
568
+ <!-- Step 4: Attribution Graph -->
569
+ <div class="step-container" id="step-4">
570
+ <div class="step-header disabled" onclick="toggleStep(4)">
571
+ <div style="display: flex; align-items: center;">
572
+ <span class="step-number">4</span>
573
+ <span class="step-title">Attribution Graph Visualization</span>
574
+ </div>
575
+ <span class="step-status" id="status-4">Locked</span>
576
+ </div>
577
+ <div class="step-body collapsed" id="body-4">
578
+
579
+ <div class="subsection">
580
+ <h4>🔧 Graph Configuration</h4>
581
+
582
+ <div class="control-group">
583
+ <label><strong>Layer Selection:</strong></label>
584
+ <input type="text" id="layers-list" placeholder="e.g., 0, 10, 20, 27" style="width: 300px;">
585
+ <button class="btn-secondary" id="btn-layer-preset-default">Recommended</button>
586
+ <button class="btn-secondary" id="btn-layer-preset-all">All Layers</button>
587
+ </div>
588
+
589
+ <div class="control-group" style="margin-top: 15px;">
590
+ <label>Backprop Mode:
591
+ <select id="graph-bp-mode">
592
+ <option value="max_logit">Max Logit</option>
593
+ <option value="logit_diff" selected>Logit Difference</option>
594
+ </select>
595
+ </label>
596
+ <label style="margin-left: 15px;">Strategy:
597
+ <select id="graph-bp-strategy">
598
+ <option value="by_topk_avg" selected>By Top-K Avg</option>
599
+ <option value="demean">Demean</option>
600
+ <option value="by_ref_token">By Reference Token</option>
601
+ </select>
602
+ </label>
603
+ </div>
604
+
605
+ <div class="control-group" style="margin-top: 15px;">
606
+ <label>Pruning Mode:
607
+ <select id="pruning-mode">
608
+ <option value="by_per_layer_cum_mass_percentile" selected>By Top-P Mass (Layer)</option>
609
+ <option value="by_global_threshold">By Global Threshold</option>
610
+ </select>
611
+ </label>
612
+ </div>
613
+
614
+ <div id="ctrl-top-p" class="control-group" style="margin-top: 10px;">
615
+ <label>Top-P Mass (0-1):
616
+ <input type="range" id="top-p-slider" min="0" max="1" step="0.01" value="0.85" style="width: 200px; margin-left: 10px;">
617
+ <input type="number" id="top-p-value" value="0.85" min="0" max="1" step="0.01" style="width: 70px; margin-left: 10px;">
618
+ </label>
619
+ </div>
620
+
621
+ <div id="ctrl-global-thresh" class="control-group hidden" style="margin-top: 10px;">
622
+ <label>Global Edge Threshold:
623
+ <input type="number" id="global-thresh-value" value="0.01" min="0" max="1" step="0.001" style="width: 100px; margin-left: 10px;">
624
+ </label>
625
+ </div>
626
+
627
+ <div class="action-buttons">
628
+ <button class="btn-primary" id="btn-compute-graph">Compute & Visualize Graph</button>
629
+ </div>
630
+ </div>
631
+
632
+ <div id="graph-results" class="hidden" style="margin-top: 20px;">
633
+ <h4>📊 Circuit Visualization</h4>
634
+
635
+ <!-- Visualization Controls -->
636
+ <div style="display: flex; gap: 15px; margin-bottom: 15px; flex-wrap: wrap; align-items: center;">
637
+ <label><input type="checkbox" id="show-all-tokens"> Show All Tokens</label>
638
+ <label><input type="checkbox" id="hide-bos"> Hide BOS Token</label>
639
+ <label><input type="checkbox" id="show-values" checked> Show Node Values</label>
640
+ </div>
641
+
642
+ <div style="display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; align-items: center;">
643
+ <label style="display: flex; align-items: center; gap: 8px;">
644
+ Line Strength:
645
+ <input type="range" id="line-strength" min="2" max="20" value="6" step="0.1" style="width: 120px;">
646
+ <span id="line-strength-val" style="min-width: 35px; font-weight: bold;">6.0</span>
647
+ </label>
648
+ <label style="display: flex; align-items: center; gap: 8px;">
649
+ Layer Spacing (px):
650
+ <input type="range" id="layer-spacing-slider" min="50" max="400" value="100" step="10" style="width: 120px;">
651
+ <input type="number" id="layer-spacing-value" min="50" max="400" value="100" step="10" style="width: 60px;">
652
+ </label>
653
+ </div>
654
+
655
+ <!-- Threshold Adjustment (Post-Compute) -->
656
+ <div style="padding: 10px; background: #f0f0f0; border-radius: 5px; margin-bottom: 15px;">
657
+ <strong>🎛️ Adjust Graph Sparsity:</strong>
658
+ <div id="post-ctrl-top-p" style="margin-top: 8px;">
659
+ <label>Top-P Mass:
660
+ <input type="range" id="post-top-p-slider" min="0" max="1" step="0.01" value="0.85" style="width: 200px; margin-left: 10px;">
661
+ <input type="number" id="post-top-p-value" value="0.85" min="0" max="1" step="0.01" style="width: 70px; margin-left: 10px;">
662
+ <button class="btn-secondary" id="btn-recompute-threshold" style="margin-left: 10px;">Recompute</button>
663
+ </label>
664
+ </div>
665
+ <div id="post-ctrl-global-thresh" class="hidden" style="margin-top: 8px;">
666
+ <label>Global Threshold:
667
+ <input type="number" id="post-global-thresh-value" value="0.01" min="0" max="1" step="0.001" style="width: 100px; margin-left: 10px;">
668
+ <button class="btn-secondary" id="btn-recompute-threshold-global" style="margin-left: 10px;">Recompute</button>
669
+ </label>
670
+ </div>
671
+ </div>
672
+
673
+ <div id="canvas-container" style="border: 1px solid #ddd; border-radius: 5px; overflow: hidden;">
674
+ <canvas id="graph-canvas"></canvas>
675
+ <div id="graph-tooltip"></div>
676
+ </div>
677
+
678
+ <div class="action-buttons" style="margin-top: 15px;">
679
+ <button class="btn-secondary" id="btn-save-graph">Save as PNG</button>
680
+ <button class="btn-secondary" id="btn-save-graph-pdf">Save as PDF</button>
681
+ </div>
682
+ </div>
683
+
684
+ <div style="margin-top: 30px; padding: 20px; background: #d4edda; border-radius: 8px;">
685
+ <h3 style="margin-top: 0; color: #155724;">✅ Analysis Complete!</h3>
686
+ <p>You have completed the full step-by-step analysis pipeline. Review the results above or export your findings.</p>
687
+ </div>
688
+ </div>
689
+ </div>
690
+
691
+ </main>
692
+ </div>
693
+
694
+ <!-- Loading Overlay -->
695
+ <div id="loading-overlay" class="hidden">
696
+ <div class="spinner"></div>
697
+ <div id="loading-message">Processing...</div>
698
+ </div>
699
+
700
+ <script src="js/main_new.js?v=20260330"></script>
701
+ </body>
702
+ </html>
frontend/index_old.html ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LLM Insider for Failure Debugging</title>
7
+ <link rel="stylesheet" href="css/style.css">
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
10
+ </head>
11
+ <body>
12
+ <div id="app">
13
+ <header>
14
+ <h1>LLM Insider for Failure Debugging</h1>
15
+ </header>
16
+
17
+ <main>
18
+ <!-- Section 1: Build Circuit (Model & Inputs) -->
19
+ <section id="section-setup" class="panel">
20
+ <h2>1. Setup & Inputs</h2>
21
+
22
+ <!-- Trace Loading (Top) -->
23
+ <div class="control-group" style="background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px dashed #ced4da;">
24
+ <label><strong>Load from Trace:</strong></label>
25
+ <label>Dataset:
26
+ <select id="trace-dataset">
27
+ <option value="">-- Select --</option>
28
+ <!-- Populated by JS -->
29
+ </select>
30
+ </label>
31
+ <label>Trace:
32
+ <select id="trace-file" disabled>
33
+ <option value="">-- Select Dataset First --</option>
34
+ </select>
35
+ </label>
36
+ </div>
37
+
38
+ <div class="control-group">
39
+ <div style="display: flex; flex-direction: column; gap: 5px;">
40
+ <div style="display: flex; gap: 10px; align-items: center;">
41
+ <label>Series:
42
+ <select id="model-series">
43
+ <option value="Qwen3">Qwen3</option>
44
+ <option value="Qwen2">Qwen2</option>
45
+ <option value="OLMo3">OLMo3</option>
46
+ </select>
47
+ </label>
48
+ <label>Model:
49
+ <select id="model-select">
50
+ <!-- Populated dynamically -->
51
+ </select>
52
+ </label>
53
+ </div>
54
+ <div style="display: flex; gap: 10px; align-items: center;">
55
+ <label>Revision:
56
+ <select id="model-revision" disabled>
57
+ <option value="">Latest (Default)</option>
58
+ </select>
59
+ </label>
60
+ <label>Path:
61
+ <input type="text" id="model-path" value="Qwen/Qwen3-0.6B" style="width: 250px;">
62
+ </label>
63
+ </div>
64
+ </div>
65
+
66
+ <label>DType:
67
+ <select id="model-dtype">
68
+ <option value="float16">float16</option>
69
+ <option value="bfloat16" selected>bfloat16</option>
70
+ <option value="float32">float32</option>
71
+ <option value="auto">auto</option>
72
+ </select>
73
+ </label>
74
+ <label>LRP Rule:
75
+ <select id="model-lrp-rule">
76
+ <option value="Attn-LRP" selected>Attn-LRP</option>
77
+ <option value="CP-LRP">CP-LRP</option>
78
+ </select>
79
+ </label>
80
+ <label>
81
+ <input type="checkbox" id="quant-4bit"> 4-bit Quantization
82
+ </label>
83
+ <button id="btn-load-model">Load Model</button>
84
+ <span id="model-status" class="status">Not Loaded</span>
85
+ </div>
86
+
87
+ <div class="control-group">
88
+ <label for="prompt-input">Prompt:</label>
89
+ <textarea id="prompt-input" rows="3">After John and Mary went to the store, John gave a bottle of milk to</textarea>
90
+ </div>
91
+
92
+ <div class="control-group">
93
+ <label for="completion-input">Completion:</label>
94
+ <textarea id="completion-input" rows="3" placeholder="Enter the completion text here..."></textarea>
95
+ </div>
96
+
97
+ <div class="control-group" style="display: flex; gap: 10px; align-items: center;">
98
+ <button id="btn-locate-error" disabled style="background-color: #e74c3c;">Locate Error Token</button>
99
+ <span id="error-locate-status" style="color: #666;"></span>
100
+ </div>
101
+ </section>
102
+
103
+ <!-- Section: Error Token Location Result -->
104
+ <section id="section-error-result" class="panel hidden">
105
+ <h2>Error Token Location Result</h2>
106
+ <div class="control-group">
107
+ <label for="error-explanation"><strong>Error Analysis:</strong></label>
108
+ <div id="error-explanation" style="padding: 10px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; margin-bottom: 10px; white-space: pre-wrap;"></div>
109
+ </div>
110
+
111
+ <div class="control-group">
112
+ <label for="truncated-text"><strong>Truncated Text (Editable):</strong></label>
113
+ <textarea id="truncated-text" rows="5" style="width: 100%; font-family: monospace;"></textarea>
114
+ </div>
115
+
116
+ <div class="control-group" style="display: flex; gap: 10px;">
117
+ <button id="btn-use-truncated" style="background-color: #28a745;">Use This Text</button>
118
+ <button id="btn-cancel-error" style="background-color: #6c757d;">Cancel</button>
119
+ </div>
120
+ </section>
121
+
122
+ <!-- Section for continuing analysis -->
123
+ <section id="section-continue-analysis" class="panel hidden">
124
+ <h2>Continue Analysis</h2>
125
+ <div class="control-group">
126
+ <label for="prompt-orig-completion">Current Text for Analysis:</label>
127
+ <textarea id="prompt-orig-completion" rows="3" readonly style="background-color: #f8f9fa;"></textarea>
128
+ </div>
129
+
130
+ <div class="control-group">
131
+ <label style="margin-right: 15px;">
132
+ <input type="checkbox" id="append-bos"> Append BOS Token
133
+ </label>
134
+ <label style="margin-right: 15px;">
135
+ <input type="checkbox" id="capture-mid"> Attn/MLP Split
136
+ </label>
137
+ <button id="btn-compute-logits" disabled>Compute Logits</button>
138
+ <span id="token-count-display" style="margin-left: 15px; color: #666;"></span>
139
+ </div>
140
+ </section>
141
+
142
+ <!-- Section: Trace Exploration Data (Hidden by default, shown when trace loaded) -->
143
+ <section id="section-trace-explore" class="panel hidden">
144
+ <details>
145
+ <summary style="cursor: pointer; font-weight: bold; font-size: 1.2em; margin-bottom: 10px;">Trace Candidate Exploration (Correctness Check)</summary>
146
+ <div style="display: flex; gap: 20px; flex-wrap: wrap; margin-top: 10px;">
147
+ <div style="flex: 1; min-width: 400px;">
148
+ <h3 style="margin-top: 0;">Current Model Candidates</h3>
149
+ <div id="container-explore-original" style="max-height: 400px; overflow-y: auto; border: 1px solid #ccc; background: white;">
150
+ <!-- Table will be injected here -->
151
+ </div>
152
+ </div>
153
+ <div style="flex: 1; min-width: 400px;">
154
+ <h3 id="header-explore-other" style="margin-top: 0;">Other Model Candidates</h3>
155
+ <div id="container-explore-4b" style="max-height: 400px; overflow-y: auto; border: 1px solid #ccc; background: white;">
156
+ <!-- Table will be injected here -->
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </details>
161
+ </section>
162
+
163
+ <!-- Section 1 Output: Logits -->
164
+ <section id="section-logits" class="panel hidden">
165
+ <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
166
+ <h2 style="margin: 0;">Logit Analysis (Top-K)</h2>
167
+ <div style="display: flex; gap: 10px; align-items: center;">
168
+ <input type="text" id="add-token-input" placeholder="Token Str or ID" style="padding: 4px; border: 1px solid #ccc; border-radius: 4px; width: 120px;">
169
+ <button id="btn-add-token" style="padding: 4px 8px; font-size: 0.9em;">Add Token</button>
170
+ <span style="border-left: 1px solid #ccc; height: 20px; margin: 0 5px;"></span>
171
+ <label style="font-size: 0.9rem;">Gen Length: <input type="number" id="gen-max-tokens" value="30" min="1" max="200" style="width: 50px; padding: 4px; border: 1px solid #ccc; border-radius: 4px;"></label>
172
+ </div>
173
+ </div>
174
+ <!-- Table -->
175
+ <div class="scrollable-table" style="margin-top: 10px;">
176
+ <table id="logits-table">
177
+ <thead>
178
+ <tr>
179
+ <th>Rank</th>
180
+ <th>Token ID</th>
181
+ <th>Token</th>
182
+ <th>Logit</th>
183
+ <th>Action</th>
184
+ <th>Continual Generation</th>
185
+ </tr>
186
+ </thead>
187
+ <tbody>
188
+ <!-- Populated by JS -->
189
+ </tbody>
190
+ </table>
191
+ </div>
192
+ <!-- Selection State for Circuit -->
193
+ <div id="circuit-selection-info">
194
+ <p><strong>Top Predicted:</strong> <span id="top-token-display">-</span></p>
195
+ <p><strong>Contrast With:</strong> <span id="contrast-token-display">None</span></p>
196
+ </div>
197
+ </section>
198
+
199
+ <!-- Section 1.5: Input Attribution -->
200
+ <section id="section-input-attr" class="panel hidden">
201
+ <h2>2. Input Attribution Visualization</h2>
202
+ <div class="controls-row">
203
+ <div class="control-col">
204
+ <h3>Attribution Config</h3>
205
+ <label>Mode:
206
+ <select id="attr-bp-mode">
207
+ <option value="max_logit">Max Logit</option>
208
+ <option value="logit_diff" selected>Logit Difference</option>
209
+ </select>
210
+ </label>
211
+ <div id="attr-diff-strategy-opts">
212
+ <label>Strategy:
213
+ <select id="attr-bp-strategy">
214
+ <option value="by_topk_avg">By Top-K Avg</option>
215
+ <option value="demean">Demean (Avg All)</option>
216
+ <option value="by_ref_token" selected>By Reference Token</option>
217
+ </select>
218
+ </label>
219
+ <label id="attr-opt-ref">Ref ID: <input type="number" id="attr-bp-ref-id" placeholder="ID"></label>
220
+ </div>
221
+ <label style="margin-top: 10px; display: block; font-size: 0.9em;">
222
+ <input type="checkbox" id="attr-hide-bos" checked> Hide First Token
223
+ </label>
224
+ <label style="margin-top: 5px; display: block; font-size: 0.9em;">
225
+ <input type="checkbox" id="attr-hide-special" checked> Hide Special Tokens (&lt;|im_start|&gt;, &lt;think&gt;, etc.)
226
+ </label>
227
+ <label style="margin-top: 5px; display: block; font-size: 0.9em;">
228
+ <input type="checkbox" id="attr-reverse-sign"> Reverse Sign (Red &harr; Blue)
229
+ </label>
230
+ <button id="btn-compute-attr">Compute Attribution</button>
231
+ </div>
232
+ <div class="control-col" style="flex: 2;">
233
+ <h3>Attribution Map</h3>
234
+ <div id="input-attribution-display" class="attribution-text-box">
235
+ <!-- Tokens will be rendered here -->
236
+ Click "Compute Attribution" to see results.
237
+ </div>
238
+ <div style="margin-top: 10px; display: flex; gap: 10px;">
239
+ <button id="btn-save-attr-png" style="background-color: #2ecc71; font-size: 0.9em; padding: 4px 10px;">Save PNG</button>
240
+ <button id="btn-save-attr-pdf" style="background-color: #e74c3c; font-size: 0.9em; padding: 4px 10px;">Save PDF</button>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </section>
245
+
246
+ <!-- Section 2: Circuit Visualization -->
247
+ <section id="section-circuit" class="panel hidden">
248
+ <h2>3. Circuit Visualization</h2>
249
+
250
+ <!-- PROGRESS BAR CONTAINER -->
251
+ <div id="compute-progress-container" style="display: none; margin-bottom: 1rem; border: 1px solid #ddd; padding: 10px; border-radius: 4px; background: #f0f8ff;">
252
+ <div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
253
+ <strong id="progress-status-text">Computation Status...</strong>
254
+ <span id="progress-percent-text">0%</span>
255
+ </div>
256
+ <progress id="compute-progress-bar" value="0" max="100" style="width: 100%; height: 20px;"></progress>
257
+ </div>
258
+
259
+ <div class="controls-row">
260
+ <!-- Column 1: Backprop Config -->
261
+ <div class="control-col">
262
+ <h3>Backprop Strategy</h3>
263
+ <label>Mode:
264
+ <select id="bp-mode">
265
+ <option value="max_logit">Max Logit (Target Only)</option>
266
+ <option value="logit_diff" selected>Logit Difference</option>
267
+ </select>
268
+ </label>
269
+
270
+ <div id="diff-strategy-opts">
271
+ <label>Strategy:
272
+ <select id="bp-strategy">
273
+ <option value="by_topk_avg" selected>By Top-K Avg</option>
274
+ <option value="demean">Demean (Avg All)</option>
275
+ <option value="by_ref_token">By Reference Token</option>
276
+ </select>
277
+ </label>
278
+
279
+ <div id="opt-topk" class="sub-opt">
280
+ <label>K: <input type="number" id="bp-k" value="10" style="width: 50px;"></label>
281
+ </div>
282
+
283
+ <div id="opt-ref" class="sub-opt hidden">
284
+ <label>Ref Token ID: <input type="number" id="bp-ref-id" placeholder="ID"></label>
285
+ </div>
286
+ </div>
287
+ </div>
288
+
289
+ <!-- Column 2: Layer Config -->
290
+ <div class="control-col">
291
+ <h3>Layer Connection</h3>
292
+ <label>Layers (Comma-sep, increasing):
293
+ <input type="text" id="layers-list" placeholder="e.g. 0, 10, 27" style="width: 100%;">
294
+ </label>
295
+ <div style="margin-top: 5px;">
296
+ <small>Presets:</small>
297
+ <button id="btn-layer-default" class="btn-micro">Recommended</button>
298
+ <button id="btn-layer-all" class="btn-micro">All Layers</button>
299
+ </div>
300
+ <button id="btn-compute-circuit" style="margin-top: 10px;">Visualize Connection</button>
301
+ </div>
302
+
303
+ <!-- Column 3: Vis Controls -->
304
+ <div class="control-col">
305
+ <h3>Vis Settings</h3>
306
+ <div class="setting-item">
307
+ <label><input type="checkbox" id="show-all-tokens"> Show All Tokens</label>
308
+ </div>
309
+ <div class="setting-item">
310
+ <label><input type="checkbox" id="hide-bos-node"> Hide First Token (BOS)</label>
311
+ </div>
312
+ <div class="setting-item">
313
+ <label><input type="checkbox" id="show-node-values" checked> Show Node Values</label>
314
+ </div>
315
+
316
+ <!-- New Pruning Controls -->
317
+ <div class="setting-item">
318
+ <label>Pruning Mode:</label>
319
+ <select id="pruning-mode" style="width: 100%;">
320
+ <option value="by_per_layer_cum_mass_percentile" selected>By Top-P Mass (Layer)</option>
321
+ <option value="by_global_threshold">By Global Threshold</option>
322
+ </select>
323
+ </div>
324
+
325
+ <div id="ctrl-top-p" class="setting-item">
326
+ <label>Top-P Mass (0-1):</label>
327
+ <div class="input-row">
328
+ <input type="range" id="vis-top-p" min="0" max="1" step="0.01" value="0.85">
329
+ <input type="number" id="val-vis-top-p" min="0" max="1" step="0.01" value="0.85" style="width: 60px;">
330
+ </div>
331
+ </div>
332
+
333
+ <div id="ctrl-global-thresh" class="setting-item hidden">
334
+ <label>Global Threshold:</label>
335
+ <div class="input-row">
336
+ <input type="number" id="val-global-thresh" value="0.01" step="0.001" style="width: 100%;">
337
+ </div>
338
+ </div>
339
+
340
+ <div class="setting-item">
341
+ <label>Line Strength:</label>
342
+ <div class="input-row">
343
+ <input type="range" id="vis-strength" min="2" max="20" step="0.1" value="6">
344
+ <input type="number" id="val-vis-strength" min="2" max="20" step="0.1" value="6" style="width: 60px;">
345
+ </div>
346
+ </div>
347
+ <div class="setting-item">
348
+ <label>Layer Spacing (px):</label>
349
+ <div class="input-row">
350
+ <input type="range" id="vis-layer-spacing" min="10" max="200" step="10" value="50">
351
+ <input type="number" id="val-vis-layer-spacing" min="10" max="200" step="10" value="50" style="width: 60px;">
352
+ </div>
353
+ </div>
354
+
355
+ <div class="setting-item" style="margin-top: 15px;">
356
+ <button id="btn-save-graph" style="width: 48%; background-color: #2ecc71;">Save PNG</button>
357
+ <button id="btn-save-pdf" style="width: 48%; background-color: #e74c3c;">Save PDF</button>
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ <div id="canvas-container">
363
+ <canvas id="circuit-canvas"></canvas>
364
+ <div id="tooltip"></div>
365
+ </div>
366
+
367
+ <div id="vis-legend">
368
+ <span class="legend-box pos"></span> Pos Relevance
369
+ <span class="legend-box neg"></span> Neg Relevance
370
+ </div>
371
+ </section>
372
+ </main>
373
+ </div>
374
+
375
+ <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
376
+ <script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
377
+
378
+ <script src="js/main.js"></script>
379
+ <div id="loading-overlay" class="hidden">
380
+ <div class="spinner"></div>
381
+ <div id="loading-message">Processing...</div>
382
+ </div>
383
+ </body>
384
+ </html>
frontend/js/main.js ADDED
@@ -0,0 +1,2231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = "/api";
2
+
3
+ // Loading Spinner Helpers
4
+ const elLoadingOverlay = document.getElementById('loading-overlay');
5
+ const elLoadingMessage = document.getElementById('loading-message');
6
+
7
+ function showLoading(msg="Processing...") {
8
+ if(elLoadingMessage) elLoadingMessage.textContent = msg;
9
+ if(elLoadingOverlay) {
10
+ elLoadingOverlay.classList.remove('hidden');
11
+ // Force browser repaint
12
+ void elLoadingOverlay.offsetWidth;
13
+ }
14
+ }
15
+
16
+ function hideLoading() {
17
+ if(elLoadingOverlay) elLoadingOverlay.classList.add('hidden');
18
+ }
19
+
20
+ // State
21
+ let appState = {
22
+ tokens: [],
23
+ selectedTargetToken: null,
24
+ selectedContrastToken: null,
25
+ circuitData: null,
26
+ mouseX: -1,
27
+ mouseY: -1,
28
+ extraTokens: [], // Stores objects: { type: 'id'|'str', value: ... }
29
+ lastRelevance: null // Cache for attribution map re-rendering
30
+ };
31
+
32
+ // Selection State for Graph
33
+ let selectedNode = null;
34
+ let lastRenderedNodes = [];
35
+
36
+ // DOM Elements
37
+ const elModelSeries = document.getElementById('model-series');
38
+ const elModelSelect = document.getElementById('model-select');
39
+ const elModelRevision = document.getElementById('model-revision');
40
+ const elModelPath = document.getElementById('model-path');
41
+ const elModelDtype = document.getElementById('model-dtype');
42
+ const elQuant = document.getElementById('quant-4bit');
43
+ const elBtnLoad = document.getElementById('btn-load-model');
44
+ const elStatus = document.getElementById('model-status');
45
+
46
+ const elTraceDataset = document.getElementById('trace-dataset');
47
+ const elTraceFile = document.getElementById('trace-file');
48
+ const elSectionTraceExplore = document.getElementById('section-trace-explore');
49
+ const elContainerExploreOriginal = document.getElementById('container-explore-original');
50
+ const elContainerExplore4b = document.getElementById('container-explore-4b');
51
+ const elHeaderExploreOther = document.getElementById('header-explore-other');
52
+
53
+ const elPrompt = document.getElementById('prompt-input');
54
+ const elCompletion = document.getElementById('completion-input');
55
+ const elPromptOrig = document.getElementById('prompt-orig-completion');
56
+ const elAppendBos = document.getElementById('append-bos');
57
+ const elBtnLogits = document.getElementById('btn-compute-logits');
58
+ const elBtnLocateError = document.getElementById('btn-locate-error');
59
+ const elErrorLocateStatus = document.getElementById('error-locate-status');
60
+
61
+ const elSectionErrorResult = document.getElementById('section-error-result');
62
+ const elSectionContinueAnalysis = document.getElementById('section-continue-analysis');
63
+ const elErrorExplanation = document.getElementById('error-explanation');
64
+ const elTruncatedText = document.getElementById('truncated-text');
65
+ const elBtnUseTruncated = document.getElementById('btn-use-truncated');
66
+ const elBtnCancelError = document.getElementById('btn-cancel-error');
67
+
68
+ const elSectionLogits = document.getElementById('section-logits');
69
+ const elSectionInputAttr = document.getElementById('section-input-attr');
70
+ const elLogitsTableBody = document.querySelector('#logits-table tbody');
71
+ const elTopTokenDisplay = document.getElementById('top-token-display');
72
+ const elContrastTokenDisplay = document.getElementById('contrast-token-display');
73
+
74
+ const elSectionCircuit = document.getElementById('section-circuit');
75
+ const elBpMode = document.getElementById('bp-mode');
76
+ const elBpStrategy = document.getElementById('bp-strategy');
77
+ const elBpK = document.getElementById('bp-k');
78
+ const elBpRefId = document.getElementById('bp-ref-id');
79
+ const elOptTopk = document.getElementById('opt-topk');
80
+ const elOptRef = document.getElementById('opt-ref');
81
+
82
+ const elTargetLayer = document.getElementById('target-layer');
83
+ const elSourceLayer = document.getElementById('source-layer');
84
+ const elLayersList = document.getElementById('layers-list'); // NEW INPUT
85
+ const elBtnCircuit = document.getElementById('btn-compute-circuit');
86
+
87
+ const elVisStrength = document.getElementById('vis-strength');
88
+ const elValVisStrength = document.getElementById('val-vis-strength');
89
+
90
+ // New Controls
91
+ const elPruningMode = document.getElementById('pruning-mode');
92
+ const elVisTopP = document.getElementById('vis-top-p');
93
+ const elValVisTopP = document.getElementById('val-vis-top-p');
94
+ const elValGlobalThresh = document.getElementById('val-global-thresh');
95
+ const elCtrlTopP = document.getElementById('ctrl-top-p');
96
+ const elCtrlGlobalThresh = document.getElementById('ctrl-global-thresh');
97
+
98
+ const elShowAllTokens = document.getElementById('show-all-tokens');
99
+ const elBtnLayerDefault = document.getElementById('btn-layer-default');
100
+ const elBtnLayerAll = document.getElementById('btn-layer-all');
101
+
102
+ // Input Attribution Elements
103
+ const elAttrBpMode = document.getElementById('attr-bp-mode');
104
+ const elAttrBpStrategy = document.getElementById('attr-bp-strategy');
105
+ const elAttrBpRefId = document.getElementById('attr-bp-ref-id');
106
+ const elAttrOptRef = document.getElementById('attr-opt-ref');
107
+ const elAttrHideBos = document.getElementById('attr-hide-bos');
108
+ const elAttrHideSpecial = document.getElementById('attr-hide-special');
109
+ const elAttrReverseSign = document.getElementById('attr-reverse-sign');
110
+ const elBtnComputeAttr = document.getElementById('btn-compute-attr');
111
+ const elBtnSaveGraph = document.getElementById('btn-save-graph'); // Save Button
112
+ const elInputAttributionDisplay = document.getElementById('input-attribution-display');
113
+ const elAttrDiffStrategyOpts = document.getElementById('attr-diff-strategy-opts'); // Container for strategy ops
114
+
115
+ const elTokenCountDisplay = document.getElementById('token-count-display');
116
+ const elVisLayerSpacing = document.getElementById('vis-layer-spacing');
117
+ const elValVisLayerSpacing = document.getElementById('val-vis-layer-spacing');
118
+
119
+ const elCanvas = document.getElementById('circuit-canvas');
120
+ const elTooltip = document.getElementById('tooltip');
121
+
122
+ // Event Listeners
123
+ console.log("Attaching event listeners...");
124
+ try {
125
+ if(elBtnLoad) elBtnLoad.addEventListener('click', loadModel);
126
+ else console.error("elBtnLoad not found");
127
+
128
+ if(elBtnLocateError) elBtnLocateError.addEventListener('click', locateErrorToken);
129
+ else console.warn("elBtnLocateError not found");
130
+
131
+ if(elBtnUseTruncated) elBtnUseTruncated.addEventListener('click', useTruncatedText);
132
+ else console.warn("elBtnUseTruncated not found");
133
+
134
+ if(elBtnCancelError) elBtnCancelError.addEventListener('click', cancelErrorLocation);
135
+ else console.warn("elBtnCancelError not found");
136
+
137
+ if(elBtnLogits) elBtnLogits.addEventListener('click', () => computeLogits(false));
138
+
139
+ // Add Token Feature
140
+ const elBtnAddToken = document.getElementById('btn-add-token');
141
+ const elAddTokenInput = document.getElementById('add-token-input');
142
+
143
+ if(elBtnAddToken && elAddTokenInput) {
144
+ elBtnAddToken.addEventListener('click', () => {
145
+ const val = elAddTokenInput.value.trim();
146
+ if(!val) return;
147
+
148
+ // Parse: is it ID or String?
149
+ // If numeric, treat as ID.
150
+ if (/^\d+$/.test(val)) {
151
+ const id = parseInt(val);
152
+ appState.extraTokens.push({ type: 'id', value: id });
153
+ } else {
154
+ appState.extraTokens.push({ type: 'str', value: val });
155
+ }
156
+
157
+ elAddTokenInput.value = ""; // Clear input
158
+ computeLogits(true); // Keep extras!
159
+ });
160
+
161
+ // Allow Enter key
162
+ elAddTokenInput.addEventListener('keypress', (e) => {
163
+ if (e.key === 'Enter') elBtnAddToken.click();
164
+ });
165
+ }
166
+ if(elBpMode) elBpMode.addEventListener('change', updateBpOptions);
167
+ if(elBpStrategy) elBpStrategy.addEventListener('change', updateBpOptions);
168
+ if(elBtnCircuit) elBtnCircuit.addEventListener('click', computeCircuit);
169
+ if(elShowAllTokens) elShowAllTokens.addEventListener('change', drawCircuit);
170
+
171
+ if(elBtnLayerDefault) elBtnLayerDefault.addEventListener('click', () => setLayerPreset('default'));
172
+ if(elBtnLayerAll) elBtnLayerAll.addEventListener('click', () => setLayerPreset('all'));
173
+
174
+ // Input Attribution Listeners
175
+ if(elAttrBpMode) elAttrBpMode.addEventListener('change', updateAttrBpOptions);
176
+ if(elAttrBpStrategy) elAttrBpStrategy.addEventListener('change', updateAttrBpOptions);
177
+ // Re-render when checkboxes change (if data exists)
178
+ const reRenderAttr = () => { if(appState.lastRelevance) renderAttributionMap(appState.lastRelevance, appState.tokens); };
179
+ if(elAttrHideBos) elAttrHideBos.addEventListener('change', reRenderAttr);
180
+ if(elAttrHideSpecial) elAttrHideSpecial.addEventListener('change', reRenderAttr);
181
+ if(elAttrReverseSign) elAttrReverseSign.addEventListener('change', reRenderAttr);
182
+
183
+ if(elBtnComputeAttr) elBtnComputeAttr.addEventListener('click', computeInputAttribution);
184
+ if(elBtnSaveGraph) elBtnSaveGraph.addEventListener('click', saveCircuitGraph);
185
+ const elBtnSavePdf = document.getElementById('btn-save-pdf');
186
+ if(elBtnSavePdf) elBtnSavePdf.addEventListener('click', saveCircuitGraphPDF);
187
+
188
+ // Attrib Save
189
+ const elBtnSaveAttrPng = document.getElementById('btn-save-attr-png');
190
+ const elBtnSaveAttrPdf = document.getElementById('btn-save-attr-pdf');
191
+ if(elBtnSaveAttrPng) elBtnSaveAttrPng.addEventListener('click', saveAttributionMapPNG);
192
+ if(elBtnSaveAttrPdf) elBtnSaveAttrPdf.addEventListener('click', saveAttributionMapPDF);
193
+
194
+
195
+ if(elPruningMode) elPruningMode.addEventListener('change', updatePruningControls);
196
+
197
+ console.log("Event listeners attached.");
198
+ } catch(e) {
199
+ console.error("Error attaching listeners:", e);
200
+ }
201
+
202
+ // Visualization Controls (Slider <-> Input Sync)
203
+ function bindControl(slider, input, callback, debounceMs = 0) {
204
+ if (!slider || !input) {
205
+ // console.error("bindControl missing elements", slider, input);
206
+ return;
207
+ }
208
+
209
+ let debounceTimer = null;
210
+ const trigger = () => {
211
+ if (debounceMs > 0) {
212
+ if (debounceTimer) clearTimeout(debounceTimer);
213
+ debounceTimer = setTimeout(() => {
214
+ if(callback) callback();
215
+ }, debounceMs);
216
+ } else {
217
+ if(callback) callback();
218
+ }
219
+ };
220
+
221
+ // Slider updates Input
222
+ slider.addEventListener('input', () => {
223
+ input.value = slider.value;
224
+ trigger();
225
+ });
226
+ // Input updates Slider
227
+ input.addEventListener('input', () => {
228
+ let val = parseFloat(input.value);
229
+ if (!isNaN(val)) {
230
+ // Clamp to slider limits
231
+ val = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), val));
232
+ slider.value = val;
233
+ trigger();
234
+ }
235
+ });
236
+ }
237
+ console.log("Binding controls...");
238
+ // Bind with specific update functions for efficiency
239
+ // Layout updates (heavy): Edge/Node Thresholds, Spacing - Debounced
240
+ // bindControl(elVisThreshold, elValVisThreshold, updateCircuitLayout, 300);
241
+ // bindControl(elVisNodeThreshold, elValVisNodeThreshold, updateCircuitLayout, 300);
242
+
243
+ // NEW Bindings for TopP (Callback: computeCircuit, Debounced)
244
+ bindControl(elVisTopP, elValVisTopP, computeCircuit, 600);
245
+
246
+ // Layout Geometry updates (moved to light): Spacing
247
+ bindControl(elVisLayerSpacing, elValVisLayerSpacing, drawCircuit, 0);
248
+ // Visual updates (light): Strength
249
+ bindControl(elVisStrength, elValVisStrength, drawCircuit, 0);
250
+
251
+ function updatePruningControls() {
252
+ const mode = elPruningMode.value;
253
+ if (mode === 'by_per_layer_cum_mass_percentile') {
254
+ elCtrlTopP.classList.remove('hidden');
255
+ elCtrlGlobalThresh.classList.add('hidden');
256
+ } else {
257
+ elCtrlTopP.classList.add('hidden');
258
+ elCtrlGlobalThresh.classList.remove('hidden');
259
+ }
260
+ }
261
+ // Init State
262
+ updatePruningControls();
263
+
264
+
265
+
266
+ // Functions
267
+
268
+ async function loadModel() {
269
+ console.log("loadModel called");
270
+ updateStatus('Loading...', 'normal');
271
+ showLoading("Loading Model...");
272
+ elBtnLoad.disabled = true;
273
+
274
+ try {
275
+ const response = await fetch(`${API_BASE}/load_model`, {
276
+ method: 'POST',
277
+ headers: {'Content-Type': 'application/json'},
278
+ body: JSON.stringify({
279
+ model_path: elModelPath.value,
280
+ quantization_4bit: elQuant.checked,
281
+ dtype: elModelDtype.value,
282
+ revision: elModelRevision ? elModelRevision.value : null,
283
+ lrp_rule: document.getElementById('model-lrp-rule').value
284
+ })
285
+ });
286
+
287
+ const data = await response.json();
288
+ if (response.ok) {
289
+ updateStatus('Loaded', 'success');
290
+ if(elBtnLogits) elBtnLogits.disabled = false;
291
+ if(elBtnLocateError) elBtnLocateError.disabled = false;
292
+ if(data.n_layers) {
293
+ appState.n_layers = data.n_layers;
294
+ // Update default presets
295
+ generateLayerPresets();
296
+ }
297
+ } else {
298
+ throw new Error(data.detail || 'Failed to load model');
299
+ }
300
+ } catch (e) {
301
+ updateStatus(`Error: ${e.message}`, 'error');
302
+ alert(e.message);
303
+ } finally {
304
+ elBtnLoad.disabled = false;
305
+ hideLoading();
306
+ }
307
+ }
308
+
309
+ async function locateErrorToken() {
310
+ const prompt = elPrompt.value.trim();
311
+ const completion = elCompletion.value.trim();
312
+
313
+ if (!prompt) {
314
+ alert("Please enter a prompt.");
315
+ return;
316
+ }
317
+
318
+ if (!completion) {
319
+ alert("Please enter a completion.");
320
+ return;
321
+ }
322
+
323
+ elBtnLocateError.disabled = true;
324
+ elErrorLocateStatus.textContent = "Locating error token...";
325
+ showLoading("Analyzing error token...");
326
+
327
+ try {
328
+ const res = await fetch(`${API_BASE}/locate_err_token`, {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({
332
+ prompt: prompt,
333
+ completion: completion
334
+ })
335
+ });
336
+
337
+ const data = await res.json();
338
+
339
+ if (res.ok && data.status === "success") {
340
+ elErrorLocateStatus.textContent = "Error token located successfully.";
341
+
342
+ // Display results
343
+ elErrorExplanation.textContent = data.explanation || "No explanation provided.";
344
+ elTruncatedText.value = data.truncated_text || "";
345
+
346
+ // Show result section
347
+ elSectionErrorResult.classList.remove('hidden');
348
+
349
+ } else {
350
+ elErrorLocateStatus.textContent = "Failed to locate error token.";
351
+ alert(`Error: ${data.message || data.detail || 'Unknown error'}`);
352
+ }
353
+ } catch (e) {
354
+ elErrorLocateStatus.textContent = "Request failed.";
355
+ alert(`Network error: ${e.message}`);
356
+ } finally {
357
+ hideLoading();
358
+ elBtnLocateError.disabled = false;
359
+ }
360
+ }
361
+
362
+ function useTruncatedText() {
363
+ const truncatedText = elTruncatedText.value.trim();
364
+
365
+ if (!truncatedText) {
366
+ alert("Truncated text is empty.");
367
+ return;
368
+ }
369
+
370
+ // Update the prompt field with truncated text
371
+ elPrompt.value = truncatedText;
372
+ elPromptOrig.value = truncatedText;
373
+
374
+ // Hide error result section and show continue analysis section
375
+ elSectionErrorResult.classList.add('hidden');
376
+ elSectionContinueAnalysis.classList.remove('hidden');
377
+
378
+ // Clear error status
379
+ elErrorLocateStatus.textContent = "";
380
+ }
381
+
382
+ function cancelErrorLocation() {
383
+ // Hide error result section
384
+ elSectionErrorResult.classList.add('hidden');
385
+
386
+ // Clear error status
387
+ elErrorLocateStatus.textContent = "";
388
+ }
389
+
390
+ async function computeLogits(keepExtras = false) {
391
+ elBtnLogits.disabled = true;
392
+ showLoading("Computing Logits...");
393
+
394
+ // If this is a fresh run (not adding a token), clear extras
395
+ if (!keepExtras) {
396
+ appState.extraTokens = [];
397
+ }
398
+
399
+ elSectionLogits.classList.add('hidden');
400
+ if(elSectionInputAttr) elSectionInputAttr.classList.add('hidden');
401
+ elSectionCircuit.classList.add('hidden');
402
+
403
+ // Prepare extras
404
+ const extraIds = [];
405
+ const extraStrs = [];
406
+
407
+ appState.extraTokens.forEach(item => {
408
+ if(item.type === 'id') extraIds.push(item.value);
409
+ else extraStrs.push(item.value);
410
+ });
411
+
412
+ try {
413
+ const response = await fetch(`${API_BASE}/compute_logits`, {
414
+ method: 'POST',
415
+ headers: {'Content-Type': 'application/json'},
416
+ body: JSON.stringify({
417
+ prompt: elPrompt.value,
418
+ is_append_bos: elAppendBos.checked,
419
+ topk: 50,
420
+ extra_token_ids: extraIds,
421
+ extra_token_strs: extraStrs,
422
+ capture_mid: document.getElementById('capture-mid') ? document.getElementById('capture-mid').checked : false
423
+ })
424
+ });
425
+
426
+ const res = await response.json();
427
+ if (response.ok) {
428
+ renderLogitsTable(res.data);
429
+ appState.tokens = res.tokens;
430
+
431
+ // Display token count
432
+ if (elTokenCountDisplay) {
433
+ elTokenCountDisplay.textContent = `Total Input Tokens: ${res.tokens.length}`;
434
+ }
435
+
436
+ elSectionLogits.classList.remove('hidden');
437
+ if(elSectionInputAttr) elSectionInputAttr.classList.remove('hidden');
438
+ elSectionCircuit.classList.remove('hidden');
439
+
440
+ // Set default selections
441
+ if (res.data.length > 0) {
442
+ appState.selectedTargetToken = res.data[0];
443
+ elTopTokenDisplay.textContent = `${appState.selectedTargetToken.token_str} (ID: ${appState.selectedTargetToken.token_id})`;
444
+ }
445
+ } else {
446
+ alert(`Error: ${res.detail}`);
447
+ }
448
+ } catch (e) {
449
+ alert(`Error: ${e.message}`);
450
+ } finally {
451
+ elBtnLogits.disabled = false;
452
+ hideLoading();
453
+ }
454
+ }
455
+
456
+ function renderLogitsTable(data) {
457
+ elLogitsTableBody.innerHTML = '';
458
+ data.forEach(item => {
459
+ // Robust escaping for the onclick handler string
460
+ const escapedTokenStr = formatTokenForDisplay(item.token_str, 'data');
461
+
462
+ // VISUAL ESCAPE for the table cell
463
+ let visualTokenStr = formatTokenForDisplay(item.token_str, 'visual');
464
+
465
+ const row = document.createElement('tr');
466
+ row.innerHTML = `
467
+ <td>${item.rank}</td>
468
+ <td>${item.token_id}</td>
469
+ <td>${visualTokenStr}</td>
470
+ <td>${item.logit.toFixed(4)}</td>
471
+ <td><button id="btn-select-${item.token_id}" class="btn-select-ref" onclick="selectContrast(${item.token_id}, '${escapedTokenStr}', ${item.logit})">Select for Contrast</button></td>
472
+ <td>
473
+ <button class="btn-generate-cont" onclick="generateContinuation(${item.token_id}, this)">Generate</button>
474
+ <div class="gen-result" style="font-size: 0.85em; color: #555; margin-top: 4px; max-width: 300px; white-space: pre-wrap;"></div>
475
+ </td>
476
+ `;
477
+ elLogitsTableBody.appendChild(row);
478
+ });
479
+ }
480
+
481
+ window.generateContinuation = async function(tokenId, btn) {
482
+ const originalText = btn.textContent;
483
+ btn.disabled = true;
484
+ const elGenMax = document.getElementById('gen-max-tokens');
485
+ const maxNewTokens = elGenMax ? parseInt(elGenMax.value) : 30;
486
+
487
+ btn.textContent = `Generating (${maxNewTokens})...`;
488
+ const resultDiv = btn.nextElementSibling;
489
+ resultDiv.textContent = "";
490
+
491
+ try {
492
+ const payload = {
493
+ prompt: elPrompt.value,
494
+ max_new_tokens: maxNewTokens,
495
+ append_token_id: tokenId
496
+ };
497
+
498
+ const response = await fetch(`${API_BASE}/generate`, {
499
+ method: 'POST',
500
+ headers: {'Content-Type': 'application/json'},
501
+ body: JSON.stringify(payload)
502
+ });
503
+
504
+ if (!response.ok) {
505
+ const err = await response.json();
506
+ throw new Error(err.detail || "Generation failed");
507
+ }
508
+
509
+ const data = await response.json();
510
+ resultDiv.textContent = data.generated_text;
511
+
512
+ } catch(e) {
513
+ resultDiv.textContent = "Error: " + e.message;
514
+ resultDiv.style.color = "red";
515
+ } finally {
516
+ btn.disabled = false;
517
+ btn.textContent = originalText;
518
+ }
519
+ };
520
+
521
+ window.selectContrast = function(id, name, logit) {
522
+ appState.selectedContrastToken = { id, name, logit };
523
+
524
+ // Calculate Diff
525
+ let diffText = "";
526
+ if (appState.selectedTargetToken) {
527
+ // Find top logit (usually the first one in the list if sorted, or stored in appState)
528
+ // appState.selectedTargetToken comes from computeLogits result[0]
529
+ const topLogit = appState.selectedTargetToken.logit;
530
+ const diff = topLogit - logit;
531
+ diffText = ` | Logit Diff: ${diff.toFixed(4)}`;
532
+ }
533
+
534
+ elContrastTokenDisplay.textContent = `${name} (ID: ${id})${diffText}`;
535
+
536
+ // Visual Updates
537
+ document.querySelectorAll('.btn-select-ref').forEach(btn => {
538
+ btn.textContent = "Select for Contrast";
539
+ btn.classList.remove('selected');
540
+ btn.style.backgroundColor = ""; // Reset inline style if any (or rely on class)
541
+ });
542
+
543
+ const activeBtn = document.getElementById(`btn-select-${id}`);
544
+ if (activeBtn) {
545
+ activeBtn.textContent = "Selected for Contrast";
546
+ activeBtn.classList.add('selected');
547
+ }
548
+
549
+ // Auto Update UI settings
550
+ elBpMode.value = "logit_diff";
551
+ updateBpOptions();
552
+ elBpStrategy.value = "by_ref_token";
553
+ updateBpOptions();
554
+ elBpRefId.value = id;
555
+
556
+ // Auto Update Input Attribution settings
557
+ if (elAttrBpMode) {
558
+ elAttrBpMode.value = "logit_diff";
559
+ updateAttrBpOptions();
560
+ }
561
+ if (elAttrBpStrategy) {
562
+ elAttrBpStrategy.value = "by_ref_token";
563
+ updateAttrBpOptions();
564
+ }
565
+ if (elAttrBpRefId) {
566
+ elAttrBpRefId.value = id;
567
+ }
568
+ };
569
+
570
+ function updateBpOptions() {
571
+ const isDiff = elBpMode.value === "logit_diff";
572
+ document.getElementById('diff-strategy-opts').style.display = isDiff ? 'block' : 'none';
573
+
574
+ if (isDiff) {
575
+ const strategy = elBpStrategy.value;
576
+ elOptTopk.classList.toggle('hidden', strategy !== 'by_topk_avg');
577
+ elOptRef.classList.toggle('hidden', strategy !== 'by_ref_token');
578
+ }
579
+ }
580
+
581
+ function updateAttrBpOptions() {
582
+ const isDiff = elAttrBpMode.value === "logit_diff";
583
+ if (elAttrDiffStrategyOpts) {
584
+ elAttrDiffStrategyOpts.style.display = isDiff ? 'block' : 'none';
585
+ }
586
+
587
+ if (isDiff) {
588
+ const strategy = elAttrBpStrategy.value;
589
+ if (elAttrOptRef) {
590
+ elAttrOptRef.classList.toggle('hidden', strategy !== 'by_ref_token');
591
+ }
592
+ }
593
+ }
594
+
595
+ async function computeInputAttribution() {
596
+ if (!appState.selectedTargetToken) {
597
+ alert("Please calculate logits and select a target token first.");
598
+ return;
599
+ }
600
+
601
+ // Default values if inputs are missing (e.g. reused from circuit config or hardcoded)
602
+ const bpK = 10;
603
+
604
+ const payload = {
605
+ target_token_id: appState.selectedTargetToken.token_id,
606
+ contrast_token_id: appState.selectedContrastToken ? appState.selectedContrastToken.id : null,
607
+ backprop_config: {
608
+ mode: elAttrBpMode.value,
609
+ strategy: elAttrBpStrategy.value,
610
+ k: bpK,
611
+ ref_token_id: elAttrBpRefId && elAttrBpRefId.value ? parseInt(elAttrBpRefId.value) : null
612
+ }
613
+ };
614
+
615
+ elInputAttributionDisplay.innerHTML = "Computing...";
616
+ elBtnComputeAttr.disabled = true;
617
+
618
+ try {
619
+ const resp = await fetch(`${API_BASE}/compute_input_attribution`, {
620
+ method: 'POST',
621
+ headers: {'Content-Type': 'application/json'},
622
+ body: JSON.stringify(payload)
623
+ });
624
+
625
+ if (!resp.ok) {
626
+ const err = await resp.json();
627
+ const errMsg = (typeof err.detail === 'object') ? JSON.stringify(err.detail) : (err.detail || "Request failed");
628
+ throw new Error(errMsg);
629
+ }
630
+
631
+ const data = await resp.json();
632
+ // data.relevance is list of floats
633
+ appState.lastRelevance = data.relevance;
634
+ renderAttributionMap(data.relevance, appState.tokens);
635
+
636
+ } catch (e) {
637
+ console.error(e);
638
+ elInputAttributionDisplay.textContent = "Error: " + e.message;
639
+ } finally {
640
+ elBtnComputeAttr.disabled = false;
641
+ }
642
+ }
643
+ if (elAttrHideBos) {
644
+ elAttrHideBos.addEventListener('change', () => {
645
+ if (appState.lastRelevance && appState.tokens) {
646
+ renderAttributionMap(appState.lastRelevance, appState.tokens);
647
+ }
648
+ });
649
+ }
650
+
651
+
652
+ const SPECIAL_TOKENS_SET = new Set(['<|im_start|>', '<|im_end|>', '<think>', '</think>']);
653
+
654
+ function renderAttributionMap(relevance, tokens) {
655
+ elInputAttributionDisplay.innerHTML = "";
656
+
657
+ if (!relevance || relevance.length === 0) {
658
+ elInputAttributionDisplay.textContent = "No data returned.";
659
+ return;
660
+ }
661
+
662
+ const hideBos = elAttrHideBos ? elAttrHideBos.checked : false;
663
+ const hideSpecial = elAttrHideSpecial ? elAttrHideSpecial.checked : false;
664
+ const reverseSign = elAttrReverseSign ? elAttrReverseSign.checked : false;
665
+
666
+ // Helper: is this token skipped?
667
+ // NOTE: This logic determines both Normalization AND Rendering visibility
668
+ function isSkipped(idx, tokenData) {
669
+ if (hideBos && idx === 0) return true;
670
+
671
+ if (hideSpecial) {
672
+ // tokenData could be string or object
673
+ let s = (typeof tokenData === 'string') ? tokenData : (tokenData.token_str || tokenData.text);
674
+ if (s) s = s.trim();
675
+ if (SPECIAL_TOKENS_SET.has(s)) return true;
676
+ }
677
+ return false;
678
+ }
679
+
680
+ // Determine range for normalization (exclude BOS if hideBos is true)
681
+ // But we render everything from index 0 now, just styling BOS differently.
682
+
683
+ let maxAbs = 0;
684
+ // Calculation Loop
685
+ for (let i = 0; i < relevance.length; i++) {
686
+ if (isSkipped(i, tokens[i])) continue; // Skip excluded tokens for dynamic range calculation
687
+ const r = relevance[i];
688
+ if (Math.abs(r) > maxAbs) maxAbs = Math.abs(r);
689
+ }
690
+ if (maxAbs === 0) maxAbs = 1;
691
+
692
+ // Helper for color
693
+ function getColor(val) {
694
+ if (Math.abs(val) === 0) return 'transparent'; // optimization
695
+
696
+ // Red for positive, Blue for negative
697
+ const norm = val / maxAbs;
698
+
699
+ // Use HSL for better control? Or RGBA.
700
+ // We want a clear white text if background is dark?
701
+ // Or keep background light.
702
+ // Let's use slight alpha backgrounds.
703
+
704
+ const alpha = Math.abs(norm);
705
+ // Cap alpha to avoid being too dark/unreadable if we don't change text color
706
+ // But we want it visible.
707
+ const cappedAlpha = Math.min(alpha, 0.6);
708
+
709
+ // Blend with white background to avoid html2canvas alpha issues (red background bug)
710
+ // Foreground: Red (255, 0, 0) or Blue (0, 0, 255)
711
+ // Background: White (255, 255, 255)
712
+ // Result = FG * alpha + BG * (1 - alpha)
713
+
714
+ if (norm > 0) {
715
+ // Red case: R=255, G=255(1-a), B=255(1-a)
716
+ const gb = Math.round(255 * (1 - cappedAlpha));
717
+ return `rgb(255, ${gb}, ${gb})`;
718
+ } else {
719
+ // Blue case: R=255(1-a), G=255(1-a), B=255
720
+ const rg = Math.round(255 * (1 - cappedAlpha));
721
+ return `rgb(${rg}, ${rg}, 255)`;
722
+ }
723
+ }
724
+
725
+ tokens.forEach((tok, idx) => {
726
+ // Handle mismatch length if any (shouldn't happen)
727
+ if (idx >= relevance.length) return;
728
+
729
+ let val = relevance[idx];
730
+ if (reverseSign) val = -val;
731
+
732
+ const span = document.createElement("span");
733
+ span.className = "token-span";
734
+
735
+ // Check if this is a "Hidden" token
736
+ const isHidden = isSkipped(idx, tok);
737
+
738
+ if (isHidden) {
739
+ // Apply special style
740
+ span.style.color = "#aaa";
741
+ span.style.backgroundColor = "#f0f0f0";
742
+ // span.style.textDecoration = "line-through"; // Optional?
743
+ }
744
+
745
+ // tok is a string from backend
746
+ let tokenText = (typeof tok === 'string') ? tok : (tok.token_str || tok.text);
747
+
748
+ // Use Central Formatter
749
+ const visualText = formatTokenForDisplay(tokenText, 'visual');
750
+
751
+ // Special structural handling for newlines
752
+ const isNewline = (tokenText === '\n' || tokenText === '\r\n');
753
+ const isDoubleNewline = (tokenText === '\n\n' || tokenText === '\r\n\r\n');
754
+
755
+ if (isNewline || isDoubleNewline) {
756
+ span.textContent = visualText;
757
+ // Ensure it has shape/padding
758
+ span.style.display = "inline-block";
759
+ span.style.minWidth = isDoubleNewline ? "24px" : "12px";
760
+ span.style.textAlign = "center";
761
+ } else {
762
+ span.textContent = tokenText; // Keep original for normal text? OR use visual?
763
+ // Usually normal text is fine, but if it has internal newlines, visualText handles it.
764
+ // Let's use visualText if it differs significantly? No, visualText escapes \n.
765
+ // For attribution map, we want visible \n, but regular text should probably wrap?
766
+ // Actually, for wrapped text block, literal \n is confusing.
767
+ // But valid tokens usually don't have internal newlines except specifically newline tokens.
768
+ // Let's stick to original tokenText for normal tokens, just in case.
769
+ // But if visualText detected a change (like mixed \n), we might want to show it.
770
+ if (visualText !== tokenText) span.textContent = visualText;
771
+ else span.textContent = tokenText;
772
+ }
773
+
774
+ const normVal = val / maxAbs;
775
+ if (!isHidden) {
776
+ span.style.backgroundColor = getColor(val);
777
+ }
778
+ span.title = `Token: "${visualText}"\nPos: ${idx}\nRaw Rel: ${val.toFixed(5)}\nNorm Rel: ${normVal.toFixed(3)}`;
779
+
780
+ elInputAttributionDisplay.appendChild(span);
781
+
782
+ // Preserve line break behavior for newlines -- DISABLED per user request
783
+ /*
784
+ if (isNewline) {
785
+ elInputAttributionDisplay.appendChild(document.createElement("br"));
786
+ } else if (isDoubleNewline) {
787
+ elInputAttributionDisplay.appendChild(document.createElement("br"));
788
+ elInputAttributionDisplay.appendChild(document.createElement("br"));
789
+ }
790
+ */
791
+ });
792
+ }
793
+
794
+ async function computeCircuit() {
795
+ // Parse Layers List
796
+ // Expect: "0, 5, 20" -> [0, 5, 20]
797
+ const rawVal = elLayersList.value;
798
+ const layers = rawVal.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
799
+
800
+ // Sort and Validate
801
+ layers.sort((a,b) => a - b);
802
+
803
+ if (layers.length < 2) {
804
+ alert("Please provide at least 2 layers (e.g., '0, 5, 20')");
805
+ return;
806
+ }
807
+
808
+ elLayersList.value = layers.join(', ');
809
+
810
+ elBtnCircuit.disabled = true;
811
+ elBtnCircuit.textContent = "Computing...";
812
+
813
+ // Progress UI
814
+ const elProgressContainer = document.getElementById("compute-progress-container");
815
+ const elProgressBar = document.getElementById("compute-progress-bar");
816
+ const elProgressStatus = document.getElementById("progress-status-text");
817
+ const elProgressPercent = document.getElementById("progress-percent-text");
818
+
819
+ if(elProgressContainer) {
820
+ elProgressContainer.style.display = "block";
821
+ elProgressBar.value = 0;
822
+ elProgressStatus.textContent = "Starting...";
823
+ elProgressPercent.textContent = "0%";
824
+ }
825
+
826
+ // Build Backprop Config
827
+ const bpConfig = {
828
+ mode: elBpMode.value,
829
+ strategy: elBpStrategy.value,
830
+ k: parseInt(elBpK.value) || 10,
831
+ ref_token_id: parseInt(elBpRefId.value) || 0,
832
+ contrast_rank: 2 // simplified defaults
833
+ };
834
+
835
+ // Pruning Config
836
+ const pruningMode = elPruningMode ? elPruningMode.value : "by_per_layer_cum_mass_percentile";
837
+ const topP = elVisTopP ? parseFloat(elVisTopP.value) : 0.9;
838
+ const edgeThresh = elValGlobalThresh ? parseFloat(elValGlobalThresh.value) : 0.01;
839
+
840
+ try {
841
+ const response = await fetch(`${API_BASE}/compute_circuit`, {
842
+ method: 'POST',
843
+ headers: {'Content-Type': 'application/json'},
844
+ body: JSON.stringify({
845
+ backprop_config: bpConfig,
846
+ layers: layers,
847
+ pruning_mode: pruningMode,
848
+ top_p: topP,
849
+ edge_threshold: edgeThresh
850
+ })
851
+ });
852
+
853
+ if (!response.ok) {
854
+ // Try to parse error
855
+ let errMsg = "Server Error";
856
+ try {
857
+ const err = await response.json();
858
+ errMsg = err.detail || errMsg;
859
+ } catch(e) {}
860
+ throw new Error(errMsg);
861
+ }
862
+
863
+ // NDJSON Streaming Reader
864
+ const reader = response.body.getReader();
865
+ const decoder = new TextDecoder();
866
+ let buffer = '';
867
+
868
+ // Clear previous data
869
+ appState.circuitData = null;
870
+
871
+ while (true) {
872
+ const { value, done } = await reader.read();
873
+ if (done) break;
874
+
875
+ const chunk = decoder.decode(value, {stream: true});
876
+ buffer += chunk;
877
+ const lines = buffer.split('\n');
878
+ buffer = lines.pop(); // Keep partial
879
+
880
+ for (const line of lines) {
881
+ if (!line.trim()) continue;
882
+ try {
883
+ const msg = JSON.parse(line);
884
+ if (msg.type === "progress") {
885
+ if(elProgressContainer) {
886
+ const pct = Math.min(100, Math.max(0, msg.percent || 0));
887
+ elProgressBar.value = pct;
888
+ elProgressStatus.textContent = msg.msg;
889
+ elProgressPercent.textContent = Math.round(pct) + "%";
890
+ }
891
+ } else if (msg.type === "graph_data") {
892
+ // Received FULL graph
893
+ appState.circuitData = {
894
+ graph: msg.graph,
895
+ pruning_details: msg.pruning_details,
896
+ layers: layers // Or extract from graph nodes
897
+ };
898
+ appState.layout = null; // Clear cached layout
899
+ } else if (msg.type === "complete") {
900
+ if (appState.circuitData) {
901
+ requestAnimationFrame(() => updateCircuitLayout()); // directly to layout
902
+ }
903
+ } else if (msg.type === "error") {
904
+ throw new Error(msg.msg);
905
+ }
906
+ } catch (e) {
907
+ if (e instanceof SyntaxError) {
908
+ console.error("JSON Parse Error on line:", line, e);
909
+ continue;
910
+ }
911
+ throw e;
912
+ }
913
+ }
914
+ }
915
+ } catch (e) {
916
+ alert(e.message);
917
+ } finally {
918
+ elBtnCircuit.disabled = false;
919
+ elBtnCircuit.textContent = "Visualize Connection";
920
+ if(elProgressContainer) {
921
+ setTimeout(() => {
922
+ elProgressContainer.style.display = "none";
923
+ }, 2000);
924
+ }
925
+ }
926
+ }
927
+
928
+ function updateStatus(msg, type) {
929
+ elStatus.textContent = msg;
930
+ elStatus.className = `status ${type}`;
931
+ }
932
+
933
+ // --- Visualization Logic ---
934
+
935
+ // NEW: Separated heavy layout calculation from rendering
936
+ function updateCircuitLayout() {
937
+ if (!appState.circuitData || !appState.circuitData.graph) return;
938
+ showLoading("Updating Layout...");
939
+
940
+ // Yield to browser to render spinner
941
+ setTimeout(() => {
942
+ try {
943
+ _updateCircuitLayoutInternal();
944
+ } catch(e) {
945
+ console.error(e);
946
+ } finally {
947
+ hideLoading();
948
+ }
949
+ }, 50);
950
+ }
951
+
952
+ function _updateCircuitLayoutInternal() {
953
+ if (!appState.circuitData || !appState.circuitData.graph) return;
954
+
955
+ // Destructure graph early
956
+ const { graph, layers: requestedLayers } = appState.circuitData;
957
+
958
+ // Define robust accessors
959
+ // NetworkX alignment: v2 uses 'links', v3 uses 'edges'
960
+ const nodes = graph.nodes || [];
961
+ const edges = graph.edges || graph.links || [];
962
+
963
+ // DEBUG: Log Graph Data Structure
964
+ console.log("Graph Data Received:", graph);
965
+ if(nodes.length > 0) {
966
+ console.log("Sample Node:", nodes[0]);
967
+ }
968
+ if(edges.length > 0) {
969
+ console.log("Sample Link/Edge:", edges[0]);
970
+ } else {
971
+ console.log("No links/edges found. (Graph might be disconnected or threshold too high)");
972
+ }
973
+
974
+ // Check tokens
975
+ if (!appState.tokens || appState.tokens.length === 0) {
976
+ console.error("appState.tokens is missing! Cannot render graph. Please run Compute Logits first.");
977
+ alert("Error: Tokens missing. Please run 'Compute Logits' first.");
978
+ return;
979
+ }
980
+
981
+ // Check toggle listeners
982
+ const toggle = document.getElementById('hide-bos-node');
983
+ if (toggle && !toggle.hasAttribute('data-listening')) {
984
+ toggle.addEventListener('change', updateCircuitLayout); // Need layout update to filter BOs
985
+ toggle.setAttribute('data-listening', 'true');
986
+ }
987
+ const showAllToggle = document.getElementById('show-all-tokens');
988
+ if (showAllToggle && !showAllToggle.hasAttribute('data-listening')) {
989
+ showAllToggle.addEventListener('change', updateCircuitLayout);
990
+ showAllToggle.setAttribute('data-listening', 'true');
991
+ }
992
+
993
+ const showNodeValuesToggle = document.getElementById('show-node-values');
994
+ if (showNodeValuesToggle && !showNodeValuesToggle.hasAttribute('data-listening')) {
995
+ showNodeValuesToggle.addEventListener('change', drawCircuit); // Visual only
996
+ showNodeValuesToggle.setAttribute('data-listening', 'true');
997
+ }
998
+
999
+ // const { graph, layers: requestedLayers } = appState.circuitData; // Moved up
1000
+ const tokens = appState.tokens;
1001
+ const layerHeight = parseFloat(elVisLayerSpacing ? elVisLayerSpacing.value : 300);
1002
+
1003
+ const fullSeqLen = tokens.length;
1004
+ const hideFirstToken = document.getElementById('hide-bos-node') && document.getElementById('hide-bos-node').checked;
1005
+ const showAllTokens = elShowAllTokens ? elShowAllTokens.checked : true;
1006
+
1007
+ // Data Slicing (if hiding BOS)
1008
+ let displaySeqLen = fullSeqLen;
1009
+ let displayTokens = tokens;
1010
+ let startTokenIdx = 0;
1011
+
1012
+ if (hideFirstToken && fullSeqLen > 1) {
1013
+ displayTokens = tokens.slice(1);
1014
+ displaySeqLen = fullSeqLen - 1;
1015
+ startTokenIdx = 1;
1016
+ }
1017
+
1018
+ // Process Graph Data
1019
+ // Nodes are { id: [layer, token], layer: L, token: T, relevance: R }
1020
+ // const nodes = graph.nodes; // Moved up
1021
+ // const edges = graph.links; // Moved up
1022
+
1023
+ // Identify Layers involved in graph
1024
+ // Use requestedLayers if available, or infer from graph
1025
+ let layersList = requestedLayers || [];
1026
+ if (layersList.length === 0) {
1027
+ const layersSet = new Set(nodes.map(n => n.layer));
1028
+ layersList = Array.from(layersSet).sort((a,b)=>a-b);
1029
+ }
1030
+
1031
+ // Determine active nodes (those in graph)
1032
+ const activeNodeMap = new Map(); // key: "layer,token" -> nodeObj
1033
+ let globalMaxValRel = 0;
1034
+ let globalMaxValEdge = 0;
1035
+
1036
+ nodes.forEach(n => {
1037
+ // Filter BOS if needed
1038
+ if (hideFirstToken && n.token === 0) return; // Assume BOS is 0? Use n.token index check
1039
+ if (hideFirstToken && n.token < startTokenIdx) return;
1040
+
1041
+ const key = `${n.layer},${n.token}`;
1042
+ activeNodeMap.set(key, n);
1043
+ globalMaxValRel = Math.max(globalMaxValRel, Math.abs(n.relevance || 0));
1044
+ });
1045
+
1046
+ edges.forEach(e => {
1047
+ // e.source and e.target are arrays [L, T]
1048
+ const srcL = e.source[0];
1049
+ const srcT = e.source[1];
1050
+ const tgtL = e.target[0];
1051
+ const tgtT = e.target[1];
1052
+
1053
+ if (hideFirstToken && (srcT < startTokenIdx || tgtT < startTokenIdx)) return;
1054
+
1055
+ globalMaxValEdge = Math.max(globalMaxValEdge, Math.abs(e.weight || 0));
1056
+ });
1057
+
1058
+ const maxNorm = Math.max(globalMaxValRel, globalMaxValEdge, 1e-9);
1059
+
1060
+ // Layout Geometry Setup
1061
+ const nHops = layersList.length > 1 ? layersList.length - 1 : 1;
1062
+ const totalHeight = Math.max(500, nHops * layerHeight + 150);
1063
+ const margin = { top: 50, left: 120, right: 50, bottom: 50 };
1064
+
1065
+
1066
+ // Generate Layout Nodes
1067
+ const nodesByLayer = {};
1068
+ const allNodesFlat = [];
1069
+ const layerTotals = {};
1070
+
1071
+ // Helper to get active node data
1072
+ const getActiveData = (l, t) => activeNodeMap.get(`${l},${t}`);
1073
+
1074
+ // DYNAMIC LAYOUT LOGIC
1075
+ // 1. Identify "Active Columns": Any token index that participates in an EDGE.
1076
+ // This filters out columns that might have nodes but no connections (pruned).
1077
+ let activeTokenIndices = new Set();
1078
+
1079
+ // Use edges to find connected tokens
1080
+ edges.forEach(e => {
1081
+ // e.source/target are [layer, token]
1082
+ activeTokenIndices.add(e.source[1]);
1083
+ activeTokenIndices.add(e.target[1]);
1084
+ });
1085
+
1086
+ // Also include target node tokens?
1087
+ // Usually target is connected, but what if we have a top-p that prunes EVERYTHING?
1088
+ // We should probably show the target node column regardless, so the user sees where they started.
1089
+ // If backend prunes everything, we might have edges=[], nodes=[target].
1090
+ // Let's iterate nodes too, but check a flag or just rely on edges.
1091
+ // User Request: "hide the token positions whose nodes in all layers do not have any connections".
1092
+ // This implies strictly edges.
1093
+ // BUT: If the target node has no incoming edges (e.g. threshold too high), it is isolated.
1094
+ // Should we hide the target node? That seems confusing.
1095
+ // Let's prioritize edges, but maybe keep target?
1096
+ // For now, strict edge adherence based on user prompt.
1097
+ // If empty set (graph empty), we might show nothing or just full seq?
1098
+ if (activeTokenIndices.size === 0 && !showAllTokens && nodes.length > 0) {
1099
+ // Fallback: Show at least the nodes that exist (like target)
1100
+ nodes.forEach(n => activeTokenIndices.add(n.token));
1101
+ }
1102
+
1103
+ // 2. Define "Render Columns"
1104
+ let renderColumns = [];
1105
+ if (showAllTokens) {
1106
+ // All tokens in display range
1107
+ for(let i=0; i<displaySeqLen; i++) renderColumns.push(startTokenIdx + i);
1108
+ } else {
1109
+ // Only active tokens
1110
+ renderColumns = Array.from(activeTokenIndices).sort((a,b) => a - b);
1111
+ // Filter by hideFirstToken if needed (activeNodeMap already filtered? No, activeNodeMap built from nodes list)
1112
+ // Ensure strictly respecting hideFirstToken setting
1113
+ if (hideFirstToken) {
1114
+ renderColumns = renderColumns.filter(t => t >= startTokenIdx);
1115
+ }
1116
+ }
1117
+
1118
+ // 3. Spacing based on Render Columns count
1119
+ const nodesInRowForSpacing = renderColumns.length;
1120
+ const minW = nodesInRowForSpacing * (showAllTokens ? 15 : 30) + 100;
1121
+ const width = Math.max(800, minW);
1122
+ const drawingW = width - margin.left - margin.right;
1123
+ const nodeSpacing = drawingW / Math.max(1, nodesInRowForSpacing);
1124
+
1125
+ const yPositions = {};
1126
+ layersList.forEach((lIdx, i) => {
1127
+ yPositions[lIdx] = (totalHeight - margin.bottom) - (i * layerHeight);
1128
+ });
1129
+
1130
+ layersList.forEach((lIdx) => {
1131
+ const y = yPositions[lIdx];
1132
+ const rowNodeMap = {};
1133
+ let runningTotal = 0;
1134
+
1135
+ // Iterate Render Columns
1136
+ renderColumns.forEach((tokenIdx, visualColIdx) => {
1137
+ const activeData = getActiveData(lIdx, tokenIdx);
1138
+ const rel = activeData ? (activeData.relevance || 0) : 0;
1139
+ const normRel = Math.abs(rel) / maxNorm;
1140
+
1141
+ // accumulate total only if we want layer sum to reflect EVERYTHING or just visible?
1142
+ // Usually layer sum is total relevance. If we hide tokens, do we hide their relevance from sum?
1143
+ // Let's sum only active data found in graph.
1144
+ if (activeData) runningTotal += rel;
1145
+
1146
+ // X alignment: Based on visual column index
1147
+ const x = margin.left + visualColIdx * nodeSpacing + (nodeSpacing/2);
1148
+
1149
+ // Token String
1150
+ const rawToken = (tokenIdx < tokens.length) ? tokens[tokenIdx] : "?";
1151
+ const tokenStr = (typeof rawToken === 'string') ? rawToken : (rawToken.token_str || "?");
1152
+
1153
+ const node = {
1154
+ layer: lIdx,
1155
+ index: tokenIdx, // True token index
1156
+ visualIndex: visualColIdx, // For gap logic
1157
+ token: { token_str: tokenStr },
1158
+ x: x,
1159
+ y: y,
1160
+ rel: rel,
1161
+ normRel: normRel,
1162
+ isActive: !!activeData
1163
+ };
1164
+ allNodesFlat.push(node);
1165
+ rowNodeMap[tokenIdx] = node;
1166
+ });
1167
+
1168
+ nodesByLayer[lIdx] = rowNodeMap;
1169
+ layerTotals[lIdx] = runningTotal;
1170
+ });
1171
+
1172
+ // Generate Edges
1173
+ const visibleEdges = [];
1174
+
1175
+ edges.forEach(e => {
1176
+ const srcL = e.source[0];
1177
+ const srcT = e.source[1];
1178
+ const tgtL = e.target[0];
1179
+ const tgtT = e.target[1];
1180
+
1181
+ // Lookup in our generated layout nodes
1182
+ // nodesByLayer[layer][tokenIndex]
1183
+ if (!nodesByLayer[srcL] || !nodesByLayer[tgtL]) return;
1184
+
1185
+ const sNode = nodesByLayer[srcL][srcT];
1186
+ const tNode = nodesByLayer[tgtL][tgtT];
1187
+
1188
+ if (sNode && tNode) {
1189
+ const val = e.weight;
1190
+ const normVal = Math.abs(val) / maxNorm;
1191
+
1192
+ visibleEdges.push({
1193
+ source: sNode,
1194
+ target: tNode,
1195
+ val: val,
1196
+ normVal: normVal
1197
+ });
1198
+ }
1199
+ });
1200
+
1201
+ // STORE LAYOUT
1202
+ appState.layout = {
1203
+ width,
1204
+ totalHeight,
1205
+ margin,
1206
+ nodeSpacing,
1207
+ maxNorm,
1208
+ layersList,
1209
+ nodesByLayer,
1210
+ allNodesFlat,
1211
+ visibleEdges,
1212
+ activeIndicesList: [], // Not used in this sparse logic
1213
+ layerTotals,
1214
+ displaySeqLen,
1215
+ displayTokens, // Add this back for gap drawing
1216
+ displayConnections: [] // Legacy compat
1217
+ };
1218
+
1219
+ drawCircuit();
1220
+ }
1221
+
1222
+ function drawCircuit() {
1223
+ if (!appState.layout) {
1224
+ // Init layout if data exists
1225
+ if(appState.circuitData) updateCircuitLayout();
1226
+ return;
1227
+ }
1228
+
1229
+ const L = appState.layout;
1230
+
1231
+ // Check Spacing Updates first (Geometry Recalc)
1232
+ const currentLayerHeight = parseFloat(elVisLayerSpacing ? elVisLayerSpacing.value : 300);
1233
+ // Use stored layers count for recalculating total height
1234
+ // We infer layersList from layout
1235
+ const nHops = L.displayConnections ? L.displayConnections.length : 1;
1236
+ let newTotalHeight = Math.max(500, nHops * currentLayerHeight + 150);
1237
+
1238
+ if (L.layersList && L.layersList.length > 0) {
1239
+ newTotalHeight = Math.max(500, (L.layersList.length - 1) * currentLayerHeight + 200);
1240
+
1241
+ // Pre-calculate Y for each layer (Optimization: O(Layers) instead of O(Nodes*Layers))
1242
+ const layerYMap = {};
1243
+ L.layersList.forEach((lid, idx) => {
1244
+ layerYMap[lid] = (newTotalHeight - L.margin.bottom) - (idx * currentLayerHeight);
1245
+ });
1246
+
1247
+ // Update Node Y Positions in place
1248
+ L.allNodesFlat.forEach(node => {
1249
+ if (layerYMap.hasOwnProperty(node.layer)) {
1250
+ node.y = layerYMap[node.layer];
1251
+ }
1252
+ });
1253
+
1254
+ // Edge coordinates update automatically since they reference node objects
1255
+ }
1256
+ L.totalHeight = newTotalHeight;
1257
+
1258
+ const ctx = elCanvas.getContext('2d');
1259
+
1260
+ // Check canvas dims
1261
+ if (elCanvas.width !== L.width || elCanvas.height !== L.totalHeight) {
1262
+ elCanvas.width = L.width;
1263
+ elCanvas.height = L.totalHeight;
1264
+ }
1265
+
1266
+ // Vis Settings
1267
+ const strengthScale = parseFloat(elVisStrength.value);
1268
+ const showNodeValues = document.getElementById('show-node-values') ? document.getElementById('show-node-values').checked : true;
1269
+ const showAllTokens = elShowAllTokens ? elShowAllTokens.checked : true;
1270
+ const hideFirstToken = document.getElementById('hide-bos-node') && document.getElementById('hide-bos-node').checked;
1271
+
1272
+ // Add startTokenIdx logic here
1273
+ let startTokenIdx = 0;
1274
+ if (hideFirstToken && appState.tokens && appState.tokens.length > 1) {
1275
+ startTokenIdx = 1;
1276
+ }
1277
+
1278
+ ctx.clearRect(0, 0, L.width, L.totalHeight);
1279
+
1280
+ // --- Interaction ---
1281
+ const mouseX = appState.mouseX || -1;
1282
+ const mouseY = appState.mouseY || -1;
1283
+ function checkHit(x, y) { return Math.sqrt((x-mouseX)**2 + (y-mouseY)**2) < 8; }
1284
+
1285
+ let hoveredNode = null;
1286
+ for (const n of L.allNodesFlat) {
1287
+ if (checkHit(n.x, n.y)) {
1288
+ hoveredNode = n;
1289
+ break;
1290
+ }
1291
+ }
1292
+ const activeNode = hoveredNode || selectedNode;
1293
+ const isHighlightActive = !!activeNode;
1294
+
1295
+ // --- Highlight Computation (One-Hop) ---
1296
+ const highlightedNodes = new Set();
1297
+ const highlightedEdges = new Set(); // store edges to paint bold
1298
+
1299
+ if (activeNode) {
1300
+ highlightedNodes.add(activeNode);
1301
+
1302
+ // Use visibleEdges directly
1303
+ L.visibleEdges.forEach(e => {
1304
+ // 1. Outgoing
1305
+ if (e.source === activeNode) {
1306
+ highlightedNodes.add(e.target);
1307
+ highlightedEdges.add(e);
1308
+ }
1309
+ // 2. Incoming
1310
+ if (e.target === activeNode) {
1311
+ highlightedNodes.add(e.source);
1312
+ highlightedEdges.add(e);
1313
+ }
1314
+ });
1315
+ }
1316
+
1317
+ // --- Draw Edges ---
1318
+ // Sort edges: Passive first, Active on top
1319
+ // Actually just draw active afterwards.
1320
+
1321
+ L.visibleEdges.forEach(e => {
1322
+ const isActive = isHighlightActive && highlightedEdges.has(e);
1323
+ const isDim = isHighlightActive && !isActive;
1324
+
1325
+ // If dim, maybe skip drawing very thin lines to save perf?
1326
+ // Or alpha.
1327
+
1328
+ let width = e.normVal * strengthScale;
1329
+ if (width < 0.5 && isDim) return; // Culling
1330
+
1331
+ width = Math.max(width, 0.5); // Minimal width for visibility
1332
+
1333
+ let alpha = isDim ? 0.1 : 1.0;
1334
+ if (isActive) {
1335
+ alpha = 1.0;
1336
+ width = Math.max(width, 1.0); // Boost active
1337
+ }
1338
+
1339
+ const color = e.val >= 0 ? `rgba(211, 47, 47, ${alpha})` : `rgba(25, 118, 210, ${alpha})`;
1340
+
1341
+ ctx.beginPath();
1342
+ ctx.moveTo(e.source.x, e.source.y);
1343
+ ctx.lineTo(e.target.x, e.target.y);
1344
+ ctx.strokeStyle = color;
1345
+ ctx.lineWidth = width;
1346
+
1347
+ // draw active later? No, simple batch is fine mostly.
1348
+ ctx.stroke();
1349
+
1350
+ if (isActive) { // Use isActive logic for edge label drawing
1351
+ // Draw Edge Label
1352
+ const midX = (e.source.x + e.target.x)/2;
1353
+ const midY = (e.source.y + e.target.y)/2;
1354
+ ctx.save();
1355
+ const dx = e.target.x - e.source.x;
1356
+ const dy = e.target.y - e.source.y;
1357
+ let angle = Math.atan2(dy, dx);
1358
+ if (Math.abs(angle) > Math.PI / 2) angle += Math.PI; // Correct text orientation
1359
+
1360
+ ctx.translate(midX, midY);
1361
+ ctx.rotate(angle);
1362
+
1363
+ ctx.font = 'bold 10px Arial';
1364
+ const text = e.val.toFixed(2);
1365
+ const metrics = ctx.measureText(text);
1366
+ const p = 2; // padding
1367
+
1368
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
1369
+ ctx.fillRect(-metrics.width/2 - p, -6, metrics.width + 2*p, 12);
1370
+
1371
+ ctx.fillStyle = e.val >= 0 ? '#b71c1c' : '#0d47a1';
1372
+ ctx.textAlign = 'center';
1373
+ ctx.textBaseline = 'middle';
1374
+ ctx.fillText(text, 0, 0);
1375
+ ctx.restore();
1376
+ }
1377
+ });
1378
+
1379
+ // --- Draw Nodes ---
1380
+ // Re-use Gap logic variables
1381
+ let prevIdx = -1;
1382
+ let listIdx = -1;
1383
+ const displayedGaps = [];
1384
+ const displayTokens = L.displayTokens;
1385
+
1386
+ L.allNodesFlat.forEach((node) => {
1387
+ // Logic for tracking gap indices per layer
1388
+ if (node.layer === L.layersList[0]) {
1389
+ listIdx++;
1390
+ } else {
1391
+ // Reset for other layers? Actually gap logic only applied to bottom layer in original code
1392
+ }
1393
+
1394
+ const isSelfActive = activeNode === node;
1395
+ const isConnected = highlightedNodes.has(node) && !isSelfActive;
1396
+ const isInteractive = isHighlightActive;
1397
+ const isDim = isInteractive && !isSelfActive && !isConnected;
1398
+
1399
+ let radius = (node.normRel * strengthScale);
1400
+ radius = Math.max(radius, 2);
1401
+ if (isConnected) radius = Math.max(radius, strengthScale * 0.75);
1402
+ if (isSelfActive) radius = Math.max(radius, strengthScale * 1.25);
1403
+
1404
+ let fillStyle = '#e0e0e0';
1405
+ if (node.rel > 0.001) fillStyle = '#ef9a9a';
1406
+ else if (node.rel < -0.001) fillStyle = '#90caf9';
1407
+ else fillStyle = '#cfd8dc';
1408
+
1409
+ if (isHighlightActive && !isDim) {
1410
+ if (node.rel > 0.001) fillStyle = '#d32f2f';
1411
+ else if (node.rel < -0.001) fillStyle = '#1976d2';
1412
+ else fillStyle = '#455a64';
1413
+ }
1414
+
1415
+ if (isDim) {
1416
+ fillStyle = '#f5f5f5';
1417
+ radius = 3;
1418
+ }
1419
+
1420
+ ctx.beginPath();
1421
+ ctx.arc(node.x, node.y, radius, 0, 2*Math.PI);
1422
+ ctx.fillStyle = fillStyle;
1423
+ ctx.fill();
1424
+
1425
+ if (!isDim) {
1426
+ ctx.strokeStyle = (isSelfActive || isConnected) ? '#333' : '#bbb';
1427
+ ctx.lineWidth = 1;
1428
+ ctx.stroke();
1429
+ }
1430
+
1431
+ // Labels (Layer)
1432
+ // Check if node is the first visible node in its layer OR if it's the very first node of the sequence
1433
+ // We use activeIndicesList logic if available, otherwise just use "first in row" logic
1434
+ const isFirstInLayer = (node.index === 0) || (Object.values(L.nodesByLayer[node.layer])[0] === node);
1435
+
1436
+ if (isFirstInLayer) {
1437
+ ctx.fillStyle = '#2c3e50';
1438
+ ctx.font = 'bold 12px Arial';
1439
+ ctx.textAlign = 'right';
1440
+
1441
+ let label = "";
1442
+ if (Array.isArray(node.layer)) {
1443
+ label = `L${node.layer[0]} (${node.layer[1]})`;
1444
+ } else {
1445
+ label = node.layer === -1 ? "Embedding" : `Layer ${node.layer}`;
1446
+ }
1447
+
1448
+ ctx.fillText(label, L.margin.left - 20, node.y + 4);
1449
+ const total = L.layerTotals[node.layer] || 0;
1450
+ ctx.font = '11px Arial';
1451
+ ctx.fillText(`Sum: ${total.toFixed(2)}`, L.margin.left - 20, node.y + 18);
1452
+ }
1453
+
1454
+ // Labels (Tokens)
1455
+ const isBottomLayer = (node.layer === L.layersList[0]);
1456
+ let showToken = isBottomLayer;
1457
+ if (isHighlightActive && (isSelfActive || isConnected)) showToken = true;
1458
+
1459
+ if (showToken) {
1460
+ ctx.fillStyle = '#000';
1461
+ ctx.font = isSelfActive ? 'bold 11px Arial' : '10px Arial';
1462
+ ctx.textAlign = 'center';
1463
+ ctx.save();
1464
+
1465
+ if (isBottomLayer && node.layer !== L.layersList[L.layersList.length-1]) {
1466
+ ctx.translate(node.x, node.y + 15);
1467
+ ctx.rotate(Math.PI/4);
1468
+ } else {
1469
+ ctx.translate(node.x, node.y - 12);
1470
+ ctx.rotate(-Math.PI/4);
1471
+ }
1472
+
1473
+ let txt = formatTokenForDisplay(node.token.token_str, 'visual');
1474
+ ctx.fillText(txt, 0, 0);
1475
+ ctx.restore();
1476
+
1477
+ // Gap Marker Logic (Bottom Layer Only)
1478
+ if (isBottomLayer && !showAllTokens) {
1479
+ // Preceding Gap (Before first visible token)
1480
+ // If this is the first visible token (listIdx or visualIndex 0), check if there are hidden tokens before it
1481
+ const trueStartIdx = hideFirstToken ? startTokenIdx : 0;
1482
+
1483
+ if (node.visualIndex === 0 && node.index > trueStartIdx) {
1484
+ const gx = node.x - L.nodeSpacing/2;
1485
+ const gy = node.y + 45;
1486
+ const hidden = [];
1487
+ for(let k=trueStartIdx; k<node.index; k++) {
1488
+ if(displayTokens && k < displayTokens.length) {
1489
+ const tObj = displayTokens[k];
1490
+ const tStr = (typeof tObj === 'string') ? tObj : tObj.token_str;
1491
+ hidden.push(formatTokenForDisplay(tStr, 'visual'));
1492
+ }
1493
+ }
1494
+ if (hidden.length > 0) displayedGaps.push({x: gx, y: gy, hidden: hidden});
1495
+ }
1496
+
1497
+ // Inter-token Gap
1498
+ // Previous Visible Node Index vs Current Node Index
1499
+ if (prevIdx !== -1 && (node.index - prevIdx) > 1) {
1500
+ // We can use visualIndex to backtrack to previous node X
1501
+ // node.visualIndex is current i. Previous was i-1.
1502
+ // xPrev = x of column (node.visualIndex - 1)
1503
+ const xPrev = node.x - L.nodeSpacing;
1504
+
1505
+ const gx = (xPrev + node.x)/2;
1506
+ const gy = node.y + 45;
1507
+ const hidden = [];
1508
+ for(let k=prevIdx+1; k<node.index; k++) {
1509
+ if(displayTokens && k < displayTokens.length) {
1510
+ const tObj = displayTokens[k];
1511
+ const tStr = (typeof tObj === 'string') ? tObj : tObj.token_str;
1512
+ hidden.push(formatTokenForDisplay(tStr, 'visual'));
1513
+ }
1514
+ }
1515
+ if (hidden.length > 0) displayedGaps.push({x: gx, y: gy, hidden: hidden});
1516
+ }
1517
+ prevIdx = node.index;
1518
+ }
1519
+ }
1520
+
1521
+ if (showNodeValues) {
1522
+ const valText = node.rel.toFixed(2);
1523
+ ctx.font = '9px Arial';
1524
+ ctx.fillStyle = node.rel >= 0 ? '#b71c1c' : '#0d47a1';
1525
+ ctx.textAlign = 'center';
1526
+ let textY = node.y - 14;
1527
+ if (!isBottomLayer) textY = node.y - 16;
1528
+ if (isSelfActive) ctx.font = 'bold 10px Arial';
1529
+ ctx.fillText(valText, node.x, textY);
1530
+ }
1531
+ });
1532
+ // --- Draw Gaps Loop ---
1533
+ let hoveredGap = null;
1534
+ // const { displayTokens } = L; // Already defined above
1535
+
1536
+ displayedGaps.forEach(gap => {
1537
+ const mx = appState.mouseX;
1538
+ const my = appState.mouseY;
1539
+ const dist = Math.sqrt(Math.pow(gap.x - mx, 2) + Math.pow(gap.y - my, 2));
1540
+
1541
+ if (dist < 15) hoveredGap = gap;
1542
+
1543
+ ctx.save();
1544
+ ctx.translate(gap.x, gap.y);
1545
+ ctx.fillStyle = (dist < 15) ? '#e0e0e0' : '#f5f5f5';
1546
+ ctx.strokeStyle = '#bdbdbd';
1547
+ ctx.lineWidth = 1;
1548
+
1549
+ ctx.beginPath();
1550
+ if(ctx.roundRect) ctx.roundRect(-12, -8, 24, 16, 4);
1551
+ else ctx.rect(-12, -8, 24, 16);
1552
+ ctx.fill();
1553
+ ctx.stroke();
1554
+
1555
+ ctx.fillStyle = '#616161';
1556
+ ctx.font = 'bold 12px Arial';
1557
+ ctx.textAlign = 'center';
1558
+ ctx.textBaseline = 'middle';
1559
+ ctx.fillText("...", 0, -2);
1560
+ ctx.restore();
1561
+ });
1562
+
1563
+ // --- Tooltip Updating ---
1564
+ const elTooltip = document.getElementById('tooltip');
1565
+ if (hoveredGap && elTooltip) {
1566
+ elTooltip.style.display = 'block';
1567
+ elTooltip.style.opacity = '1';
1568
+
1569
+ // Update content FIRST to measure size
1570
+ const fullText = hoveredGap.hidden ? hoveredGap.hidden.join(" ") : "";
1571
+ elTooltip.textContent = fullText || "[Empty]";
1572
+ elTooltip.style.maxWidth = "300px";
1573
+
1574
+ const rect = elCanvas.getBoundingClientRect();
1575
+ const gapX = rect.left + hoveredGap.x;
1576
+ const gapY = rect.top + hoveredGap.y;
1577
+
1578
+ // Default: Bottom-Right
1579
+ let finalLeft = gapX + 10;
1580
+ let finalTop = gapY + 10;
1581
+
1582
+ // Check Bounds
1583
+ const tooltipRect = elTooltip.getBoundingClientRect();
1584
+
1585
+ // Vertical overflow (flip up)
1586
+ if (finalTop + tooltipRect.height > window.innerHeight) {
1587
+ finalTop = gapY - tooltipRect.height - 10;
1588
+ }
1589
+
1590
+ // Horizontal overflow (flip left)
1591
+ if (finalLeft + tooltipRect.width > window.innerWidth) {
1592
+ finalLeft = gapX - tooltipRect.width - 10;
1593
+ }
1594
+
1595
+ elTooltip.style.left = finalLeft + 'px';
1596
+ elTooltip.style.top = finalTop + 'px';
1597
+
1598
+ } else if (elTooltip) {
1599
+ elTooltip.style.display = 'none';
1600
+ elTooltip.style.opacity = '0';
1601
+ }
1602
+
1603
+ // Mouse Interaction Tracking
1604
+ // Note: Re-binding these every draw call is inefficient but matches original logic structure.
1605
+ // Ideally move these out of drawCircuit.
1606
+ elCanvas.onmousemove = function(e) {
1607
+ const rect = elCanvas.getBoundingClientRect();
1608
+ appState.mouseX = e.clientX - rect.left;
1609
+ appState.mouseY = e.clientY - rect.top;
1610
+ requestAnimationFrame(drawCircuit);
1611
+ };
1612
+
1613
+ elCanvas.onmouseleave = function() {
1614
+ appState.mouseX = -1;
1615
+ appState.mouseY = -1;
1616
+ requestAnimationFrame(drawCircuit);
1617
+ };
1618
+
1619
+ elCanvas.onclick = function(e) {
1620
+ const rect = elCanvas.getBoundingClientRect();
1621
+ const x = e.clientX - rect.left;
1622
+ const y = e.clientY - rect.top;
1623
+
1624
+ let hit = null;
1625
+ if(L && L.allNodesFlat) {
1626
+ for (const n of L.allNodesFlat) {
1627
+ if (Math.sqrt(Math.pow(x - n.x, 2) + Math.pow(y - n.y, 2)) < 10) {
1628
+ hit = n;
1629
+ break;
1630
+ }
1631
+ }
1632
+ }
1633
+
1634
+ if (hit) {
1635
+ if (activeNode && activeNode.layer === hit.layer && activeNode.index === hit.index && !hoveredNode) {
1636
+ selectedNode = null;
1637
+ } else {
1638
+ selectedNode = hit;
1639
+ }
1640
+ } else {
1641
+ selectedNode = null;
1642
+ }
1643
+ requestAnimationFrame(drawCircuit);
1644
+ };
1645
+ }
1646
+
1647
+ // Initialization and Cleanup
1648
+ window.addEventListener('DOMContentLoaded', async () => {
1649
+ // Call cleanup endpoint to release GPU memory on page refresh
1650
+ try {
1651
+ await fetch(`${API_BASE}/cleanup`, { method: 'POST' });
1652
+ console.log("Backend memory cleaned up.");
1653
+ } catch (e) {
1654
+ console.warn("Failed to cleanup backend memory:", e);
1655
+ }
1656
+
1657
+ // Init Datasets
1658
+ await fetchDatasets();
1659
+ });
1660
+
1661
+ // --- Trace Loading Logic ---
1662
+
1663
+ async function fetchDatasets() {
1664
+ try {
1665
+ const resp = await fetch(`${API_BASE}/datasets`);
1666
+ const data = await resp.json();
1667
+
1668
+ elTraceDataset.innerHTML = '<option value="">-- Select --</option>';
1669
+ data.datasets.forEach(ds => {
1670
+ const opt = document.createElement('option');
1671
+ opt.value = ds;
1672
+ opt.textContent = ds;
1673
+ elTraceDataset.appendChild(opt);
1674
+ });
1675
+ } catch (e) {
1676
+ console.error("Failed to fetch datasets:", e);
1677
+ }
1678
+ }
1679
+
1680
+ async function fetchTraces(dataset) {
1681
+ elTraceFile.disabled = true;
1682
+ elTraceFile.innerHTML = '<option value="">Loading...</option>';
1683
+ showLoading("Fetching Traces...");
1684
+
1685
+ try {
1686
+ const resp = await fetch(`${API_BASE}/traces/${dataset}`);
1687
+ if (!resp.ok) throw new Error("Failed");
1688
+
1689
+ const data = await resp.json();
1690
+
1691
+ elTraceFile.innerHTML = '<option value="">-- Select Trace --</option>';
1692
+ data.traces.forEach(t => {
1693
+ const opt = document.createElement('option');
1694
+ opt.value = t;
1695
+ opt.textContent = t;
1696
+ elTraceFile.appendChild(opt);
1697
+ });
1698
+ elTraceFile.disabled = false;
1699
+ } catch (e) {
1700
+ console.error("Failed to fetch traces:", e);
1701
+ elTraceFile.innerHTML = '<option value="">Error</option>';
1702
+ } finally {
1703
+ hideLoading();
1704
+ }
1705
+ }
1706
+
1707
+ async function loadTraceDetails(dataset, traceId) {
1708
+ showLoading("Loading Trace Config...");
1709
+ try {
1710
+ const resp = await fetch(`${API_BASE}/trace_details/${dataset}/${traceId}`);
1711
+ if (!resp.ok) throw new Error("Failed");
1712
+
1713
+ const data = await resp.json();
1714
+
1715
+ // Update UI
1716
+ if(data.model_path) elModelPath.value = data.model_path;
1717
+ if(data.prompt) elPrompt.value = data.prompt;
1718
+
1719
+ // Populate "Prompt + Original Completion"
1720
+ if(elPromptOrig) {
1721
+ // Prefer raw_prompt if available (added to backend), else prompt (which might be full concat)
1722
+ const basePrompt = data.raw_prompt !== undefined ? data.raw_prompt : (data.prompt || "");
1723
+ elPromptOrig.value = basePrompt + (data.completion || "");
1724
+ }
1725
+
1726
+ // Set Config Defaults as requested
1727
+ elQuant.checked = data.quantization; // Default false
1728
+ elModelDtype.value = data.dtype || "bfloat16";
1729
+
1730
+ // Determine "Other" candidates data
1731
+ let otherData = null;
1732
+ let otherLabel = "Other Model Candidates";
1733
+
1734
+ if (data.other_candidates && Object.keys(data.other_candidates).length > 0) {
1735
+ // Pick first likely candidate key
1736
+ const keys = Object.keys(data.other_candidates);
1737
+ const key = keys[0];
1738
+ otherData = data.other_candidates[key];
1739
+
1740
+ // Heuristic for label
1741
+ if (key === '4b') otherLabel = "Qwen3-4B Candidates";
1742
+ else if (key === '1.7b' || key === '1_7b') otherLabel = "Qwen3-1.7B Candidates";
1743
+ else if (key.toLowerCase().includes('qwen')) otherLabel = key;
1744
+ else otherLabel = key + " Candidates";
1745
+
1746
+ } else if (data.topk_token_explore_4b && data.topk_token_explore_4b.length) {
1747
+ // Legacy Fallback
1748
+ otherData = data.topk_token_explore_4b;
1749
+ otherLabel = "Qwen3-4B Candidates";
1750
+ }
1751
+
1752
+ // Render Exploration Data if available
1753
+ if (elSectionTraceExplore) {
1754
+ let hasContent = false;
1755
+
1756
+ if (data.topk_token_explore && data.topk_token_explore.length) {
1757
+ renderExploreTable(elContainerExploreOriginal, data.topk_token_explore);
1758
+ hasContent = true;
1759
+ } else {
1760
+ elContainerExploreOriginal.innerHTML = "<div style='padding:10px;'>No data</div>";
1761
+ }
1762
+
1763
+ if (otherData && otherData.length) {
1764
+ renderExploreTable(elContainerExplore4b, otherData);
1765
+ if (elHeaderExploreOther) elHeaderExploreOther.textContent = otherLabel;
1766
+ hasContent = true;
1767
+ } else {
1768
+ elContainerExplore4b.innerHTML = "<div style='padding:10px; color:#666;'>No exploration data available.</div>";
1769
+ if (elHeaderExploreOther) elHeaderExploreOther.textContent = otherLabel;
1770
+ }
1771
+
1772
+ if (hasContent) {
1773
+ elSectionTraceExplore.classList.remove('hidden');
1774
+ } else {
1775
+ elSectionTraceExplore.classList.add('hidden');
1776
+ }
1777
+ }
1778
+
1779
+ // Flash success?
1780
+ console.log("Trace loaded:", data);
1781
+ } catch (e) {
1782
+ alert("Failed to load trace details: " + e.message);
1783
+ } finally {
1784
+ hideLoading();
1785
+ }
1786
+ }
1787
+
1788
+ // Init Trace Listeners
1789
+ if (elTraceDataset) {
1790
+ elTraceDataset.addEventListener('change', (e) => {
1791
+ const ds = e.target.value;
1792
+ if (ds) {
1793
+ fetchTraces(ds);
1794
+ } else {
1795
+ elTraceFile.innerHTML = '<option value="">-- Select Dataset First --</option>';
1796
+ elTraceFile.disabled = true;
1797
+ }
1798
+ });
1799
+ }
1800
+
1801
+ if (elTraceFile) {
1802
+ elTraceFile.addEventListener('change', (e) => {
1803
+ const t = e.target.value;
1804
+ const ds = elTraceDataset.value;
1805
+ if (t && ds) {
1806
+ loadTraceDetails(ds, t);
1807
+ }
1808
+ });
1809
+ }
1810
+ // ---------------------------
1811
+
1812
+ function generateLayerPresets() {
1813
+ // Helper update
1814
+ }
1815
+
1816
+ function setLayerPreset(mode) {
1817
+ const N = appState.n_layers || 28;
1818
+ let layers = [];
1819
+ if (mode === 'all') {
1820
+ layers.push(-1);
1821
+ for(let i=0; i<N; i++) layers.push(i);
1822
+ } else {
1823
+ // Default: 5 parts
1824
+ // 0, N/4, 2N/4, 3N/4, N-1
1825
+ const steps = 5;
1826
+ const stepSize = (N - 1) / steps;
1827
+ const set = new Set();
1828
+ set.add(-1); // Always include Embedding as requested
1829
+ for(let i=0; i<=steps; i++) {
1830
+ set.add(Math.round(i * stepSize));
1831
+ }
1832
+ layers = Array.from(set).sort((a,b)=>a-b);
1833
+ }
1834
+ elLayersList.value = layers.join(', ');
1835
+ }
1836
+
1837
+ // FORMAT HELPER
1838
+ function formatTokenForDisplay(tokenStr, escapeMode='visual') {
1839
+ if (!tokenStr) return tokenStr;
1840
+
1841
+ // 1. Escaping for Data Attributes or Generic Log usage
1842
+ if (escapeMode === 'data') {
1843
+ return tokenStr.replace(/\\/g, '\\\\')
1844
+ .replace(/'/g, "\\'")
1845
+ .replace(/\n/g, '\\n')
1846
+ .replace(/\r/g, '\\r')
1847
+ .replace(/"/g, '&quot;');
1848
+ }
1849
+
1850
+ // 2. Visual Display (Visible \n)
1851
+ if (escapeMode === 'visual') {
1852
+ if (tokenStr === '\n') return '\\n';
1853
+ if (tokenStr === '\n\n') return '\\n\\n';
1854
+ if (tokenStr === '\r\n') return '\\r\\n';
1855
+
1856
+ // Check for mixed content
1857
+ if (tokenStr.trim() === '' && tokenStr.length > 0) {
1858
+ // It is whitespace
1859
+ if (tokenStr === ' ') return '␣'; // Optional
1860
+ }
1861
+
1862
+ // If containing newlines mixed with text, escape the newlines
1863
+ if (tokenStr.includes('\n')) return tokenStr.replace(/\n/g, '\\n');
1864
+
1865
+ return tokenStr;
1866
+ }
1867
+
1868
+ return tokenStr;
1869
+ }
1870
+
1871
+ // Initialize Preset on Load Model success (or when appState.n_layers is known)
1872
+ document.addEventListener('DOMContentLoaded', () => {
1873
+ setLayerPreset('default');
1874
+ });
1875
+
1876
+
1877
+ function renderExploreTable(container, data) {
1878
+ if (!container) return;
1879
+
1880
+ if (!data || data.length === 0) {
1881
+ container.innerHTML = '<div style="padding:10px; color: #888;">No exploration data available.</div>';
1882
+ return;
1883
+ }
1884
+
1885
+ let html = `
1886
+ <table style="width: 100%; border-collapse: collapse; font-size: 0.85em;">
1887
+ <thead>
1888
+ <tr style="background: #f8f8f8; text-align: left; position: sticky; top: 0; z-index: 10;">
1889
+ <th style="padding: 6px; border-bottom: 2px solid #ddd;">Rank</th>
1890
+ <th style="padding: 6px; border-bottom: 2px solid #ddd;">Token</th>
1891
+ <th style="padding: 6px; border-bottom: 2px solid #ddd;">Logit</th>
1892
+ <th style="padding: 6px; border-bottom: 2px solid #ddd;">Res</th>
1893
+ <th style="padding: 6px; border-bottom: 2px solid #ddd;">Completion Start</th>
1894
+ </tr>
1895
+ </thead>
1896
+ <tbody>
1897
+ `;
1898
+
1899
+ data.forEach(item => {
1900
+ const isCorrect = item.eval_result === true;
1901
+ const correctColor = isCorrect ? '#2e7d32' : '#c62828';
1902
+ const correctBg = isCorrect ? '#e8f5e9' : '#ffebee';
1903
+ const correctIcon = isCorrect ? 'OK' : 'Fail';
1904
+
1905
+ let tokenStr = item.token_str || "";
1906
+ // Use the existing formatTokenForDisplay helper if available, or simple replacement
1907
+ let displayToken = tokenStr;
1908
+ if (typeof formatTokenForDisplay === 'function') {
1909
+ displayToken = formatTokenForDisplay(tokenStr);
1910
+ } else {
1911
+ displayToken = tokenStr.replace(/Ġ/g, ' ').replace(/Ċ/g, '\n');
1912
+ }
1913
+
1914
+ // Truncate completion
1915
+ let rawCompletion = item.completion || "";
1916
+ let displayCompletion = rawCompletion;
1917
+
1918
+ const maxLen = 60;
1919
+ if (displayCompletion.length > maxLen) {
1920
+ displayCompletion = displayCompletion.substring(0, maxLen) + "...";
1921
+ }
1922
+
1923
+ // Escape HTML
1924
+ const escapeHtml = (text) => {
1925
+ return text
1926
+ .replace(/&/g, "&amp;")
1927
+ .replace(/</g, "&lt;")
1928
+ .replace(/>/g, "&gt;")
1929
+ .replace(/"/g, "&quot;")
1930
+ .replace(/'/g, "&#039;");
1931
+ };
1932
+
1933
+ const fullCompletionEscaped = escapeHtml(rawCompletion);
1934
+ const displayCompletionEscaped = escapeHtml(displayCompletion);
1935
+
1936
+ html += `
1937
+ <tr style="border-bottom: 1px solid #eee;">
1938
+ <td style="padding: 4px 6px;">${item.rank}</td>
1939
+ <td style="padding: 4px 6px; font-family: monospace; background: #fafafa;">${displayToken}</td>
1940
+ <td style="padding: 4px 6px;">${(item.logits || 0).toFixed(2)}</td>
1941
+ <td style="padding: 4px 6px;">
1942
+ <span style="font-size: 0.8em; padding: 2px 4px; border-radius: 4px; background: ${correctBg}; color: ${correctColor}; font-weight: bold;">
1943
+ ${correctIcon}
1944
+ </span>
1945
+ </td>
1946
+ <td style="padding: 4px 6px; color: #555; font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px;" title="${fullCompletionEscaped}">${displayCompletionEscaped}</td>
1947
+ </tr>
1948
+ `;
1949
+ });
1950
+
1951
+ html += '</tbody></table>';
1952
+ container.innerHTML = html;
1953
+ }
1954
+
1955
+ /* MODEL SELECTION LOGIC */
1956
+ async function initModelSelectors() {
1957
+ if(!elModelSeries || !elModelSelect) return;
1958
+
1959
+ // Series change handler
1960
+ elModelSeries.addEventListener('change', async () => {
1961
+ const series = elModelSeries.value;
1962
+ elModelSelect.innerHTML = "<option>Loading...</option>";
1963
+ elModelSelect.disabled = true;
1964
+
1965
+ try {
1966
+ const res = await fetch(`${API_BASE}/list_hf_models?series=${series}`);
1967
+ const data = await res.json();
1968
+
1969
+ elModelSelect.innerHTML = "";
1970
+ if(data.models && data.models.length > 0) {
1971
+ // Sort models alphabetically
1972
+ data.models.sort();
1973
+ data.models.forEach(modelName => {
1974
+ const opt = document.createElement("option");
1975
+ opt.value = modelName;
1976
+ opt.textContent = modelName;
1977
+ elModelSelect.appendChild(opt);
1978
+ });
1979
+ // Select first
1980
+ elModelSelect.disabled = false;
1981
+ elModelSelect.dispatchEvent(new Event('change'));
1982
+ } else {
1983
+ elModelSelect.innerHTML = "<option>No models found</option>";
1984
+ }
1985
+ } catch(e) {
1986
+ console.error("Failed to list models", e);
1987
+ elModelSelect.innerHTML = "<option>Error loading list</option>";
1988
+ }
1989
+ });
1990
+
1991
+ // Model change handler
1992
+ elModelSelect.addEventListener('change', async () => {
1993
+ const modelName = elModelSelect.value;
1994
+ if(!modelName || modelName.includes("Loading")) return;
1995
+
1996
+ // Update Path Input
1997
+ elModelPath.value = modelName;
1998
+
1999
+ // Update Revisions
2000
+ elModelRevision.innerHTML = '<option>Loading...</option>';
2001
+ elModelRevision.disabled = true;
2002
+
2003
+ try {
2004
+ const res = await fetch(`${API_BASE}/list_model_revisions?model_id=${encodeURIComponent(modelName)}`);
2005
+ const data = await res.json();
2006
+
2007
+ elModelRevision.innerHTML = '<option value="">Latest (Default)</option>';
2008
+
2009
+ const allRevs = [...(data.branches || []), ...(data.tags || [])];
2010
+
2011
+ if(allRevs.length > 0) {
2012
+ // Sort revisions alphabetically
2013
+ allRevs.sort();
2014
+ allRevs.forEach(rev => {
2015
+ if (rev === 'main') return; // Skip main as it is usually default
2016
+ const opt = document.createElement("option");
2017
+ opt.value = rev;
2018
+ opt.textContent = rev;
2019
+ elModelRevision.appendChild(opt);
2020
+ });
2021
+ }
2022
+ elModelRevision.disabled = false;
2023
+ } catch (e) {
2024
+ console.error("Failed to list revisions", e);
2025
+ elModelRevision.innerHTML = '<option value="">Latest (Default)</option>';
2026
+ elModelRevision.disabled = false;
2027
+ }
2028
+ });
2029
+
2030
+ // Trigger initial population
2031
+ elModelSeries.dispatchEvent(new Event('change'));
2032
+ }
2033
+ console.log("Initializing model selectors...");
2034
+
2035
+ function saveCircuitGraph() {
2036
+ if (!elCanvas) return;
2037
+
2038
+ // Create a temporary link
2039
+ const link = document.createElement('a');
2040
+
2041
+ // Generate filename with timestamp
2042
+ const date = new Date();
2043
+ const timestamp = date.toISOString().replace(/[:.]/g, '-');
2044
+ link.download = `circuit_graph_${timestamp}.png`;
2045
+
2046
+ // Convert canvas to blob/dataURL
2047
+ // High quality PNG
2048
+ const dataUrl = elCanvas.toDataURL('image/png', 1.0);
2049
+
2050
+ link.href = dataUrl;
2051
+ document.body.appendChild(link);
2052
+ link.click();
2053
+ document.body.removeChild(link);
2054
+ }
2055
+
2056
+ function saveCircuitGraphPDF() {
2057
+ if (!elCanvas) return;
2058
+
2059
+ try {
2060
+ const { jsPDF } = window.jspdf;
2061
+
2062
+ // Create temp canvas to flatten background
2063
+ const tempCanvas = document.createElement('canvas');
2064
+ tempCanvas.width = elCanvas.width;
2065
+ tempCanvas.height = elCanvas.height;
2066
+ const tCtx = tempCanvas.getContext('2d');
2067
+
2068
+ // Fill white
2069
+ tCtx.fillStyle = '#ffffff';
2070
+ tCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
2071
+
2072
+ // Draw original
2073
+ tCtx.drawImage(elCanvas, 0, 0);
2074
+
2075
+ // Calculate PDF size
2076
+ // Orientation based on aspect ratio
2077
+ const orientation = (elCanvas.width > elCanvas.height) ? 'l' : 'p';
2078
+
2079
+ const doc = new jsPDF({
2080
+ orientation: orientation,
2081
+ unit: 'px',
2082
+ format: [elCanvas.width, elCanvas.height] // Custom size matching canvas
2083
+ });
2084
+
2085
+ const imgData = tempCanvas.toDataURL('image/jpeg', 1.0);
2086
+
2087
+ doc.addImage(imgData, 'JPEG', 0, 0, elCanvas.width, elCanvas.height);
2088
+
2089
+ const date = new Date();
2090
+ const timestamp = date.toISOString().replace(/[:.]/g, '-');
2091
+ doc.save(`circuit_graph_${timestamp}.pdf`);
2092
+ } catch (e) {
2093
+ console.error("PDF generation failed:", e);
2094
+ alert("PDF generation failed. Ensure jsPDF is loaded.");
2095
+ }
2096
+ }
2097
+
2098
+ async function saveAttributionMapPNG() {
2099
+ if (!elInputAttributionDisplay) return;
2100
+ try {
2101
+ const canvas = await html2canvas(elInputAttributionDisplay, {
2102
+ backgroundColor: '#ffffff'
2103
+ });
2104
+
2105
+ const link = document.createElement('a');
2106
+ const date = new Date();
2107
+ const timestamp = date.toISOString().replace(/[:.]/g, '-');
2108
+ link.download = `attribution_map_${timestamp}.png`;
2109
+ link.href = canvas.toDataURL('image/png');
2110
+ document.body.appendChild(link);
2111
+ link.click();
2112
+ document.body.removeChild(link);
2113
+ } catch (e) {
2114
+ console.error("PNG save failed", e);
2115
+ alert("Failed to save PNG");
2116
+ }
2117
+ }
2118
+
2119
+ async function saveAttributionMapPDF() {
2120
+ if (!elInputAttributionDisplay) return;
2121
+ try {
2122
+ const { jsPDF } = window.jspdf;
2123
+ // Capture the full DOM node. For long content, we must paginate into multiple PDF pages
2124
+ // instead of trying to create a single ultra-tall page (many viewers/printers will clamp).
2125
+ const canvas = await html2canvas(elInputAttributionDisplay, {
2126
+ backgroundColor: '#ffffff',
2127
+ scale: 2 // Better quality (also increases memory usage)
2128
+ });
2129
+ const imgWidth = canvas.width;
2130
+ const imgHeight = canvas.height;
2131
+ // Create an A4 portrait PDF in px units.
2132
+ // In jsPDF, with unit='px', 1px ~ 1/96 inch.
2133
+ // So A4 is ~ 794 x 1123 px.
2134
+ const doc = new jsPDF({
2135
+ orientation: 'p',
2136
+ unit: 'px',
2137
+ format: 'a4'
2138
+ });
2139
+ const pageWidth = doc.internal.pageSize.getWidth();
2140
+ const pageHeight = doc.internal.pageSize.getHeight();
2141
+ // We scale the capture to fit page width, then slice the source canvas into page-sized chunks.
2142
+ // This is more robust than relying on negative-y offsets (some jsPDF builds/viewers don't clip as expected).
2143
+ const renderWidth = pageWidth;
2144
+ const scale = renderWidth / imgWidth; // PDF px per canvas px
2145
+ // How many source (canvas) pixels fit into one PDF page height after scaling?
2146
+ const pageHeightInCanvasPx = pageHeight / scale;
2147
+ // Basic debug info to help diagnose issues in-browser.
2148
+ const estimatedPages = Math.max(1, Math.ceil(imgHeight / pageHeightInCanvasPx));
2149
+ console.log('[saveAttributionMapPDF] canvas:', { width: imgWidth, height: imgHeight, scale });
2150
+ console.log('[saveAttributionMapPDF] page:', { pageWidth, pageHeight, pageHeightInCanvasPx, estimatedPages });
2151
+ // Re-use a single offscreen canvas for each page slice to save memory.
2152
+ const pageCanvas = document.createElement('canvas');
2153
+ pageCanvas.width = imgWidth;
2154
+ pageCanvas.height = Math.ceil(pageHeightInCanvasPx);
2155
+ const pageCtx = pageCanvas.getContext('2d');
2156
+ let pageIndex = 0;
2157
+ for (let sy = 0; sy < imgHeight; sy += pageHeightInCanvasPx) {
2158
+ const sliceHeight = Math.min(pageHeightInCanvasPx, imgHeight - sy);
2159
+ // Resize pageCanvas height for the last slice to avoid stretching.
2160
+ if (pageCanvas.height !== Math.ceil(sliceHeight)) {
2161
+ pageCanvas.height = Math.ceil(sliceHeight);
2162
+ }
2163
+ // White background
2164
+ pageCtx.fillStyle = '#ffffff';
2165
+ pageCtx.fillRect(0, 0, pageCanvas.width, pageCanvas.height);
2166
+ // Draw slice
2167
+ pageCtx.drawImage(
2168
+ canvas,
2169
+ 0,
2170
+ sy,
2171
+ imgWidth,
2172
+ sliceHeight,
2173
+ 0,
2174
+ 0,
2175
+ imgWidth,
2176
+ sliceHeight
2177
+ );
2178
+ const imgData = pageCanvas.toDataURL('image/jpeg', 0.95);
2179
+ if (pageIndex > 0) doc.addPage();
2180
+ const sliceRenderHeight = sliceHeight * scale;
2181
+ doc.addImage(imgData, 'JPEG', 0, 0, renderWidth, sliceRenderHeight);
2182
+ pageIndex++;
2183
+ }
2184
+ const date = new Date();
2185
+ const timestamp = date.toISOString().replace(/[:.]/g, '-');
2186
+ doc.save(`attribution_map_${timestamp}.pdf`);
2187
+ } catch (e) {
2188
+ console.error("PDF save failed", e);
2189
+ alert("Failed to save PDF. Ensure html2canvas/jspdf loaded.");
2190
+ }
2191
+ }
2192
+
2193
+ // async function saveAttributionMapPDF() {
2194
+ // if (!elInputAttributionDisplay) return;
2195
+ // try {
2196
+ // const { jsPDF } = window.jspdf;
2197
+ // const canvas = await html2canvas(elInputAttributionDisplay, {
2198
+ // backgroundColor: '#ffffff',
2199
+ // scale: 2 // Better quality
2200
+ // });
2201
+
2202
+ // const imgData = canvas.toDataURL('image/jpeg', 1.0);
2203
+
2204
+ // // Calculate PDF dims to match canvas (scaled back to points if needed, or just px)
2205
+ // // Unit 'px' in jsPDF usually corresponds to 1/96 inch, same as canvas pixel?
2206
+ // // Let's match the canvas pixel dimensions exactly for custom format.
2207
+
2208
+ // const imgWidth = canvas.width;
2209
+ // const imgHeight = canvas.height;
2210
+
2211
+ // const orientation = (imgWidth > imgHeight) ? 'l' : 'p';
2212
+
2213
+ // const doc = new jsPDF({
2214
+ // orientation: orientation,
2215
+ // unit: 'px',
2216
+ // format: [imgWidth, imgHeight]
2217
+ // });
2218
+
2219
+ // doc.addImage(imgData, 'JPEG', 0, 0, imgWidth, imgHeight);
2220
+
2221
+ // const date = new Date();
2222
+ // const timestamp = date.toISOString().replace(/[:.]/g, '-');
2223
+ // doc.save(`attribution_map_${timestamp}.pdf`);
2224
+ // } catch (e) {
2225
+ // console.error("PDF save failed", e);
2226
+ // alert("Failed to save PDF. Ensure html2canvas/jspdf loaded.");
2227
+ // }
2228
+ // }
2229
+
2230
+
2231
+ initModelSelectors();
frontend/js/main_new.js ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn>=0.24.0
3
+ pydantic>=2.0.0
4
+ torch>=2.0.0
5
+ transformers>=4.40.0
6
+ numpy>=1.24.0
7
+ scipy>=1.10.0
8
+ pandas>=2.0.0
9
+ networkx>=3.0
10
+ huggingface_hub>=0.20.0
11
+ jinja2>=3.1.0
12
+ pyyaml>=6.0
13
+ openai>=1.0.0
14
+ lxt>=0.3.0