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 +10 -0
- gutenberg/client.py +682 -0
- gutenberg/models.py +202 -0
- gutenberg_sdk-0.1.0.dist-info/METADATA +116 -0
- gutenberg_sdk-0.1.0.dist-info/RECORD +7 -0
- gutenberg_sdk-0.1.0.dist-info/WHEEL +4 -0
- gutenberg_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|