anotheruserishere commited on
Commit
f6f5ee3
·
verified ·
1 Parent(s): 706589e
Files changed (4) hide show
  1. README.md +39 -13
  2. __pycache__/app.cpython-312.pyc +0 -0
  3. app.py +1028 -0
  4. requirements.txt +7 -0
README.md CHANGED
@@ -1,13 +1,39 @@
1
- ---
2
- title: Bertographer
3
- emoji: 🔥
4
- colorFrom: gray
5
- colorTo: gray
6
- sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Bertographer
3
+ emoji: 🔭
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: false
9
+ license: mit
10
+ short_description: Mechanistic-interpretability cockpit for encoder models
11
+ ---
12
+
13
+ # Bertographer
14
+
15
+ A mechanistic-interpretability **cockpit for encoder *classifiers*** (BERT /
16
+ RoBERTa / DeBERTa / DistilBERT / ELECTRA), framed in **production-observability**
17
+ language rather than lab notation.
18
+
19
+ Where a decoder probe reads a model's next-token futures, Bertographer reads an
20
+ encoder's **decision**: for every layer and attention head it projects the
21
+ pooling token's register into two spaces —
22
+
23
+ - **vocab** (logit-lens via the tied word embeddings) — what token this register "says"
24
+ - **class** (the model's own classification head) — which label this head votes for
25
+
26
+ …and lets an operator intervene (**mute / scale a head**) and watch the
27
+ prediction move. The framing is the engine room, not the notebook:
28
+
29
+ - input = a **trace** · the layer stack = the **waterfall** · heads = **spans / voters**
30
+ - mute / scale = the **counterfactual** ("what if the model hadn't seen this evidence?")
31
+ - compare = a **trace‑vs‑trace diff** ("what differed between this request and the last?")
32
+
33
+ Default model: `cardiffnlp/twitter-roberta-base-sentiment-latest` (3‑class
34
+ sentiment). Architecture is auto‑discovered; per‑head views degrade gracefully
35
+ when a model's attention output isn't a clean per‑head concatenation.
36
+
37
+ ---
38
+
39
+ Built by **James J. Davison**, with Claude (Anthropic) as coding collaborator. **Responsibility for the code and its design choices rests with the human author.** MIT licensed. © 2026 James J. Davison.
__pycache__/app.cpython-312.pyc ADDED
Binary file (72.2 kB). View file
 
app.py ADDED
@@ -0,0 +1,1028 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bertographer — Gradio Space: a mechanistic-interpretability cockpit for
2
+ encoder *classifiers* (BERT / RoBERTa / DeBERTa / DistilBERT / ELECTRA).
3
+
4
+ The decoder sibling (Cartogemma) reads a causal LM's next-token futures.
5
+ Bertographer reads an encoder classifier's *decision*: for every layer and
6
+ attention head, it projects the pooling token's register into TWO spaces —
7
+
8
+ · vocab (logit lens via tied word_embeddings) → what token this register "says"
9
+ · class (the model's own classification head) → which label this head votes
10
+
11
+ …and lets an operator intervene (mute / scale a head, fire a trigger) and watch
12
+ the prediction move. Framed for the engine room, not the lab:
13
+
14
+ input = a trace layers = the waterfall heads = spans / voters
15
+ mute/scale = the counterfactual ("what if the model hadn't seen this evidence?")
16
+ cmp = trace-vs-trace diff ("what differed between this request and the last?")
17
+
18
+ Default model: cardiffnlp/twitter-roberta-base-sentiment-latest (3-class sentiment).
19
+ Architecture is auto-discovered; per-head views degrade gracefully when a model's
20
+ attention output projection isn't a clean per-head concatenation.
21
+
22
+ Methodology lifted from the cartographer family:
23
+ cartographer/mechanistic_work/negation/deberta_cartographer.py (encoder cell: tok|class|cos)
24
+ cartographer/mechanistic_work/twitter_roberta/capture_*.py (4 projection spaces, per-head)
25
+ cartographer/mechanistic_work/twitter_roberta/intervene_sentiment.py (head-slot interventions)
26
+ gradiographer/cartogemma/app.py (Gradio cockpit + REPL + CSS)
27
+ """
28
+
29
+ import os
30
+ import re
31
+ import sys
32
+ import json
33
+ import unicodedata
34
+ from dataclasses import dataclass, field
35
+
36
+ import torch
37
+ import torch.nn as nn
38
+ import torch.nn.functional as F
39
+ import gradio as gr
40
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer, set_seed
41
+
42
+ # ── Config ──────────────────────────────────────────────────────────────────
43
+ DEFAULT_MODEL_ID = os.environ.get("BERTOGRAPHER_MODEL",
44
+ "cardiffnlp/twitter-roberta-base-sentiment-latest")
45
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
46
+ SEED = 42
47
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
48
+
49
+ # ── HEAT palette for token-probability (mirrors cartographer3) ──────────────
50
+ HEAT_HEX = ["#59749C", "#948863", "#ECA60F", "#FFCF67", "#219C7F", "#E0D05B", "#64D32A"]
51
+
52
+ def heat_color(p: float) -> str:
53
+ if p >= 0.85: return HEAT_HEX[6]
54
+ if p >= 0.70: return HEAT_HEX[5]
55
+ if p >= 0.50: return HEAT_HEX[4]
56
+ if p >= 0.30: return HEAT_HEX[3]
57
+ if p >= 0.15: return HEAT_HEX[2]
58
+ if p >= 0.05: return HEAT_HEX[1]
59
+ return HEAT_HEX[0]
60
+
61
+ # Class colors: semantic for sentiment, palette cycle otherwise.
62
+ _CLASS_PALETTE = ["#64D32A", "#FFCF67", "#21C5C5", "#E78BFF", "#ff6b6b", "#9aa0ff", "#E0D05B"]
63
+ def class_color(label_name: str, idx: int) -> str:
64
+ n = label_name.lower()
65
+ if n.startswith("pos") or n in ("entailment", "positive", "label_2"):
66
+ return "#64D32A"
67
+ if n.startswith("neg") or n in ("contradiction", "negative", "label_0"):
68
+ return "#ff6b6b"
69
+ if n.startswith("neu") or n in ("neutral", "label_1"):
70
+ return "#FFCF67"
71
+ return _CLASS_PALETTE[idx % len(_CLASS_PALETTE)]
72
+
73
+ def char_width(ch: str) -> int:
74
+ cat = unicodedata.category(ch)
75
+ if cat in ("Mn", "Me"): return 0
76
+ eaw = unicodedata.east_asian_width(ch)
77
+ if eaw in ("W", "F"): return 2
78
+ return 1
79
+
80
+ def display_width(s: str) -> int:
81
+ return sum(char_width(c) for c in s)
82
+
83
+ def fmt_tok(tok: str, max_w: int = 7) -> str:
84
+ tok = tok.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
85
+ tok = tok.lstrip("▁").lstrip("Ġ") # SP / GPT-2 leading-space markers
86
+ if not tok:
87
+ tok = "·"
88
+ w, out = 0, []
89
+ for ch in tok:
90
+ cw = char_width(ch)
91
+ if w + cw > max_w: break
92
+ out.append(ch); w += cw
93
+ return "".join(out)
94
+
95
+ def html_escape(s: str) -> str:
96
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
97
+
98
+ def preprocess_tweet(text: str) -> str:
99
+ """Cardiff TweetEval normalization — applied only for twitter-* models."""
100
+ text = re.sub(r"@\w+", "@user", text)
101
+ text = re.sub(r"http\S+", "http", text)
102
+ return text
103
+
104
+
105
+ # ── Architecture resolver ───────────────────────────────────────────────────
106
+ # An encoder classifier I can fully read needs three things:
107
+ # 1. an encoder block stack (ModuleList of layers), each with an attention
108
+ # output projection whose INPUT is the per-head concatenation
109
+ # (attention.output.dense for BERT/RoBERTa/DeBERTa/ELECTRA, attention.out_lin
110
+ # for DistilBERT). This gives per-head decomposition (Tier-2).
111
+ # 2. word_embeddings, for the tied-embedding logit lens (vocab projection).
112
+ # 3. a classification head I can replay on a single pooled vector.
113
+ # When (1) doesn't decompose cleanly I degrade to Tier-1 (full-layer only).
114
+
115
+ _BASE_ATTRS = ("roberta", "bert", "deberta", "distilbert", "electra",
116
+ "xlm_roberta", "camembert", "mpnet", "albert")
117
+
118
+ def _resolve_base(model):
119
+ for a in _BASE_ATTRS:
120
+ b = getattr(model, a, None)
121
+ if b is not None:
122
+ return b
123
+ # generic: first child module exposing embeddings + an encoder/transformer
124
+ for _, mod in model.named_children():
125
+ if hasattr(mod, "embeddings") and (hasattr(mod, "encoder") or hasattr(mod, "transformer")):
126
+ return mod
127
+ return model
128
+
129
+ def _resolve_layers(base):
130
+ enc = getattr(base, "encoder", None)
131
+ if enc is not None and hasattr(enc, "layer") and isinstance(enc.layer, nn.ModuleList):
132
+ return enc.layer
133
+ tr = getattr(base, "transformer", None) # DistilBERT
134
+ if tr is not None and hasattr(tr, "layer") and isinstance(tr.layer, nn.ModuleList):
135
+ return tr.layer
136
+ # ALBERT: encoder.albert_layer_groups[0].albert_layers (shared) — best effort
137
+ if enc is not None and hasattr(enc, "albert_layer_groups"):
138
+ grp = enc.albert_layer_groups[0]
139
+ if hasattr(grp, "albert_layers"):
140
+ return grp.albert_layers
141
+ # generic: longest attention-bearing ModuleList
142
+ best = None
143
+ for _, mod in base.named_modules():
144
+ if isinstance(mod, nn.ModuleList) and len(mod) and hasattr(mod[0], "attention"):
145
+ if best is None or len(mod) > len(best):
146
+ best = mod
147
+ if best is None:
148
+ raise RuntimeError("Could not locate an encoder layer stack for this model.")
149
+ return best
150
+
151
+ def _resolve_attn_out(layer):
152
+ """The attention output projection whose input is per-head-concatenated."""
153
+ attn = getattr(layer, "attention", None)
154
+ if attn is not None:
155
+ out = getattr(attn, "output", None)
156
+ if out is not None and hasattr(out, "dense"): # BERT/RoBERTa/DeBERTa/ELECTRA
157
+ return out.dense
158
+ if hasattr(attn, "out_lin"): # DistilBERT
159
+ return attn.out_lin
160
+ if hasattr(attn, "dense"):
161
+ return attn.dense
162
+ return None
163
+
164
+ def _resolve_word_embeddings(base, model):
165
+ emb = getattr(base, "embeddings", None)
166
+ if emb is not None and hasattr(emb, "word_embeddings"):
167
+ return emb.word_embeddings
168
+ ie = model.get_input_embeddings()
169
+ return ie
170
+
171
+
172
+ class ClassHead:
173
+ """Replays a model's classification head on a single pooled [hidden] vector.
174
+ Adapts across the five common encoder-classifier head shapes."""
175
+ def __init__(self, model):
176
+ self.kind = None
177
+ self.fns = []
178
+ clf = getattr(model, "classifier", None)
179
+ cname = type(clf).__name__ if clf is not None else ""
180
+
181
+ if hasattr(model, "pre_classifier") and clf is not None:
182
+ # DistilBERT: pre_classifier(Linear) -> ReLU -> classifier(Linear)
183
+ self.kind = "distilbert"
184
+ pre, c = model.pre_classifier, clf
185
+ self.fns = [lambda x: torch.relu(pre(x)), c]
186
+ elif clf is not None and hasattr(clf, "out_proj") and hasattr(clf, "dense"):
187
+ # RoBERTa(tanh) / ELECTRA(gelu) classification head
188
+ act = torch.tanh
189
+ if "electra" in cname.lower() or "electra" in type(model).__name__.lower():
190
+ act = F.gelu
191
+ d, op = clf.dense, clf.out_proj
192
+ self.kind = "roberta_head"
193
+ self.fns = [lambda x, d=d, act=act: act(d(x)), op]
194
+ elif getattr(model, "pooler", None) is not None and clf is not None:
195
+ # BERT pooler(dense+tanh) or DeBERTa ContextPooler(dense+gelu) -> classifier(Linear)
196
+ pooler = model.pooler
197
+ pname = type(pooler).__name__.lower()
198
+ act = F.gelu if "context" in pname or "deberta" in pname else torch.tanh
199
+ dense = getattr(pooler, "dense", None)
200
+ if dense is not None:
201
+ self.kind = "pooled"
202
+ self.fns = [lambda x, d=dense, act=act: act(d(x)), clf]
203
+ else:
204
+ self.kind = "linear"; self.fns = [clf]
205
+ elif clf is not None:
206
+ self.kind = "linear"; self.fns = [clf]
207
+ else:
208
+ raise RuntimeError("Could not locate a classification head for this model.")
209
+
210
+ # weight vectors of the final projection, for cosine-to-class
211
+ final = self.fns[-1]
212
+ self.final_weight = final.weight.detach() if hasattr(final, "weight") else None
213
+
214
+ def logits(self, vec: torch.Tensor) -> torch.Tensor:
215
+ x = vec
216
+ if x.dim() == 1:
217
+ x = x.unsqueeze(0)
218
+ for f in self.fns:
219
+ x = f(x)
220
+ return x # [1, num_labels]
221
+
222
+ def probs(self, vec: torch.Tensor):
223
+ with torch.no_grad():
224
+ return F.softmax(self.logits(vec).float(), dim=-1)[0]
225
+
226
+ def cosine(self, vec: torch.Tensor, class_idx: int) -> float:
227
+ if self.final_weight is None or class_idx >= self.final_weight.shape[0]:
228
+ return 0.0
229
+ v = vec.squeeze().float()
230
+ c = self.final_weight[class_idx].float()
231
+ return float(F.cosine_similarity(v.unsqueeze(0), c.unsqueeze(0)).item())
232
+
233
+
234
+ @dataclass
235
+ class Snapshot:
236
+ """One forward pass, shared by every pane."""
237
+ class_logits: torch.Tensor # [num_labels]
238
+ class_probs: torch.Tensor # [num_labels]
239
+ hidden_states: tuple # len L+1, each [1, seq, hidden]
240
+ head_inputs: dict = field(default_factory=dict) # layer -> attn-out input [1, seq, H*hd]
241
+ pred: int = 0
242
+ margin: float = 0.0
243
+ entropy: float = 0.0 # over class distribution (bits)
244
+
245
+
246
+ # ── The Probe ───────────────────────────────────────────────────────────────
247
+ class EncoderProbe:
248
+ def __init__(self, model_id: str = None):
249
+ model_id = model_id or DEFAULT_MODEL_ID
250
+ self.model_id = model_id
251
+ print(f"[*] Loading {model_id} on {DEVICE}...")
252
+ kw = {"token": HF_TOKEN} if HF_TOKEN else {}
253
+ self.tokenizer = AutoTokenizer.from_pretrained(model_id, **kw)
254
+ self.model = AutoModelForSequenceClassification.from_pretrained(model_id, **kw)
255
+ self.model.to(DEVICE).eval()
256
+ self.is_tweet = "twitter" in model_id.lower()
257
+
258
+ cfg = self.model.config
259
+ self.base = _resolve_base(self.model)
260
+ self.layers = _resolve_layers(self.base)
261
+ self.num_layers = len(self.layers)
262
+ self.num_heads = int(getattr(cfg, "num_attention_heads", 0)) or 1
263
+ self.hidden_size = int(getattr(cfg, "hidden_size", 0)) or 0
264
+ self.head_dim = self.hidden_size // self.num_heads if self.num_heads else 0
265
+ self.word_emb = _resolve_word_embeddings(self.base, self.model)
266
+ self.vocab_size = self.word_emb.weight.shape[0]
267
+ self.head = ClassHead(self.model)
268
+
269
+ self.num_labels = int(getattr(cfg, "num_labels", 0)) or self.head.logits(
270
+ torch.zeros(self.hidden_size, device=DEVICE)).shape[-1]
271
+ id2label = getattr(cfg, "id2label", None) or {i: f"LABEL_{i}" for i in range(self.num_labels)}
272
+ self.label_names = [str(id2label.get(i, f"LABEL_{i}")) for i in range(self.num_labels)]
273
+ self.label_codes = self._make_codes(self.label_names)
274
+
275
+ # Tier-2 (per-head) support: attn-out input must decompose as heads*head_dim.
276
+ o0 = _resolve_attn_out(self.layers[0])
277
+ self._attn_outs = [_resolve_attn_out(l) for l in self.layers]
278
+ self.head_scan_supported = bool(
279
+ o0 is not None and self.head_dim
280
+ and o0.weight.shape[1] == self.num_heads * self.head_dim
281
+ )
282
+ self.arch_name = type(self.model).__name__
283
+
284
+ print(f"[*] {self.arch_name}: {self.num_layers}L x {self.num_heads}H, "
285
+ f"head_dim={self.head_dim}, hidden={self.hidden_size}, vocab={self.vocab_size}, "
286
+ f"labels={self.label_names}, head_scan="
287
+ f"{'yes' if self.head_scan_supported else 'NO (Tier-1)'}")
288
+
289
+ self.input_ids = None
290
+ self.attn_mask = None
291
+ self.pos = 0
292
+ self.snapshot = None
293
+ self.scaled_heads = {} # (layer, head) -> scale (0.0 == muted)
294
+ self._iv_handles = []
295
+ self.full_log = []
296
+
297
+ @staticmethod
298
+ def _make_codes(names):
299
+ codes, seen = [], set()
300
+ for n in names:
301
+ base = re.sub(r"[^A-Za-z0-9]", "", n).upper() or "X"
302
+ c = base[:3]
303
+ i = 1
304
+ while c in seen:
305
+ c = (base[:2] + str(i))[:3]; i += 1
306
+ seen.add(c); codes.append(c)
307
+ return codes
308
+
309
+ # ── architecture accessors ──
310
+ def attn_out(self, li):
311
+ return self._attn_outs[li]
312
+
313
+ def vocab_logits(self, vec):
314
+ """Tied-embedding logit lens — encoder classifiers ship no lm_head."""
315
+ return F.linear(vec, self.word_emb.weight.to(vec.dtype))
316
+
317
+ def set_seed(self):
318
+ set_seed(SEED)
319
+
320
+ def load_input(self, text: str, text_pair: str = None):
321
+ text = text or ""
322
+ if self.is_tweet:
323
+ text = preprocess_tweet(text)
324
+ if text_pair:
325
+ text_pair = preprocess_tweet(text_pair)
326
+ enc = self.tokenizer(text, text_pair, return_tensors="pt",
327
+ truncation=True, max_length=512)
328
+ self.input_ids = enc["input_ids"].to(DEVICE)
329
+ self.attn_mask = enc.get("attention_mask")
330
+ if self.attn_mask is not None:
331
+ self.attn_mask = self.attn_mask.to(DEVICE)
332
+ self.pos = 0
333
+ self.full_log.append({"type": "load", "text": text[:200]})
334
+
335
+ # ── intervention hooks (operate on per-head slots of the attn-out input) ──
336
+ def _install_iv_hooks(self):
337
+ for h in self._iv_handles:
338
+ h.remove()
339
+ self._iv_handles = []
340
+ if not self.scaled_heads or not self.head_scan_supported:
341
+ return
342
+ by_layer = {}
343
+ for (l, h), s in self.scaled_heads.items():
344
+ by_layer.setdefault(l, {})[h] = s
345
+ hd = self.head_dim
346
+ for li, scales in by_layer.items():
347
+ mod = self.attn_out(li)
348
+ if mod is None:
349
+ continue
350
+ def make_hook(scales, hd=hd):
351
+ def fn(module, args):
352
+ x = args[0].clone()
353
+ for h, s in scales.items():
354
+ x[:, :, h * hd:(h + 1) * hd] *= s
355
+ return (x,) + args[1:]
356
+ return fn
357
+ self._iv_handles.append(mod.register_forward_pre_hook(make_hook(scales)))
358
+
359
+ def scale_head(self, layer, head, scale):
360
+ if not self.head_scan_supported:
361
+ return f"per-head intervention unsupported for {self.arch_name} (Tier-1 only)"
362
+ if not (0 <= layer < self.num_layers): return f"layer {layer} out of range"
363
+ if not (0 <= head < self.num_heads): return f"head {head} out of range"
364
+ if scale == 1.0:
365
+ self.scaled_heads.pop((layer, head), None)
366
+ else:
367
+ self.scaled_heads[(layer, head)] = float(scale)
368
+ self._install_iv_hooks()
369
+ self.full_log.append({"type": "scale", "layer": layer, "head": head, "scale": scale})
370
+ verb = "MUTED" if scale == 0.0 else (f"x{scale}" if scale != 1.0 else "cleared")
371
+ return f"L{layer}H{head} {verb}"
372
+
373
+ def clear_interventions(self):
374
+ n = len(self.scaled_heads)
375
+ self.scaled_heads.clear()
376
+ self._install_iv_hooks()
377
+ self.full_log.append({"type": "clear_iv"})
378
+ return f"cleared {n} intervention(s)"
379
+
380
+ # ── single shared forward pass ──
381
+ def forward_snapshot(self):
382
+ if self.input_ids is None:
383
+ self.snapshot = None
384
+ return None
385
+ captured = {}
386
+ handles = []
387
+ if self.head_scan_supported:
388
+ # capture the (already intervention-scaled) input to each attn-out proj
389
+ for li in range(self.num_layers):
390
+ op = self.attn_out(li)
391
+ if op is not None:
392
+ handles.append(op.register_forward_pre_hook(
393
+ (lambda li: (lambda m, a: captured.__setitem__(li, a[0].detach())))(li)))
394
+ try:
395
+ with torch.no_grad():
396
+ out = self.model(self.input_ids, attention_mask=self.attn_mask,
397
+ output_hidden_states=True)
398
+ finally:
399
+ for h in handles:
400
+ h.remove()
401
+ logits = out.logits[0].float()
402
+ probs = F.softmax(logits, dim=-1)
403
+ sp = torch.sort(probs, descending=True).values
404
+ margin = float((sp[0] - sp[1]).item()) if probs.numel() > 1 else float(sp[0].item())
405
+ ent = float(-(probs * torch.log2(probs + 1e-12)).sum().item())
406
+ self.snapshot = Snapshot(
407
+ class_logits=logits, class_probs=probs, hidden_states=out.hidden_states,
408
+ head_inputs=captured, pred=int(probs.argmax().item()),
409
+ margin=margin, entropy=ent,
410
+ )
411
+ return self.snapshot
412
+
413
+ # ── per-head register projections at self.pos ──
414
+ def _project_vec(self, vec, width=3):
415
+ """Project a [hidden] register → (top tokens, class probs, top class, cos)."""
416
+ with torch.no_grad():
417
+ vl = self.vocab_logits(vec)
418
+ vp = F.softmax(vl, dim=-1)
419
+ tp, ti = torch.topk(vp, width)
420
+ toks = [(self.tokenizer.decode([ti[i].item()]), float(tp[i].item()))
421
+ for i in range(width)]
422
+ cp = self.head.probs(vec)
423
+ top_c = int(cp.argmax().item())
424
+ cos = self.head.cosine(vec, top_c)
425
+ return {"tokens": toks, "class_probs": cp.cpu(), "top_class": top_c, "cosine": cos}
426
+
427
+ def scan_heads(self, target_layers=None, target_heads=None, width=3):
428
+ snap = self.snapshot
429
+ if snap is None:
430
+ return None
431
+ if target_layers is None: target_layers = list(range(self.num_layers))
432
+ if target_heads is None: target_heads = list(range(self.num_heads))
433
+ target_layers = [l for l in target_layers if 0 <= l < self.num_layers]
434
+ target_heads = [h for h in target_heads if 0 <= h < self.num_heads]
435
+ pos = min(self.pos, snap.hidden_states[0].shape[1] - 1)
436
+
437
+ rows = {li: {} for li in target_layers}
438
+ # Layer column: full residual at this position
439
+ for li in target_layers:
440
+ vec = snap.hidden_states[li + 1][0, pos, :]
441
+ rows[li]["Layer"] = self._project_vec(vec, width)
442
+
443
+ if not (self.head_scan_supported and snap.head_inputs):
444
+ return {"rows": rows, "heads": [], "pos": pos}
445
+
446
+ for li in target_layers:
447
+ inp = snap.head_inputs.get(li)
448
+ if inp is None:
449
+ continue
450
+ wo = self.attn_out(li).weight # [hidden, hidden]
451
+ wv = wo.view(wo.shape[0], self.num_heads, self.head_dim)
452
+ x = inp[0, pos, :].view(self.num_heads, self.head_dim) # [H, hd]
453
+ # proj[h] = Wo[:, h, :] @ x[h] → [H, hidden]
454
+ proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
455
+ for hi in target_heads:
456
+ rows[li][hi] = self._project_vec(proj_all[hi], width)
457
+ return {"rows": rows, "heads": target_heads, "pos": pos}
458
+
459
+ # ── per-(head,layer) trace of one vocab token's rank ──
460
+ def vocab_rank_trace(self, token_str, rank_lo=0, rank_hi=None):
461
+ snap = self.snapshot
462
+ if snap is None or not (self.head_scan_supported and snap.head_inputs):
463
+ return "unsupported" if snap is not None else None
464
+ if token_str.isdigit():
465
+ tid = int(token_str); name = self.tokenizer.decode([tid])
466
+ else:
467
+ ids = self.tokenizer.encode(token_str, add_special_tokens=False)
468
+ if not ids: return None
469
+ tid = ids[0]; name = token_str
470
+ if rank_hi is None: rank_hi = self.vocab_size
471
+ pos = min(self.pos, snap.hidden_states[0].shape[1] - 1)
472
+ rows, index = [], []
473
+ for li in sorted(snap.head_inputs.keys()):
474
+ wo = self.attn_out(li).weight
475
+ wv = wo.view(wo.shape[0], self.num_heads, self.head_dim)
476
+ x = snap.head_inputs[li][0, pos, :].view(self.num_heads, self.head_dim)
477
+ proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
478
+ for hi in range(self.num_heads):
479
+ rows.append(proj_all[hi]); index.append((hi, li))
480
+ ranks = {}
481
+ if rows:
482
+ with torch.no_grad():
483
+ logits = self.vocab_logits(torch.stack(rows, 0)) # [N, vocab]
484
+ target = logits[:, tid].unsqueeze(1)
485
+ rk = (logits > target).sum(dim=1).add(1).tolist()
486
+ for (hi, li), r in zip(index, rk):
487
+ ranks[(hi, li)] = int(r)
488
+ return {"name": name, "tid": tid, "rank_lo": rank_lo, "rank_hi": rank_hi,
489
+ "ranks": ranks, "mode": "rank"}
490
+
491
+ # ── per-(head,layer) probability of one class ──
492
+ def class_prob_trace(self, class_idx):
493
+ snap = self.snapshot
494
+ if snap is None or not (self.head_scan_supported and snap.head_inputs):
495
+ return "unsupported" if snap is not None else None
496
+ if not (0 <= class_idx < self.num_labels):
497
+ return None
498
+ pos = min(self.pos, snap.hidden_states[0].shape[1] - 1)
499
+ vals = {}
500
+ for li in sorted(snap.head_inputs.keys()):
501
+ wo = self.attn_out(li).weight
502
+ wv = wo.view(wo.shape[0], self.num_heads, self.head_dim)
503
+ x = snap.head_inputs[li][0, pos, :].view(self.num_heads, self.head_dim)
504
+ proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
505
+ for hi in range(self.num_heads):
506
+ vals[(hi, li)] = float(self.head.probs(proj_all[hi])[class_idx].item())
507
+ return {"class_idx": class_idx, "name": self.label_names[class_idx],
508
+ "vals": vals, "mode": "class"}
509
+
510
+
511
+ # ── HTML renderers ──────────────────────────────────────────────────────────
512
+ CELL_W = 6
513
+
514
+ def render_input(p: EncoderProbe) -> str:
515
+ if p is None or p.input_ids is None:
516
+ return "<div class='pane-body'><i>No trace loaded. Enter text and click Load.</i></div>"
517
+ toks = p.tokenizer.convert_ids_to_tokens(p.input_ids[0])
518
+ special = set(p.tokenizer.all_special_tokens)
519
+ parts = [f"<div class='dim'>[{len(toks)} tokens · inspecting pos {p.pos}]</div>",
520
+ "<div class='context-text'>"]
521
+ for i, t in enumerate(toks):
522
+ disp = html_escape(t.replace("▁", "·").replace("Ġ", "·"))
523
+ if i == p.pos:
524
+ parts.append(f"<span class='pos-here'>[{i}:{disp}]</span> ")
525
+ elif t in special:
526
+ parts.append(f"<span class='dim'>[{i}:{disp}]</span> ")
527
+ else:
528
+ parts.append(f"<span class='tokn'><span class='dim'>{i}:</span>{disp}</span> ")
529
+ parts.append("</div>")
530
+ return "<div class='pane-body scroll'>" + "".join(parts) + "</div>"
531
+
532
+ def _cell(p, cell):
533
+ tok, tp = cell["tokens"][0]
534
+ ci = cell["top_class"]
535
+ cp = float(cell["class_probs"][ci].item())
536
+ code = p.label_codes[ci]
537
+ tc = heat_color(tp)
538
+ cc = class_color(p.label_names[ci], ci)
539
+ return (f"<td class='hm-cell'>"
540
+ f"<span style='color:{tc}'>{html_escape(fmt_tok(tok, CELL_W))}</span>"
541
+ f"<span class='cls' style='color:{cc}'>{code}{int(cp*100):02d}</span>"
542
+ f"<span class='cos'>.{int(abs(cell['cosine'])*99):02d}</span></td>")
543
+
544
+ def render_headmap(p: EncoderProbe, data: dict) -> str:
545
+ if not data:
546
+ return ("<div class='pane-body'><i>No scan yet. <code>h *</code> scans all "
547
+ "layers, <code>h L</code> one layer.</i></div>")
548
+ rows_data = data["rows"]
549
+ heads = data["heads"]
550
+ layers = sorted(rows_data.keys())
551
+ if not layers:
552
+ return "<div class='pane-body'><i>Empty scan.</i></div>"
553
+
554
+ hdr = ["<tr><th class='lay'>Lay</th>"]
555
+ for h in heads: hdr.append(f"<th class='ps'>H{h}</th>")
556
+ hdr.append("<th class='full'>Layer</th></tr>")
557
+ sub = ["<tr class='subhdr'><th></th>"]
558
+ if heads:
559
+ sub.append(f"<th class='ps' colspan='{len(heads)}'>per-head span "
560
+ f"(head→o_proj→vocab|class)</th>")
561
+ sub.append("<th class='full'>residual</th></tr>")
562
+
563
+ body = []
564
+ for l in layers:
565
+ r = [f"<tr><td class='lay'>L{l}</td>"]
566
+ row = rows_data[l]
567
+ for h in heads:
568
+ r.append(_cell(p, row[h]) if h in row else "<td class='hm-cell dim'>·</td>")
569
+ r.append(_cell(p, row["Layer"]).replace("hm-cell", "hm-cell full")
570
+ if "Layer" in row else "<td class='hm-cell full dim'>·</td>")
571
+ r.append("</tr>")
572
+ body.append("".join(r))
573
+
574
+ note = ""
575
+ if not heads:
576
+ note = ("<div class='dim' style='padding:2px 0'>per-head decomposition unavailable "
577
+ "for this architecture — full-layer residual only (Tier-1)</div>")
578
+ return (f"<div class='pane-body scroll'>{note}"
579
+ f"<table class='headmap'>{''.join(sub)}{''.join(hdr)}{''.join(body)}</table>"
580
+ f"<div class='legend dim'>cell: <b>tok</b> (logit-lens) · "
581
+ f"<b>CODE pp</b> (head's class vote) · <b>.cos</b> (cosine to that class)</div></div>")
582
+
583
+ def render_prediction(p: EncoderProbe) -> str:
584
+ if p is None or p.snapshot is None:
585
+ return "<div class='pane-body'><i>Load a trace to see the prediction.</i></div>"
586
+ s = p.snapshot
587
+ pred = s.pred
588
+ rows = ["<div class='pane-body scroll'>"]
589
+ rows.append(f"<div class='predline'>prediction "
590
+ f"<b style='color:{class_color(p.label_names[pred], pred)}'>"
591
+ f"{html_escape(p.label_names[pred])}</b> "
592
+ f"<span class='dim'>({s.class_probs[pred]*100:.1f}%) · "
593
+ f"margin {s.margin*100:.1f}pp · H={s.entropy:.2f} bits</span></div>")
594
+ rows.append("<table class='probs'>")
595
+ for i in range(p.num_labels):
596
+ pr = float(s.class_probs[i].item())
597
+ c = class_color(p.label_names[i], i)
598
+ bar = int(round(pr * 28))
599
+ mark = " ◀" if i == pred else ""
600
+ rows.append(
601
+ f"<tr><td class='plabel' style='color:{c}'>{html_escape(p.label_names[i])}</td>"
602
+ f"<td class='pbar'><span style='color:{c}'>{'█'*bar}{'·'*(28-bar)}</span></td>"
603
+ f"<td class='ppct'>{pr*100:5.1f}%{mark}</td></tr>")
604
+ rows.append("</table>")
605
+
606
+ # final-layer per-head vote tally (the "span attributes" view)
607
+ if p.head_scan_supported and s.head_inputs:
608
+ li = p.num_layers - 1
609
+ inp = s.head_inputs.get(li)
610
+ if inp is not None:
611
+ pos = min(p.pos, s.hidden_states[0].shape[1] - 1)
612
+ wo = p.attn_out(li).weight
613
+ wv = wo.view(wo.shape[0], p.num_heads, p.head_dim)
614
+ x = inp[0, pos, :].view(p.num_heads, p.head_dim)
615
+ proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
616
+ cells = []
617
+ for hi in range(p.num_heads):
618
+ ci = int(p.head.probs(proj_all[hi]).argmax().item())
619
+ c = class_color(p.label_names[ci], ci)
620
+ cells.append(f"<span class='vote' style='color:{c}' title='H{hi}'>"
621
+ f"{p.label_codes[ci]}</span>")
622
+ rows.append(f"<div class='votes'><span class='dim'>final-layer head votes "
623
+ f"(L{li}):</span> {' '.join(cells)}</div>")
624
+ rows.append("</div>")
625
+ return "".join(rows)
626
+
627
+ def render_trace(p: EncoderProbe, td: dict) -> str:
628
+ if not td:
629
+ return ("<div class='pane-body'><i>Trace one signal across heads×layers: "
630
+ "<code>spark &lt;token&gt;</code> (vocab rank) or "
631
+ "<code>class &lt;label&gt;</code> (vote probability).</i></div>")
632
+ n_layers, n_heads = p.num_layers, p.num_heads
633
+ band = 8
634
+ head_colors = ["#64D32A", "#FFCF67", "#21C5C5", "#E78BFF", "#ff6b6b", "#9aa0ff"]
635
+ lines = []
636
+ hdr = " " + "".join(f"{l:>3}" for l in range(n_layers))
637
+
638
+ if td["mode"] == "rank":
639
+ rank_lo, rank_hi = td["rank_lo"], td["rank_hi"]
640
+ span = max(rank_hi - rank_lo, 1)
641
+ ranks = td["ranks"]
642
+ lines.append(f"vocab rank of '{html_escape(td['name'])}' (id={td['tid']}) — "
643
+ f"{rank_lo} (top) → {rank_hi} (bottom)")
644
+ lines.append(f"<span class='dim'>{html_escape(hdr)}</span>")
645
+ lines.append(f" {'─'*(n_layers*3)}")
646
+ for hi in range(n_heads):
647
+ color = head_colors[hi % len(head_colors)]
648
+ grid = [[" "]*n_layers for _ in range(band)]
649
+ for li in range(n_layers):
650
+ r = ranks.get((hi, li), rank_hi + 1)
651
+ if r < rank_lo: grid[0][li] = "▲"
652
+ elif r > rank_hi: grid[band-1][li] = "▼"
653
+ else:
654
+ row = int((r - rank_lo)/span*(band-1))
655
+ grid[max(0, min(band-1, row))][li] = "●"
656
+ _emit_band(lines, grid, band, hi, n_layers, color, rank_lo, rank_hi, span)
657
+ if hi < n_heads - 1: lines.append(f" {'┈'*(n_layers*3)}")
658
+ lines.append(f" {'─'*(n_layers*3)}")
659
+ else:
660
+ vals = td["vals"]
661
+ c = class_color(td["name"], td["class_idx"])
662
+ lines.append(f"P({html_escape(td['name'])}) per head — 1.0 (top) → 0.0 (bottom)")
663
+ lines.append(f"<span class='dim'>{html_escape(hdr)}</span>")
664
+ lines.append(f" {'─'*(n_layers*3)}")
665
+ for hi in range(n_heads):
666
+ grid = [[" "]*n_layers for _ in range(band)]
667
+ for li in range(n_layers):
668
+ v = vals.get((hi, li), 0.0)
669
+ row = int((1.0 - v)*(band-1))
670
+ grid[max(0, min(band-1, row))][li] = "●"
671
+ _emit_band(lines, grid, band, hi, n_layers, c, 1.0, 0.0, 1.0, pct=True)
672
+ if hi < n_heads - 1: lines.append(f" {'┈'*(n_layers*3)}")
673
+ lines.append(f" {'─'*(n_layers*3)}")
674
+ return f"<div class='pane-body scroll'><pre class='spark'>{chr(10).join(lines)}</pre></div>"
675
+
676
+ def _emit_band(lines, grid, band, hi, n_layers, color, top, bot, span, pct=False):
677
+ for ri in range(band):
678
+ if ri == 0:
679
+ lab = f"H{hi:<2}{(top if not pct else 1.0):>6}│" if not pct else f"H{hi:<2}{1.0:>6.2f}│"
680
+ elif ri == band - 1:
681
+ lab = f" {bot:>6}│" if not pct else f" {0.0:>6.2f}│"
682
+ else:
683
+ lab = " │"
684
+ cells = []
685
+ for li in range(n_layers):
686
+ ch = grid[ri][li]
687
+ if ch in ("●", "▲", "▼"):
688
+ cells.append(f"<span style='color:{color}'> {ch} </span>")
689
+ else:
690
+ cells.append(f" {ch} ")
691
+ lines.append(html_escape(lab) + "".join(cells))
692
+
693
+ def render_summary(s) -> str:
694
+ if s.probe is None:
695
+ return "<div class='summary dim'>no model loaded</div>"
696
+ p = s.probe
697
+ snap = p.snapshot
698
+ n_tok = p.input_ids.shape[1] if p.input_ids is not None else 0
699
+ tier = "T2" if p.head_scan_supported else "T1"
700
+ pred = (f"<b style='color:{class_color(p.label_names[snap.pred], snap.pred)}'>"
701
+ f"{html_escape(p.label_names[snap.pred])}</b> {snap.class_probs[snap.pred]*100:.0f}%"
702
+ if snap else "—")
703
+ iv = (f"<span style='color:#ff6b6b'>iv {len(p.scaled_heads)}</span>"
704
+ if p.scaled_heads else "iv 0")
705
+ items = [
706
+ f"<b>{html_escape(p.arch_name)}</b> <span class='dim'>{html_escape(p.model_id)}</span>",
707
+ f"<span class='dim'>tier</span> {tier}",
708
+ f"<span class='dim'>L</span>{p.num_layers} <span class='dim'>×H</span>{p.num_heads}",
709
+ f"<span class='dim'>tok</span> {n_tok}",
710
+ f"<span class='dim'>pos</span> {p.pos}",
711
+ f"<span class='dim'>pred</span> {pred}",
712
+ f"<span class='dim'>H</span>=<b>{snap.entropy:.2f}</b>" if snap else "",
713
+ iv,
714
+ ]
715
+ return "<div class='summary'>" + " <span class='sep'>·</span> ".join(x for x in items if x) + "</div>"
716
+
717
+ def status_html(s) -> str:
718
+ if not s.transcript:
719
+ return "<div class='pane-body dim'>Ready.</div>"
720
+ rows = []
721
+ for cmd, st in s.transcript[-22:]:
722
+ rows.append(f"<div><span class='prompt'>›</span> <code>{html_escape(cmd)}</code> "
723
+ f"<span class='dim'>— {html_escape(st)}</span></div>")
724
+ return f"<div class='pane-body scroll'>{''.join(rows)}</div>"
725
+
726
+
727
+ # ── Session state ───────────────────────────────────────────────────────────
728
+ class Session:
729
+ def __init__(self):
730
+ self.probe = None
731
+ self.scan_data = None
732
+ self.trace_data = None
733
+ self.transcript = []
734
+ self.cmp = None # (label_names, probs, pred) of a compared trace
735
+ self.loaded_model = None
736
+ self.loaded_text = None
737
+ self.loaded_pair = None
738
+
739
+ def load(self, model_id, text, pair, force=False):
740
+ changed = (force or self.probe is None or self.loaded_model != model_id
741
+ or self.loaded_text != text or self.loaded_pair != pair)
742
+ if not changed:
743
+ return False
744
+ if self.probe is None or self.loaded_model != model_id:
745
+ self.probe = EncoderProbe(model_id)
746
+ self.probe.set_seed()
747
+ self.probe.scaled_heads.clear(); self.probe._install_iv_hooks()
748
+ self.probe.load_input(text, pair or None)
749
+ self.scan_data = None; self.trace_data = None; self.transcript = []; self.cmp = None
750
+ self.loaded_model, self.loaded_text, self.loaded_pair = model_id, text, pair
751
+ return True
752
+
753
+ def ensure(self, model_id, text=""):
754
+ if self.probe is None:
755
+ self.load(model_id, text, None)
756
+
757
+
758
+ def panes(s):
759
+ return (render_summary(s), render_input(s.probe),
760
+ render_headmap(s.probe, s.scan_data),
761
+ render_prediction(s.probe), render_trace(s.probe, s.trace_data),
762
+ status_html(s))
763
+
764
+ def _refresh(s, rescan=True):
765
+ if s.probe is None or s.probe.input_ids is None:
766
+ return
767
+ s.probe.forward_snapshot()
768
+ if rescan and s.scan_data is not None:
769
+ layers = sorted(s.scan_data["rows"].keys())
770
+ heads = s.scan_data["heads"] or None
771
+ s.scan_data = s.probe.scan_heads(target_layers=layers, target_heads=heads)
772
+
773
+
774
+ # ── command handling ────────────────────────────────────────────────────────
775
+ def initial_load(model_id, text, pair, s):
776
+ rebuilt = s.load(model_id, text, pair or None)
777
+ _refresh(s, rescan=False)
778
+ s.scan_data = s.probe.scan_heads()
779
+ tier = "Tier-2 (per-head)" if s.probe.head_scan_supported else "Tier-1 only"
780
+ s.transcript.append(("(load)", f"{'loaded' if rebuilt else 'ready'} {s.probe.arch_name} "
781
+ f"{s.probe.num_layers}L×{s.probe.num_heads}H · "
782
+ f"labels {','.join(s.probe.label_codes)} · {tier}"))
783
+ return panes(s)
784
+
785
+ def _resolve_class_arg(p, arg):
786
+ arg = arg.strip()
787
+ if arg.isdigit():
788
+ return int(arg)
789
+ al = arg.lower()
790
+ for i, (n, c) in enumerate(zip(p.label_names, p.label_codes)):
791
+ if al == c.lower() or n.lower().startswith(al) or al in n.lower():
792
+ return i
793
+ return None
794
+
795
+ def handle(cmd, s, model_id):
796
+ cmd = (cmd or "").strip()
797
+ if not cmd:
798
+ return panes(s)
799
+ s.ensure(model_id)
800
+ p = s.probe
801
+ st = "ok"
802
+ try:
803
+ lc = cmd.lower()
804
+ if lc.startswith("h "):
805
+ parts = cmd.split()
806
+ tl = None if parts[1] == "*" else [int(parts[1])]
807
+ th = None
808
+ if len(parts) > 2:
809
+ th = None if parts[2] == "*" else [int(parts[2])]
810
+ if p.snapshot is None: p.forward_snapshot()
811
+ s.scan_data = p.scan_heads(target_layers=tl, target_heads=th)
812
+ st = f"scan layers={parts[1]} heads={parts[2] if len(parts)>2 else '*'}"
813
+
814
+ elif lc.startswith("pos "):
815
+ p.pos = max(0, int(cmd[4:].strip()))
816
+ _refresh(s); st = f"pos={p.pos}"
817
+
818
+ elif lc.startswith("spark "):
819
+ rest = cmd[6:].strip().split()
820
+ tok = rest[0]; rl, rh = 0, None
821
+ if len(rest) > 1 and ":" in rest[1]:
822
+ a, b = rest[1].split(":"); rl, rh = int(a), int(b)
823
+ if p.snapshot is None: p.forward_snapshot()
824
+ td = p.vocab_rank_trace(tok, rl, rh)
825
+ if td == "unsupported": st = f"rank trace unsupported ({p.arch_name}, Tier-1)"
826
+ elif td is None: st = f"token '{tok}' not in vocab"
827
+ else: s.trace_data = td; st = f"spark '{tok}'"
828
+
829
+ elif lc.startswith("class "):
830
+ ci = _resolve_class_arg(p, cmd[6:])
831
+ if ci is None: st = f"unknown class '{cmd[6:].strip()}' (have {','.join(p.label_codes)})"
832
+ else:
833
+ if p.snapshot is None: p.forward_snapshot()
834
+ td = p.class_prob_trace(ci)
835
+ if td == "unsupported": st = f"class trace unsupported ({p.arch_name}, Tier-1)"
836
+ else: s.trace_data = td; st = f"class {p.label_names[ci]}"
837
+
838
+ elif lc.startswith("mute "):
839
+ a = cmd.split(); st = p.scale_head(int(a[1]), int(a[2]), 0.0); _refresh(s)
840
+ elif lc.startswith("scale "):
841
+ a = cmd.split(); st = p.scale_head(int(a[1]), int(a[2]), float(a[3])); _refresh(s)
842
+ elif lc.startswith("unmute "):
843
+ a = cmd.split(); st = p.scale_head(int(a[1]), int(a[2]), 1.0); _refresh(s)
844
+ elif lc in ("clear", "unmute all"):
845
+ st = p.clear_interventions(); _refresh(s)
846
+
847
+ elif lc.startswith("cmp "):
848
+ other = cmd[4:].strip()
849
+ base_ids, base_mask, base_pos = p.input_ids, p.attn_mask, p.pos
850
+ base_pred = p.snapshot.pred if p.snapshot else None
851
+ base_probs = p.snapshot.class_probs.clone() if p.snapshot else None
852
+ p.load_input(other); p.forward_snapshot()
853
+ diff = []
854
+ if base_probs is not None:
855
+ for i in range(p.num_labels):
856
+ d = float(p.snapshot.class_probs[i] - base_probs[i])
857
+ diff.append(f"{p.label_codes[i]}{d*100:+.0f}")
858
+ new_pred = p.label_names[p.snapshot.pred]
859
+ old_pred = p.label_names[base_pred] if base_pred is not None else "?"
860
+ s.cmp = (new_pred, diff)
861
+ # restore the primary trace
862
+ p.input_ids, p.attn_mask, p.pos = base_ids, base_mask, base_pos
863
+ p.forward_snapshot()
864
+ if s.scan_data is not None:
865
+ s.scan_data = p.scan_heads(target_layers=sorted(s.scan_data["rows"].keys()),
866
+ target_heads=s.scan_data["heads"] or None)
867
+ st = f"cmp: {old_pred}→{new_pred} Δ[{' '.join(diff)}]"
868
+
869
+ elif lc == "s":
870
+ from datetime import datetime
871
+ fn = f"/tmp/bertographer_{datetime.now():%Y%m%d_%H%M%S}.json"
872
+ try:
873
+ with open(fn, "w") as f: json.dump(p.full_log, f, indent=2)
874
+ st = f"saved {fn}"
875
+ except Exception as e:
876
+ st = f"save failed: {e}"
877
+ elif lc in ("r", "refresh"):
878
+ _refresh(s); st = "refreshed"
879
+ elif lc in ("help", "?"):
880
+ st = "see help line under the command bar"
881
+ else:
882
+ st = f"unknown: {cmd}"
883
+ except Exception as e:
884
+ st = f"error: {type(e).__name__}: {e}"
885
+ s.transcript.append((cmd, st))
886
+ return panes(s)
887
+
888
+
889
+ # ── Gradio UI ───────────────────────────────────────────────────────────────
890
+ CSS = """
891
+ .gradio-container { max-width: 100% !important; padding: 6px 10px !important; }
892
+ .gradio-container .main { padding: 0 !important; gap: 4px !important; }
893
+ .gradio-container .gap, .gradio-container .form { gap: 4px !important; }
894
+ .gradio-container .block { padding: 3px !important; border-radius: 4px !important; }
895
+ .gradio-container .prose { margin: 0 !important; }
896
+ #hdr h1 { font-size: 18px !important; margin: 0 !important; line-height: 1.2 !important; }
897
+ #hdr p, #hdr em { font-size: 11px !important; margin: 0 !important; color: #8b949e !important; line-height: 1.25 !important; }
898
+ #hdr { margin: 0 !important; padding: 4px 0 !important; }
899
+ #topbar { gap: 6px !important; }
900
+ #topbar textarea, #topbar input[type="text"] { font-size: 12px !important; padding: 4px 6px !important; min-height: 28px !important; line-height: 1.3 !important; }
901
+ #topbar label, #topbar span[data-testid="block-label"] { font-size: 10px !important; padding: 0 0 2px 0 !important; margin: 0 !important; text-transform: uppercase; letter-spacing: .5px; color: #8b949e !important; }
902
+ #topbar button { min-height: 56px !important; align-self: stretch !important; font-size: 13px !important; }
903
+ #cmdbar textarea, #cmdbar input[type="text"] { font-size: 12px !important; padding: 4px 8px !important; min-height: 28px !important; font-family: 'JetBrains Mono','Fira Code',Consolas,monospace !important; }
904
+ #cmdbar label, #cmdbar span[data-testid="block-label"] { font-size: 10px !important; color: #8b949e !important; margin: 0 !important; }
905
+ #cmdbar button { min-height: 36px !important; font-size: 12px !important; }
906
+ #help { font-size: 10.5px !important; color: #8b949e !important; margin: 2px 0 !important; }
907
+ #help code { font-size: 10.5px !important; padding: 0 2px !important; background: #161b22 !important; }
908
+ .pane { border: 1px solid #30363d; border-radius: 4px; background: #0d1117; color: #c9d1d9; font-family: 'JetBrains Mono','Fira Code',Consolas,monospace; font-size: 12px; }
909
+ .pane h3 { margin: 0; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; font-size: 10px; letter-spacing: .5px; text-transform: uppercase; color: #8b949e; }
910
+ .pane-body { padding: 5px 8px; max-height: 460px; overflow: auto; }
911
+ .dim { color: #6e7681; }
912
+ .context-text { white-space: pre-wrap; word-break: break-word; margin-top: 4px; line-height: 1.7; }
913
+ .tokn { background: #11161c; border: 1px solid #21262d; border-radius: 3px; padding: 0 3px; }
914
+ .pos-here { background: #0f2418; outline: 1px solid #64D32A; color: #b8f0c4; border-radius: 3px; padding: 0 3px; font-weight: bold; }
915
+ table.headmap { border-collapse: collapse; font-size: 11px; }
916
+ table.headmap th, table.headmap td { padding: 1px 4px; border: 1px solid #21262d; text-align: center; white-space: pre; }
917
+ table.headmap th.ps { background: #112; } table.headmap th.full { background: #133; color: #64D32A; }
918
+ table.headmap td.lay { color: #8b949e; } table.headmap td.full { background: #0a1410; }
919
+ table.headmap tr.subhdr th { background: #161b22; color: #8b949e; font-weight: normal; font-size: 10px; }
920
+ .hm-cell .cls { font-weight: bold; padding-left: 3px; } .hm-cell .cos { color: #6e7681; padding-left: 2px; }
921
+ .legend { padding: 4px 0 0 0; font-size: 10px; }
922
+ .predline { padding: 2px 0 6px 0; font-size: 13px; }
923
+ table.probs { border-collapse: collapse; font-size: 12px; width: 100%; }
924
+ table.probs td { padding: 1px 6px; white-space: pre; }
925
+ table.probs td.plabel { font-weight: bold; } table.probs td.pbar { font-family: monospace; letter-spacing: -1px; }
926
+ table.probs td.ppct { text-align: right; color: #c9d1d9; }
927
+ .votes { margin-top: 8px; font-size: 11px; line-height: 1.6; }
928
+ .vote { display: inline-block; min-width: 22px; text-align: center; background: #11161c; border: 1px solid #21262d; border-radius: 3px; margin: 1px; padding: 0 2px; font-weight: bold; }
929
+ #summary-strip { padding: 0 !important; margin: 0 !important; }
930
+ .summary { font-family: 'JetBrains Mono',Consolas,monospace; font-size: 11px; padding: 4px 8px; background: #0d1117; border: 1px solid #30363d; border-radius: 4px; color: #c9d1d9; line-height: 1.5; }
931
+ .summary .sep { color: #30363d; padding: 0 4px; } .summary .dim { color: #6e7681; }
932
+ #dual-row { align-items: stretch !important; }
933
+ #dual-row > div { display: flex; flex-direction: column; }
934
+ #dual-row #col-pred, #dual-row #col-head { height: 56vh; min-height: 360px; max-height: 660px; }
935
+ #dual-row .pane { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
936
+ #dual-row .pane h3 { flex: 0 0 auto; } #dual-row .pane .pane-body { flex: 1 1 auto; max-height: none !important; overflow: auto; }
937
+ pre.spark { margin: 0; font-size: 11px; line-height: 1.1; }
938
+ .prompt { color: #64D32A; }
939
+ .gradio-container textarea::placeholder, .gradio-container input[type="text"]::placeholder { color: #6e7681 !important; opacity: 1 !important; }
940
+ """
941
+
942
+ HELP = (
943
+ "`h *` / `h L` / `h L H` head×layer scan · `pos N` inspect token position · "
944
+ "`spark <tok> [lo:hi]` vocab-rank trace · `class <label>` per-head vote trace · "
945
+ "`mute L H` · `scale L H X` · `unmute L H` · `clear` · "
946
+ "`cmp <text>` diff vs another trace · `r` refresh · `s` save"
947
+ )
948
+
949
+ MODEL_PRESETS = [
950
+ "cardiffnlp/twitter-roberta-base-sentiment-latest",
951
+ "cardiffnlp/twitter-roberta-base-emotion",
952
+ "distilbert-base-uncased-finetuned-sst-2-english",
953
+ "textattack/bert-base-uncased-SST-2",
954
+ "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli",
955
+ "google/electra-base-discriminator",
956
+ "ProsusAI/finbert",
957
+ ]
958
+
959
+ def build_ui():
960
+ with gr.Blocks(title="Bertographer", css=CSS, theme=gr.themes.Base()) as demo:
961
+ gr.Markdown(
962
+ "# Bertographer \n*Mechanistic cockpit for encoder **classifiers** — "
963
+ "per-head logit-lens · per-head class votes · vocab/class rank traces · "
964
+ "head interventions — on BERT / RoBERTa / DeBERTa / DistilBERT / ELECTRA. "
965
+ "An input is a **trace**; layers are the **waterfall**; heads are **voters**; "
966
+ "muting a head is the **counterfactual**.*",
967
+ elem_id="hdr")
968
+
969
+ session = gr.State(Session())
970
+
971
+ with gr.Row(elem_id="topbar"):
972
+ model_id = gr.Dropdown(choices=MODEL_PRESETS, value=DEFAULT_MODEL_ID, label="Model",
973
+ allow_custom_value=True, filterable=True, scale=4)
974
+ text = gr.Textbox(label="Text (trace)",
975
+ value="I can't believe how good this turned out — absolutely thrilled.",
976
+ lines=1, max_lines=3, scale=6)
977
+ pair = gr.Textbox(label="Text pair (NLI/optional)", value="", lines=1, max_lines=3, scale=4)
978
+ load_btn = gr.Button("Load", variant="primary", scale=1, min_width=80)
979
+
980
+ summary_pane = gr.HTML(value="<div class='summary dim'>no model loaded</div>", elem_id="summary-strip")
981
+ ctx_pane = gr.HTML(value="<div class='pane'><h3>Trace · tokenized input</h3>"
982
+ "<div class='pane-body'><i>Click <b>Load</b> to begin.</i></div></div>")
983
+
984
+ with gr.Row(elem_id="cmdbar"):
985
+ cmd = gr.Textbox(label="CMD", placeholder=HELP, scale=10, autofocus=True,
986
+ lines=1, max_lines=1)
987
+ run_btn = gr.Button("Run", scale=1, variant="primary", min_width=70)
988
+ gr.Markdown(HELP, elem_id="help")
989
+
990
+ with gr.Row(elem_id="dual-row"):
991
+ with gr.Column(scale=3, min_width=300, elem_id="col-pred"):
992
+ pred_pane = gr.HTML(value="<div class='pane'><h3>Prediction · class probs · head votes</h3>"
993
+ "<div class='pane-body'><i>Appears after Load.</i></div></div>")
994
+ with gr.Column(scale=7, elem_id="col-head"):
995
+ hm_pane = gr.HTML(value="<div class='pane'><h3>Head Map · per-head vocab|class at pos</h3>"
996
+ "<div class='pane-body'><i>Auto-scans after each step.</i></div></div>")
997
+ trace_pane = gr.HTML(value="<div class='pane'><h3>Rank / Vote Trace · per (head,layer)</h3>"
998
+ "<div class='pane-body'><i>Run <code>spark &lt;token&gt;</code> or "
999
+ "<code>class &lt;label&gt;</code>.</i></div></div>")
1000
+ status = gr.HTML(value="<div class='pane'><h3>Transcript</h3>"
1001
+ "<div class='pane-body dim'>Ready.</div></div>")
1002
+
1003
+ def _wrap(summ, ctx, hm, pred, tr, stt):
1004
+ return (summ,
1005
+ f"<div class='pane'><h3>Trace · tokenized input</h3>{ctx}</div>",
1006
+ f"<div class='pane'><h3>Head Map · per-head vocab|class at pos</h3>{hm}</div>",
1007
+ f"<div class='pane'><h3>Prediction · class probs · head votes</h3>{pred}</div>",
1008
+ f"<div class='pane'><h3>Rank / Vote Trace · per (head,layer)</h3>{tr}</div>",
1009
+ f"<div class='pane'><h3>Transcript</h3>{stt}</div>")
1010
+
1011
+ OUT = [summary_pane, ctx_pane, hm_pane, pred_pane, trace_pane, status]
1012
+
1013
+ def on_load(mid, t, pr, s):
1014
+ summ, ctx, hm, pred, tr, stt = initial_load(mid, t, pr, s)
1015
+ return _wrap(summ, ctx, hm, pred, tr, stt) + (s,)
1016
+
1017
+ def on_cmd(c, s, mid):
1018
+ summ, ctx, hm, pred, tr, stt = handle(c, s, mid)
1019
+ return _wrap(summ, ctx, hm, pred, tr, stt) + ("", s)
1020
+
1021
+ load_btn.click(on_load, inputs=[model_id, text, pair, session], outputs=OUT + [session])
1022
+ run_btn.click(on_cmd, inputs=[cmd, session, model_id], outputs=OUT + [cmd, session])
1023
+ cmd.submit(on_cmd, inputs=[cmd, session, model_id], outputs=OUT + [cmd, session])
1024
+ return demo
1025
+
1026
+
1027
+ if __name__ == "__main__":
1028
+ build_ui().queue().launch()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=4.44
2
+ torch>=2.2
3
+ transformers>=4.45
4
+ sentencepiece
5
+ tiktoken
6
+ protobuf
7
+ accelerate