brainpatch 1.2.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 (82) hide show
  1. brainpatch/__init__.py +92 -0
  2. brainpatch/backends/__init__.py +19 -0
  3. brainpatch/backends/llamacpp.py +383 -0
  4. brainpatch/backends/mlx_backend.py +213 -0
  5. brainpatch/backends/transformers_backend.py +473 -0
  6. brainpatch/backends/vllm_backend.py +299 -0
  7. brainpatch/backends/vllm_worker.py +129 -0
  8. brainpatch/cli.py +825 -0
  9. brainpatch/config.py +245 -0
  10. brainpatch/datasets/__init__.py +20 -0
  11. brainpatch/datasets/contrast_sets.py +64 -0
  12. brainpatch/evaluation/__init__.py +28 -0
  13. brainpatch/evaluation/metrics.py +223 -0
  14. brainpatch/patch/__init__.py +64 -0
  15. brainpatch/patch/compiler.py +324 -0
  16. brainpatch/patch/format.py +489 -0
  17. brainpatch/patch/loader.py +312 -0
  18. brainpatch/patch/registry.py +300 -0
  19. brainpatch/patch/tensors.py +236 -0
  20. brainpatch/patch/validation.py +157 -0
  21. brainpatch/paths.py +184 -0
  22. brainpatch/py.typed +0 -0
  23. brainpatch/research/__init__.py +16 -0
  24. brainpatch/research/antisycophancy.py +348 -0
  25. brainpatch/research/behaviour_eval.py +711 -0
  26. brainpatch/research/generation_eval.py +346 -0
  27. brainpatch/research/ml/__init__.py +35 -0
  28. brainpatch/research/ml/activation_store.py +232 -0
  29. brainpatch/research/ml/causal.py +386 -0
  30. brainpatch/research/ml/corpus.py +165 -0
  31. brainpatch/research/ml/evaluation.py +188 -0
  32. brainpatch/research/ml/extraction.py +464 -0
  33. brainpatch/research/ml/feature_analysis.py +317 -0
  34. brainpatch/research/ml/generation.py +109 -0
  35. brainpatch/research/ml/hooks.py +183 -0
  36. brainpatch/research/ml/intervention.py +274 -0
  37. brainpatch/research/ml/model.py +219 -0
  38. brainpatch/research/ml/patch_search.py +337 -0
  39. brainpatch/research/ml/runtime.py +343 -0
  40. brainpatch/research/ml/sae.py +383 -0
  41. brainpatch/research/ml/training.py +376 -0
  42. brainpatch/research/stance_rubric.py +170 -0
  43. brainpatch/research/sycophancy_data.py +982 -0
  44. brainpatch/research/sycophancy_data_r1.py +1701 -0
  45. brainpatch/research/sycophancy_data_v2.py +1649 -0
  46. brainpatch/research/sycophancy_data_v3.py +2288 -0
  47. brainpatch/research/sycophancy_v2_build.py +362 -0
  48. brainpatch/research/sycophancy_v3_build.py +188 -0
  49. brainpatch/research/utility_probe.py +139 -0
  50. brainpatch/runtime/__init__.py +50 -0
  51. brainpatch/runtime/auto.py +157 -0
  52. brainpatch/runtime/base.py +311 -0
  53. brainpatch/runtime/capabilities.py +96 -0
  54. brainpatch/runtime/model.py +260 -0
  55. brainpatch/runtime/scheduling.py +13 -0
  56. brainpatch/schemas/__init__.py +35 -0
  57. brainpatch/schemas/contrast.py +161 -0
  58. brainpatch/schemas/feature.py +193 -0
  59. brainpatch/schemas/manifest.py +167 -0
  60. brainpatch/schemas/patch.py +379 -0
  61. brainpatch/schemas/patch_io.py +88 -0
  62. brainpatch/schemas/sae.py +146 -0
  63. brainpatch/server/__init__.py +11 -0
  64. brainpatch/server/app.py +269 -0
  65. brainpatch/steering/__init__.py +13 -0
  66. brainpatch/steering/plan.py +177 -0
  67. brainpatch/steering/schedule.py +138 -0
  68. brainpatch/ui/__init__.py +11 -0
  69. brainpatch/ui/app.py +201 -0
  70. brainpatch/verify/__init__.py +66 -0
  71. brainpatch/verify/behavioural.py +156 -0
  72. brainpatch/verify/checks.py +204 -0
  73. brainpatch/verify/corruptions.py +335 -0
  74. brainpatch/verify/report.py +133 -0
  75. brainpatch/verify/vectors.py +95 -0
  76. brainpatch/verify/workflow.py +331 -0
  77. brainpatch-1.2.0.dist-info/METADATA +556 -0
  78. brainpatch-1.2.0.dist-info/RECORD +82 -0
  79. brainpatch-1.2.0.dist-info/WHEEL +5 -0
  80. brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
  81. brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
  82. brainpatch-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,188 @@
