logogram 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.
Files changed (54) hide show
  1. logogram/__init__.py +6 -0
  2. logogram/__main__.py +5 -0
  3. logogram/analysis.py +419 -0
  4. logogram/atp.py +120 -0
  5. logogram/backends/__init__.py +5 -0
  6. logogram/backends/base.py +202 -0
  7. logogram/backends/hub.py +375 -0
  8. logogram/backends/saes.py +277 -0
  9. logogram/backends/transformer_lens.py +872 -0
  10. logogram/cli.py +496 -0
  11. logogram/compare.py +177 -0
  12. logogram/datasets.py +159 -0
  13. logogram/direct.py +193 -0
  14. logogram/engine.py +550 -0
  15. logogram/examples/ioi-gpt2/.gitignore +3 -0
  16. logogram/examples/ioi-gpt2/datasets/ioi.jsonl +32 -0
  17. logogram/examples/ioi-gpt2/experiments/ioi-head-patching/spec.json +42 -0
  18. logogram/examples/ioi-gpt2/project.json +6 -0
  19. logogram/exports.py +33 -0
  20. logogram/features.py +368 -0
  21. logogram/fileio.py +63 -0
  22. logogram/ioi.py +220 -0
  23. logogram/paths.py +204 -0
  24. logogram/project.py +444 -0
  25. logogram/prompts.py +204 -0
  26. logogram/research.py +84 -0
  27. logogram/results.py +240 -0
  28. logogram/runner.py +396 -0
  29. logogram/runs.py +98 -0
  30. logogram/sae.py +161 -0
  31. logogram/schema.py +302 -0
  32. logogram/server/__init__.py +1 -0
  33. logogram/server/app.py +1083 -0
  34. logogram/server/models.py +426 -0
  35. logogram/server/security.py +212 -0
  36. logogram/server/state.py +585 -0
  37. logogram/sites.py +249 -0
  38. logogram/spec.py +518 -0
  39. logogram/stats.py +171 -0
  40. logogram/steering.py +258 -0
  41. logogram/system.py +379 -0
  42. logogram/updates.py +194 -0
  43. logogram/verify.py +39 -0
  44. logogram/web_dist/assets/index-BvCU-2uy.js +54 -0
  45. logogram/web_dist/assets/index-DTr8_ucV.css +1 -0
  46. logogram/web_dist/assets/instrument-sans-latin-ext-standard-normal-C5E2Gvlv.woff2 +0 -0
  47. logogram/web_dist/assets/instrument-sans-latin-standard-normal-BVScPF0l.woff2 +0 -0
  48. logogram/web_dist/favicon.svg +1 -0
  49. logogram/web_dist/index.html +15 -0
  50. logogram-0.1.0.dist-info/METADATA +550 -0
  51. logogram-0.1.0.dist-info/RECORD +54 -0
  52. logogram-0.1.0.dist-info/WHEEL +4 -0
  53. logogram-0.1.0.dist-info/entry_points.txt +2 -0
  54. logogram-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,426 @@
