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,386 @@
1
+ """Causal validation of feature interventions.
2
+
3
+ The question this module answers is *not* "does steering this feature change the
4
+ output" -- almost any large enough perturbation changes the output. It is
5
+ "does steering **this direction** change the output in a way that a
6
+ **scale-matched perturbation in another direction** does not".
7
+
8
+ Conditions run for every prompt:
9
+
10
+ ====================== =========================================================
11
+ ``baseline`` no hook installed at all
12
+ ``zero`` hook installed, strength 0 -- must equal ``baseline``
13
+ ``positive`` the feature direction at ``+strength``
14
+ ``negative`` the feature direction at ``-strength``
15
+ ``random_positive`` a random unit direction at ``+strength`` (same L2 norm)
16
+ ``random_negative`` a random unit direction at ``-strength``
17
+ ``unrelated_positive`` a *different, real* feature at ``+strength``
18
+ ====================== =========================================================
19
+
20
+ The random control isolates "is this direction special". The unrelated-feature
21
+ control isolates "is this feature special, or does any dictionary direction do
22
+ this". The ``zero`` condition is a correctness check on the harness itself.
23
+
24
+ Every generation is stored. There is no filtering, no best-of, and no
25
+ cherry-picking: the artifacts contain what the model actually produced under
26
+ each condition, including the incoherent ones.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any, Sequence
36
+
37
+ from brainpatch.evaluation.metrics import compare_generations, score_generation
38
+ from brainpatch.research.ml.generation import GenerationConfig
39
+ from brainpatch.research.ml.runtime import BrainPatchedModel
40
+ from brainpatch.paths import VolumePaths
41
+ from brainpatch.schemas.patch import BrainPatchSpec, FeatureEdit, SAEReference
42
+
43
+ #: Conditions that constitute the intervention itself.
44
+ INTERVENTION_CONDITIONS = ("positive", "negative")
45
+ #: Conditions that exist to rule out alternative explanations.
46
+ CONTROL_CONDITIONS = ("zero", "random_positive", "random_negative", "unrelated_positive")
47
+
48
+
49
+ @dataclass
50
+ class ConditionResult:
51
+ """One (prompt, condition) generation with its model-free metrics."""
52
+
53
+ condition: str
54
+ prompt_index: int
55
+ prompt: str
56
+ text: str
57
+ feature_id: int | None
58
+ strength: float
59
+ steering_stats: dict[str, Any] = field(default_factory=dict)
60
+ metrics: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "condition": self.condition,
65
+ "prompt_index": self.prompt_index,
66
+ "prompt": self.prompt,
67
+ "text": self.text,
68
+ "feature_id": self.feature_id,
69
+ "strength": self.strength,
70
+ "steering_stats": self.steering_stats,
71
+ "metrics": self.metrics,
72
+ }
73
+
74
+
75
+ def _spec_for(
76
+ model: BrainPatchedModel, feature_id: int, strength: float, name: str
77
+ ) -> BrainPatchSpec:
78
+ """Build a throwaway single-feature patch spec for one condition."""
79
+ assert model.sae is not None and model.sae_reference is not None
80
+ return BrainPatchSpec(
81
+ name=name,
82
+ base_model=model.bundle.model_id,
83
+ model_revision=model.bundle.revision,
84
+ sae=SAEReference(
85
+ reference=model.sae_reference,
86
+ layer=int(model.sae.config.layer),
87
+ hook=model.sae.config.hook or "residual_post",
88
+ d_in=model.sae.d_in,
89
+ d_sae=model.sae.d_sae,
90
+ input_scale=model.input_scale,
91
+ ),
92
+ features=[FeatureEdit(feature_id=feature_id, strength=strength)],
93
+ description="Transient experimental condition; not a shipped patch.",
94
+ evidence_level="none",
95
+ )
96
+
97
+
98
+ def run_intervention_experiment(
99
+ model: BrainPatchedModel,
100
+ prompts: Sequence[str],
101
+ *,
102
+ feature_id: int,
103
+ strength: float,
104
+ unrelated_feature_id: int,
105
+ generation: GenerationConfig | None = None,
106
+ control_seed: int = 1234,
107
+ ) -> list[ConditionResult]:
108
+ """Run every condition over every prompt and return all generations.
109
+
110
+ Generation settings are identical across conditions by construction: the
111
+ same :class:`GenerationConfig` object is passed to each call.
112
+ """
113
+ cfg = generation or GenerationConfig()
114
+ results: list[ConditionResult] = []
115
+
116
+ def run(condition: str, prompt_index: int, prompt: str, **kwargs) -> ConditionResult:
117
+ text = model.generate(prompt, config=cfg, **kwargs)
118
+ return ConditionResult(
119
+ condition=condition,
120
+ prompt_index=prompt_index,
121
+ prompt=prompt,
122
+ text=text,
123
+ feature_id=kwargs.get("_feature_id"),
124
+ strength=kwargs.get("_strength", 0.0),
125
+ steering_stats=model.last_steering_stats,
126
+ metrics=score_generation(text).to_dict(),
127
+ )
128
+
129
+ for i, prompt in enumerate(prompts):
130
+ # --- baseline: nothing installed --------------------------------------
131
+ saved = dict(model.plan.patches)
132
+ model.plan.patches = {}
133
+ baseline_text = model.generate(prompt, config=cfg)
134
+ results.append(
135
+ ConditionResult(
136
+ condition="baseline",
137
+ prompt_index=i,
138
+ prompt=prompt,
139
+ text=baseline_text,
140
+ feature_id=None,
141
+ strength=0.0,
142
+ steering_stats={},
143
+ metrics=score_generation(baseline_text).to_dict(),
144
+ )
145
+ )
146
+ model.plan.patches = saved
147
+
148
+ conditions: list[tuple[str, int, float, str]] = [
149
+ ("zero", feature_id, 0.0, "none"),
150
+ ("positive", feature_id, strength, "none"),
151
+ ("negative", feature_id, -strength, "none"),
152
+ ("random_positive", feature_id, strength, "random"),
153
+ ("random_negative", feature_id, -strength, "random"),
154
+ ("unrelated_positive", unrelated_feature_id, strength, "none"),
155
+ ]
156
+
157
+ for condition, fid, magnitude, control in conditions:
158
+ model.plan.patches = {}
159
+ model.install(_spec_for(model, fid, magnitude, f"cond-{condition}"))
160
+ text = model.generate(prompt, config=cfg, control=control, control_seed=control_seed)
161
+ results.append(
162
+ ConditionResult(
163
+ condition=condition,
164
+ prompt_index=i,
165
+ prompt=prompt,
166
+ text=text,
167
+ feature_id=fid,
168
+ strength=magnitude,
169
+ steering_stats=model.last_steering_stats,
170
+ metrics=score_generation(text).to_dict(),
171
+ )
172
+ )
173
+ model.plan.patches = {}
174
+
175
+ model.plan.patches = saved
176
+
177
+ return results
178
+
179
+
180
+ def summarize_experiment(results: Sequence[ConditionResult]) -> dict[str, Any]:
181
+ """Aggregate per-condition statistics and the zero-strength sanity check.
182
+
183
+ ``divergence_from_baseline`` is ``1 - Jaccard(3-gram)`` between a condition's
184
+ output and the baseline for the same prompt: 0 means identical text, 1 means
185
+ no shared trigrams. Comparing an intervention's divergence against its
186
+ scale-matched random control is the core of the causal claim.
187
+ """
188
+ by_prompt: dict[int, dict[str, ConditionResult]] = {}
189
+ for r in results:
190
+ by_prompt.setdefault(r.prompt_index, {})[r.condition] = r
191
+
192
+ conditions = sorted({r.condition for r in results})
193
+ summary: dict[str, Any] = {"num_prompts": len(by_prompt), "conditions": {}}
194
+
195
+ zero_identical = 0
196
+ zero_total = 0
197
+
198
+ for condition in conditions:
199
+ divergences: list[float] = []
200
+ lengths: list[int] = []
201
+ degenerations = 0
202
+ distinct2: list[float] = []
203
+ delta_norms: list[float] = []
204
+ count = 0
205
+
206
+ for prompt_results in by_prompt.values():
207
+ result = prompt_results.get(condition)
208
+ baseline = prompt_results.get("baseline")
209
+ if result is None or baseline is None:
210
+ continue
211
+ count += 1
212
+ comparison = compare_generations(baseline.text, result.text)
213
+ divergences.append(1.0 - comparison["jaccard_3"])
214
+ lengths.append(result.metrics["num_words"])
215
+ distinct2.append(result.metrics["distinct_2"])
216
+ if result.metrics["degeneration_flag"]:
217
+ degenerations += 1
218
+ if result.steering_stats:
219
+ delta_norms.append(result.steering_stats.get("mean_delta_norm", 0.0))
220
+ if condition == "zero":
221
+ zero_total += 1
222
+ zero_identical += int(comparison["identical"])
223
+
224
+ summary["conditions"][condition] = {
225
+ "n": count,
226
+ "mean_divergence_from_baseline": _mean(divergences),
227
+ "mean_num_words": _mean(lengths),
228
+ "mean_distinct_2": _mean(distinct2),
229
+ "degeneration_count": degenerations,
230
+ "degeneration_rate": degenerations / count if count else 0.0,
231
+ "mean_delta_norm": _mean(delta_norms) if delta_norms else None,
232
+ }
233
+
234
+ summary["zero_strength_matches_baseline"] = {
235
+ "identical": zero_identical,
236
+ "total": zero_total,
237
+ "all_identical": zero_total > 0 and zero_identical == zero_total,
238
+ }
239
+
240
+ # The comparison that licenses (or refuses) a causal claim.
241
+ def divergence(condition: str) -> float | None:
242
+ entry = summary["conditions"].get(condition)
243
+ return entry["mean_divergence_from_baseline"] if entry else None
244
+
245
+ pos = divergence("positive")
246
+ rand = divergence("random_positive")
247
+ unrelated = divergence("unrelated_positive")
248
+ summary["effect_vs_controls"] = {
249
+ "positive_divergence": pos,
250
+ "random_control_divergence": rand,
251
+ "unrelated_feature_divergence": unrelated,
252
+ "positive_minus_random": (pos - rand) if pos is not None and rand is not None else None,
253
+ "positive_minus_unrelated": (
254
+ (pos - unrelated) if pos is not None and unrelated is not None else None
255
+ ),
256
+ "interpretation_note": (
257
+ "A positive difference means the feature direction moved the output "
258
+ "further from baseline than a scale-matched control. It does NOT by "
259
+ "itself identify what changed, or establish any semantic label. "
260
+ "With a handful of prompts and no repeated sampling these differences "
261
+ "carry no statistical significance."
262
+ ),
263
+ }
264
+ return summary
265
+
266
+
267
+ def write_experiment_artifacts(
268
+ paths: VolumePaths,
269
+ experiment: str,
270
+ config: dict[str, Any],
271
+ results: Sequence[ConditionResult],
272
+ summary: dict[str, Any],
273
+ ) -> dict[str, str]:
274
+ """Persist config, all generations, metrics and a markdown report."""
275
+ out_dir = Path(paths.experiment(experiment))
276
+ out_dir.mkdir(parents=True, exist_ok=True)
277
+
278
+ written: dict[str, str] = {}
279
+
280
+ def dump_jsonl(filename: str, rows: Sequence[ConditionResult]) -> None:
281
+ path = out_dir / filename
282
+ with path.open("w", encoding="utf-8") as handle:
283
+ for row in rows:
284
+ handle.write(json.dumps(row.to_dict(), ensure_ascii=False) + "\n")
285
+ written[filename] = str(path)
286
+
287
+ (out_dir / "config.json").write_text(
288
+ json.dumps(config, indent=2, sort_keys=True, default=str), encoding="utf-8"
289
+ )
290
+ written["config.json"] = str(out_dir / "config.json")
291
+
292
+ dump_jsonl("baseline.jsonl", [r for r in results if r.condition == "baseline"])
293
+ dump_jsonl("interventions.jsonl", [r for r in results if r.condition in INTERVENTION_CONDITIONS])
294
+ dump_jsonl("controls.jsonl", [r for r in results if r.condition in CONTROL_CONDITIONS])
295
+ dump_jsonl("all_generations.jsonl", list(results))
296
+
297
+ (out_dir / "metrics.json").write_text(
298
+ json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8"
299
+ )
300
+ written["metrics.json"] = str(out_dir / "metrics.json")
301
+
302
+ report = render_report(experiment, config, summary, results)
303
+ (out_dir / "report.md").write_text(report, encoding="utf-8")
304
+ written["report.md"] = str(out_dir / "report.md")
305
+ return written
306
+
307
+
308
+ def render_report(
309
+ experiment: str,
310
+ config: dict[str, Any],
311
+ summary: dict[str, Any],
312
+ results: Sequence[ConditionResult],
313
+ ) -> str:
314
+ """Render a markdown report that states what was measured and nothing more."""
315
+ lines: list[str] = [
316
+ f"# Intervention experiment: `{experiment}`",
317
+ "",
318
+ f"Generated {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}.",
319
+ "",
320
+ "## Configuration",
321
+ "",
322
+ "```json",
323
+ json.dumps(config, indent=2, sort_keys=True, default=str),
324
+ "```",
325
+ "",
326
+ "## Harness sanity check",
327
+ "",
328
+ ]
329
+ zero = summary["zero_strength_matches_baseline"]
330
+ status = "PASS" if zero["all_identical"] else "FAIL"
331
+ lines += [
332
+ f"`strength=0` reproduces baseline exactly: **{status}** "
333
+ f"({zero['identical']}/{zero['total']} prompts identical).",
334
+ "",
335
+ "A failure here invalidates every other number on this page, because it "
336
+ "would mean the hook perturbs the model even when asked to do nothing.",
337
+ "",
338
+ "## Per-condition summary",
339
+ "",
340
+ "| condition | n | divergence from baseline | mean words | distinct-2 | degenerate | mean delta norm |",
341
+ "|---|---|---|---|---|---|---|",
342
+ ]
343
+ for condition, entry in summary["conditions"].items():
344
+ norm = entry["mean_delta_norm"]
345
+ lines.append(
346
+ f"| `{condition}` | {entry['n']} | {entry['mean_divergence_from_baseline']:.3f} | "
347
+ f"{entry['mean_num_words']:.1f} | {entry['mean_distinct_2']:.3f} | "
348
+ f"{entry['degeneration_count']} | {f'{norm:.3f}' if norm is not None else '-'} |"
349
+ )
350
+
351
+ effect = summary["effect_vs_controls"]
352
+ lines += [
353
+ "",
354
+ "## Effect versus controls",
355
+ "",
356
+ f"- positive intervention divergence: `{_fmt(effect['positive_divergence'])}`",
357
+ f"- scale-matched random direction: `{_fmt(effect['random_control_divergence'])}`",
358
+ f"- unrelated real feature: `{_fmt(effect['unrelated_feature_divergence'])}`",
359
+ f"- positive minus random: `{_fmt(effect['positive_minus_random'])}`",
360
+ f"- positive minus unrelated: `{_fmt(effect['positive_minus_unrelated'])}`",
361
+ "",
362
+ "> " + effect["interpretation_note"],
363
+ "",
364
+ "## Sample generations",
365
+ "",
366
+ "Unfiltered. The first prompt is shown under every condition.",
367
+ "",
368
+ ]
369
+ for result in [r for r in results if r.prompt_index == 0]:
370
+ lines += [
371
+ f"### `{result.condition}` (feature={result.feature_id}, strength={result.strength:+.2f})",
372
+ "",
373
+ "```text",
374
+ result.text.strip() or "(empty)",
375
+ "```",
376
+ "",
377
+ ]
378
+ return "\n".join(lines)
379
+
380
+
381
+ def _mean(values: Sequence[float]) -> float:
382
+ return sum(values) / len(values) if values else 0.0
383
+
384
+
385
+ def _fmt(value: float | None) -> str:
386
+ return "n/a" if value is None else f"{value:.4f}"
@@ -0,0 +1,165 @@
1
+ """Text-corpus ingestion for activation extraction.
2
+
3
+ Turns a Hugging Face dataset into a deterministic stream of fixed-length token
4
+ blocks. Determinism matters more than it might seem: two extraction runs with
5
+ the same seed must produce byte-identical shards, otherwise "resume" is not
6
+ resume, and an SAE cannot be attributed to a specific corpus.
7
+
8
+ Chunking strategy
9
+ -----------------
10
+ Documents are tokenized individually and split into non-overlapping blocks of
11
+ exactly ``sequence_length`` tokens. Trailing remainders shorter than
12
+ ``min_block_tokens`` are discarded rather than padded. The result is that every
13
+ stored activation corresponds to a real token in real context -- there is no
14
+ padding to mask out, and no boundary artifacts from concatenating unrelated
15
+ documents inside one block.
16
+
17
+ Licensing note
18
+ --------------
19
+ The default corpus (``Salesforce/wikitext``) is CC BY-SA 3.0, derived from
20
+ Wikipedia. Derived numerical artifacts (SAE weights, activation statistics) are
21
+ publishable; verbatim text is redistributed only as short attributed snippets
22
+ inside feature contexts.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import random
28
+ from dataclasses import dataclass
29
+ from typing import Any, Iterator
30
+
31
+ #: Small, permissively-licensed default. Good enough to exercise the pipeline;
32
+ #: not claimed to be the right corpus for behaviour-relevant features.
33
+ DEFAULT_DATASET = "Salesforce/wikitext"
34
+ DEFAULT_CONFIG = "wikitext-2-raw-v1"
35
+ DEFAULT_SPLIT = "train"
36
+
37
+
38
+ @dataclass
39
+ class TokenBlock:
40
+ """One fixed-length block of tokens with provenance back to its document."""
41
+
42
+ example_index: int
43
+ input_ids: list[int]
44
+ text: str
45
+ source_doc: int
46
+ char_offset: int
47
+
48
+
49
+ @dataclass
50
+ class CorpusConfig:
51
+ """How to turn a dataset into token blocks."""
52
+
53
+ dataset: str = DEFAULT_DATASET
54
+ config: str | None = DEFAULT_CONFIG
55
+ split: str = DEFAULT_SPLIT
56
+ text_column: str = "text"
57
+ sequence_length: int = 256
58
+ min_block_tokens: int = 64
59
+ min_doc_chars: int = 200
60
+ seed: int = 0
61
+ #: Documents drawn from the head of the split before shuffling. Bounding
62
+ #: this keeps a smoke run from streaming the whole dataset.
63
+ max_documents: int = 20_000
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return {
67
+ "dataset": self.dataset,
68
+ "config": self.config,
69
+ "split": self.split,
70
+ "text_column": self.text_column,
71
+ "sequence_length": self.sequence_length,
72
+ "min_block_tokens": self.min_block_tokens,
73
+ "min_doc_chars": self.min_doc_chars,
74
+ "seed": self.seed,
75
+ "max_documents": self.max_documents,
76
+ }
77
+
78
+ @property
79
+ def dataset_id(self) -> str:
80
+ """Human-readable identifier recorded in the manifest."""
81
+ return f"{self.dataset}:{self.config}" if self.config else self.dataset
82
+
83
+
84
+ def load_documents(cfg: CorpusConfig) -> list[str]:
85
+ """Load and deterministically shuffle raw documents.
86
+
87
+ Short documents are dropped first, so the shuffle operates on the set that
88
+ will actually be used and the seed therefore selects the same documents
89
+ regardless of how many are ultimately consumed.
90
+ """
91
+ from datasets import load_dataset
92
+
93
+ dataset = load_dataset(cfg.dataset, cfg.config, split=cfg.split)
94
+
95
+ docs: list[str] = []
96
+ for i, row in enumerate(dataset):
97
+ if i >= cfg.max_documents:
98
+ break
99
+ text = row.get(cfg.text_column)
100
+ if not isinstance(text, str):
101
+ continue
102
+ text = text.strip()
103
+ if len(text) < cfg.min_doc_chars:
104
+ continue
105
+ docs.append(text)
106
+
107
+ random.Random(cfg.seed).shuffle(docs)
108
+ return docs
109
+
110
+
111
+ def iter_token_blocks(
112
+ tokenizer: Any,
113
+ cfg: CorpusConfig,
114
+ *,
115
+ documents: list[str] | None = None,
116
+ start_example: int = 0,
117
+ ) -> Iterator[TokenBlock]:
118
+ """Yield fixed-length token blocks, deterministically.
119
+
120
+ Parameters
121
+ ----------
122
+ start_example:
123
+ Skip this many blocks before yielding. Used on resume so a restarted
124
+ run reproduces exactly the block sequence it would have produced had it
125
+ never stopped.
126
+ """
127
+ docs = documents if documents is not None else load_documents(cfg)
128
+ example_index = 0
129
+
130
+ for doc_index, text in enumerate(docs):
131
+ ids = tokenizer(text, add_special_tokens=False)["input_ids"]
132
+ n_blocks = len(ids) // cfg.sequence_length
133
+ remainder = len(ids) - n_blocks * cfg.sequence_length
134
+
135
+ spans: list[tuple[int, int]] = [
136
+ (b * cfg.sequence_length, (b + 1) * cfg.sequence_length) for b in range(n_blocks)
137
+ ]
138
+ if remainder >= cfg.min_block_tokens:
139
+ spans.append((n_blocks * cfg.sequence_length, len(ids)))
140
+
141
+ for start, end in spans:
142
+ block_ids = ids[start:end]
143
+ if len(block_ids) < cfg.min_block_tokens:
144
+ continue
145
+ if example_index >= start_example:
146
+ yield TokenBlock(
147
+ example_index=example_index,
148
+ input_ids=block_ids,
149
+ text=tokenizer.decode(block_ids),
150
+ source_doc=doc_index,
151
+ char_offset=start,
152
+ )
153
+ example_index += 1
154
+
155
+
156
+ def batched(iterator: Iterator[TokenBlock], batch_size: int) -> Iterator[list[TokenBlock]]:
157
+ """Group blocks into batches, yielding a short final batch if needed."""
158
+ batch: list[TokenBlock] = []
159
+ for block in iterator:
160
+ batch.append(block)
161
+ if len(batch) == batch_size:
162
+ yield batch
163
+ batch = []
164
+ if batch:
165
+ yield batch