gutenberg-sdk 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.
gutenberg/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from gutenberg.client import gutenberg
4
+
5
+ try:
6
+ __version__ = version("gutenberg-sdk")
7
+ except PackageNotFoundError: # editable / source checkout without metadata
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ __all__ = ["gutenberg", "__version__"]
gutenberg/client.py ADDED
@@ -0,0 +1,682 @@
1
+ """Gutenberg Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+ from gutenberg.models import (
11
+ activation_response,
12
+ aggregation_info,
13
+ autointerp_run_info,
14
+ dataset_info,
15
+ dataset_phenomenon,
16
+ experiment_info,
17
+ feature_example,
18
+ feature_score,
19
+ interpret_response,
20
+ job_info,
21
+ meta_autointerp_report,
22
+ meta_autointerp_run_info,
23
+ model_info,
24
+ sae_info,
25
+ )
26
+
27
+ DEFAULT_BASE_URL = "https://api.gutenberg.ai"
28
+
29
+
30
+ class gutenberg:
31
+ """Client for the Gutenberg SAE Activation API.
32
+
33
+ Usage:
34
+ from gutenberg import gutenberg
35
+
36
+ client = gutenberg(api_key="gtn_xxx")
37
+ result = client.activations("I like cats")
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None,
43
+ base_url: str | None = None,
44
+ timeout: float = 120,
45
+ ):
46
+ import os
47
+ if not api_key:
48
+ api_key = os.environ.get("GUTENBERG_API_KEY", "")
49
+ if not api_key:
50
+ raise ValueError(
51
+ "API key required. Pass api_key= or set GUTENBERG_API_KEY env var."
52
+ )
53
+ if base_url is None:
54
+ base_url = os.environ.get("GUTENBERG_API_URL", DEFAULT_BASE_URL)
55
+
56
+ if base_url != DEFAULT_BASE_URL:
57
+ import sys
58
+ print(f"[gutenberg] hitting {base_url}", file=sys.stderr)
59
+
60
+ self._http = httpx.Client(
61
+ base_url=base_url,
62
+ headers={"Authorization": f"Bearer {api_key}"},
63
+ timeout=timeout,
64
+ )
65
+ self.jobs = _jobs(self._http)
66
+ self.datasets = _datasets(self._http)
67
+ self.experiments = _experiments(self._http)
68
+ self.aggregations = _aggregations(self._http)
69
+ self.subsets = _subsets(self._http)
70
+ self.autointerp = _autointerp(self._http)
71
+ self.meta_autointerp = _meta_autointerp(self._http)
72
+
73
+ def activations(
74
+ self,
75
+ text: str,
76
+ model_id: str = "google/gemma-3-12b-it",
77
+ sae_id: str | None = None,
78
+ top_k: int = 50,
79
+ ) -> activation_response:
80
+ """Extract SAE activations for a text string (sync).
81
+
82
+ Args:
83
+ sae_id: Optional. Specific SAE to use. If omitted, the model's
84
+ default SAE is used. See `client.saes(model_id=...)` for the
85
+ list per model.
86
+
87
+ Note: the SAE-extraction window size is not user-tunable — the server
88
+ always uses the SAE's registry `max_window_size` (= max of base model
89
+ native context and what was probed for the model+GPU pair). Reducing
90
+ it would mean both more compute *and* worse features.
91
+ """
92
+ body: dict = {
93
+ "text": text,
94
+ "model_id": model_id,
95
+ "top_k": top_k,
96
+ }
97
+ if sae_id is not None:
98
+ body["sae_id"] = sae_id
99
+ r = self._http.post("/v1/activations", json=body)
100
+ r.raise_for_status()
101
+ return activation_response(**r.json())
102
+
103
+ def interpret(
104
+ self,
105
+ examples: list[dict],
106
+ model: str | None = None,
107
+ ) -> interpret_response:
108
+ """Interpret a feature from token/activation examples."""
109
+ body: dict = {"examples": examples}
110
+ if model:
111
+ body["model"] = model
112
+ r = self._http.post("/v1/interpret", json=body)
113
+ r.raise_for_status()
114
+ return interpret_response(**r.json())
115
+
116
+ def models(self) -> list[model_info]:
117
+ """List supported model + SAE combinations.
118
+
119
+ Each `model_info` includes an `available_saes` list. For a flat list
120
+ of all SAEs across models (optionally filtered), use `client.saes()`.
121
+ """
122
+ r = self._http.get("/v1/models")
123
+ r.raise_for_status()
124
+ return [model_info(**m) for m in r.json()["models"]]
125
+
126
+ def saes(self, model_id: str | None = None) -> list[sae_info]:
127
+ """List available SAEs. Optionally filter by `model_id`.
128
+
129
+ SDK-first discovery: `client.saes("Qwen/Qwen3.5-9B-Base")` returns
130
+ every SAE registered for that model, including the default.
131
+ """
132
+ out: list[sae_info] = []
133
+ for m in self.models():
134
+ if model_id is not None and m.model_id != model_id:
135
+ continue
136
+ for s in m.available_saes:
137
+ out.append(sae_info(model_id=m.model_id, **s.model_dump()))
138
+ return out
139
+
140
+ def close(self):
141
+ self._http.close()
142
+
143
+ def __enter__(self):
144
+ return self
145
+
146
+ def __exit__(self, *args):
147
+ self.close()
148
+
149
+
150
+ class _jobs:
151
+ def __init__(self, http: httpx.Client):
152
+ self._http = http
153
+
154
+ def create(
155
+ self,
156
+ dataset_id: str,
157
+ model_id: str = "google/gemma-3-12b-it",
158
+ sae_id: str | None = None,
159
+ top_k: int = 100,
160
+ num_workers: int = 1,
161
+ timeout_seconds: int = 21600,
162
+ ) -> job_info:
163
+ """Submit an async batch activation job.
164
+
165
+ Args:
166
+ sae_id: Optional. Specific SAE to use. If omitted, the model's
167
+ default SAE is used. See `client.saes(model_id=...)`.
168
+ num_workers: Parallel GPU workers. The server partitions the
169
+ dataset across workers and stitches their outputs into one
170
+ parquet. 1 = single-worker (default); higher = faster on
171
+ large datasets at proportional cost.
172
+ timeout_seconds: Per-worker GPU timeout (default 6h).
173
+
174
+ Note: the SAE-extraction window size is not user-tunable — the server
175
+ always uses the SAE's registry `max_window_size`. See
176
+ `activations()` for the rationale.
177
+ """
178
+ sae_config: dict = {
179
+ "model_id": model_id,
180
+ "top_k": top_k,
181
+ }
182
+ if sae_id is not None:
183
+ sae_config["sae_id"] = sae_id
184
+ r = self._http.post("/v1/jobs", json={
185
+ "dataset_id": dataset_id,
186
+ "sae_config": sae_config,
187
+ "job_config": {
188
+ "num_workers": num_workers,
189
+ "timeout_seconds": timeout_seconds,
190
+ },
191
+ })
192
+ r.raise_for_status()
193
+ return job_info(**r.json())
194
+
195
+ def get(self, job_id: str) -> job_info:
196
+ """Get job status."""
197
+ r = self._http.get(f"/v1/jobs/{job_id}")
198
+ r.raise_for_status()
199
+ return job_info(**r.json())
200
+
201
+ def list(self) -> list[job_info]:
202
+ """List all jobs."""
203
+ r = self._http.get("/v1/jobs")
204
+ r.raise_for_status()
205
+ return [job_info(**j) for j in r.json()["jobs"]]
206
+
207
+ def wait(self, job_id: str, poll_interval: float = 5.0) -> job_info:
208
+ """Poll until job completes or fails.
209
+
210
+ Renders a tqdm progress bar driven by `rows_processed / rows_total` if
211
+ tqdm is installed; falls back to a `\\r`-style print otherwise.
212
+ """
213
+ try:
214
+ from tqdm import tqdm # type: ignore
215
+ _has_tqdm = True
216
+ except ImportError:
217
+ tqdm = None # type: ignore
218
+ _has_tqdm = False
219
+
220
+ bar = None
221
+ last_processed = 0
222
+ try:
223
+ while True:
224
+ job = self.get(job_id)
225
+ total = job.rows_total or 0
226
+ processed = job.rows_processed or 0
227
+
228
+ if _has_tqdm and bar is None and total:
229
+ bar = tqdm(total=total, desc=f"job {job_id[:8]}", unit="row")
230
+ if bar is not None:
231
+ bar.update(processed - last_processed)
232
+ last_processed = processed
233
+ elif total:
234
+ print(f"\rjob {job_id[:8]}: {processed}/{total}", end="", flush=True)
235
+
236
+ if job.status in ("completed", "failed"):
237
+ if bar is not None:
238
+ bar.close()
239
+ elif total:
240
+ print() # newline after the \r-style progress
241
+ return job
242
+ time.sleep(poll_interval)
243
+ except BaseException:
244
+ if bar is not None:
245
+ bar.close()
246
+ raise
247
+
248
+ def download(self, job_id: str, output_path: str = "activations.parquet") -> Path:
249
+ """Download job results to a local file."""
250
+ r = self._http.get(f"/v1/jobs/{job_id}/result")
251
+ r.raise_for_status()
252
+ data = r.json()
253
+
254
+ # Download from presigned URL
255
+ with httpx.stream("GET", data["download_url"]) as stream:
256
+ stream.raise_for_status()
257
+ path = Path(output_path)
258
+ with path.open("wb") as f:
259
+ for chunk in stream.iter_bytes():
260
+ f.write(chunk)
261
+ return path
262
+
263
+ def load_activations_df(self, job_id: str):
264
+ """Fetch a completed job's activations parquet and return a pandas DataFrame.
265
+
266
+ Skips the file-on-disk step — useful in notebooks or scripts that diff
267
+ activations across different extraction params (e.g. window_size=1024
268
+ vs window_size=8192 on the same dataset).
269
+
270
+ Requires the `pandas` extra: `pip install gutenberg-sdk[pandas]`.
271
+ """
272
+ try:
273
+ import io
274
+ import pandas as pd # type: ignore
275
+ except ImportError as e:
276
+ raise ImportError(
277
+ "load_activations_df requires pandas + pyarrow. "
278
+ "Install with: pip install gutenberg-sdk[pandas]"
279
+ ) from e
280
+
281
+ r = self._http.get(f"/v1/jobs/{job_id}/result")
282
+ r.raise_for_status()
283
+ data = r.json()
284
+
285
+ buf = io.BytesIO()
286
+ with httpx.stream("GET", data["download_url"]) as stream:
287
+ stream.raise_for_status()
288
+ for chunk in stream.iter_bytes():
289
+ buf.write(chunk)
290
+ buf.seek(0)
291
+ return pd.read_parquet(buf)
292
+
293
+
294
+ class _datasets:
295
+ def __init__(self, http: httpx.Client):
296
+ self._http = http
297
+
298
+ def upload(self, filepath: str | Path) -> dataset_info:
299
+ """Upload a parquet file as a dataset.
300
+
301
+ Gets a presigned URL, PUTs the file to S3, then confirms the upload.
302
+ """
303
+ filepath = Path(filepath)
304
+ if not filepath.exists():
305
+ raise FileNotFoundError(f"{filepath} not found")
306
+
307
+ # Get presigned upload URL
308
+ r = self._http.post("/v1/datasets", json={"filename": filepath.name})
309
+ r.raise_for_status()
310
+ data = r.json()
311
+ dataset_id = data["dataset_id"]
312
+
313
+ # PUT file to S3
314
+ with filepath.open("rb") as f:
315
+ put_r = httpx.put(
316
+ data["upload_url"],
317
+ content=f,
318
+ headers={"Content-Type": "application/octet-stream"},
319
+ timeout=600,
320
+ )
321
+ put_r.raise_for_status()
322
+
323
+ # Confirm upload
324
+ confirm_r = self._http.post(f"/v1/datasets/{dataset_id}/confirm")
325
+ confirm_r.raise_for_status()
326
+ confirm_data = confirm_r.json()
327
+
328
+ return dataset_info(
329
+ dataset_id=dataset_id,
330
+ name=filepath.name,
331
+ size_bytes=confirm_data.get("size_bytes"),
332
+ )
333
+
334
+ def get(self, dataset_id: str) -> dataset_info:
335
+ """Get dataset detail."""
336
+ r = self._http.get(f"/v1/datasets/{dataset_id}")
337
+ r.raise_for_status()
338
+ return dataset_info(**r.json())
339
+
340
+ def list(self) -> list[dataset_info]:
341
+ """List all datasets."""
342
+ r = self._http.get("/v1/datasets")
343
+ r.raise_for_status()
344
+ return [dataset_info(**d) for d in r.json()["datasets"]]
345
+
346
+ def columns(self, dataset_id: str) -> list[str]:
347
+ """Get column names from the dataset parquet."""
348
+ r = self._http.get(f"/v1/datasets/{dataset_id}/columns")
349
+ r.raise_for_status()
350
+ return r.json()["columns"]
351
+
352
+ def update(
353
+ self, dataset_id: str, *,
354
+ name: str | None = None, description: str | None = None,
355
+ ) -> dataset_info:
356
+ """Update dataset display metadata. Only fields passed are changed."""
357
+ body: dict = {}
358
+ if name is not None:
359
+ body["name"] = name
360
+ if description is not None:
361
+ body["description"] = description
362
+ r = self._http.patch(f"/v1/datasets/{dataset_id}", json=body)
363
+ r.raise_for_status()
364
+ return dataset_info(**r.json())
365
+
366
+ def share(self, dataset_id: str) -> dict:
367
+ """Make a dataset publicly accessible. Returns share_url."""
368
+ r = self._http.post(f"/v1/datasets/{dataset_id}/share")
369
+ r.raise_for_status()
370
+ return r.json()
371
+
372
+ def unshare(self, dataset_id: str) -> dict:
373
+ """Revoke public access to a dataset."""
374
+ r = self._http.delete(f"/v1/datasets/{dataset_id}/share")
375
+ r.raise_for_status()
376
+ return r.json()
377
+
378
+
379
+ class _experiments:
380
+ def __init__(self, http: httpx.Client):
381
+ self._http = http
382
+
383
+ def create(
384
+ self,
385
+ job_id: str,
386
+ target_column: str,
387
+ target_column_type: str = "binary",
388
+ positive_value: str | None = None,
389
+ scoring_method: str = "auroc",
390
+ activation_aggregation: str = "max",
391
+ name: str | None = None,
392
+ subset_id: str | None = None,
393
+ ) -> experiment_info:
394
+ """Create a scoring experiment against a target column.
395
+
396
+ Every experiment scores all four aggregations (sum/mean/max/bin) and
397
+ ranks by their averaged composite, server-side on Modal. If the
398
+ aggregate_v2 stats substrate for the experiment's role mask isn't
399
+ ready yet, the experiment is returned as `queued` and dispatched
400
+ automatically when the aggregation completes. Pass `subset_id` to
401
+ scope scoring to a subset's samples. (The legacy single-aggregation
402
+ mode is retired; the old `composite` flag is ignored by the server.)
403
+ """
404
+ body: dict = {
405
+ "job_id": job_id,
406
+ "target_column": target_column,
407
+ "target_column_type": target_column_type,
408
+ "scoring_method": scoring_method,
409
+ "activation_aggregation": activation_aggregation,
410
+ }
411
+ if positive_value is not None:
412
+ body["positive_value"] = positive_value
413
+ if name is not None:
414
+ body["name"] = name
415
+ if subset_id is not None:
416
+ body["subset_id"] = subset_id
417
+ r = self._http.post("/v1/experiments", json=body)
418
+ r.raise_for_status()
419
+ return experiment_info(**r.json())
420
+
421
+ def samples(self, experiment_id: str, offset: int = 0, limit: int = 50) -> dict:
422
+ """List the samples this experiment scored (its subset) + 'N of Y' counts."""
423
+ r = self._http.get(
424
+ f"/v1/experiments/{experiment_id}/samples",
425
+ params={"offset": offset, "limit": limit},
426
+ )
427
+ r.raise_for_status()
428
+ return r.json()
429
+
430
+ def get(self, experiment_id: str) -> experiment_info:
431
+ """Get experiment status and summary."""
432
+ r = self._http.get(f"/v1/experiments/{experiment_id}")
433
+ r.raise_for_status()
434
+ return experiment_info(**r.json())
435
+
436
+ def wait(self, experiment_id: str, poll_interval: float = 5.0) -> experiment_info:
437
+ """Poll until experiment completes or fails."""
438
+ while True:
439
+ exp = self.get(experiment_id)
440
+ if exp.status in ("completed", "failed"):
441
+ return exp
442
+ time.sleep(poll_interval)
443
+
444
+ def features(self, experiment_id: str) -> list[feature_score]:
445
+ """Get ranked features for a completed experiment."""
446
+ r = self._http.get(f"/v1/experiments/{experiment_id}/features")
447
+ r.raise_for_status()
448
+ return [feature_score(**f) for f in r.json()["features"]]
449
+
450
+ def examples(self, experiment_id: str, feature_id: int) -> list[feature_example]:
451
+ """Get token-level examples for a specific feature."""
452
+ r = self._http.get(f"/v1/experiments/{experiment_id}/features/{feature_id}/examples")
453
+ r.raise_for_status()
454
+ return [feature_example(**e) for e in r.json()["examples"]]
455
+
456
+
457
+ class _aggregations:
458
+ """Build the aggregate_v2 scoring substrate for a job.
459
+
460
+ Production runs are auto-chained off extraction (role_mask='all'); use this
461
+ for an explicit re-run, a non-default role_mask, or the sharded strategy on a
462
+ big job. Composite experiments read the resulting per_sample_feature_stats.
463
+ """
464
+
465
+ def __init__(self, http: httpx.Client):
466
+ self._http = http
467
+
468
+ def create(
469
+ self,
470
+ job_id: str,
471
+ role_mask: str = "all",
472
+ tokens_uri: str | None = None,
473
+ tier: str | None = None,
474
+ strategy: str | None = None,
475
+ ) -> aggregation_info:
476
+ """Trigger a hosted aggregate_v2 run (idempotent per (job, role_mask))."""
477
+ body = {"job_id": job_id, "role_mask": role_mask}
478
+ if tokens_uri is not None:
479
+ body["tokens_uri"] = tokens_uri
480
+ if tier is not None:
481
+ body["tier"] = tier
482
+ if strategy is not None:
483
+ body["strategy"] = strategy
484
+ r = self._http.post("/v1/aggregations", json=body)
485
+ r.raise_for_status()
486
+ return aggregation_info(**r.json())
487
+
488
+ def get(self, aggregation_id: str) -> aggregation_info:
489
+ """Get aggregate_v2 run status."""
490
+ r = self._http.get(f"/v1/aggregations/{aggregation_id}")
491
+ r.raise_for_status()
492
+ return aggregation_info(**r.json())
493
+
494
+ def list(self, job_id: str) -> list[aggregation_info]:
495
+ """List aggregate_v2 runs for a job."""
496
+ r = self._http.get("/v1/aggregations", params={"job_id": job_id})
497
+ r.raise_for_status()
498
+ return [aggregation_info(**a) for a in r.json()["aggregations"]]
499
+
500
+ def wait(self, aggregation_id: str, poll_interval: float = 10.0) -> aggregation_info:
501
+ """Poll until the aggregate_v2 run completes or fails."""
502
+ while True:
503
+ agg = self.get(aggregation_id)
504
+ if agg.status in ("completed", "failed"):
505
+ return agg
506
+ time.sleep(poll_interval)
507
+
508
+
509
+ class _autointerp:
510
+ """Hosted feature labeling (autointerp) — LLM explanations of an
511
+ experiment's top features, written to the dataset-scoped explanation store
512
+ (labels survive across experiments on the same dataset+SAE).
513
+
514
+ `create(experiment_id=...)` plans the work server-side: the experiment's
515
+ top-N features by composite, minus features already explained under the
516
+ same config signature, then runs the explain loop on Modal. Returns None
517
+ when everything is already labeled (a noop costs nothing).
518
+ """
519
+
520
+ def __init__(self, http: httpx.Client):
521
+ self._http = http
522
+
523
+ def create(
524
+ self,
525
+ experiment_id: str,
526
+ top_n: int = 200,
527
+ config: dict | None = None,
528
+ feature_ids: list[int] | None = None,
529
+ name: str | None = None,
530
+ ) -> autointerp_run_info | None:
531
+ """Dispatch a hosted autointerp run for an experiment's top features.
532
+
533
+ config overrides merge over the cheap default (haiku-4-5 / 0-shot /
534
+ 20 examples); pass e.g. {"model": "anthropic/claude-sonnet-4-6",
535
+ "kshot_variant": "k9_full"} for the calibrated high-quality config.
536
+ Owner-only. Returns None if all planned features are already explained.
537
+ """
538
+ r = self._http.get(f"/v1/experiments/{experiment_id}")
539
+ r.raise_for_status()
540
+ exp = r.json()
541
+ dataset_id, sae_id = exp.get("dataset_id"), exp.get("sae_id")
542
+ if not dataset_id or not sae_id:
543
+ raise RuntimeError(
544
+ f"experiment {experiment_id} has no dataset/SAE linkage"
545
+ )
546
+ body: dict = {"experiment_id": experiment_id, "top_n": top_n,
547
+ "config": config or {}}
548
+ if feature_ids is not None:
549
+ body["feature_ids"] = feature_ids
550
+ if name is not None:
551
+ body["name"] = name
552
+ r = self._http.post(
553
+ f"/v1/datasets/{dataset_id}/saes/{sae_id}/autointerp/runs", json=body,
554
+ )
555
+ r.raise_for_status()
556
+ js = r.json()
557
+ if js.get("status") == "noop":
558
+ return None
559
+ return autointerp_run_info(**js)
560
+
561
+ def get(self, run_id: str) -> autointerp_run_info:
562
+ """Get autointerp run status/progress."""
563
+ r = self._http.get(f"/v1/autointerp/runs/{run_id}")
564
+ r.raise_for_status()
565
+ return autointerp_run_info(**r.json())
566
+
567
+ def wait(self, run_id: str, poll_interval: float = 5.0) -> autointerp_run_info:
568
+ """Poll until the run finishes. NOTE: autointerp's terminal success
569
+ status is 'complete' (not 'completed' like jobs/experiments)."""
570
+ while True:
571
+ run = self.get(run_id)
572
+ if run.status in ("complete", "failed"):
573
+ return run
574
+ time.sleep(poll_interval)
575
+
576
+ def explanations(self, run_id: str, offset: int = 0, limit: int = 200) -> list[dict]:
577
+ """Explanation rows for a run (explanation, patterns, scores, cost)."""
578
+ r = self._http.get(
579
+ f"/v1/autointerp/runs/{run_id}/explanations",
580
+ params={"offset": offset, "limit": limit},
581
+ )
582
+ r.raise_for_status()
583
+ return r.json()["explanations"]
584
+
585
+
586
+ class _meta_autointerp:
587
+ """Hosted analysis over autointerp'd SAE features.
588
+
589
+ `create(experiment_id=..., interest=...)` derives dataset/SAE server-side,
590
+ chooses the default completed autointerp run unless one is supplied, and
591
+ dispatches a hosted plan-to-report job.
592
+ """
593
+
594
+ def __init__(self, http: httpx.Client):
595
+ self._http = http
596
+
597
+ def create(
598
+ self,
599
+ experiment_id: str,
600
+ interest: str,
601
+ autointerp_run_id: str | None = None,
602
+ name: str | None = None,
603
+ config: dict | None = None,
604
+ ) -> meta_autointerp_run_info:
605
+ body: dict = {"interest": interest, "config": config or {}}
606
+ if autointerp_run_id is not None:
607
+ body["autointerp_run_id"] = autointerp_run_id
608
+ if name is not None:
609
+ body["name"] = name
610
+ r = self._http.post(
611
+ f"/v1/experiments/{experiment_id}/meta-autointerp/runs",
612
+ json=body,
613
+ )
614
+ r.raise_for_status()
615
+ return meta_autointerp_run_info(**r.json())
616
+
617
+ def list(self, experiment_id: str) -> list[meta_autointerp_run_info]:
618
+ r = self._http.get(f"/v1/experiments/{experiment_id}/meta-autointerp/runs")
619
+ r.raise_for_status()
620
+ return [meta_autointerp_run_info(**row) for row in r.json()["runs"]]
621
+
622
+ def get(self, run_id: str) -> meta_autointerp_run_info:
623
+ r = self._http.get(f"/v1/meta-autointerp/runs/{run_id}")
624
+ r.raise_for_status()
625
+ return meta_autointerp_run_info(**r.json())
626
+
627
+ def wait(self, run_id: str, poll_interval: float = 10.0) -> meta_autointerp_run_info:
628
+ while True:
629
+ run = self.get(run_id)
630
+ if run.status in ("complete", "failed"):
631
+ return run
632
+ time.sleep(poll_interval)
633
+
634
+ def phenomena(self, run_id: str) -> list[dataset_phenomenon]:
635
+ r = self._http.get(f"/v1/meta-autointerp/runs/{run_id}/phenomena")
636
+ r.raise_for_status()
637
+ return [dataset_phenomenon(**row) for row in r.json()["phenomena"]]
638
+
639
+ def report(self, run_id: str) -> meta_autointerp_report:
640
+ r = self._http.get(f"/v1/meta-autointerp/runs/{run_id}/report")
641
+ r.raise_for_status()
642
+ return meta_autointerp_report(**r.json())
643
+
644
+
645
+ class _subsets:
646
+ """Named, reusable sample subsets over a dataset (a DuckDB predicate +
647
+ optional role_mask). Reference one from `experiments.create(subset_id=...)`
648
+ to scope scoring to its samples."""
649
+
650
+ def __init__(self, http: httpx.Client):
651
+ self._http = http
652
+
653
+ def create(
654
+ self,
655
+ dataset_id: str,
656
+ name: str,
657
+ filter_sql: str,
658
+ description: str | None = None,
659
+ role_mask: str | None = None,
660
+ ) -> dict:
661
+ """Create a subset from a DuckDB boolean predicate over the dataset
662
+ (e.g. "text_type <> 'ai_edited'"). Returns the subset incl. matched count."""
663
+ body: dict = {"name": name, "filter_sql": filter_sql}
664
+ if description is not None:
665
+ body["description"] = description
666
+ if role_mask is not None:
667
+ body["role_mask"] = role_mask
668
+ r = self._http.post(f"/v1/datasets/{dataset_id}/subsets", json=body)
669
+ r.raise_for_status()
670
+ return r.json()
671
+
672
+ def list(self, dataset_id: str) -> list[dict]:
673
+ """List subsets defined on a dataset."""
674
+ r = self._http.get(f"/v1/datasets/{dataset_id}/subsets")
675
+ r.raise_for_status()
676
+ return r.json()["subsets"]
677
+
678
+ def filterables(self, dataset_id: str) -> list[dict]:
679
+ """List dataset columns + low-cardinality distinct values (predicate aid)."""
680
+ r = self._http.get(f"/v1/datasets/{dataset_id}/filterables")
681
+ r.raise_for_status()
682
+ return r.json()["filterables"]
gutenberg/models.py ADDED
@@ -0,0 +1,202 @@
1
+ """Response models for the Gutenberg SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class feature(BaseModel):
9
+ id: int
10
+ value: float
11
+
12
+
13
+ class token_activations(BaseModel):
14
+ pos: int
15
+ token: str
16
+ features: list[feature]
17
+
18
+
19
+ class activation_response(BaseModel):
20
+ tokens: list[token_activations]
21
+ model_id: str
22
+ sae_id: str
23
+ token_count: int
24
+
25
+
26
+ class interpret_response(BaseModel):
27
+ label: str
28
+ explanation: str
29
+
30
+
31
+ class dataset_info(BaseModel):
32
+ dataset_id: str
33
+ name: str | None = None
34
+ description: str | None = None
35
+ sample_count: int | None = None
36
+ size_bytes: int | None = None
37
+ created_at: str | None = None
38
+
39
+
40
+ class job_info(BaseModel):
41
+ job_id: str
42
+ status: str
43
+ model_id: str | None = None
44
+ sae_id: str | None = None
45
+ top_k: int | None = None
46
+ window_size: int | None = None
47
+ activations_path: str | None = None
48
+ error: str | None = None
49
+ rows_total: int | None = None
50
+ rows_processed: int | None = None
51
+ created_at: str | None = None
52
+ completed_at: str | None = None
53
+
54
+
55
+ class sae_summary(BaseModel):
56
+ sae_id: str
57
+ hook_layer: int
58
+ d_sae: int
59
+ l0: int
60
+ max_window_size: int
61
+ is_default: bool = False
62
+
63
+
64
+ class sae_info(BaseModel):
65
+ """Flat SAE descriptor returned by `client.saes()` — includes `model_id`.
66
+
67
+ `sae_summary` (nested under `model_info.available_saes`) omits `model_id`
68
+ since it's implicit from the parent.
69
+ """
70
+ model_id: str
71
+ sae_id: str
72
+ hook_layer: int
73
+ d_sae: int
74
+ l0: int
75
+ max_window_size: int
76
+ is_default: bool = False
77
+
78
+
79
+ class model_info(BaseModel):
80
+ model_id: str
81
+ vram_gb: int
82
+ # Back-compat with pre-multi-SAE callers; populated from the model's
83
+ # default SAE.
84
+ sae_release: str | None = None
85
+ sae_id: str | None = None
86
+ hook_layer: int | None = None
87
+ max_window_size: int | None = None
88
+ default_sae_id: str | None = None
89
+ available_saes: list[sae_summary] = []
90
+
91
+
92
+ class experiment_info(BaseModel):
93
+ experiment_id: str
94
+ status: str
95
+ name: str | None = None
96
+ target_column: str | None = None
97
+ scoring_method: str | None = None
98
+ summary: str | None = None
99
+ error: str | None = None
100
+ created_at: str | None = None
101
+
102
+
103
+ class aggregation_info(BaseModel):
104
+ aggregation_id: str
105
+ job_id: str
106
+ status: str
107
+ role_mask: str | None = None
108
+ tier: str | None = None
109
+ strategy: str | None = None
110
+ trigger: str | None = None
111
+ output_prefix: str | None = None
112
+ error: str | None = None
113
+ progress: dict | None = None
114
+ created_at: str | None = None
115
+ completed_at: str | None = None
116
+
117
+
118
+ class feature_score(BaseModel):
119
+ feature_id: int
120
+ score: float
121
+ p_value: float
122
+ rank: int
123
+ n_samples: int
124
+ explanation: str | None = None
125
+
126
+
127
+ class feature_example(BaseModel):
128
+ feature_id: int
129
+ sample_id: str
130
+ tokens: list[str]
131
+ values: list[float]
132
+ # pick-semantics v2: which pool the window came from
133
+ # ('strongest' | 'stratified' | 'random' | legacy 'max'), and the window's
134
+ # anchor — (sample_id, anchor_token_pos) is the identity to dedupe on when
135
+ # drawing across pools. None on v1 artifacts / fallback windows.
136
+ pick_class: str | None = None
137
+ anchor_token_pos: int | None = None
138
+
139
+
140
+ class autointerp_run_info(BaseModel):
141
+ id: str
142
+ dataset_id: str | None = None
143
+ sae_id: str | None = None
144
+ name: str | None = None
145
+ status: str
146
+ config: dict | None = None
147
+ error: str | None = None
148
+ n_features: int | None = None
149
+ n_features_completed: int | None = None
150
+ total_cost_usd: float | None = None
151
+ is_default: bool | None = None
152
+ created_at: str | None = None
153
+ completed_at: str | None = None
154
+
155
+
156
+ class meta_autointerp_run_info(BaseModel):
157
+ id: str
158
+ dataset_id: str | None = None
159
+ sae_id: str | None = None
160
+ experiment_id: str
161
+ autointerp_run_id: str | None = None
162
+ name: str | None = None
163
+ interest: str
164
+ config: dict | None = None
165
+ status: str
166
+ stage: str | None = None
167
+ progress: dict | None = None
168
+ report_markdown: str | None = None
169
+ report_data: dict | None = None
170
+ total_tokens_in: int | None = None
171
+ total_tokens_out: int | None = None
172
+ total_cost_usd: float | None = None
173
+ modal_call_id: str | None = None
174
+ error: str | None = None
175
+ created_at: str | None = None
176
+ updated_at: str | None = None
177
+ completed_at: str | None = None
178
+
179
+
180
+ class dataset_phenomenon(BaseModel):
181
+ id: str
182
+ run_id: str
183
+ dataset_id: str
184
+ sae_id: str
185
+ node_id: str
186
+ name: str
187
+ description: str
188
+ trend_direction: str
189
+ interest_relevance: int
190
+ candidate_feature_ids: list[int]
191
+ rubric_text: str
192
+ rubric_version: int
193
+ stats: dict
194
+ trend: dict
195
+ evidence: list[dict]
196
+ created_at: str | None = None
197
+
198
+
199
+ class meta_autointerp_report(BaseModel):
200
+ run_id: str
201
+ report_markdown: str | None = None
202
+ report_data: dict | None = None
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: gutenberg-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Gutenberg SAE Activation API
5
+ Project-URL: Homepage, https://gutenberg.ai
6
+ Project-URL: Console, https://console.gutenberg.ai
7
+ Project-URL: Documentation, https://console.gutenberg.ai/d/docs
8
+ Project-URL: Repository, https://github.com/gutenbergpbc/code
9
+ Author: Gutenberg PBC
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: activations,interpretability,llm,mechanistic-interpretability,observability,sae
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx>=0.28
27
+ Requires-Dist: pydantic>=2.0
28
+ Requires-Dist: tqdm>=4.66
29
+ Provides-Extra: pandas
30
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
31
+ Requires-Dist: pyarrow>=14; extra == 'pandas'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # gutenberg-sdk
35
+
36
+ Python SDK for the **Gutenberg** SAE activation API — interpretability-based
37
+ observability for language models. Upload text, read it through a sparse
38
+ autoencoder (SAE) feature dictionary, and find the features that separate any
39
+ two classes of documents.
40
+
41
+ - **Docs:** https://console.gutenberg.ai/d/docs
42
+ - **Console:** https://console.gutenberg.ai
43
+ - **Get an API key:** https://console.gutenberg.ai/d/keys
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ uv add gutenberg-sdk # or: pip install gutenberg-sdk
49
+ ```
50
+
51
+ The distribution is `gutenberg-sdk`; the import is `gutenberg`:
52
+
53
+ ```python
54
+ from gutenberg import gutenberg
55
+ ```
56
+
57
+ Optional `pandas` extra (for `load_activations_df` and parquet helpers):
58
+
59
+ ```bash
60
+ uv add "gutenberg-sdk[pandas]"
61
+ ```
62
+
63
+ ## Quickstart
64
+
65
+ ```python
66
+ from gutenberg import gutenberg
67
+
68
+ client = gutenberg(api_key="gtn_...") # or set GUTENBERG_API_KEY
69
+
70
+ # 1. upload a parquet dataset (text + a binary target column)
71
+ dataset = client.datasets.upload("examples/simple_binary_features_extraction_100.parquet")
72
+
73
+ # 2. launch hosted SAE feature extraction
74
+ job = client.jobs.create(
75
+ dataset_id=dataset.dataset_id,
76
+ model_id="google/gemma-3-27b-it",
77
+ sae_id="layer_31_width_262k_l0_medium",
78
+ )
79
+ job = client.jobs.wait(job.job_id)
80
+
81
+ # 3. score every feature against the target with AUROC
82
+ exp = client.experiments.create(
83
+ job_id=job.job_id,
84
+ target_column="is_ai",
85
+ target_column_type="binary",
86
+ positive_value="1",
87
+ scoring_method="auroc",
88
+ )
89
+ exp = client.experiments.wait(exp.experiment_id)
90
+
91
+ # 4. read back ranked features and token-level examples
92
+ for feature in client.experiments.features(exp.experiment_id)[:10]:
93
+ print(feature.rank, feature.feature_id, feature.score)
94
+ ```
95
+
96
+ The full runnable script lives in
97
+ [`examples/simple_binary_features_extraction.py`](examples/simple_binary_features_extraction.py),
98
+ with a companion 100-row parquet. On production the whole flow runs in a couple
99
+ of minutes. See [`docs/getting-started.md`](docs/getting-started.md) for the
100
+ walkthrough, SAE selection guidance, and how token examples are served.
101
+
102
+ > The bundled example is a **curated showcase**, not a benchmark — its 50 AI
103
+ > passages were picked to exhibit a handful of recognizable AI-writing features,
104
+ > so those features separate the two classes near-perfectly there. Run it on
105
+ > your own data to see a realistic ranking.
106
+
107
+ ## API surface
108
+
109
+ A single `gutenberg(...)` client with namespaced resources: `datasets`,
110
+ `jobs`, `experiments`, `aggregations`, `autointerp`, `meta_autointerp`,
111
+ `subsets`, plus the sync helpers `activations()`, `interpret()`, `models()`,
112
+ and `saes()`.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,7 @@
1
+ gutenberg/__init__.py,sha256=ePMhRc35gcVWvzXXN1qfsX7uRTYbd6xYUY-0SEIvaHc,300
2
+ gutenberg/client.py,sha256=Ac9hF6Gm9iZlDFsEV93nBH1Dy-b84yW0fPPLgqSCbSs,24701
3
+ gutenberg/models.py,sha256=i9BR3QgnsC5i1W3WN6_Mee3-zoNzFaayvAZiSnhK-As,4945
4
+ gutenberg_sdk-0.1.0.dist-info/METADATA,sha256=vzRkWNEughQGZUUkA_Oh69Sxyacfe7DEAHfXd7Xat3c,3950
5
+ gutenberg_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ gutenberg_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=vrlyLJVXcsI3eM10HkMDoUZ3Q628-SOvCZtDGbogI34,1070
7
+ gutenberg_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gutenberg PBC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.