open-interpretability 0.0.20__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 (256) hide show
  1. open_interpretability-0.0.20.dist-info/METADATA +157 -0
  2. open_interpretability-0.0.20.dist-info/RECORD +256 -0
  3. open_interpretability-0.0.20.dist-info/WHEEL +4 -0
  4. open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
  5. open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
  6. openinterp/__init__.py +6 -0
  7. openinterp/agents/__init__.py +25 -0
  8. openinterp/agents/base.py +229 -0
  9. openinterp/agents/judge.py +149 -0
  10. openinterp/agents/proposer.py +471 -0
  11. openinterp/agents/proposer_profiles.py +79 -0
  12. openinterp/agents/review_measurements.py +173 -0
  13. openinterp/agents/reviewer.py +498 -0
  14. openinterp/agreement.py +63 -0
  15. openinterp/audit_options.py +79 -0
  16. openinterp/baseline.py +254 -0
  17. openinterp/benchmark.py +450 -0
  18. openinterp/blends.py +239 -0
  19. openinterp/builder/__init__.py +6 -0
  20. openinterp/builder/agents.py +260 -0
  21. openinterp/builder/aligned.py +41 -0
  22. openinterp/builder/charts.py +411 -0
  23. openinterp/builder/circuit_pages.py +37 -0
  24. openinterp/builder/circuits.py +264 -0
  25. openinterp/builder/compiler.py +495 -0
  26. openinterp/builder/corpora.py +98 -0
  27. openinterp/builder/coverage.py +46 -0
  28. openinterp/builder/dashboard.py +255 -0
  29. openinterp/builder/disk_memo.py +65 -0
  30. openinterp/builder/experiment.py +105 -0
  31. openinterp/builder/heat.py +121 -0
  32. openinterp/builder/hrefs.py +71 -0
  33. openinterp/builder/learned_baseline.py +204 -0
  34. openinterp/builder/model_facts.py +138 -0
  35. openinterp/builder/neuronpedia_export.py +107 -0
  36. openinterp/builder/parallel.py +100 -0
  37. openinterp/builder/parquet_export.py +458 -0
  38. openinterp/builder/pieces.py +127 -0
  39. openinterp/builder/pii.py +211 -0
  40. openinterp/builder/registry_stats.py +144 -0
  41. openinterp/builder/research.py +109 -0
  42. openinterp/builder/sections.py +28 -0
  43. openinterp/builder/significance.py +103 -0
  44. openinterp/builder/sister_graph.py +180 -0
  45. openinterp/builder/standing.py +185 -0
  46. openinterp/builder/statuses.py +34 -0
  47. openinterp/builder/stopped.py +91 -0
  48. openinterp/builder/stops.py +30 -0
  49. openinterp/builder/superpositions.py +102 -0
  50. openinterp/builder/templates/_agent_role_cells.html +9 -0
  51. openinterp/builder/templates/_causal_chart.html +67 -0
  52. openinterp/builder/templates/_circuit.html +8 -0
  53. openinterp/builder/templates/_dashboard.html +133 -0
  54. openinterp/builder/templates/_experiment.html +500 -0
  55. openinterp/builder/templates/_macros.html +82 -0
  56. openinterp/builder/templates/_patchscope.html +33 -0
  57. openinterp/builder/templates/_prior_art.html +24 -0
  58. openinterp/builder/templates/_specificity.html +21 -0
  59. openinterp/builder/templates/_steering.html +34 -0
  60. openinterp/builder/templates/_superposition.html +62 -0
  61. openinterp/builder/templates/_vocab_chart.html +88 -0
  62. openinterp/builder/templates/_wiring.html +44 -0
  63. openinterp/builder/templates/about.html +95 -0
  64. openinterp/builder/templates/agent.html +69 -0
  65. openinterp/builder/templates/agents.html +80 -0
  66. openinterp/builder/templates/base.html +103 -0
  67. openinterp/builder/templates/circuit.html +64 -0
  68. openinterp/builder/templates/circuits.html +46 -0
  69. openinterp/builder/templates/experiment.html +21 -0
  70. openinterp/builder/templates/hub.html +16 -0
  71. openinterp/builder/templates/index.html +387 -0
  72. openinterp/builder/templates/layer.html +69 -0
  73. openinterp/builder/templates/learned-baseline.html +44 -0
  74. openinterp/builder/templates/model.html +151 -0
  75. openinterp/builder/templates/models.html +47 -0
  76. openinterp/builder/templates/neuron.html +234 -0
  77. openinterp/builder/templates/paper.html +39 -0
  78. openinterp/builder/templates/redirect.html +1 -0
  79. openinterp/builder/templates/research.html +31 -0
  80. openinterp/builder/templates/user.html +54 -0
  81. openinterp/builder/templates/users.html +39 -0
  82. openinterp/builder/templates/wiki/experiment.md +271 -0
  83. openinterp/builder/templates/wiki/index.md +32 -0
  84. openinterp/builder/templates/wiki/neuron.md +30 -0
  85. openinterp/builder/templates/wiki/stop.md +36 -0
  86. openinterp/builder/tree.py +470 -0
  87. openinterp/builder/users.py +142 -0
  88. openinterp/builder/wiki.py +427 -0
  89. openinterp/checks.py +429 -0
  90. openinterp/cli.py +494 -0
  91. openinterp/cli_baseline.py +332 -0
  92. openinterp/cli_benchmark.py +300 -0
  93. openinterp/cli_build.py +128 -0
  94. openinterp/cli_check.py +51 -0
  95. openinterp/cli_concepts.py +76 -0
  96. openinterp/cli_corpus.py +252 -0
  97. openinterp/cli_costs.py +257 -0
  98. openinterp/cli_coverage.py +76 -0
  99. openinterp/cli_crossref.py +86 -0
  100. openinterp/cli_errata.py +142 -0
  101. openinterp/cli_evidence.py +495 -0
  102. openinterp/cli_evidence_store.py +93 -0
  103. openinterp/cli_explain.py +101 -0
  104. openinterp/cli_loop.py +125 -0
  105. openinterp/cli_memory.py +28 -0
  106. openinterp/cli_neuronpedia.py +175 -0
  107. openinterp/cli_pause.py +45 -0
  108. openinterp/cli_pole_effects.py +133 -0
  109. openinterp/cli_probing.py +119 -0
  110. openinterp/cli_registry.py +348 -0
  111. openinterp/cli_reproduce.py +446 -0
  112. openinterp/cli_review.py +302 -0
  113. openinterp/cli_scan.py +223 -0
  114. openinterp/cli_score.py +446 -0
  115. openinterp/cli_sisters.py +86 -0
  116. openinterp/cli_submission.py +217 -0
  117. openinterp/cli_superpositions.py +118 -0
  118. openinterp/cli_wiring.py +54 -0
  119. openinterp/codeid.py +73 -0
  120. openinterp/concepts.py +400 -0
  121. openinterp/core/__init__.py +59 -0
  122. openinterp/core/agent_names.py +76 -0
  123. openinterp/core/attribution.py +101 -0
  124. openinterp/core/cache_dirs.py +15 -0
  125. openinterp/core/causal.py +82 -0
  126. openinterp/core/confounds.py +103 -0
  127. openinterp/core/contributors.py +26 -0
  128. openinterp/core/corpora.py +126 -0
  129. openinterp/core/costs.py +430 -0
  130. openinterp/core/draws.py +109 -0
  131. openinterp/core/errata.py +244 -0
  132. openinterp/core/evidence.py +160 -0
  133. openinterp/core/examples.py +93 -0
  134. openinterp/core/exceptions.py +29 -0
  135. openinterp/core/explanation.py +130 -0
  136. openinterp/core/fence.py +42 -0
  137. openinterp/core/heavy_lock.py +114 -0
  138. openinterp/core/identifiers.py +198 -0
  139. openinterp/core/isolation.py +149 -0
  140. openinterp/core/ledger.py +130 -0
  141. openinterp/core/licenses.py +28 -0
  142. openinterp/core/manifold.py +117 -0
  143. openinterp/core/partners.py +32 -0
  144. openinterp/core/patchscope.py +59 -0
  145. openinterp/core/paths.py +40 -0
  146. openinterp/core/pause.py +96 -0
  147. openinterp/core/pricing.py +178 -0
  148. openinterp/core/prior_art.py +44 -0
  149. openinterp/core/probing.py +146 -0
  150. openinterp/core/profiles.py +221 -0
  151. openinterp/core/project.py +28 -0
  152. openinterp/core/run_environment.py +38 -0
  153. openinterp/core/schemas.py +484 -0
  154. openinterp/core/specificity.py +49 -0
  155. openinterp/core/stats.py +172 -0
  156. openinterp/core/steering.py +49 -0
  157. openinterp/core/strict.py +19 -0
  158. openinterp/core/takedowns.py +136 -0
  159. openinterp/core/transcripts.py +130 -0
  160. openinterp/core/wiring.py +80 -0
  161. openinterp/corpus.py +476 -0
  162. openinterp/corpus_cache.py +145 -0
  163. openinterp/corpus_layouts.py +108 -0
  164. openinterp/corpus_presets.py +168 -0
  165. openinterp/corpus_rebuild.py +93 -0
  166. openinterp/coverage.py +155 -0
  167. openinterp/crossref.py +442 -0
  168. openinterp/data/__init__.py +9 -0
  169. openinterp/data/offensive_words.txt +58 -0
  170. openinterp/data/public_suffix_list.dat +15771 -0
  171. openinterp/data/sample_corpus.txt +40 -0
  172. openinterp/draw_steps.py +359 -0
  173. openinterp/errata_checks.py +129 -0
  174. openinterp/evidence.py +462 -0
  175. openinterp/evidence_branch.py +119 -0
  176. openinterp/evidence_lenses.py +75 -0
  177. openinterp/experiment_loop.py +357 -0
  178. openinterp/explain.py +252 -0
  179. openinterp/half_precision.py +79 -0
  180. openinterp/harness/__init__.py +37 -0
  181. openinterp/harness/ane/__init__.py +9 -0
  182. openinterp/harness/ane/backend.py +324 -0
  183. openinterp/harness/ane/cache.py +276 -0
  184. openinterp/harness/ane/chunks.py +163 -0
  185. openinterp/harness/ane/convert.py +116 -0
  186. openinterp/harness/ane/forward.py +258 -0
  187. openinterp/harness/ane/qualified.py +40 -0
  188. openinterp/harness/annotations.py +329 -0
  189. openinterp/harness/attribution.py +96 -0
  190. openinterp/harness/causal.py +264 -0
  191. openinterp/harness/confounds.py +184 -0
  192. openinterp/harness/covariance.py +301 -0
  193. openinterp/harness/crossref.py +195 -0
  194. openinterp/harness/cuda_devices.py +88 -0
  195. openinterp/harness/dead.py +124 -0
  196. openinterp/harness/devices.py +401 -0
  197. openinterp/harness/dictionaries.py +496 -0
  198. openinterp/harness/dreaming.py +116 -0
  199. openinterp/harness/environment.py +64 -0
  200. openinterp/harness/explanation_scores.py +371 -0
  201. openinterp/harness/features.py +276 -0
  202. openinterp/harness/first_position.py +137 -0
  203. openinterp/harness/heads.py +226 -0
  204. openinterp/harness/hooks.py +499 -0
  205. openinterp/harness/input_attribution.py +367 -0
  206. openinterp/harness/jlens.py +183 -0
  207. openinterp/harness/layer_cache.py +301 -0
  208. openinterp/harness/layer_major.py +274 -0
  209. openinterp/harness/lookahead.py +93 -0
  210. openinterp/harness/manifold.py +200 -0
  211. openinterp/harness/memory.py +471 -0
  212. openinterp/harness/moe.py +134 -0
  213. openinterp/harness/patcher.py +322 -0
  214. openinterp/harness/patchscope.py +177 -0
  215. openinterp/harness/probing.py +383 -0
  216. openinterp/harness/scan.py +462 -0
  217. openinterp/harness/scan_backend.py +201 -0
  218. openinterp/harness/sister_passes.py +463 -0
  219. openinterp/harness/sisters.py +363 -0
  220. openinterp/harness/sites.py +101 -0
  221. openinterp/harness/specificity.py +107 -0
  222. openinterp/harness/steering.py +159 -0
  223. openinterp/harness/superpositions.py +116 -0
  224. openinterp/harness/units.py +203 -0
  225. openinterp/harness/wiring.py +432 -0
  226. openinterp/harness/wiring_activations.py +285 -0
  227. openinterp/langid.py +73 -0
  228. openinterp/names.py +129 -0
  229. openinterp/neuronpedia_baseline.py +88 -0
  230. openinterp/neuronpedia_review.py +148 -0
  231. openinterp/planner.py +466 -0
  232. openinterp/planner_effects.py +89 -0
  233. openinterp/planner_heads.py +92 -0
  234. openinterp/planner_models.py +174 -0
  235. openinterp/planner_sisters.py +122 -0
  236. openinterp/planner_states.py +57 -0
  237. openinterp/planner_superpositions.py +114 -0
  238. openinterp/pole_effects.py +102 -0
  239. openinterp/prior_art.py +143 -0
  240. openinterp/reproduce.py +450 -0
  241. openinterp/revision.py +45 -0
  242. openinterp/runner_cache.py +35 -0
  243. openinterp/score_controls.py +169 -0
  244. openinterp/sensitive.py +243 -0
  245. openinterp/show.py +236 -0
  246. openinterp/signing.py +209 -0
  247. openinterp/sisters.py +476 -0
  248. openinterp/superposition_finders.py +225 -0
  249. openinterp/sync/__init__.py +5 -0
  250. openinterp/sync/r2.py +280 -0
  251. openinterp/syntax.py +399 -0
  252. openinterp/takedown_checks.py +76 -0
  253. openinterp/toxicity.py +158 -0
  254. openinterp/verdict_regression.py +79 -0
  255. openinterp/wiring_backfill.py +170 -0
  256. openinterp/wordnet.py +362 -0
