actlens 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
actlens/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ActLens: interactive activation viewer backend."""
2
+
3
+ __version__ = "0.1.0"
actlens/app.py ADDED
@@ -0,0 +1,318 @@
1
+ """FastAPI app: model management, runs, and windowed activation slices."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import gc
6
+ import hmac
7
+ import json
8
+ import os
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from contextlib import asynccontextmanager
13
+ from collections import OrderedDict
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ from fastapi import FastAPI, HTTPException, Query, Request
19
+ from fastapi.responses import FileResponse, PlainTextResponse, RedirectResponse
20
+ from fastapi.staticfiles import StaticFiles
21
+ from pydantic import BaseModel
22
+
23
+ from . import slicing
24
+ from .cache import ActivationCache
25
+ from .capture import ATTN_ACT, Capture, Run
26
+ from .providers import ActivationProvider, NNsightProvider
27
+ from .wire import frame_response
28
+
29
+ DEFAULT_MODEL = "Qwen/Qwen3-0.6B"
30
+ PRESET_MODELS = [
31
+ {"id": "Qwen/Qwen3-0.6B", "label": "Qwen3-0.6B"},
32
+ {"id": "Qwen/Qwen2.5-0.5B", "label": "Qwen2.5-0.5B"},
33
+ {"id": "HuggingFaceTB/SmolLM2-360M", "label": "SmolLM2-360M"},
34
+ {"id": "openai-community/gpt2", "label": "GPT-2"},
35
+ ]
36
+ MAX_RUNS = 3
37
+ TOKEN_COOKIE = "actlens_token"
38
+ DEFAULT_CACHE_MB = 2048
39
+ CORPUS = json.loads((Path(__file__).parent / "corpus.json").read_text())
40
+ _PACKAGED_DIST = Path(__file__).parent / "static" # bundled into the wheel
41
+ _DEV_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist" # source checkout
42
+ FRONTEND_DIST = _PACKAGED_DIST if (_PACKAGED_DIST / "index.html").is_file() else _DEV_DIST
43
+
44
+
45
+ def cache_budget_bytes() -> int:
46
+ try:
47
+ mb = float(os.environ.get("ACTLENS_CACHE_MB", DEFAULT_CACHE_MB))
48
+ except ValueError:
49
+ mb = DEFAULT_CACHE_MB
50
+ return int(mb * 2**20)
51
+
52
+
53
+ class ModelManager:
54
+ """Owns the single loaded model. All model work runs on one dedicated thread."""
55
+
56
+ def __init__(self, factory: Callable[[str, str, str], ActivationProvider] = NNsightProvider,
57
+ cache_bytes: int | None = None):
58
+ self.factory = factory
59
+ self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="model")
60
+ self.provider: ActivationProvider | None = None
61
+ self.state = "idle" # idle | loading | ready | error
62
+ self.target: str | None = None
63
+ self.error: str | None = None
64
+ self.runs: OrderedDict[str, Run] = OrderedDict()
65
+ self.cache = ActivationCache(cache_budget_bytes() if cache_bytes is None else cache_bytes)
66
+ self._lock = threading.Lock()
67
+
68
+ def status(self) -> dict:
69
+ return {
70
+ "state": self.state,
71
+ "model_id": self.provider.model_id if self.provider else self.target,
72
+ "target": self.target,
73
+ "error": self.error,
74
+ "info": self.provider.info if self.state == "ready" and self.provider else None,
75
+ }
76
+
77
+ def load(self, model_id: str, device: str = "auto", dtype: str = "float32") -> None:
78
+ with self._lock:
79
+ if self.state == "loading":
80
+ raise HTTPException(409, "A model is already loading")
81
+ self.state, self.target, self.error = "loading", model_id, None
82
+ self.executor.submit(self._load, model_id, device, dtype)
83
+
84
+ def _load(self, model_id: str, device: str, dtype: str) -> None:
85
+ try:
86
+ if self.provider is not None:
87
+ self.provider.close()
88
+ self.provider = None
89
+ self.runs.clear()
90
+ self.cache.clear()
91
+ gc.collect()
92
+ self.provider = self.factory(model_id, device, dtype)
93
+ self.state = "ready"
94
+ except Exception as e: # surfaced to the UI via /api/status
95
+ self.error = f"{type(e).__name__}: {e}"
96
+ self.state = "error"
97
+
98
+ def _register(self, provider: ActivationProvider, text: str, max_tokens: int) -> Run:
99
+ t0 = time.perf_counter()
100
+ ids, tokens, truncated = provider.tokenize(text, max_tokens)
101
+ specs = {s.id: s for s in provider.activations()}
102
+ keys = ("n_layers", "hidden_size", "intermediate_size", "n_heads", "n_kv_heads", "head_dim")
103
+ return Run(run_id=uuid.uuid4().hex[:12], model_id=provider.model_id, text=text, token_ids=list(ids),
104
+ tokens=list(tokens), truncated=truncated, specs=specs,
105
+ model={k: provider.info.get(k) for k in keys}, elapsed_ms=(time.perf_counter() - t0) * 1000)
106
+
107
+ async def run(self, text: str, max_tokens: int) -> Run:
108
+ """Tokenize and register a run. No activations are captured yet."""
109
+ if self.state != "ready" or self.provider is None:
110
+ raise HTTPException(409, f"Model not ready (state: {self.state})")
111
+ loop = asyncio.get_running_loop()
112
+ try:
113
+ run = await loop.run_in_executor(self.executor, self._register, self.provider, text, max_tokens)
114
+ except ValueError as e:
115
+ raise HTTPException(400, str(e))
116
+ self.runs[run.run_id] = run
117
+ while len(self.runs) > MAX_RUNS:
118
+ old, _ = self.runs.popitem(last=False)
119
+ self.cache.drop_run(old)
120
+ return run
121
+
122
+ def get(self, run_id: str) -> Run:
123
+ run = self.runs.get(run_id)
124
+ if run is None:
125
+ raise HTTPException(404, "Unknown run (it may have been evicted); run the prompt again")
126
+ self.runs.move_to_end(run_id)
127
+ return run
128
+
129
+ def capture(self, run: Run, act: str) -> Capture:
130
+ """Blocking (call from a worker thread): return the cached capture, building it on the model thread if needed.
131
+ Concurrent callers for the same (run, act) share one forward pass."""
132
+ provider = self.provider
133
+ if provider is None or run.run_id not in self.runs or provider.model_id != run.model_id:
134
+ raise HTTPException(404, "Unknown run (it may have been evicted); run the prompt again")
135
+
136
+ def build() -> Capture:
137
+ arr = self.executor.submit(provider.capture, run.token_ids, act).result()
138
+ return Capture(act=act, arr=arr)
139
+
140
+ try:
141
+ cap = self.cache.get((run.run_id, act), build)
142
+ except HTTPException:
143
+ raise
144
+ except ValueError as e:
145
+ raise HTTPException(400, str(e))
146
+ except Exception as e:
147
+ raise HTTPException(500, f"Capture of {act!r} failed: {type(e).__name__}: {e}")
148
+ if run.run_id not in self.runs: # run was evicted while we were capturing
149
+ self.cache.drop_run(run.run_id)
150
+ return cap
151
+
152
+
153
+ class LoadRequest(BaseModel):
154
+ model_id: str
155
+ device: str = "auto"
156
+ dtype: str = "float32"
157
+
158
+
159
+ class RunRequest(BaseModel):
160
+ text: str
161
+ max_tokens: int = 512
162
+
163
+
164
+ def create_app(manager: ModelManager | None = None, autoload: bool = True, model_id: str = DEFAULT_MODEL,
165
+ device: str = "auto", dtype: str = "float32", token: str | None = None) -> FastAPI:
166
+ mgr = manager or ModelManager()
167
+
168
+ @asynccontextmanager
169
+ async def lifespan(_: FastAPI):
170
+ if autoload and mgr.state == "idle":
171
+ mgr.load(model_id, device, dtype)
172
+ yield
173
+
174
+ app = FastAPI(title="ActLens", lifespan=lifespan)
175
+ app.state.manager = mgr
176
+
177
+ if token:
178
+ # Access token for a server that is reachable from outside (e.g. a tunnel from Colab). Open
179
+ # `/?token=...` once: the token is stored in an HttpOnly cookie and the URL is cleaned up.
180
+ # Scripts can send `Authorization: Bearer <token>` instead.
181
+ def valid(candidate: str | None) -> bool:
182
+ return bool(candidate) and hmac.compare_digest(candidate.encode(), token.encode())
183
+
184
+ @app.middleware("http")
185
+ async def require_token(request: Request, call_next):
186
+ bearer = request.headers.get("authorization", "")
187
+ if valid(request.cookies.get(TOKEN_COOKIE)) or valid(bearer.removeprefix("Bearer ").strip()):
188
+ return await call_next(request)
189
+ if request.method == "GET" and valid(request.query_params.get("token")):
190
+ url = request.url.remove_query_params("token")
191
+ resp = RedirectResponse(url.path + (f"?{url.query}" if url.query else ""), status_code=303)
192
+ # Lax, not Strict: the link is usually opened from another site (Colab), and a Strict cookie is
193
+ # not sent on the redirect that follows a cross-site navigation.
194
+ https = "https" in (request.url.scheme, request.headers.get("x-forwarded-proto", ""))
195
+ resp.set_cookie(TOKEN_COOKIE, token, httponly=True, samesite="lax", secure=https)
196
+ return resp
197
+ return PlainTextResponse("ActLens: missing or invalid access token. Open the full link printed "
198
+ "by the server (it ends in ?token=...).", status_code=401)
199
+
200
+ def guarded(fn, *a, **kw):
201
+ try:
202
+ return fn(*a, **kw)
203
+ except ValueError as e:
204
+ raise HTTPException(400, str(e))
205
+
206
+ def token_capture(run_id: str, act: str, layer: int | None = None) -> Capture:
207
+ """Validate `act` (a token-kind activation of this run's model) and capture it lazily."""
208
+ run = mgr.get(run_id)
209
+ spec = run.specs.get(act)
210
+ if spec is None:
211
+ raise HTTPException(400, f"unknown act {act!r}; expected one of {list(run.specs)}")
212
+ if spec.kind != "token":
213
+ raise HTTPException(400, f"act {act!r} is an attention pattern; use the /attn endpoints")
214
+ if layer is not None and not 0 <= layer < spec.n_layers:
215
+ raise HTTPException(400, f"layer {layer} out of range for {act} (0..{spec.n_layers - 1})")
216
+ return mgr.capture(run, act)
217
+
218
+ def attn_capture(run_id: str) -> Capture:
219
+ run = mgr.get(run_id)
220
+ if ATTN_ACT not in run.specs:
221
+ raise HTTPException(400, "this model does not expose attention patterns")
222
+ return mgr.capture(run, ATTN_ACT)
223
+
224
+ # ----- models / corpus -----
225
+ @app.get("/api/status")
226
+ def status():
227
+ return {**mgr.status(), "presets": PRESET_MODELS}
228
+
229
+ @app.post("/api/models/load", status_code=202)
230
+ def load_model(req: LoadRequest):
231
+ mgr.load(req.model_id, req.device, req.dtype)
232
+ return mgr.status()
233
+
234
+ @app.get("/api/corpus")
235
+ def corpus():
236
+ return CORPUS
237
+
238
+ # ----- runs -----
239
+ @app.post("/api/run")
240
+ async def run(req: RunRequest):
241
+ r = await mgr.run(req.text, max(1, min(req.max_tokens, 1024)))
242
+ return {
243
+ "run_id": r.run_id, "model_id": r.model_id, "tokens": r.tokens, "token_ids": r.token_ids,
244
+ "truncated": r.truncated, "elapsed_ms": round(r.elapsed_ms, 1), "model": r.model,
245
+ "activations": [sp.info() for sp in r.specs.values()],
246
+ }
247
+
248
+ @app.get("/api/run/{run_id}/overview")
249
+ def overview(run_id: str, act: str = "resid_post", stat: str = "norm", dim: int = 0):
250
+ cap = token_capture(run_id, act)
251
+ arr, meta = guarded(slicing.overview, cap, stat, dim)
252
+ return frame_response(meta, arr)
253
+
254
+ @app.get("/api/run/{run_id}/slice")
255
+ def slice_(run_id: str, act: str, layer: int, t0: int = 0, t1: int = 64, d0: int = 0, d1: int = 128,
256
+ max_h: int = Query(512, ge=1, le=2048), max_w: int = Query(1024, ge=1, le=4096),
257
+ agg: str = "absmax", order: str = "natural"):
258
+ cap = token_capture(run_id, act, layer)
259
+ arr, meta = guarded(slicing.token_dim_slice, cap, layer, t0, t1, d0, d1, max_h, max_w, agg, order)
260
+ return frame_response(meta, arr)
261
+
262
+ @app.get("/api/run/{run_id}/profile")
263
+ def profile(run_id: str, act: str, layer: int, order: str = "natural", bins: int = Query(512, ge=8, le=4096)):
264
+ cap = token_capture(run_id, act, layer)
265
+ arr, meta = guarded(slicing.dim_profile, cap, layer, order, bins)
266
+ return frame_response(meta, arr)
267
+
268
+ @app.get("/api/run/{run_id}/stats")
269
+ def stats(run_id: str, act: str, layer: int, t0: int = 0, t1: int = 64, d0: int = 0, d1: int = 128,
270
+ order: str = "natural", clip: bool = False):
271
+ cap = token_capture(run_id, act, layer)
272
+ return guarded(slicing.token_region_stats, cap, layer, t0, t1, d0, d1, order, clip)
273
+
274
+ @app.get("/api/run/{run_id}/axis_stats")
275
+ def axis_stats(run_id: str, act: str, layer: int, axis: str = "channel", stat: str = "norm",
276
+ t0: int = 0, t1: int = 64, d0: int = 0, d1: int = 128, order: str = "natural",
277
+ clip: bool = False, top: int = 10, bins: int = 64):
278
+ cap = token_capture(run_id, act, layer)
279
+ return guarded(slicing.axis_stats, cap, layer, axis, stat, t0, t1, d0, d1, order, clip, top, bins)
280
+
281
+ @app.get("/api/run/{run_id}/attn")
282
+ def attn(run_id: str, layer: int, head: int = -1, q0: int = 0, q1: int = 100000, k0: int = 0, k1: int = 100000,
283
+ max_q: int = Query(512, ge=1, le=1024), max_k: int = Query(512, ge=1, le=1024), agg: str = "max"):
284
+ cap = attn_capture(run_id)
285
+ if not 0 <= layer < cap.n_layers:
286
+ raise HTTPException(400, f"layer {layer} out of range (0..{cap.n_layers - 1})")
287
+ arr, meta = guarded(slicing.attn_slice, cap, layer, head, q0, q1, k0, k1, max_q, max_k, agg)
288
+ return frame_response(meta, arr)
289
+
290
+ @app.get("/api/run/{run_id}/attn_overview")
291
+ def attn_overview(run_id: str, stat: str = "entropy"):
292
+ cap = attn_capture(run_id)
293
+ arr, meta = guarded(slicing.attn_overview, cap, stat)
294
+ return frame_response(meta, arr)
295
+
296
+ @app.get("/api/run/{run_id}/attn_stats")
297
+ def attn_stats(run_id: str, layer: int, head: int, q0: int = 0, q1: int = 100000, k0: int = 0, k1: int = 100000,
298
+ clip: bool = False):
299
+ cap = attn_capture(run_id)
300
+ if not (0 <= layer < cap.n_layers and 0 <= head < cap.n_heads):
301
+ raise HTTPException(400, "layer/head out of range")
302
+ return guarded(slicing.attn_region_stats, cap, layer, head, q0, q1, k0, k1, clip)
303
+
304
+ # ----- static frontend (production build) -----
305
+ if (FRONTEND_DIST / "index.html").is_file():
306
+ app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
307
+
308
+ @app.get("/{path:path}")
309
+ def spa(path: str):
310
+ if path.startswith("api/"):
311
+ raise HTTPException(404)
312
+ f = FRONTEND_DIST / path
313
+ return FileResponse(f if path and f.is_file() else FRONTEND_DIST / "index.html")
314
+
315
+ return app
316
+
317
+
318
+ app = create_app()
@@ -0,0 +1,7 @@
1
+ """Architecture adapters: how each model family exposes its activations. See `base.ArchAdapter`."""
2
+ from . import gpt2, llama # noqa: F401 (importing registers the built-in adapters)
3
+ from .base import ActDef, ArchAdapter, Dims, PreNormBlockAdapter, TraceCtx, get, has
4
+ from .registry import ADAPTERS, load_plugins, register, resolve_adapter, supported_model_types
5
+
6
+ __all__ = ["ActDef", "ArchAdapter", "Dims", "PreNormBlockAdapter", "TraceCtx", "ADAPTERS", "get", "has",
7
+ "load_plugins", "register", "resolve_adapter", "supported_model_types"]
actlens/archs/base.py ADDED
@@ -0,0 +1,153 @@
1
+ """The architecture-adapter contract.
2
+
3
+ An adapter answers two questions about one family of Hugging Face models: *which* activations does it expose, and
4
+ *how* is each one read from a block during a forward pass. Everything else (tokenizing, tracing, caching, the HTTP
5
+ API, the UI) is architecture-neutral and only sees the resulting `ActivationSpec`s and arrays.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable
11
+
12
+ import torch
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Dims:
17
+ """Model dimensions the provider and the specs need. `inter` is None for models without an MLP width."""
18
+ n_layers: int
19
+ hidden: int
20
+ inter: int | None
21
+ n_heads: int
22
+ n_kv_heads: int
23
+ head_dim: int
24
+
25
+
26
+ @dataclass
27
+ class TraceCtx:
28
+ """Per-capture state handed to readers. `aux` holds values an adapter computes once before the blocks run
29
+ (see `ArchAdapter.prepare`), e.g. the RoPE cos/sin tables."""
30
+ n_tokens: int
31
+ aux: dict[str, Any] = field(default_factory=dict)
32
+
33
+ def host(self, t: torch.Tensor, dtype: torch.dtype = torch.float32) -> torch.Tensor:
34
+ return t.detach().to(dtype).cpu()
35
+
36
+ def tok(self, t: torch.Tensor) -> torch.Tensor:
37
+ """[1, T, C] or [1, T, heads, Dh] -> host float32 [T, C]."""
38
+ return self.host(t).reshape(self.n_tokens, -1)
39
+
40
+
41
+ # (block, ctx) -> host tensor: [T, C] float32 for token activations, [H, T, T] float16 for attention patterns.
42
+ # Runs inside an nnsight trace, so `block.<module>.input/output` are lazy proxies; readers must touch modules in
43
+ # forward-execution order and use `ctx` to move data to the host.
44
+ Reader = Callable[[Any, TraceCtx], torch.Tensor]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ActDef:
49
+ """How one activation is exposed on one architecture. `label`/`description` override the registry defaults
50
+ (use them to name the model's real modules); channels/n_heads/head_dim become the `ActivationSpec`."""
51
+ read: Reader
52
+ channels: int | None = None # None only for attention patterns
53
+ n_heads: int | None = None # set when the channel axis is heads x head_dim (attention patterns: n_heads)
54
+ head_dim: int | None = None
55
+ label: str | None = None
56
+ description: str | None = None
57
+
58
+
59
+ class ArchAdapter:
60
+ """Base class. Subclass, fill in the class attributes, implement `probe`, `dims` and `acts`, and register with
61
+ `@register` (see `archs/gpt2.py` for a small complete example)."""
62
+
63
+ name: str = ""
64
+ # `config.model_type` values that select this adapter directly. Adapters are also tried structurally (`probe`),
65
+ # so a model with a different `model_type` but the same module layout works without being listed here.
66
+ model_types: tuple[str, ...] = ()
67
+ layers_path: str = "" # dotted path from the model to the list of blocks, e.g. "model.layers"
68
+
69
+ def probe(self, root: torch.nn.Module) -> list[str]:
70
+ """Paths this adapter needs but `root` lacks. An empty list means the model is compatible."""
71
+ raise NotImplementedError
72
+
73
+ def dims(self, root: torch.nn.Module) -> Dims:
74
+ raise NotImplementedError
75
+
76
+ def acts(self, root: torch.nn.Module, dims: Dims) -> dict[str, ActDef]:
77
+ """The activations this model exposes. Keys are registry ids (`capture.REGISTRY`); anything the model
78
+ does not have (QK-norm, RoPE, a gate, ...) is simply left out. Display order is the registry's."""
79
+ raise NotImplementedError
80
+
81
+ def prepare(self, model, ctx: TraceCtx, act: str) -> None:
82
+ """Runs inside the trace before any block executes; may stash values in `ctx.aux` for readers.
83
+ Only do work `act` needs, since every hook adds cost."""
84
+
85
+
86
+ # ----- helpers shared by adapters -----
87
+ def get(obj, path: str):
88
+ """Resolve a dotted path; all-digit parts index. Works on `nn.Module`s and nnsight envoys alike."""
89
+ for part in path.split("."):
90
+ obj = obj[int(part)] if part.isdigit() else getattr(obj, part)
91
+ return obj
92
+
93
+
94
+ def has(obj, path: str) -> bool:
95
+ try:
96
+ get(obj, path)
97
+ except (AttributeError, IndexError, TypeError, KeyError):
98
+ return False
99
+ return True
100
+
101
+
102
+ def missing_paths(root, paths: list[str], block_paths: list[str], layers_path: str) -> list[str]:
103
+ """Which of `paths` (relative to the model) and `block_paths` (relative to block 0) are absent from `root`."""
104
+ missing = [p for p in [layers_path, *paths] if not has(root, p)]
105
+ if missing:
106
+ return missing
107
+ return [f"{layers_path}.0.{p}" for p in block_paths if not has(get(root, layers_path)[0], p)]
108
+
109
+
110
+ def layer_output(block) -> torch.Tensor:
111
+ out = block.output
112
+ return out[0] if isinstance(out, tuple) else out
113
+
114
+
115
+ class PreNormBlockAdapter(ArchAdapter):
116
+ """Sequential pre-norm blocks (`x + attn(norm1(x))`, then `x + mlp(norm2(x))`): Llama, Qwen, Mistral, GPT-2, ...
117
+
118
+ Implements the activations that do not depend on how the projections are laid out (residual stream, norms,
119
+ attention pattern/context/output, MLP output). Subclasses add q/k/v (+ RoPE, QK-norm) and the MLP internals.
120
+ Module names are relative to a block.
121
+ """
122
+
123
+ norm1: str # pre-attention norm
124
+ attn: str
125
+ o_proj: str # attention output projection, relative to the block
126
+ norm2: str # pre-MLP norm
127
+ down_proj: str # MLP output projection, relative to the block
128
+
129
+ def shared_acts(self, d: Dims) -> dict[str, ActDef]:
130
+ n1, n2, o, dn = self.norm1, self.norm2, self.o_proj, self.down_proj
131
+ D, H, Dh = d.hidden, d.n_heads, d.head_dim
132
+ o_name, dn_name = o.rsplit(".", 1)[-1], dn.rsplit(".", 1)[-1] # labels name the module, not its path
133
+
134
+ return {
135
+ "resid_pre": ActDef(lambda b, c: c.tok(get(b, n1).input), D),
136
+ "resid_mid": ActDef(lambda b, c: c.tok(get(b, n2).input), D),
137
+ "resid_post": ActDef(lambda b, c: c.tok(layer_output(b)), D),
138
+ "attn_norm": ActDef(lambda b, c: c.tok(get(b, n1).output), D, label=f"attn_norm — {n1}"),
139
+ "attn_pattern": ActDef(lambda b, c: c.host(get(b, self.attn).output[1], torch.float16)[0],
140
+ None, H, None),
141
+ "attn_ctx": ActDef(lambda b, c: c.tok(get(b, o).input), H * Dh, H, Dh, label=f"attn_ctx — {o_name} input"),
142
+ "o": ActDef(lambda b, c: c.tok(get(b, o).output), D, label=f"o — {o_name} output"),
143
+ "mlp_norm": ActDef(lambda b, c: c.tok(get(b, n2).output), D, label=f"mlp_norm — {n2}"),
144
+ "down": ActDef(lambda b, c: c.tok(get(b, dn).output), D, label=f"down — {dn_name} output"),
145
+ }
146
+
147
+ def probe_paths(self) -> tuple[list[str], list[str]]:
148
+ """(model-level paths, block-level paths) that must exist; subclasses extend."""
149
+ return [], [self.norm1, self.norm2, self.attn, self.o_proj, self.down_proj]
150
+
151
+ def probe(self, root) -> list[str]:
152
+ model_paths, block_paths = self.probe_paths()
153
+ return missing_paths(root, model_paths, block_paths, self.layers_path)
actlens/archs/gpt2.py ADDED
@@ -0,0 +1,42 @@
1
+ """GPT-2 (and DistilGPT-2): LayerNorm pre-norm blocks, fused QKV, learned absolute positions (no RoPE),
2
+ non-gated MLP. A compact example of adding an architecture: a layout, the fused-QKV split and the MLP internals."""
3
+ from __future__ import annotations
4
+
5
+ from .base import ActDef, Dims, PreNormBlockAdapter, get
6
+ from .registry import register
7
+
8
+
9
+ @register
10
+ class GPT2Adapter(PreNormBlockAdapter):
11
+ name = "gpt2"
12
+ model_types = ("gpt2",)
13
+ layers_path = "transformer.h"
14
+ norm1, attn, o_proj = "ln_1", "attn", "attn.c_proj"
15
+ norm2, down_proj = "ln_2", "mlp.c_proj"
16
+
17
+ def probe_paths(self):
18
+ model_paths, block_paths = super().probe_paths()
19
+ return ([*model_paths, "transformer.wte", "transformer.wpe"],
20
+ [*block_paths, "attn.c_attn", "mlp.c_fc", "mlp.c_proj"])
21
+
22
+ def dims(self, root) -> Dims:
23
+ cfg = root.config # GPT2Config maps hidden_size / num_attention_heads onto n_embd / n_head
24
+ return Dims(n_layers=len(get(root, self.layers_path)), hidden=cfg.hidden_size,
25
+ inter=getattr(cfg, "n_inner", None) or 4 * cfg.hidden_size, n_heads=cfg.num_attention_heads,
26
+ n_kv_heads=cfg.num_attention_heads, head_dim=cfg.hidden_size // cfg.num_attention_heads)
27
+
28
+ def acts(self, root, d: Dims) -> dict[str, ActDef]:
29
+ D, H, Dh, I = d.hidden, d.n_heads, d.head_dim, d.inter
30
+ out = self.shared_acts(d)
31
+
32
+ def qkv(i, name): # c_attn's output is [q | k | v] along the channel axis
33
+ return ActDef(lambda b, c: c.tok(b.attn.c_attn.output)[:, i * D:(i + 1) * D], D, H, Dh,
34
+ label=f"{name} — c_attn[{name}]")
35
+
36
+ out |= {
37
+ "q": qkv(0, "q"), "k": qkv(1, "k"), "v": qkv(2, "v"),
38
+ "up": ActDef(lambda b, c: c.tok(b.mlp.c_fc.output), I, label="up — c_fc"),
39
+ # c_proj's input is exactly act(c_fc(x)); reading it avoids depending on how `act` is implemented.
40
+ "mlp_act": ActDef(lambda b, c: c.tok(b.mlp.c_proj.input), I),
41
+ }
42
+ return out
actlens/archs/llama.py ADDED
@@ -0,0 +1,79 @@
1
+ """Llama-style decoders: Llama, Qwen2/2.5/3, Mistral, SmolLM, ... (gated MLP, RoPE, optional QK-norm, GQA)."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+
6
+ from .base import ActDef, Dims, PreNormBlockAdapter, TraceCtx, get, has
7
+ from .registry import register
8
+ from .rope import apply_rope
9
+
10
+ ROPE_ACTS = ("q_rope", "k_rope")
11
+
12
+
13
+ @register
14
+ class LlamaAdapter(PreNormBlockAdapter):
15
+ name = "llama"
16
+ model_types = ("llama", "qwen2", "qwen3", "mistral")
17
+ layers_path = "model.layers"
18
+ norm1, attn, o_proj = "input_layernorm", "self_attn", "self_attn.o_proj"
19
+ norm2, down_proj = "post_attention_layernorm", "mlp.down_proj"
20
+
21
+ def probe_paths(self):
22
+ model_paths, block_paths = super().probe_paths()
23
+ return ([*model_paths, "model.embed_tokens"],
24
+ [*block_paths, "self_attn.q_proj", "self_attn.k_proj", "self_attn.v_proj",
25
+ "mlp.gate_proj", "mlp.up_proj", "mlp.act_fn"])
26
+
27
+ def dims(self, root) -> Dims:
28
+ cfg = root.config
29
+ n_heads = cfg.num_attention_heads
30
+ return Dims(n_layers=len(get(root, self.layers_path)), hidden=cfg.hidden_size,
31
+ inter=cfg.intermediate_size, n_heads=n_heads,
32
+ n_kv_heads=getattr(cfg, "num_key_value_heads", None) or n_heads,
33
+ head_dim=getattr(cfg, "head_dim", None) or cfg.hidden_size // n_heads)
34
+
35
+ def acts(self, root, d: Dims) -> dict[str, ActDef]:
36
+ block0 = get(root, self.layers_path)[0]
37
+ has_qk_norm = has(block0, "self_attn.q_norm") and has(block0, "self_attn.k_norm")
38
+ has_rope = has(root, "model.rotary_emb")
39
+ act_fn_is_module = isinstance(block0.mlp.act_fn, torch.nn.Module)
40
+ nH, nKV, Dh, I = d.n_heads, d.n_kv_heads, d.head_dim, d.inter
41
+ out = self.shared_acts(d)
42
+
43
+ def proj(name, heads):
44
+ return ActDef(lambda b, c: c.tok(get(b, f"self_attn.{name}_proj").output), heads * Dh, heads, Dh)
45
+
46
+ def qk_norm(name, heads):
47
+ return ActDef(lambda b, c: c.tok(get(b, f"self_attn.{name}_norm").output), heads * Dh, heads, Dh)
48
+
49
+ def rope(name, heads):
50
+ src = f"self_attn.{name}_norm" if has_qk_norm else f"self_attn.{name}_proj"
51
+
52
+ def read(b, c: TraceCtx):
53
+ x = c.host(get(b, src).output).reshape(c.n_tokens, heads, Dh)
54
+ return apply_rope(x, *c.aux["rope"]).reshape(c.n_tokens, -1)
55
+ return ActDef(read, heads * Dh, heads, Dh)
56
+
57
+ out |= {"q": proj("q", nH), "k": proj("k", nKV), "v": proj("v", nKV)}
58
+ if has_qk_norm:
59
+ out |= {"q_norm": qk_norm("q", nH), "k_norm": qk_norm("k", nKV)}
60
+ if has_rope:
61
+ out |= {"q_rope": rope("q", nH), "k_rope": rope("k", nKV)}
62
+
63
+ def silu(b, c):
64
+ if act_fn_is_module:
65
+ return c.tok(b.mlp.act_fn.output)
66
+ return c.tok(torch.nn.functional.silu(b.mlp.gate_proj.output))
67
+
68
+ out |= {
69
+ "gate": ActDef(lambda b, c: c.tok(b.mlp.gate_proj.output), I),
70
+ "up": ActDef(lambda b, c: c.tok(b.mlp.up_proj.output), I),
71
+ "silu": ActDef(silu, I),
72
+ "swiglu": ActDef(lambda b, c: c.tok(b.mlp.down_proj.input), I),
73
+ }
74
+ return out
75
+
76
+ def prepare(self, model, ctx: TraceCtx, act: str) -> None:
77
+ if act in ROPE_ACTS: # cos/sin are produced before the first block runs
78
+ cos, sin = model.model.rotary_emb.output
79
+ ctx.aux["rope"] = (ctx.host(cos), ctx.host(sin))