1
+ """Utility-retention probes.
2
+
3
+ Steering a residual stream toward a target behaviour is worthless if it also
4
+ breaks arithmetic, instruction-following, or basic factual recall. These probes
5
+ are the "did we damage the model" half of every intervention experiment.
6
+
7
+ They are small, exact-match, model-free-to-score, and require no paid API. They
8
+ are also deliberately easy: the point is to detect *breakage*, not to rank model
9
+ capability. A baseline Qwen2.5-1.5B-Instruct should get nearly all of them, so
10
+ a drop is signal rather than noise.
11
+
12
+ Like the contrast sets, these are hand-written development fixtures. A score
13
+ here is not a benchmark result.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Sequence
21
+
22
+ #: (prompt, accepted answer patterns). Matching is case-insensitive substring
23
+ #: on the normalized generation, which tolerates the model's phrasing.
24
+ UTILITY_PROBES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
25
+ ("arithmetic", "What is 17 + 25? Reply with just the number.", ("42",)),
26
+ ("arithmetic", "What is 8 times 7? Reply with just the number.", ("56",)),
27
+ ("arithmetic", "What is 100 divided by 4? Reply with just the number.", ("25",)),
28
+ ("factual_qa", "What is the capital of France? Reply with just the city name.", ("paris",)),
29
+ ("factual_qa", "What is the chemical symbol for water? Reply with just the symbol.", ("h2o",)),
30
+ ("factual_qa", "How many continents are there? Reply with just the number.", ("7", "seven")),
31
+ (
32
+ "instruction_following",
33
+ "Reply with exactly the word BANANA and nothing else.",
34
+ ("banana",),
35
+ ),
36
+ (
37
+ "instruction_following",
38
+ "List the first three positive even numbers, separated by commas.",
39
+ ("2, 4, 6", "2,4,6"),
40
+ ),
41
+ (
42
+ "reasoning",
43
+ "If all cats are mammals and Whiskers is a cat, is Whiskers a mammal? Answer yes or no.",
44
+ ("yes",),
45
+ ),
46
+ (
47
+ "reasoning",
48
+ "Tom is older than Ana. Ana is older than Bo. Who is youngest? Reply with just the name.",
49
+ ("bo",),
50
+ ),
51
+ )
52
+
53
+ #: Open-ended continuation prompts. Scored only for fluency/degeneration, since
54
+ #: there is no single correct answer.
55
+ CONTINUATION_PROBES: tuple[str, ...] = (
56
+ "Write two sentences about the sea.",
57
+ "Explain what a database index does, in about thirty words.",
58
+ "Describe the process of making tea.",
59
+ )
60
+
61
+
62
+ def normalize(text: str) -> str:
63
+ """Lowercase, collapse whitespace, strip surrounding punctuation."""
64
+ return re.sub(r"\s+", " ", text.strip().lower())
65
+
66
+
67
+ def probe_correct(generation: str, accepted: Sequence[str]) -> bool:
68
+ """Whether any accepted answer appears in the generation."""
69
+ normalized = normalize(generation)
70
+ return any(normalize(answer) in normalized for answer in accepted)
71
+
72
+
73
+ @dataclass
74
+ class UtilityReport:
75
+ """Capability retention under one condition."""
76
+
77
+ condition: str
78
+ total: int
79
+ correct: int
80
+ by_category: dict[str, dict[str, int]] = field(default_factory=dict)
81
+ continuation_degeneration_rate: float = 0.0
82
+ mean_continuation_words: float = 0.0
83
+ generations: list[dict[str, Any]] = field(default_factory=list)
84
+
85
+ @property
86
+ def accuracy(self) -> float:
87
+ return self.correct / self.total if self.total else 0.0
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ return {
91
+ "condition": self.condition,
92
+ "total": self.total,
93
+ "correct": self.correct,
94
+ "accuracy": self.accuracy,
95
+ "by_category": {
96
+ cat: {**counts, "accuracy": counts["correct"] / counts["total"]}
97
+ for cat, counts in self.by_category.items()
98
+ },
99
+ "continuation_degeneration_rate": self.continuation_degeneration_rate,
100
+ "mean_continuation_words": self.mean_continuation_words,
101
+ }
102
+
103
+
104
+ def run_utility_probes(
105
+ model: Any,
106
+ *,
107
+ condition: str = "baseline",
108
+ generation: Any = None,
109
+ include_continuations: bool = True,
110
+ ) -> UtilityReport:
111
+ """Run the capability probes against a (possibly patched) model.
112
+
113
+ Parameters
114
+ ----------
115
+ model:
116
+ A :class:`~brainpatch.research.ml.runtime.BrainPatchedModel`. Whatever patches
117
+ are currently installed are active, which is the point: this is called
118
+ once with them disabled and once with them enabled.
119
+ """
120
+ from brainpatch.evaluation.metrics import score_generation
121
+ from brainpatch.research.ml.generation import GenerationConfig
122
+
123
+ cfg = generation or GenerationConfig(max_new_tokens=48)
124
+ report = UtilityReport(condition=condition, total=0, correct=0)
125
+
126
+ for category, prompt, accepted in UTILITY_PROBES:
127
+ text = model.generate(prompt, config=cfg)
128
+ ok = probe_correct(text, accepted)
129
+ report.total += 1
130
+ report.correct += int(ok)
131
+ bucket = report.by_category.setdefault(category, {"total": 0, "correct": 0})
132
+ bucket["total"] += 1
133
+ bucket["correct"] += int(ok)
134
+ report.generations.append(
135
+ {
136
+ "category": category,
137
+ "prompt": prompt,
138
+ "generation": text,
139
+ "accepted": list(accepted),
140
+ "correct": ok,
141
+ }
142
+ )
143
+
144
+ if include_continuations:
145
+ degenerate = 0
146
+ words: list[int] = []
147
+ long_cfg = GenerationConfig(max_new_tokens=96, do_sample=cfg.do_sample, seed=cfg.seed)
148
+ for prompt in CONTINUATION_PROBES:
149
+ text = model.generate(prompt, config=long_cfg)
150
+ metrics = score_generation(text)
151
+ degenerate += int(metrics.degeneration_flag)
152
+ words.append(metrics.num_words)
153
+ report.generations.append(
154
+ {
155
+ "category": "continuation",
156
+ "prompt": prompt,
157
+ "generation": text,
158
+ "degeneration_flag": metrics.degeneration_flag,
159
+ "num_words": metrics.num_words,
160
+ }
161
+ )
162
+ report.continuation_degeneration_rate = degenerate / len(CONTINUATION_PROBES)
163
+ report.mean_continuation_words = sum(words) / len(words)
164
+
165
+ return report
166
+
167
+
168
+ def compare_utility(baseline: UtilityReport, patched: UtilityReport) -> dict[str, Any]:
169
+ """Quantify capability change between two conditions."""
170
+ return {
171
+ "baseline": baseline.to_dict(),
172
+ "patched": patched.to_dict(),
173
+ "accuracy_delta": patched.accuracy - baseline.accuracy,
174
+ "degeneration_delta": (
175
+ patched.continuation_degeneration_rate - baseline.continuation_degeneration_rate
176
+ ),
177
+ "length_ratio": (
178
+ patched.mean_continuation_words / baseline.mean_continuation_words
179
+ if baseline.mean_continuation_words
180
+ else None
181
+ ),
182
+ "note": (
183
+ f"{baseline.total} hand-written probes. A drop is a signal worth "
184
+ "investigating, not evidence of degradation: at this sample size a "
185
+ "one-item change carries no statistical weight. The absolute score is "
186
+ "not a benchmark result."
187
+ ),
188
+ }
@@ -0,0 +1,464 @@
1
+ """Activation extraction into immutable, resumable shards.
2
+
3
+ The output of a run is a directory on the Volume::
4
+
5
+ /vol/activations/<experiment>/
6
+ manifest.json provenance + shard index
7
+ examples.jsonl one row per token block (text stored once)
8
+ shard_000000.safetensors activations + token metadata
9
+ shard_000001.safetensors
10
+ ...
11
+
12
+ Two properties this module is built around:
13
+
14
+ **Immutability.** A shard, once written and recorded in the manifest, is never
15
+ touched again. A run that dies mid-shard leaves an unrecorded partial file that
16
+ the next run simply overwrites; everything already in the manifest is safe.
17
+
18
+ **No string duplication.** Storing the surrounding text next to every one of
19
+ hundreds of thousands of activations would multiply the corpus size by an order
20
+ of magnitude. Instead each activation row carries
21
+ ``(example_index, token_position, token_id)`` as int32, and the text lives once
22
+ in ``examples.jsonl``. Recovering the context for a high-activating token is a
23
+ lookup, not a scan.
24
+
25
+ The first ``skip_first_n_tokens`` positions of each block are dropped by
26
+ default. Position 0 was measured to carry an extreme residual-stream activation
27
+ outlier -- norm 11052 against a corpus mean of ~70 at layer 18 of
28
+ Qwen2.5-1.5B-Instruct, a factor of 156. Including it distorts the input
29
+ normalization and spends dictionary capacity on a single positional artifact.
30
+
31
+ Outliers at the first token are commonly attributed to attention-sink
32
+ behaviour. That is plausible here but unverified: no attention weights were
33
+ measured, so the docstring records the outlier, not a mechanism.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import time
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+ from typing import Any, Callable
43
+
44
+ import torch
45
+
46
+ from brainpatch.research.ml.corpus import CorpusConfig, TokenBlock, batched, iter_token_blocks
47
+ from brainpatch.research.ml.hooks import ResidualCapture
48
+ from brainpatch.research.ml.model import ModelBundle, validate_hook, validate_layer
49
+ from brainpatch.paths import VolumePaths, shard_filename
50
+ from brainpatch.schemas.manifest import ActivationManifest, ShardRecord
51
+
52
+
53
+ @dataclass
54
+ class ExtractionConfig:
55
+ """Everything that determines what gets extracted and how."""
56
+
57
+ experiment: str
58
+ layer: int = 18
59
+ hook: str = "residual_post"
60
+ target_tokens: int = 20_000
61
+ shard_size: int = 100_000
62
+ batch_size: int = 8
63
+ #: Drop this many leading positions per block (measured position-0 outlier).
64
+ skip_first_n_tokens: int = 1
65
+ #: Storage dtype. bfloat16 is lossless relative to a bf16 forward pass and
66
+ #: halves the corpus size versus float32.
67
+ store_dtype: str = "bfloat16"
68
+ seed: int = 0
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return {
72
+ "experiment": self.experiment,
73
+ "layer": self.layer,
74
+ "hook": self.hook,
75
+ "target_tokens": self.target_tokens,
76
+ "shard_size": self.shard_size,
77
+ "batch_size": self.batch_size,
78
+ "skip_first_n_tokens": self.skip_first_n_tokens,
79
+ "store_dtype": self.store_dtype,
80
+ "seed": self.seed,
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class ExtractionResult:
86
+ """Measured outcome of an extraction run -- the basis for cost estimates."""
87
+
88
+ manifest: ActivationManifest
89
+ tokens_written: int
90
+ seconds: float
91
+ tokens_per_second: float
92
+ bytes_per_token: float
93
+ peak_vram_mb: float
94
+ resumed_from: int
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ return {
98
+ "experiment": self.manifest.experiment,
99
+ "tokens_written": self.tokens_written,
100
+ "completed_tokens": self.manifest.completed_tokens,
101
+ "num_shards": len(self.manifest.shards),
102
+ "seconds": round(self.seconds, 3),
103
+ "tokens_per_second": round(self.tokens_per_second, 1),
104
+ "bytes_per_token": round(self.bytes_per_token, 2),
105
+ "peak_vram_mb": round(self.peak_vram_mb, 1),
106
+ "resumed_from_tokens": self.resumed_from,
107
+ }
108
+
109
+
110
+ def load_manifest(paths: VolumePaths, experiment: str) -> ActivationManifest | None:
111
+ """Read an existing manifest, or ``None`` if this is a fresh run."""
112
+ path = Path(paths.activation_manifest(experiment))
113
+ if not path.is_file():
114
+ return None
115
+ manifest = ActivationManifest.from_json(path.read_text(encoding="utf-8"))
116
+ manifest.validate()
117
+ return manifest
118
+
119
+
120
+ def _write_manifest(paths: VolumePaths, manifest: ActivationManifest) -> None:
121
+ manifest.validate()
122
+ path = Path(paths.activation_manifest(manifest.experiment))
123
+ path.parent.mkdir(parents=True, exist_ok=True)
124
+ path.write_text(manifest.to_json(), encoding="utf-8")
125
+
126
+
127
+ def _write_shard(
128
+ paths: VolumePaths,
129
+ experiment: str,
130
+ index: int,
131
+ activations: torch.Tensor,
132
+ meta: torch.Tensor,
133
+ ) -> ShardRecord:
134
+ """Write one immutable shard and return its manifest record."""
135
+ from safetensors.torch import save_file
136
+
137
+ path = Path(paths.activation_shard(experiment, index))
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ save_file(
140
+ {"activations": activations.contiguous(), "meta": meta.contiguous()},
141
+ str(path),
142
+ metadata={"format": "brainpatch-activations-v0.1"},
143
+ )
144
+ size = path.stat().st_size
145
+ return ShardRecord(
146
+ index=index,
147
+ filename=shard_filename(index),
148
+ num_tokens=int(activations.shape[0]),
149
+ first_example=int(meta[0, 0].item()),
150
+ last_example=int(meta[-1, 0].item()),
151
+ bytes=size,
152
+ )
153
+
154
+
155
+ def extract_activations(
156
+ bundle: ModelBundle,
157
+ corpus_cfg: CorpusConfig,
158
+ cfg: ExtractionConfig,
159
+ paths: VolumePaths,
160
+ *,
161
+ force: bool = False,
162
+ commit: Callable[[], None] | None = None,
163
+ provenance: dict[str, Any] | None = None,
164
+ ) -> ExtractionResult:
165
+ """Extract residual-stream activations into sharded storage.
166
+
167
+ Parameters
168
+ ----------
169
+ force:
170
+ Discard any existing manifest and start over. Without this, an existing
171
+ run is resumed, which is the safe default for expensive GPU work.
172
+ commit:
173
+ Called after each shard lands, to flush the Modal Volume. Passing
174
+ ``volume.commit`` makes the run durable against container loss.
175
+
176
+ Returns
177
+ -------
178
+ ExtractionResult
179
+ Real measurements -- tokens/sec, bytes/token, peak VRAM -- which are
180
+ what later cost projections are built from.
181
+ """
182
+ layer = validate_layer(cfg.layer, bundle.num_layers)
183
+ hook_name = validate_hook(cfg.hook)
184
+ store_dtype = getattr(torch, cfg.store_dtype)
185
+
186
+ manifest = None if force else load_manifest(paths, cfg.experiment)
187
+ if manifest is not None:
188
+ _assert_manifest_compatible(manifest, bundle, cfg, corpus_cfg, layer, hook_name)
189
+ if manifest.is_complete:
190
+ print(
191
+ f"[extraction] {cfg.experiment}: already complete "
192
+ f"({manifest.completed_tokens:,} tokens). Pass force=True to redo."
193
+ )
194
+ return ExtractionResult(
195
+ manifest=manifest,
196
+ tokens_written=0,
197
+ seconds=0.0,
198
+ tokens_per_second=0.0,
199
+ bytes_per_token=manifest.bytes_per_token or 0.0,
200
+ peak_vram_mb=0.0,
201
+ resumed_from=manifest.completed_tokens,
202
+ )
203
+ else:
204
+ manifest = ActivationManifest(
205
+ experiment=cfg.experiment,
206
+ model=bundle.model_id,
207
+ model_revision=bundle.revision,
208
+ layer=layer,
209
+ hook=hook_name,
210
+ hidden_size=bundle.hidden_size,
211
+ dtype=cfg.store_dtype,
212
+ dataset=corpus_cfg.dataset_id,
213
+ dataset_split=corpus_cfg.split,
214
+ sequence_length=corpus_cfg.sequence_length,
215
+ requested_tokens=cfg.target_tokens,
216
+ shard_size=cfg.shard_size,
217
+ seed=cfg.seed,
218
+ created_at=_now(),
219
+ provenance=dict(provenance or {}),
220
+ )
221
+
222
+ resumed_from = manifest.completed_tokens
223
+ start_example = manifest.num_examples
224
+ remaining = cfg.target_tokens - manifest.completed_tokens
225
+
226
+ examples_path = Path(paths.activation_examples(cfg.experiment))
227
+ examples_path.parent.mkdir(parents=True, exist_ok=True)
228
+ if force and examples_path.exists():
229
+ examples_path.unlink()
230
+
231
+ torch.manual_seed(cfg.seed)
232
+ if torch.cuda.is_available():
233
+ torch.cuda.reset_peak_memory_stats()
234
+
235
+ layer_module = bundle.layer_module(layer)
236
+ capture = ResidualCapture(to_cpu=True, dtype=store_dtype)
237
+
238
+ act_buffer: list[torch.Tensor] = []
239
+ meta_buffer: list[torch.Tensor] = []
240
+ buffered = 0
241
+ shard_index = manifest.next_shard_index
242
+ tokens_written = 0
243
+ example_index = start_example
244
+
245
+ print(
246
+ f"[extraction] {cfg.experiment}: layer {layer} ({hook_name}), "
247
+ f"target {cfg.target_tokens:,} tokens, resuming from {resumed_from:,}"
248
+ )
249
+ start_time = time.perf_counter()
250
+
251
+ handle = capture.attach(layer_module)
252
+ try:
253
+ with open(examples_path, "a", encoding="utf-8") as examples_file:
254
+ blocks = iter_token_blocks(bundle.tokenizer, corpus_cfg, start_example=start_example)
255
+ for batch in batched(blocks, cfg.batch_size):
256
+ if tokens_written >= remaining:
257
+ break
258
+
259
+ acts, metas = _forward_batch(
260
+ bundle, capture, batch, cfg.skip_first_n_tokens, store_dtype
261
+ )
262
+ if acts.shape[0] == 0:
263
+ continue
264
+
265
+ # Never overshoot the requested token count.
266
+ budget = remaining - tokens_written
267
+ if acts.shape[0] > budget:
268
+ acts = acts[:budget]
269
+ metas = metas[:budget]
270
+
271
+ for block in batch:
272
+ examples_file.write(
273
+ json.dumps(
274
+ {
275
+ "index": block.example_index,
276
+ "source_doc": block.source_doc,
277
+ "char_offset": block.char_offset,
278
+ "num_tokens": len(block.input_ids),
279
+ "text": block.text,
280
+ },
281
+ ensure_ascii=False,
282
+ )
283
+ + "\n"
284
+ )
285
+ example_index = max(example_index, block.example_index + 1)
286
+
287
+ act_buffer.append(acts)
288
+ meta_buffer.append(metas)
289
+ buffered += acts.shape[0]
290
+ tokens_written += acts.shape[0]
291
+
292
+ if buffered >= cfg.shard_size:
293
+ examples_file.flush()
294
+ shard_index, buffered = _flush_shard(
295
+ paths, cfg, manifest, act_buffer, meta_buffer, shard_index, example_index
296
+ )
297
+ _write_manifest(paths, manifest)
298
+ if commit is not None:
299
+ commit()
300
+
301
+ examples_file.flush()
302
+
303
+ if buffered > 0:
304
+ shard_index, buffered = _flush_shard(
305
+ paths, cfg, manifest, act_buffer, meta_buffer, shard_index, example_index
306
+ )
307
+ finally:
308
+ handle.remove()
309
+
310
+ elapsed = time.perf_counter() - start_time
311
+ manifest.num_examples = example_index
312
+ manifest.updated_at = _now()
313
+ manifest.provenance.update(provenance or {})
314
+ manifest.provenance["extraction_seconds"] = round(elapsed, 3)
315
+ _write_manifest(paths, manifest)
316
+ if commit is not None:
317
+ commit()
318
+
319
+ peak_vram = (
320
+ torch.cuda.max_memory_allocated() / 1024**2 if torch.cuda.is_available() else 0.0
321
+ )
322
+ return ExtractionResult(
323
+ manifest=manifest,
324
+ tokens_written=tokens_written,
325
+ seconds=elapsed,
326
+ tokens_per_second=tokens_written / elapsed if elapsed > 0 else 0.0,
327
+ bytes_per_token=manifest.bytes_per_token or 0.0,
328
+ peak_vram_mb=peak_vram,
329
+ resumed_from=resumed_from,
330
+ )
331
+
332
+
333
+ def _flush_shard(
334
+ paths: VolumePaths,
335
+ cfg: ExtractionConfig,
336
+ manifest: ActivationManifest,
337
+ act_buffer: list[torch.Tensor],
338
+ meta_buffer: list[torch.Tensor],
339
+ shard_index: int,
340
+ example_index: int,
341
+ ) -> tuple[int, int]:
342
+ """Concatenate the buffer into one shard, record it, clear the buffer."""
343
+ activations = torch.cat(act_buffer, dim=0)
344
+ meta = torch.cat(meta_buffer, dim=0)
345
+ record = _write_shard(paths, cfg.experiment, shard_index, activations, meta)
346
+ manifest.shards.append(record)
347
+ manifest.completed_tokens += record.num_tokens
348
+ manifest.num_examples = example_index
349
+ manifest.updated_at = _now()
350
+ act_buffer.clear()
351
+ meta_buffer.clear()
352
+ print(
353
+ f"[extraction] shard {shard_index:06d}: {record.num_tokens:,} tokens, "
354
+ f"{record.bytes / 1024**2:.1f} MB (total {manifest.completed_tokens:,})"
355
+ )
356
+ return shard_index + 1, 0
357
+
358
+
359
+ @torch.inference_mode()
360
+ def _forward_batch(
361
+ bundle: ModelBundle,
362
+ capture: ResidualCapture,
363
+ batch: list[TokenBlock],
364
+ skip_first_n: int,
365
+ store_dtype: torch.dtype,
366
+ ) -> tuple[torch.Tensor, torch.Tensor]:
367
+ """Run one batch and return (activations, metadata) for kept positions.
368
+
369
+ Blocks in a batch may differ in length (the trailing block of a document is
370
+ shorter), so they are right-padded and the padded positions are then
371
+ excluded via the attention mask -- padded activations are meaningless and
372
+ must never enter the corpus.
373
+ """
374
+ lengths = [len(b.input_ids) for b in batch]
375
+ max_len = max(lengths)
376
+ pad_id = bundle.tokenizer.pad_token_id
377
+ if pad_id is None:
378
+ pad_id = bundle.tokenizer.eos_token_id or 0
379
+
380
+ input_ids = torch.full((len(batch), max_len), pad_id, dtype=torch.long)
381
+ attention_mask = torch.zeros((len(batch), max_len), dtype=torch.long)
382
+ for i, block in enumerate(batch):
383
+ n = len(block.input_ids)
384
+ input_ids[i, :n] = torch.tensor(block.input_ids, dtype=torch.long)
385
+ attention_mask[i, :n] = 1
386
+
387
+ input_ids = input_ids.to(bundle.device)
388
+ attention_mask = attention_mask.to(bundle.device)
389
+
390
+ capture.activations = None
391
+ bundle.model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
392
+ hidden = capture.activations
393
+ if hidden is None:
394
+ raise RuntimeError("capture hook did not fire -- wrong module or model layout changed")
395
+
396
+ keep_acts: list[torch.Tensor] = []
397
+ keep_meta: list[torch.Tensor] = []
398
+ for i, block in enumerate(batch):
399
+ n = lengths[i]
400
+ if n <= skip_first_n:
401
+ continue
402
+ positions = torch.arange(skip_first_n, n, dtype=torch.int32)
403
+ keep_acts.append(hidden[i, skip_first_n:n, :].to(store_dtype))
404
+ meta = torch.stack(
405
+ [
406
+ torch.full((positions.numel(),), block.example_index, dtype=torch.int32),
407
+ positions,
408
+ torch.tensor(block.input_ids[skip_first_n:n], dtype=torch.int32),
409
+ ],
410
+ dim=1,
411
+ )
412
+ keep_meta.append(meta)
413
+
414
+ if not keep_acts:
415
+ empty_dtype = store_dtype
416
+ return (
417
+ torch.empty((0, bundle.hidden_size), dtype=empty_dtype),
418
+ torch.empty((0, 3), dtype=torch.int32),
419
+ )
420
+ return torch.cat(keep_acts, dim=0), torch.cat(keep_meta, dim=0)
421
+
422
+
423
+ def _assert_manifest_compatible(
424
+ manifest: ActivationManifest,
425
+ bundle: ModelBundle,
426
+ cfg: ExtractionConfig,
427
+ corpus_cfg: CorpusConfig,
428
+ layer: int,
429
+ hook: str,
430
+ ) -> None:
431
+ """Refuse to append activations from a different setup to an existing corpus.
432
+
433
+ Silently mixing layer-17 and layer-18 activations would produce a corpus
434
+ that trains an SAE on nothing coherent, and the failure would be invisible.
435
+ """
436
+ mismatches: list[str] = []
437
+ if manifest.model != bundle.model_id:
438
+ mismatches.append(f"model: manifest={manifest.model} run={bundle.model_id}")
439
+ if manifest.layer != layer:
440
+ mismatches.append(f"layer: manifest={manifest.layer} run={layer}")
441
+ if manifest.hook != hook:
442
+ mismatches.append(f"hook: manifest={manifest.hook} run={hook}")
443
+ if manifest.hidden_size != bundle.hidden_size:
444
+ mismatches.append(f"hidden_size: manifest={manifest.hidden_size} run={bundle.hidden_size}")
445
+ if manifest.dtype != cfg.store_dtype:
446
+ mismatches.append(f"dtype: manifest={manifest.dtype} run={cfg.store_dtype}")
447
+ if manifest.sequence_length != corpus_cfg.sequence_length:
448
+ mismatches.append(
449
+ f"sequence_length: manifest={manifest.sequence_length} run={corpus_cfg.sequence_length}"
450
+ )
451
+ if manifest.dataset != corpus_cfg.dataset_id:
452
+ mismatches.append(f"dataset: manifest={manifest.dataset} run={corpus_cfg.dataset_id}")
453
+ if mismatches:
454
+ raise ValueError(
455
+ f"cannot resume extraction for {manifest.experiment!r}: configuration changed.\n "
456
+ + "\n ".join(mismatches)
457
+ + "\nUse a new experiment name, or pass force=True to discard the existing corpus."
458
+ )
459
+
460
+
461
+ def _now() -> str:
462
+ from datetime import datetime, timezone
463
+
464
+ return datetime.now(timezone.utc).isoformat()