openinterp/corpus.py ADDED
@@ -0,0 +1,476 @@
1
+ """Build scan corpora from Hugging Face datasets (`open-interpretability corpus`).
2
+
3
+ A corpus is a JSONL file of ``{"text": ...}`` documents, plus a ``<name>.source.json``
4
+ sidecar (:class:`CorpusSource`) that pins where it came from. Sampling does not download
5
+ whole datasets. It reads Parquet row groups over HTTP. So a 500-document sample of
6
+ fineweb-edu's 10B-token sample costs a few dozen small reads. :mod:`openinterp.corpus_cache`
7
+ keeps what it reads.
8
+
9
+ Sampling is deterministic for a given dataset revision and seed. It visits row groups in
10
+ a seeded random order across all files, and takes a seeded handful of documents from
11
+ each. This spreads the corpus over sources and crawl dumps.
12
+
13
+ The registry's scan corpus is not such a sample. It is pile-10k's first 500 documents,
14
+ each cut to 3,000 characters, with non-ASCII characters escaped. Rebuild it
15
+ byte for byte with ``open-interpretability corpus --dataset pile-10k --first --ascii`` (README).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import math
23
+ import random
24
+ import re
25
+ from collections.abc import Iterator, Sequence
26
+ from concurrent.futures import ThreadPoolExecutor
27
+ from functools import partial
28
+ from pathlib import Path
29
+
30
+ from openinterp.core.confounds import LANGUAGE, PROGRAMMING_LANGUAGE, SOURCE
31
+ from openinterp.core.schemas import CorpusSample, CorpusSource
32
+ from openinterp.corpus_cache import read_row_group, row_groups
33
+ from openinterp.corpus_layouts import ( # noqa: F401 (wikitext_articles is re-exported)
34
+ LAYOUTS,
35
+ chat_formatter,
36
+ wikitext_articles,
37
+ )
38
+ from openinterp.corpus_presets import ( # noqa: F401
39
+ FINEWEB2_LANGUAGES,
40
+ PRESETS,
41
+ UNSTATED_LICENSES,
42
+ WIKIPEDIA_LANGUAGES,
43
+ Preset,
44
+ )
45
+
46
+ # What `open-interpretability audit` scans by default (protocol 0.7). The seed is fixed, so every
47
+ # audit of a corpus reads the same documents, and their scales are comparable.
48
+ DEFAULT_SAMPLE_TOKENS = 200_000
49
+ DEFAULT_SAMPLE_SEED = 0
50
+
51
+
52
+ # Document fields a corpus may carry besides "text", and the label set each gives the scan
53
+ # (`harness/annotations.py`). Labels from the dataset, or detected where the source
54
+ # sidecar names a detector (`lang_detector`, `code_detector`).
55
+ DOCUMENT_LABELS = {
56
+ "lang": LANGUAGE,
57
+ "source": SOURCE,
58
+ "code_language": PROGRAMMING_LANGUAGE,
59
+ }
60
+
61
+
62
+ def source_path(corpus_path: Path) -> Path:
63
+ """``corpus.jsonl`` -> ``corpus.source.json``."""
64
+ return corpus_path.with_name(corpus_path.name.split(".")[0] + ".source.json")
65
+
66
+
67
+ def sample_documents(
68
+ fs,
69
+ paths: list[str],
70
+ *,
71
+ text_column: str,
72
+ documents: int,
73
+ seed: int,
74
+ max_chars: int,
75
+ docs_per_row_group: int,
76
+ layout: str | None = None,
77
+ label_column: str | None = None,
78
+ labels: list | None = None,
79
+ chat=None,
80
+ ) -> list[str]:
81
+ """Return up to ``documents`` non-empty texts from Parquet files on ``fs``.
82
+
83
+ Each text is cut to ``max_chars``. Comma-separated ``text_column`` names join into one
84
+ text (:func:`_read_groups`). With ``chat``, a function, each row's conversation in
85
+ ``text_column`` becomes the text that ``chat`` returns. With a ``layout``, each row
86
+ group's rows are first joined into its documents. With a ``label_column``, each
87
+ returned text's value in that column joins the list ``labels``.
88
+ """
89
+ rng = random.Random(seed)
90
+ paths = sorted(paths)
91
+ # One footer read per file: in parallel, since a dataset of 880 files takes minutes serially.
92
+ with ThreadPoolExecutor(16) as pool:
93
+ counts = list(pool.map(lambda path: row_groups(fs, path), paths))
94
+ groups = [(path, i) for path, n in zip(paths, counts, strict=True) for i in range(n)]
95
+ rng.shuffle(groups)
96
+ # Few row groups (a small dataset): take more from each, so the sample still fills.
97
+ take = max(docs_per_row_group, math.ceil(documents / max(len(groups), 1)))
98
+ out: list[str] = []
99
+ for texts, column in _read_groups(fs, groups, text_column, label_column, chat):
100
+ if layout is not None:
101
+ texts = LAYOUTS[layout](texts)
102
+ order = rng.sample(range(len(texts)), len(texts))
103
+ picked = [j for j in order if (texts[j] or "").strip()][:take] # empties don't count
104
+ for j in sorted(picked):
105
+ out.append(texts[j].strip()[:max_chars])
106
+ if label_column:
107
+ labels.append(column[j])
108
+ if len(out) >= documents:
109
+ return out
110
+ return out
111
+
112
+
113
+ def first_documents(
114
+ fs,
115
+ paths: list[str],
116
+ *,
117
+ text_column: str,
118
+ documents: int,
119
+ max_chars: int,
120
+ layout: str | None = None,
121
+ label_column: str | None = None,
122
+ labels: list | None = None,
123
+ chat=None,
124
+ ) -> list[str]:
125
+ """Return the first ``documents`` non-empty texts of the Parquet files on ``fs``.
126
+
127
+ The files are in sorted order. Each text is cut to ``max_chars`` but not stripped, as
128
+ the registry's corpus was. ``label_column``, ``labels`` and ``chat`` are as in
129
+ :func:`sample_documents`.
130
+ """
131
+ out: list[str] = []
132
+ for path in sorted(paths):
133
+ groups = [(path, i) for i in range(row_groups(fs, path))]
134
+ for texts, column in _read_groups(fs, groups, text_column, label_column, chat):
135
+ if layout is not None:
136
+ texts = LAYOUTS[layout](texts)
137
+ for j, text in enumerate(texts):
138
+ if (text or "").strip():
139
+ out.append(text[:max_chars])
140
+ if label_column:
141
+ labels.append(column[j])
142
+ if len(out) >= documents:
143
+ return out
144
+ return out
145
+
146
+
147
+ def _read_groups(
148
+ fs, groups, text_column: str, label_column: str | None = None, chat=None
149
+ ) -> Iterator[tuple[list, list | None]]:
150
+ """Yield each row group's texts and, if there is a ``label_column``, that column.
151
+
152
+ Comma-separated ``text_column`` names join into one text per row, with a blank line
153
+ between them. A null value adds nothing, so a row with only nulls gives an empty text.
154
+ With ``chat``, ``text_column`` is one column of conversations, and ``chat`` writes each
155
+ as its row's text. An empty conversation gives an empty text.
156
+ """
157
+ text_columns = text_column.split(",")
158
+ if chat and len(text_columns) > 1:
159
+ raise ValueError("a chat template writes one column of conversations")
160
+ columns = [*text_columns, label_column] if label_column else text_columns
161
+ for path, i in groups:
162
+ table = read_row_group(fs, path, i, columns)
163
+ parts = [table.column(name).to_pylist() for name in text_columns]
164
+ texts = (
165
+ [chat(row) if row else "" for row in parts[0]]
166
+ if chat
167
+ else ["\n\n".join(filter(None, row)) for row in zip(*parts, strict=True)]
168
+ )
169
+ yield texts, table.column(label_column).to_pylist() if label_column else None
170
+
171
+
172
+ def jsonl_bytes(
173
+ texts: Sequence[str], ascii: bool = False, labels: dict[str, Sequence[str]] | None = None
174
+ ) -> bytes:
175
+ """Return ``texts`` as a JSONL corpus, one ``{"text": ...}`` per line.
176
+
177
+ Each of ``labels`` follows the text. ``labels`` maps a field of
178
+ :data:`DOCUMENT_LABELS` to one value per document. With ``ascii``, non-ASCII
179
+ characters are \\u-escaped.
180
+ """
181
+ labels = labels or {}
182
+ rows = ({"text": t} | {k: v[i] for k, v in labels.items()} for i, t in enumerate(texts))
183
+ return "".join(json.dumps(row, ensure_ascii=ascii) + "\n" for row in rows).encode()
184
+
185
+
186
+ def sample_tokens(
187
+ tokenizer, texts: Sequence[str], max_tokens: int, seed: int
188
+ ) -> tuple[list[str], CorpusSample]:
189
+ """Return a seeded sample of whole documents, in corpus order, and its description.
190
+
191
+ The sample has at most ``max_tokens`` tokens (see :class:`CorpusSample`).
192
+ """
193
+ picked, sample = sample_indices(tokenizer, texts, max_tokens, seed)
194
+ return [texts[i] for i in picked], sample
195
+
196
+
197
+ def sample_indices(
198
+ tokenizer, texts: Sequence[str], max_tokens: int, seed: int
199
+ ) -> tuple[list[int], CorpusSample]:
200
+ """:func:`sample_tokens`, as the sampled documents' indices in corpus order."""
201
+ if not texts:
202
+ raise ValueError("the corpus has no documents to sample")
203
+ order = random.Random(seed).sample(range(len(texts)), len(texts))
204
+ picked: list[int] = []
205
+ total = 0
206
+ for i in order:
207
+ n = len(tokenizer.encode(texts[i], add_special_tokens=False))
208
+ if picked and total + n > max_tokens:
209
+ continue # a shorter document later in the order may still fit
210
+ picked.append(i)
211
+ total += n
212
+ picked.sort()
213
+ sample = [texts[i] for i in picked]
214
+ return picked, CorpusSample(
215
+ seed=seed,
216
+ max_tokens=max_tokens,
217
+ documents=len(sample),
218
+ corpus_documents=len(texts),
219
+ tokens=total,
220
+ sha256=hashlib.sha256(jsonl_bytes(sample)).hexdigest(),
221
+ )
222
+
223
+
224
+ def write_corpus(
225
+ texts: list[str], out: Path, *, ascii: bool = False, labels=None, **source
226
+ ) -> CorpusSource:
227
+ """Write the JSONL corpus, with each document's ``labels``, and its source sidecar.
228
+
229
+ Returns the source.
230
+ """
231
+ out.parent.mkdir(parents=True, exist_ok=True)
232
+ data = jsonl_bytes(texts, ascii, labels)
233
+ out.write_bytes(data)
234
+ record = CorpusSource(
235
+ documents=len(texts), sha256=hashlib.sha256(data).hexdigest(), ascii=ascii, **source
236
+ )
237
+ source_path(out).write_text(record.model_dump_json(indent=2) + "\n", encoding="utf-8")
238
+ return record
239
+
240
+
241
+ def load_source(corpus_path: Path, sha256: str) -> CorpusSource | None:
242
+ """The corpus's source sidecar, if it exists and describes these exact bytes."""
243
+ path = source_path(corpus_path)
244
+ if not path.exists():
245
+ return None
246
+ source = CorpusSource.model_validate_json(path.read_text(encoding="utf-8"))
247
+ return source if source.sha256 == sha256 else None
248
+
249
+
250
+ def load_sources(paths: Sequence[Path]) -> dict[str, CorpusSource]:
251
+ """Committed sources by the SHA-256 they describe; missing paths are skipped.
252
+
253
+ A file holds one sidecar, or a list of sources (``corpus-sources.json``). The build
254
+ uses them to show the source of records that scanned a corpus before its sidecar
255
+ existed. It matches them by ``provenance.corpus_sha256``, and shows the source as
256
+ inferred, not recorded.
257
+ """
258
+ sources = {}
259
+ for path in paths:
260
+ if Path(path).exists():
261
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
262
+ for item in data if isinstance(data, list) else [data]:
263
+ source = CorpusSource.model_validate(item)
264
+ sources[source.sha256] = source
265
+ return sources
266
+
267
+
268
+ def build_from_hub(
269
+ dataset: str,
270
+ out: Path,
271
+ *,
272
+ files: str,
273
+ text_column: str = "text",
274
+ revision: str | None = None,
275
+ documents: int = 500,
276
+ seed: int = 0,
277
+ max_chars: int = 3000,
278
+ docs_per_row_group: int = 20,
279
+ layout: str | None = None,
280
+ first: bool = False,
281
+ ascii: bool = False,
282
+ lang_pattern: str | None = None,
283
+ detect_lang: bool = False,
284
+ code_language_column: str | None = None,
285
+ detect_code: bool = False,
286
+ chat_template: str | None = None,
287
+ chat_template_revision: str | None = None,
288
+ ) -> CorpusSource:
289
+ """Sample a corpus from a Hugging Face dataset at a pinned revision.
290
+
291
+ With ``first``, take its first documents. With ``lang_pattern`` (see :class:`Preset`),
292
+ each document records its glob's language as ``lang``. With ``detect_lang``, ``lang``
293
+ is the language that :mod:`openinterp.langid` detects, where it is sure of one. With
294
+ ``code_language_column``, each document records that column's value as ``code_language``.
295
+ With ``detect_code``, ``code_language`` is the programming language that
296
+ :mod:`openinterp.codeid` detects, where it is sure of one.
297
+ With ``chat_template``, a model ID, ``text_column`` holds conversations. That model's
298
+ chat template writes each one as a document. The template comes from the model's
299
+ commit ``chat_template_revision`` (default: its latest commit, pinned on use).
300
+ """
301
+ texts, labels, source = _sample_hub(
302
+ dataset,
303
+ files=files,
304
+ text_column=text_column,
305
+ revision=revision,
306
+ documents=documents,
307
+ seed=seed,
308
+ max_chars=max_chars,
309
+ docs_per_row_group=docs_per_row_group,
310
+ layout=layout,
311
+ first=first,
312
+ lang_pattern=lang_pattern,
313
+ detect_lang=detect_lang,
314
+ code_language_column=code_language_column,
315
+ detect_code=detect_code,
316
+ chat_template=chat_template,
317
+ chat_template_revision=chat_template_revision,
318
+ )
319
+ return write_corpus(texts, out, ascii=ascii, labels=labels, **source)
320
+
321
+
322
+ def _sample_hub(
323
+ dataset: str,
324
+ *,
325
+ files: str,
326
+ text_column: str,
327
+ revision: str | None,
328
+ documents: int,
329
+ seed: int,
330
+ max_chars: int,
331
+ docs_per_row_group: int,
332
+ layout: str | None,
333
+ first: bool = False,
334
+ lang_pattern: str | None = None,
335
+ detect_lang: bool = False,
336
+ code_language_column: str | None = None,
337
+ detect_code: bool = False,
338
+ chat_template: str | None = None,
339
+ chat_template_revision: str | None = None,
340
+ ) -> tuple[list[str], dict | None, dict]:
341
+ """Return the documents that :func:`build_from_hub` samples, and their labels.
342
+
343
+ Also returns the fields of their :class:`CorpusSource`, except those of the written file.
344
+ """
345
+ _check_options(layout, lang_pattern, detect_lang, code_language_column, detect_code)
346
+ chat, chat_template_revision = _chat(chat_template, chat_template_revision, layout)
347
+ from huggingface_hub import HfApi, HfFileSystem
348
+
349
+ api = HfApi()
350
+ info = api.dataset_info(dataset, revision=revision)
351
+ license_ = _license(api, dataset, info, revision)
352
+ fs = HfFileSystem()
353
+ code_languages: list[str | None] = []
354
+ column = {"label_column": code_language_column, "labels": code_languages, "chat": chat}
355
+ read = {"text_column": text_column, "max_chars": max_chars, "layout": layout, **column}
356
+ sampled = {"seed": seed, "docs_per_row_group": docs_per_row_group}
357
+ reader = first_documents if first else partial(sample_documents, **sampled)
358
+ take = partial(reader, fs, **read)
359
+ texts, globs = _read_globs(fs, f"{dataset}@{info.sha}", files, documents, take)
360
+ labels, detector = _document_labels(texts, globs, lang_pattern, detect_lang)
361
+ code_detector = _code_labels(labels, texts, code_language_column, code_languages, detect_code)
362
+ source = dict(
363
+ dataset=dataset,
364
+ revision=info.sha,
365
+ files=files,
366
+ text_column=text_column,
367
+ seed=seed,
368
+ max_chars=max_chars,
369
+ docs_per_row_group=docs_per_row_group,
370
+ layout=layout,
371
+ first=first,
372
+ lang_pattern=lang_pattern,
373
+ lang_detector=detector,
374
+ code_language_column=code_language_column,
375
+ code_detector=code_detector,
376
+ chat_template=chat_template,
377
+ chat_template_revision=chat_template_revision,
378
+ license=", ".join(license_) if isinstance(license_, list) else license_,
379
+ )
380
+ return texts, labels or None, source
381
+
382
+
383
+ def _check_options(layout, lang_pattern, detect_lang, code_language_column, detect_code):
384
+ """Raise ``ValueError`` for labelling options of :func:`_sample_hub` that can't go together."""
385
+ if lang_pattern and detect_lang:
386
+ raise ValueError("a corpus's languages come from lang_pattern or detect_lang, not both")
387
+ if code_language_column and detect_code:
388
+ raise ValueError(
389
+ "a corpus's programming languages come from code_language_column or detect_code, "
390
+ "not both"
391
+ )
392
+ if code_language_column and layout:
393
+ raise ValueError("a layout joins rows, so a column can't label its documents")
394
+
395
+
396
+ def _chat(chat_template: str | None, revision: str | None, layout: str | None):
397
+ """Return the chat formatter of ``chat_template`` and its pinned revision, or ``None``s.
398
+
399
+ Raises ``ValueError`` with a ``layout``, which joins rows of text, not conversations.
400
+ """
401
+ if not chat_template:
402
+ return None, None
403
+ if layout:
404
+ raise ValueError("a layout joins rows of text, not conversations")
405
+ return chat_formatter(chat_template, revision)
406
+
407
+
408
+ def _document_labels(
409
+ texts: list[str], globs: list[str], lang_pattern: str | None, detect_lang: bool
410
+ ) -> tuple[dict, str | None]:
411
+ """Return the ``lang`` labels, if the corpus has them, and the detector that gave them."""
412
+ if detect_lang:
413
+ from openinterp import langid
414
+
415
+ return {"lang": langid.detect(texts)}, langid.DETECTOR
416
+ return ({"lang": [_glob_lang(lang_pattern, g) for g in globs]} if lang_pattern else {}), None
417
+
418
+
419
+ def _code_labels(
420
+ labels: dict, texts: list[str], column: str | None, exact: list, detect_code: bool
421
+ ) -> str | None:
422
+ """Add each document's programming language to ``labels``, if the corpus has them.
423
+
424
+ With a dataset ``column``, ``exact`` holds its values. With ``detect_code``,
425
+ :mod:`openinterp.codeid` detects the languages. Returns the detector, or ``None``.
426
+ """
427
+ if column:
428
+ labels["code_language"] = exact
429
+ elif detect_code:
430
+ from openinterp import codeid
431
+
432
+ labels["code_language"] = codeid.detect(texts)
433
+ return codeid.detector()
434
+ return None
435
+
436
+
437
+ def _read_globs(fs, repo: str, files: str, documents: int, take):
438
+ """Return the documents that ``take(paths, documents=n)`` reads from ``files``' globs.
439
+
440
+ Also returns the glob that each came from. Several globs (for example, one per
441
+ language) give an equal share each, and the first globs take the extras.
442
+ """
443
+ texts, origins = [], []
444
+ globs = files.split(",")
445
+ for i, glob in enumerate(globs):
446
+ paths = fs.glob(f"datasets/{repo}/{glob}")
447
+ if not paths:
448
+ raise FileNotFoundError(f"no files match {glob!r} in {repo}")
449
+ share = documents // len(globs) + (i < documents % len(globs))
450
+ texts += take(paths, documents=share)
451
+ origins += [glob] * (len(texts) - len(origins))
452
+ return texts, origins
453
+
454
+
455
+ def _glob_lang(pattern: str | None, glob: str) -> str | None:
456
+ match = re.search(pattern, glob) if pattern else None
457
+ if pattern and match is None:
458
+ raise ValueError(f"{glob!r} names no language under the pattern {pattern!r}")
459
+ if match is None:
460
+ return None
461
+ return FINEWEB2_LANGUAGES.get(match.group(1), match.group(1))
462
+
463
+
464
+ def _license(api, dataset: str, info, revision: str | None):
465
+ """The dataset card's licence (a string or a list), else :data:`UNSTATED_LICENSES`'s."""
466
+ if info.card_data is None and revision is not None:
467
+ # Branches like refs/convert/parquet carry no dataset card: use the default branch's.
468
+ card_data = api.dataset_info(dataset).card_data
469
+ else:
470
+ card_data = info.card_data
471
+ card = card_data.to_dict() if card_data else {}
472
+ # OpenWebMath's card puts the licence inside its single `dataset_info`, which is a mapping.
473
+ # Cards with several configs have a list there, and it holds no licence.
474
+ nested = card.get("dataset_info")
475
+ nested_license = nested.get("license") if isinstance(nested, dict) else None
476
+ return card.get("license") or nested_license or UNSTATED_LICENSES.get(dataset)
@@ -0,0 +1,145 @@
1
+ """On-disk cache of the Parquet data that ``open-interpretability corpus`` reads from the Hub.
2
+
3
+ A file at a dataset commit never changes, so an entry can't go stale. The cache keeps only
4
+ paths that name a full commit hash, as ``_read_globs`` in :mod:`openinterp.corpus` writes
5
+ them. It stores each file's row-group count, and each row group's columns.
6
+
7
+ A repeat of a build reads almost nothing from row groups on the Hub, for example
8
+ ``open-interpretability corpus --same-as``. A build with a new seed reads other row groups, so it
9
+ saves little. A cache failure never fails a build: the code reads the Hub instead. Once
10
+ the cache outgrows its size cap, the least recently used entries go.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import os
17
+ import re
18
+ import secrets
19
+ from collections.abc import Sequence
20
+ from contextlib import suppress
21
+ from pathlib import Path
22
+
23
+ import pyarrow as pa
24
+ import pyarrow.parquet as pq
25
+
26
+ from openinterp.core.cache_dirs import cache_root
27
+
28
+ ENV = "OPENINTERP_CORPUS_CACHE" # cache directory, or "off"
29
+ SIZE_ENV = "OPENINTERP_CORPUS_CACHE_GB"
30
+ DEFAULT_GB = 5.0 # a 500-document build stores about 100 MB at most
31
+ _PINNED = re.compile(r"@[0-9a-f]{40}/")
32
+
33
+
34
+ class ParquetCache:
35
+ """The cache under ``root``, capped at ``max_bytes``."""
36
+
37
+ def __init__(self, root: Path, max_bytes: int | None = None) -> None:
38
+ self.root = Path(root)
39
+ if max_bytes is None:
40
+ max_bytes = int(float(os.environ.get(SIZE_ENV, DEFAULT_GB)) * 1e9)
41
+ self.max_bytes = max_bytes
42
+
43
+ @classmethod
44
+ def default(cls) -> ParquetCache | None:
45
+ """The cache at the default location, or ``None`` when caching is off."""
46
+ root = cache_root(ENV, "corpora")
47
+ return None if root is None else cls(root)
48
+
49
+ def row_groups(self, fs, path: str) -> int:
50
+ """Return how many row groups the file at ``path`` has."""
51
+ entry = self.root / f"{_key(path)}.groups"
52
+ with suppress(OSError, ValueError):
53
+ count = int(entry.read_text(encoding="utf-8"))
54
+ os.utime(entry)
55
+ return count
56
+ count = _remote_row_groups(fs, path)
57
+ self._store(entry, lambda tmp: tmp.write_text(str(count), encoding="utf-8"))
58
+ return count
59
+
60
+ def read(self, fs, path: str, group: int, columns: Sequence[str]) -> pa.Table:
61
+ """Return ``columns`` of one row group of the file at ``path``."""
62
+ entry = self.root / f"{_key(path, str(group), *columns)}.parquet"
63
+ with suppress(OSError, pa.ArrowInvalid):
64
+ table = pq.read_table(entry)
65
+ os.utime(entry)
66
+ return table
67
+ table = _remote_read(fs, path, group, columns)
68
+ self._store(entry, lambda tmp: pq.write_table(table, tmp, compression="zstd"))
69
+ return table
70
+
71
+ def entries(self) -> list[Path]:
72
+ """Every entry, least recently used first."""
73
+ found = []
74
+ for path in self.root.glob("*"):
75
+ with suppress(OSError): # another build can evict it during this loop
76
+ found.append((path.stat().st_mtime, path))
77
+ return [path for _, path in sorted(found)]
78
+
79
+ def size(self, entries: Sequence[Path] | None = None) -> int:
80
+ """Return the bytes that ``entries`` use, by default the whole cache."""
81
+ total = 0
82
+ for path in self.entries() if entries is None else entries:
83
+ with suppress(OSError):
84
+ total += path.stat().st_size
85
+ return total
86
+
87
+ def evict(self, keep: Path | None = None) -> None:
88
+ """Delete least recently used entries until the cache fits its cap."""
89
+ entries = self.entries()
90
+ total = self.size(entries)
91
+ for path in entries:
92
+ if total <= self.max_bytes:
93
+ return
94
+ if path != keep:
95
+ total -= self.size([path])
96
+ path.unlink(missing_ok=True)
97
+
98
+ def clear(self) -> int:
99
+ """Delete every entry and return how many there were."""
100
+ entries = self.entries()
101
+ for path in entries:
102
+ path.unlink(missing_ok=True)
103
+ return len(entries)
104
+
105
+ def _store(self, entry: Path, write) -> None:
106
+ """Write ``entry`` through a temporary file, so a concurrent read never sees part of it."""
107
+ tmp = entry.with_name(f"{entry.name}.{secrets.token_hex(4)}.tmp")
108
+ with suppress(OSError):
109
+ self.root.mkdir(parents=True, exist_ok=True)
110
+ try:
111
+ write(tmp)
112
+ tmp.replace(entry)
113
+ finally:
114
+ tmp.unlink(missing_ok=True)
115
+ self.evict(keep=entry)
116
+
117
+
118
+ def row_groups(fs, path: str) -> int:
119
+ """Return how many row groups the file has, through the cache if ``path`` is pinned."""
120
+ cache = _cache_for(path)
121
+ return cache.row_groups(fs, path) if cache else _remote_row_groups(fs, path)
122
+
123
+
124
+ def read_row_group(fs, path: str, group: int, columns: Sequence[str]) -> pa.Table:
125
+ """Return ``columns`` of one row group, through the cache if ``path`` is pinned."""
126
+ cache = _cache_for(path)
127
+ return cache.read(fs, path, group, columns) if cache else _remote_read(fs, path, group, columns)
128
+
129
+
130
+ def _cache_for(path: str) -> ParquetCache | None:
131
+ return ParquetCache.default() if _PINNED.search(path) else None
132
+
133
+
134
+ def _key(*parts: str) -> str:
135
+ return hashlib.sha256("\0".join(parts).encode()).hexdigest()
136
+
137
+
138
+ def _remote_row_groups(fs, path: str) -> int:
139
+ with fs.open(path, "rb") as fh:
140
+ return pq.ParquetFile(fh).num_row_groups
141
+
142
+
143
+ def _remote_read(fs, path: str, group: int, columns: Sequence[str]) -> pa.Table:
144
+ with fs.open(path, "rb") as fh:
145
+ return pq.ParquetFile(fh).read_row_group(group, columns=list(columns))