1
+ """Response models for the JSON API. Request bodies are defined beside their routes in app.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ from logogram.schema import RunListing, SiteSummary
10
+ from logogram.spec import ModelRef, PredictionSettings
11
+
12
+
13
+ class _Out(BaseModel):
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+
17
+ # -- models and jobs ---------------------------------------------------------------------------
18
+
19
+
20
+ class ModelInfoOut(_Out):
21
+ id: str
22
+ revision: str | None
23
+ architecture: str
24
+ n_layers: int
25
+ n_heads: int
26
+ d_model: int
27
+ d_head: int
28
+ d_mlp: int | None
29
+ d_vocab: int
30
+ n_ctx: int
31
+ n_params: int | None
32
+ dtype: str
33
+ device: str
34
+ device_name: str
35
+ process_weights: bool
36
+ site_kinds: list[str]
37
+ backend: str
38
+ backend_version: str
39
+ extra: dict[str, Any]
40
+
41
+
42
+ class ModelStatus(_Out):
43
+ state: Literal["none", "loading", "ready", "error"]
44
+ id: str | None = None
45
+ model_id: str | None = None
46
+ stage: str | None = None
47
+ done: int | None = None
48
+ total: int | None = None
49
+ file: str | None = None
50
+ revision: str | None = None
51
+ error: str | None = None
52
+ info: ModelInfoOut | None = None
53
+ memory: int | None = None
54
+ ref: ModelRef | None = None
55
+
56
+
57
+ class JobInfo(_Out):
58
+ id: str
59
+ kind: Literal["load_model", "load_sae", "run"]
60
+ title: str
61
+ status: Literal["running", "finished", "failed", "cancelled"]
62
+ run_id: str | None
63
+ project_session: str | None = None
64
+ progress: dict[str, Any]
65
+ error: str | None
66
+ started: float
67
+ version: int
68
+ cancelling: bool | None = None
69
+
70
+
71
+ class StartedRun(_Out):
72
+ run_id: str
73
+ job: JobInfo
74
+
75
+
76
+ class CancelOut(_Out):
77
+ job: JobInfo | None
78
+
79
+
80
+ class DraftSaved(_Out):
81
+ run_id: str
82
+ path: str
83
+
84
+
85
+ # -- projects and files ------------------------------------------------------------------------
86
+
87
+
88
+ class DatasetListing(_Out):
89
+ name: str
90
+ path: str
91
+ modified: str | None
92
+ n: int | None = None
93
+ has_positions: bool | None = None
94
+ sha256: str | None = None
95
+ error: str | None = None
96
+
97
+
98
+ class ProjectInfo(_Out):
99
+ name: str
100
+ description: str
101
+ path: str
102
+ session_id: str
103
+ datasets: list[DatasetListing]
104
+
105
+
106
+ class RecentProject(_Out):
107
+ path: str
108
+ name: str
109
+ opened: str | None
110
+ modified: str
111
+
112
+
113
+ class FolderEntry(_Out):
114
+ name: str
115
+ path: str
116
+ is_project: bool
117
+
118
+
119
+ class FolderListing(_Out):
120
+ path: str
121
+ parent: str | None
122
+ is_project: bool
123
+ entries: list[FolderEntry]
124
+
125
+
126
+ class UpdateStatus(_Out):
127
+ current: str
128
+ released: str | None
129
+ automatic: bool | None
130
+ latest: str | None
131
+ available: bool
132
+ old: bool
133
+ checked_at: str | None
134
+ error: str | None
135
+ notes_url: str | None
136
+ command: str
137
+
138
+
139
+ class ServerState(_Out):
140
+ version: str
141
+ project: ProjectInfo | None
142
+ model: ModelStatus
143
+ job: JobInfo | None
144
+ first_run: bool
145
+ theme: Literal["light", "dark", "system"]
146
+ projects_parent: str
147
+ update: UpdateStatus
148
+ sae: dict[str, Any] = {"state": "none"}
149
+
150
+
151
+ class Settings(BaseModel):
152
+ model_config = ConfigDict(extra="allow")
153
+
154
+ system_check_seen: bool | None = None
155
+ theme: str | None = None
156
+ update_check: bool | None = None
157
+
158
+
159
+ class OkOut(_Out):
160
+ ok: bool
161
+
162
+
163
+ # -- datasets and prompts ----------------------------------------------------------------------
164
+
165
+
166
+ class PromptIssue(_Out):
167
+ index: int
168
+ kind: str
169
+ message: str
170
+
171
+
172
+ class DatasetDetail(_Out):
173
+ name: str
174
+ path: str
175
+ sha256: str
176
+ n: int
177
+ records: list[dict[str, Any]]
178
+ issues: list[PromptIssue] | None = None
179
+ lengths: list[int] | None = None
180
+
181
+
182
+ class DatasetCreated(_Out):
183
+ name: str
184
+ path: str
185
+ n: int
186
+
187
+
188
+ class IOITemplateOut(_Out):
189
+ id: str
190
+ text: str
191
+ default: bool
192
+
193
+
194
+ class TokenRow(_Out):
195
+ tokens: list[str]
196
+ ids: list[int]
197
+
198
+
199
+ class AnswerTokens(_Out):
200
+ text: str
201
+ tokens: list[str]
202
+ id: int | None
203
+
204
+
205
+ class TokenStrip(_Out):
206
+ clean: TokenRow
207
+ corrupt: TokenRow
208
+ aligned: bool
209
+ differs: list[int]
210
+ labels: dict[str, int]
211
+ answer: AnswerTokens
212
+ distractor: AnswerTokens
213
+ issues: list[PromptIssue]
214
+
215
+
216
+ # -- analyses ----------------------------------------------------------------------------------
217
+
218
+
219
+ class TopToken(_Out):
220
+ token: str
221
+ id: int
222
+ prob: float
223
+
224
+
225
+ class BaselinePrompt(_Out):
226
+ index: int
227
+ clean: str
228
+ corrupt: str
229
+ answer: str
230
+ distractor: str
231
+ clean_logit_diff: float | None
232
+ corrupt_logit_diff: float | None
233
+ clean_answer_prob: float | None
234
+ corrupt_answer_prob: float | None
235
+ clean_top: list[TopToken]
236
+ corrupt_top: list[TopToken]
237
+
238
+
239
+ class BaselineSummaryOut(_Out):
240
+ clean_logit_diff: float | None
241
+ corrupt_logit_diff: float | None
242
+ gap: float | None
243
+ clean_prefers_answer: int
244
+ corrupt_prefers_answer: int
245
+ clean_answer_prob: float | None
246
+ corrupt_answer_prob: float | None
247
+
248
+
249
+ class BaselineReport(_Out):
250
+ n: int
251
+ prompts: list[BaselinePrompt]
252
+ issues: list[PromptIssue]
253
+ summary: BaselineSummaryOut | None
254
+
255
+
256
+ class AttentionData(_Out):
257
+ layer: int
258
+ head: int
259
+ which: Literal["clean", "corrupt"]
260
+ index: int
261
+ tokens: list[str]
262
+ labels: dict[str, int]
263
+ pattern: list[list[float]]
264
+ average: list[list[float]]
265
+ average_tokens: list[str | None]
266
+ average_labels: dict[str, int]
267
+ n_average: int
268
+ n_total: int
269
+ length: int
270
+
271
+
272
+ class PredictionLayer(_Out):
273
+ layer: int
274
+ top: list[TopToken]
275
+ answer_prob: float
276
+ distractor_prob: float
277
+ logit_diff: float
278
+
279
+
280
+ class PredictionReport(_Out):
281
+ index: int
282
+ position: int
283
+ tokens: list[str]
284
+ settings: PredictionSettings
285
+ layers: list[PredictionLayer]
286
+ batch_members: list[int]
287
+ answer: str
288
+ distractor: str
289
+ issues: list[PromptIssue]
290
+
291
+
292
+ class Presets(_Out):
293
+ presets: list[dict[str, Any]]
294
+ suggestions: list[str]
295
+
296
+
297
+ class ArchitectureOut(_Out):
298
+ n_layers: int
299
+ n_heads: int
300
+ d_model: int
301
+ d_mlp: int
302
+ d_vocab: int
303
+ n_ctx: int
304
+ architecture: str
305
+
306
+
307
+ class MemoryEstimateOut(_Out):
308
+ device: str
309
+ dtype: str
310
+ n_params: int
311
+ weights: int
312
+ activations: int
313
+ margin: int
314
+ total: int
315
+ available: int
316
+ verdict: Literal["fits", "tight", "wont_fit"]
317
+ explanation: str
318
+
319
+
320
+ class EstimateOut(_Out):
321
+ id: str
322
+ revision: str
323
+ architecture: ArchitectureOut
324
+ download_bytes: int
325
+ total_bytes: int
326
+ gated: bool
327
+ # Whether TransformerLens can load this architecture, and if not, why (before downloading).
328
+ supported: bool
329
+ support_note: str | None
330
+ estimate: MemoryEstimateOut
331
+
332
+
333
+ # -- runs --------------------------------------------------------------------------------------
334
+
335
+
336
+ class RunDetail(_Out):
337
+ """A run's files as stored. They were validated when written; older files are passed through."""
338
+
339
+ id: str
340
+ spec: dict[str, Any] | None
341
+ summary: dict[str, Any] | None
342
+ manifest: dict[str, Any] | None
343
+ predictions: PredictionReport | None = None
344
+ listing: RunListing | None
345
+ folder: str
346
+
347
+
348
+ class PromptEvidence(_Out):
349
+ index: int
350
+ clean: str | None
351
+ corrupt: str | None
352
+ answer: str | None
353
+ distractor: str | None
354
+ effect: float | None
355
+ delta: float | None
356
+ patched_logit_diff: float | None
357
+ receiver_logit_diff: float | None
358
+ reference_logit_diff: float | None
359
+ patched_answer_prob: float | None
360
+ receiver_answer_prob: float | None
361
+ flipped: bool
362
+
363
+
364
+ class SiteDetail(_Out):
365
+ run_id: str
366
+ site: SiteSummary
367
+ receiver: Literal["clean", "corrupt"]
368
+ reference: Literal["clean", "corrupt"]
369
+ metric: dict[str, Any]
370
+ statistics: dict[str, Any]
371
+ prompts: list[PromptEvidence]
372
+ strongest: list[int]
373
+ weakest: list[int]
374
+ dataset_changed: bool
375
+ dataset_available: bool
376
+
377
+
378
+ class ComparisonChange(_Out):
379
+ label: str
380
+ kind: str
381
+ layer: int
382
+ head: int | None
383
+ feature: int | None = None
384
+ position_key: str
385
+ variant_key: str | None = None
386
+ index_a: int
387
+ index_b: int
388
+ row: int
389
+ col: int
390
+ effect_a: dict[str, float | None]
391
+ effect_b: dict[str, float | None]
392
+ rank_a: int
393
+ rank_b: int
394
+ flags: list[Literal["sign", "left_top", "entered_top"]]
395
+
396
+
397
+ class DiffCell(_Out):
398
+ index_a: int
399
+ row: int
400
+ col: int
401
+ value: float
402
+
403
+
404
+ class SpecDifference(_Out):
405
+ path: str
406
+ a: Any
407
+ b: Any
408
+
409
+
410
+ class Comparison(_Out):
411
+ run_a: str
412
+ run_b: str
413
+ n_common: int
414
+ n_a: int
415
+ n_b: int
416
+ spearman: float | None
417
+ top_k: int
418
+ top_overlap: int
419
+ top_a: list[str]
420
+ top_b: list[str]
421
+ changes: list[ComparisonChange]
422
+ flagged: list[int]
423
+ n_sign_changes: int
424
+ diff: list[DiffCell]
425
+ same_layout: bool
426
+ spec_differences: list[SpecDifference]
@@ -0,0 +1,212 @@
1
+ """Local server security.
2
+
3
+ * The server binds to 127.0.0.1 only.
4
+ * Every HTTP and WebSocket request must carry the session token: as the HttpOnly cookie set when
5
+ the launch URL (``/?token=...``) is opened, or as an ``Authorization: Bearer`` header.
6
+ * The browser is opened with a single-use launch code instead of the token, because a browser's
7
+ command line can be read by other users of the machine. The URL printed in the terminal carries
8
+ the token itself, for opening more tabs or another browser.
9
+ * The Host header must name this server (defeats DNS rebinding). Requests that carry an Origin
10
+ must come from this server's origin; WebSocket handshakes must carry one, and so must
11
+ cookie-authenticated requests that change anything (or a Fetch Metadata header). Fetch Metadata
12
+ headers must say same-origin. No CORS headers are sent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hmac
18
+ import secrets
19
+ import threading
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from urllib.parse import urlsplit
23
+
24
+ from starlette.types import ASGIApp, Receive, Scope, Send
25
+
26
+ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
27
+ # The launch URL. /api/session also works behind the Vite dev proxy.
28
+ LOGIN_PATHS = {"/", "/api/session"}
29
+
30
+
31
+ def new_token() -> str:
32
+ return secrets.token_urlsafe(32)
33
+
34
+
35
+ @dataclass
36
+ class SecurityConfig:
37
+ token: str
38
+ port: int
39
+ dev_origins: list[str] = field(default_factory=list)
40
+ _launch: tuple[str, float] | None = field(default=None, repr=False)
41
+ _launch_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
42
+
43
+ @property
44
+ def cookie_name(self) -> str:
45
+ return f"logogram_{self.port}"
46
+
47
+ def allowed_hosts(self) -> set[str]:
48
+ hosts = {f"127.0.0.1:{self.port}", f"localhost:{self.port}", f"[::1]:{self.port}"}
49
+ for origin in self.dev_origins:
50
+ hosts.add(urlsplit(origin).netloc)
51
+ return hosts
52
+
53
+ def allowed_origins(self) -> set[str]:
54
+ origins = {f"http://{h}" for h in self.allowed_hosts()}
55
+ origins.update(self.dev_origins)
56
+ return origins
57
+
58
+ def token_ok(self, candidate: str | None) -> bool:
59
+ return bool(candidate) and hmac.compare_digest(candidate.encode(), self.token.encode())
60
+
61
+ def new_launch_code(self, ttl: float = 600.0) -> str:
62
+ """A code that logs in once, within ``ttl`` seconds, for the URL the browser is opened
63
+ with."""
64
+ code = secrets.token_urlsafe(24)
65
+ with self._launch_lock:
66
+ self._launch = (code, time.monotonic() + ttl)
67
+ return code
68
+
69
+ def use_launch_code(self, candidate: str) -> bool:
70
+ with self._launch_lock:
71
+ if self._launch is None or not candidate:
72
+ return False
73
+ code, expires = self._launch
74
+ if time.monotonic() > expires:
75
+ self._launch = None
76
+ return False
77
+ if not hmac.compare_digest(candidate.encode(), code.encode()):
78
+ return False
79
+ self._launch = None # once only
80
+ return True
81
+
82
+
83
+ def _header(scope: Scope, name: bytes) -> str | None:
84
+ for key, value in scope.get("headers", []):
85
+ if key == name:
86
+ return value.decode("latin-1")
87
+ return None
88
+
89
+
90
+ def _cookies(scope: Scope, name: str) -> list[str]:
91
+ """Every cookie called ``name``. Cookies aren't separated by port, so a page served from
92
+ another local port can add one with the same name; it mustn't hide the real one."""
93
+ values = []
94
+ for key, value in scope.get("headers", []):
95
+ if key != b"cookie":
96
+ continue
97
+ for part in value.decode("latin-1").split(";"):
98
+ k, _, v = part.strip().partition("=")
99
+ if k == name:
100
+ values.append(v)
101
+ return values
102
+
103
+
104
+ def _query_token(scope: Scope) -> str | None:
105
+ query = scope.get("query_string", b"").decode("latin-1")
106
+ for part in query.split("&"):
107
+ key, _, value = part.partition("=")
108
+ if key == "token":
109
+ return value
110
+ return None
111
+
112
+
113
+ class SecurityMiddleware:
114
+ """Pure ASGI middleware so it covers HTTP and WebSocket alike."""
115
+
116
+ def __init__(self, app: ASGIApp, config: SecurityConfig) -> None:
117
+ self.app = app
118
+ self.config = config
119
+
120
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
121
+ if scope["type"] not in ("http", "websocket"):
122
+ await self.app(scope, receive, send)
123
+ return
124
+ cfg = self.config
125
+ host = _header(scope, b"host")
126
+ if host not in cfg.allowed_hosts():
127
+ await self._deny(scope, receive, send, 421, "Unknown host.")
128
+ return
129
+ origin = _header(scope, b"origin")
130
+ method = scope.get("method", "GET")
131
+ needs_origin = scope["type"] == "websocket" or method not in SAFE_METHODS
132
+ if origin is not None and origin not in cfg.allowed_origins():
133
+ await self._deny(scope, receive, send, 403, "Cross-origin requests are not allowed.")
134
+ return
135
+ if needs_origin and origin is None and scope["type"] == "websocket":
136
+ await self._deny(scope, receive, send, 403, "Missing origin.")
137
+ return
138
+ # Fetch Metadata: refuse requests started by another site, including other local ports
139
+ # (which count as the same *site*, so SameSite cookies alone don't stop them).
140
+ fetch_site = _header(scope, b"sec-fetch-site")
141
+ if fetch_site is not None and fetch_site not in ("same-origin", "none"):
142
+ await self._deny(scope, receive, send, 403, "Cross-site requests are not allowed.")
143
+ return
144
+
145
+ # Opening the launch URL exchanges the token (or the single-use launch code) for a
146
+ # cookie, then drops it from the URL.
147
+ query_token = _query_token(scope)
148
+ if scope["type"] == "http" and query_token is not None and scope["path"] in LOGIN_PATHS:
149
+ if cfg.token_ok(query_token) or cfg.use_launch_code(query_token):
150
+ await self._login(send)
151
+ else:
152
+ await self._deny(scope, receive, send, 401, _LOGIN_HELP)
153
+ return
154
+
155
+ bearer = _header(scope, b"authorization")
156
+ bearer_token = bearer[7:] if bearer and bearer.lower().startswith("bearer ") else None
157
+ if cfg.token_ok(bearer_token):
158
+ await self.app(scope, receive, send)
159
+ return
160
+ if any(cfg.token_ok(c) for c in _cookies(scope, cfg.cookie_name)):
161
+ # A browser sends the cookie by itself, so a request that changes something must
162
+ # show where it came from (every current browser sends one of these).
163
+ if needs_origin and origin is None and fetch_site is None:
164
+ await self._deny(scope, receive, send, 403, "Missing origin.")
165
+ return
166
+ await self.app(scope, receive, send)
167
+ return
168
+ await self._deny(scope, receive, send, 401, _LOGIN_HELP)
169
+
170
+ async def _login(self, send: Send) -> None:
171
+ cookie = f"{self.config.cookie_name}={self.config.token}; HttpOnly; SameSite=Strict; Path=/"
172
+ await send(
173
+ {
174
+ "type": "http.response.start",
175
+ "status": 303,
176
+ "headers": [
177
+ (b"location", b"/"),
178
+ (b"set-cookie", cookie.encode("latin-1")),
179
+ (b"cache-control", b"no-store"),
180
+ (b"referrer-policy", b"no-referrer"),
181
+ ],
182
+ }
183
+ )
184
+ await send({"type": "http.response.body", "body": b""})
185
+
186
+ async def _deny(
187
+ self, scope: Scope, receive: Receive, send: Send, status: int, text: str
188
+ ) -> None:
189
+ if scope["type"] == "websocket":
190
+ await send({"type": "websocket.close", "code": 4401 if status == 401 else 4403})
191
+ return
192
+ body = text.encode("utf-8")
193
+ await send(
194
+ {
195
+ "type": "http.response.start",
196
+ "status": status,
197
+ "headers": [
198
+ (b"content-type", b"text/plain; charset=utf-8"),
199
+ (b"content-length", str(len(body)).encode()),
200
+ (b"cache-control", b"no-store"),
201
+ ],
202
+ }
203
+ )
204
+ await send({"type": "http.response.body", "body": body})
205
+
206
+
207
+ _LOGIN_HELP = (
208
+ "Logogram needs its session token.\n\n"
209
+ "Open the link printed in the terminal where you started `logogram` "
210
+ "(it ends with ?token=...). Each start creates a new token, and the link the browser was "
211
+ "first opened with works only once.\n"
212
+ )