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
@@ -0,0 +1,450 @@
1
+ """A frozen, versioned benchmark for tools that explain neurons (docs/benchmark.md).
2
+
3
+ The benchmark is a set of native MLP neuron poles and probe texts. Each probe has a label:
4
+ whether the model measured the pole as active on it. An explainer writes one explanation
5
+ per unit. A judge applies each explanation to the unit's probes. The score is the
6
+ balanced accuracy of the judge's labels against the measured ones.
7
+
8
+ :func:`freeze` builds a version from the registry's records. It takes only units that a
9
+ random draw chose, so that the scores are not read as covering a hand-picked set. A hash
10
+ of each unit's slug puts the unit in the dev split or the test split. The dev split
11
+ publishes the probes and labels that the records hold. Those records are public, so the
12
+ test split uses fresh probes instead. A maintainer measures them
13
+ (``open-interpretability benchmark measure``) and keeps them out of the repository. The manifest
14
+ publishes only their SHA-256 (:func:`seal`).
15
+
16
+ This module loads no model. ``openinterp/cli_benchmark.py`` has the commands.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import random
24
+ from collections.abc import Callable, Iterable, Sequence
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from openinterp.builder.wiki import experiment_id
29
+ from openinterp.core.schemas import AuditRecord, ReviewPolicy
30
+ from openinterp.core.stats import balanced_accuracy, effective_counts
31
+
32
+ NAME = "openinterp-explainer-benchmark"
33
+ LICENSE = "CC-BY-4.0" # the records' licence (wiki/LICENSE.md)
34
+ DEFAULT_DIR = Path("benchmark")
35
+ TEST_FRACTION = 0.2
36
+ MIN_PER_CLASS = 3 # active and inactive probes a unit needs, so that both rates exist
37
+ PROBE_KINDS = ("hard_negative", "positive_holdout")
38
+ CHANCE = 0.5 # the score of a unit that has no explanation
39
+ # A unit whose judge listed this share of its texts or more is flagged (issue #3303). An
40
+ # injection that makes the judge list every text would look like that.
41
+ LISTED_ALL_SHARE = 0.9
42
+ BOOTSTRAP_SAMPLES = 2000
43
+
44
+ MANIFEST = "manifest.json"
45
+ UNITS = "units.jsonl"
46
+ DEV_PROBES = "dev/probes.jsonl"
47
+ DEV_EXPLANATIONS = "dev/registry_explanations.jsonl"
48
+ TEST_EXPLANATIONS = "test/registry_explanations.jsonl"
49
+
50
+
51
+ @dataclass
52
+ class Unit:
53
+ """One benchmark unit: a neuron pole, its split and what measures it."""
54
+
55
+ unit: str # the slug, e.g. ``Qwen--Qwen2.5-0.5B/L3/N7-positive``
56
+ model: str
57
+ model_revision: str | None
58
+ layer: int
59
+ neuron: int
60
+ pole: str
61
+ split: str
62
+ records: list[str] = field(default_factory=list) # experiment IDs, oldest first
63
+ # What fresh test probes are measured against: the newest record's scan maximum and
64
+ # threshold, and whether the first token is measured.
65
+ scale: float | None = None
66
+ threshold: float | None = None
67
+ first_token_floor: bool | None = None
68
+
69
+
70
+ def split_of(slug: str, version: str, test_fraction: float = TEST_FRACTION) -> str:
71
+ """Return ``"test"`` or ``"dev"`` for ``slug``, from a hash salted with ``version``."""
72
+ digest = hashlib.sha256(f"{version}:{slug}".encode()).hexdigest()
73
+ return "test" if int(digest[:8], 16) / 0x1_0000_0000 < test_fraction else "dev"
74
+
75
+
76
+ ORDERS = ("slug", "shuffled") # the orders that `benchmark score --limit` takes units in
77
+
78
+
79
+ def ordered(units: Iterable[Unit], order: str, version: str) -> list[Unit]:
80
+ """``units`` in slug order, or in a shuffled order fixed by ``version`` (issue #3302).
81
+
82
+ A shuffled unit's place is a hash of its slug, salted with ``version`` and its split.
83
+ So the first N units of a split are a random sample of it, the same one on every run.
84
+ Adding units to a later version doesn't move the others within it.
85
+ """
86
+ if order not in ORDERS:
87
+ raise ValueError(f"unknown order {order!r}; use one of {', '.join(ORDERS)}")
88
+ if order == "slug":
89
+ return sorted(units, key=lambda u: u.unit)
90
+
91
+ def key(u: Unit) -> str:
92
+ return hashlib.sha256(f"order:{version}:{u.split}:{u.unit}".encode()).hexdigest()
93
+
94
+ return sorted(units, key=key)
95
+
96
+
97
+ def probe_labels(record: AuditRecord) -> list[tuple[str, bool, str]]:
98
+ """Return each measured reviewer probe in ``record`` as (text, active, kind).
99
+
100
+ A probe passed when the model's activity matched the hypothesis's label. So the
101
+ activity is the label when the probe passed, and the opposite when it failed.
102
+ """
103
+ rows = []
104
+ for r in record.verdict.adversarial_test_results:
105
+ if r.kind in PROBE_KINDS and r.measured and r.expected_active is not None:
106
+ rows.append((r.prompt, r.expected_active == r.passed, r.kind))
107
+ return rows
108
+
109
+
110
+ def _eligible(records: Sequence[AuditRecord]) -> bool:
111
+ first = records[0].submission
112
+ prov = first.provenance
113
+ return first.identifier.kind == "mlp_neuron_pole" and bool(prov and prov.selection == "random")
114
+
115
+
116
+ def _unit_probes(slug: str, records: Sequence[AuditRecord]) -> list[dict]:
117
+ """Every distinct probe text over ``records``, dropping texts whose labels disagree."""
118
+ seen: dict[str, dict] = {}
119
+ conflicts: set[str] = set()
120
+ for record in records:
121
+ for text, active, kind in probe_labels(record):
122
+ row = {
123
+ "unit": slug,
124
+ "text": text,
125
+ "active": active,
126
+ "kind": kind,
127
+ "record": experiment_id(record),
128
+ }
129
+ if text in seen and seen[text]["active"] != active:
130
+ conflicts.add(text)
131
+ seen.setdefault(text, row)
132
+ return [row for text, row in seen.items() if text not in conflicts]
133
+
134
+
135
+ def _explanation(slug: str, records: Sequence[AuditRecord]) -> dict:
136
+ """The registry's baseline for a unit: its newest VERIFIED explanation, else its newest."""
137
+ verified = [r for r in records if r.verdict.status == "VERIFIED"]
138
+ best = (verified or list(records))[-1]
139
+ return {
140
+ "unit": slug,
141
+ "explanation": best.submission.natural_language_explanation,
142
+ "status": best.verdict.status,
143
+ "record": experiment_id(best),
144
+ }
145
+
146
+
147
+ def _unit(slug: str, records: Sequence[AuditRecord], split: str) -> Unit:
148
+ newest = records[-1].submission
149
+ ident = newest.identifier
150
+ policy = records[-1].verdict.policy or ReviewPolicy()
151
+ scale = newest.max_activation
152
+ return Unit(
153
+ unit=slug,
154
+ model=ident.model_name,
155
+ model_revision=newest.provenance.model_revision if newest.provenance else None,
156
+ layer=ident.layer,
157
+ neuron=ident.neuron_idx,
158
+ pole=ident.pole,
159
+ split=split,
160
+ records=[experiment_id(r) for r in records],
161
+ scale=scale,
162
+ threshold=None if scale is None else round(policy.activation_threshold * scale, 6),
163
+ first_token_floor=newest.first_token_floor,
164
+ )
165
+
166
+
167
+ @dataclass
168
+ class Frozen:
169
+ """What :func:`freeze` returns, before it is written."""
170
+
171
+ manifest: dict
172
+ units: list[Unit]
173
+ dev_probes: list[dict]
174
+ explanations: dict[str, list[dict]] # per split
175
+
176
+
177
+ def freeze(
178
+ records: Iterable[AuditRecord],
179
+ version: str,
180
+ *,
181
+ test_fraction: float = TEST_FRACTION,
182
+ min_per_class: int = MIN_PER_CLASS,
183
+ ) -> Frozen:
184
+ """Select the units in ``records``, split them and collect the dev probes.
185
+
186
+ ``records`` must be oldest first, as :func:`openinterp.builder.wiki.load_archive`
187
+ returns them. A unit qualifies when its first record was a random draw of a native
188
+ neuron pole. Its probes must also have at least ``min_per_class`` of each label.
189
+ """
190
+ by_unit: dict[str, list[AuditRecord]] = {}
191
+ for record in records:
192
+ by_unit.setdefault(record.submission.identifier.slug, []).append(record)
193
+ units, dev_probes = [], []
194
+ explanations: dict[str, list[dict]] = {"dev": [], "test": []}
195
+ skipped = {"not_random_neuron_pole": 0, "too_few_probes": 0}
196
+ for slug in sorted(by_unit):
197
+ group = by_unit[slug]
198
+ if not _eligible(group):
199
+ skipped["not_random_neuron_pole"] += 1
200
+ continue
201
+ probes = _unit_probes(slug, group)
202
+ active = sum(p["active"] for p in probes)
203
+ if min(active, len(probes) - active) < min_per_class:
204
+ skipped["too_few_probes"] += 1
205
+ continue
206
+ split = split_of(slug, version, test_fraction)
207
+ units.append(_unit(slug, group, split))
208
+ explanations[split].append(_explanation(slug, group))
209
+ if split == "dev":
210
+ dev_probes.extend(probes)
211
+ manifest = _manifest(version, test_fraction, min_per_class, skipped, units, dev_probes)
212
+ return Frozen(manifest, units, dev_probes, explanations)
213
+
214
+
215
+ def _manifest(version, test_fraction, min_per_class, skipped, units, dev_probes) -> dict:
216
+ return {
217
+ "name": NAME,
218
+ "version": version,
219
+ "license": LICENSE,
220
+ "selection": {
221
+ "unit_kind": "mlp_neuron_pole",
222
+ "first_record_selection": "random",
223
+ "min_probes_per_label": min_per_class,
224
+ "skipped_units": skipped,
225
+ },
226
+ "split": {"test_fraction": test_fraction, "salt": version},
227
+ "units": {s: sum(u.split == s for u in units) for s in ("dev", "test")},
228
+ "units_per_model": _per_model(units),
229
+ "dev_probes": len(dev_probes),
230
+ "newest_record": max((u.records[-1] for u in units), default=None),
231
+ "test_labels_sha256": None,
232
+ "test_probes": None,
233
+ }
234
+
235
+
236
+ def _per_model(units: Sequence[Unit]) -> dict[str, dict[str, int]]:
237
+ out: dict[str, dict[str, int]] = {}
238
+ for u in units:
239
+ counts = out.setdefault(u.model, {"dev": 0, "test": 0})
240
+ counts[u.split] += 1
241
+ return dict(sorted(out.items()))
242
+
243
+
244
+ def write_jsonl(path: Path, rows: Iterable[dict]) -> None:
245
+ """Write ``rows`` to ``path``, one JSON object per line, keys sorted."""
246
+ path.parent.mkdir(parents=True, exist_ok=True)
247
+ text = "".join(json.dumps(r, sort_keys=True, ensure_ascii=False) + "\n" for r in rows)
248
+ path.write_text(text, encoding="utf-8")
249
+
250
+
251
+ def read_jsonl(path: Path) -> list[dict]:
252
+ """Read the JSON objects in ``path``, one per line."""
253
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
254
+ return [json.loads(line) for line in lines if line.strip()]
255
+
256
+
257
+ def write(frozen: Frozen, out_dir: Path) -> None:
258
+ """Write a frozen version to ``out_dir`` (e.g. ``benchmark/v1``)."""
259
+ out_dir.mkdir(parents=True, exist_ok=True)
260
+ write_manifest(out_dir, frozen.manifest)
261
+ write_jsonl(out_dir / UNITS, (u.__dict__ for u in frozen.units))
262
+ write_jsonl(out_dir / DEV_PROBES, frozen.dev_probes)
263
+ write_jsonl(out_dir / DEV_EXPLANATIONS, frozen.explanations["dev"])
264
+ write_jsonl(out_dir / TEST_EXPLANATIONS, frozen.explanations["test"])
265
+
266
+
267
+ def write_manifest(out_dir: Path, manifest: dict) -> None:
268
+ """Write ``manifest.json`` in ``out_dir``."""
269
+ text = json.dumps(manifest, indent=2, sort_keys=True) + "\n"
270
+ (out_dir / MANIFEST).write_text(text, encoding="utf-8")
271
+
272
+
273
+ def read_manifest(bench_dir: Path) -> dict:
274
+ """Read ``manifest.json`` in ``bench_dir``."""
275
+ return json.loads((Path(bench_dir) / MANIFEST).read_text(encoding="utf-8"))
276
+
277
+
278
+ def read_units(bench_dir: Path) -> dict[str, Unit]:
279
+ """Read the units of a version, by slug."""
280
+ return {row["unit"]: Unit(**row) for row in read_jsonl(Path(bench_dir) / UNITS)}
281
+
282
+
283
+ def labels_sha256(path: Path) -> str:
284
+ """The SHA-256 of a labels file's bytes."""
285
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
286
+
287
+
288
+ def seal(bench_dir: Path, labels_path: Path) -> dict:
289
+ """Sort the private test labels, and put their hash and counts in the manifest.
290
+
291
+ Returns the new manifest. Fails if the labels name a unit outside the test split, or
292
+ leave a test unit with too few probes of a label.
293
+ """
294
+ units = read_units(bench_dir)
295
+ manifest = read_manifest(bench_dir)
296
+ rows = sorted(read_jsonl(labels_path), key=lambda r: (r["unit"], r["text"]))
297
+ _check_test_labels(rows, units, manifest["selection"]["min_probes_per_label"])
298
+ write_jsonl(labels_path, rows)
299
+ manifest["test_labels_sha256"] = labels_sha256(labels_path)
300
+ manifest["test_probes"] = len(rows)
301
+ write_manifest(Path(bench_dir), manifest)
302
+ return manifest
303
+
304
+
305
+ def _check_test_labels(rows: Sequence[dict], units: dict[str, Unit], minimum: int) -> None:
306
+ counts: dict[str, list[int]] = {}
307
+ for row in rows:
308
+ unit = units.get(row["unit"])
309
+ if unit is None or unit.split != "test":
310
+ raise ValueError(f"{row['unit']}: not a test unit of this version")
311
+ counts.setdefault(row["unit"], [0, 0])[bool(row["active"])] += 1
312
+ missing = sorted(u.unit for u in units.values() if u.split == "test" and u.unit not in counts)
313
+ if missing:
314
+ raise ValueError(f"{len(missing)} test units have no labels, e.g. {missing[0]}")
315
+ thin = sorted(slug for slug, c in counts.items() if min(c) < minimum)
316
+ if thin:
317
+ raise ValueError(f"{len(thin)} test units have under {minimum} probes of a label: {thin}")
318
+
319
+
320
+ def load_labels(bench_dir: Path, split: str, labels_path: Path | None) -> dict[str, list[dict]]:
321
+ """Return the probes of ``split``, by unit.
322
+
323
+ The test split's labels come from ``labels_path``, which must match the manifest's hash.
324
+ """
325
+ if split == "dev":
326
+ rows = read_jsonl(Path(bench_dir) / DEV_PROBES)
327
+ else:
328
+ expected = read_manifest(bench_dir)["test_labels_sha256"]
329
+ if expected is None:
330
+ raise ValueError("this version has no sealed test labels yet")
331
+ if labels_path is None:
332
+ raise ValueError("the test split needs --labels, the private labels file")
333
+ if labels_sha256(labels_path) != expected:
334
+ raise ValueError(f"{labels_path} does not match the manifest's test_labels_sha256")
335
+ rows = read_jsonl(labels_path)
336
+ by_unit: dict[str, list[dict]] = {}
337
+ for row in rows:
338
+ by_unit.setdefault(row["unit"], []).append(row)
339
+ return by_unit
340
+
341
+
342
+ def shuffled(probes: Sequence[dict], slug: str) -> list[dict]:
343
+ """``probes`` in an order fixed by ``slug``, so that no label shows in the order."""
344
+ out = sorted(probes, key=lambda p: p["text"])
345
+ random.Random(slug).shuffle(out)
346
+ return out
347
+
348
+
349
+ def unit_score(
350
+ probes: Sequence[dict], predicted: Sequence[bool], cluster_threshold: float
351
+ ) -> dict[str, float | int | None]:
352
+ """Balanced accuracy of ``predicted`` against the probes' labels.
353
+
354
+ Near-duplicate probes count once, as in the review (docs/PROTOCOL.md §4). The row also
355
+ gives ``listed_share``, the share of texts that the judge listed (issue #3303).
356
+ """
357
+
358
+ def rate(active: bool):
359
+ pairs = zip(probes, predicted, strict=True)
360
+ rows = [(p["text"], pr == active) for p, pr in pairs if p["active"] is active]
361
+ return effective_counts([t for t, _ in rows], [ok for _, ok in rows], cluster_threshold)
362
+
363
+ inactive, active = rate(False), rate(True)
364
+ return {
365
+ "balanced_accuracy": round(balanced_accuracy(inactive, active), 6),
366
+ "listed_share": round(sum(predicted) / len(predicted), 6) if predicted else None,
367
+ "specificity": None if inactive.rate is None else round(inactive.rate, 6),
368
+ "sensitivity": None if active.rate is None else round(active.rate, 6),
369
+ "active_probes": active.raw,
370
+ "inactive_probes": inactive.raw,
371
+ }
372
+
373
+
374
+ def summarize(
375
+ rows: Sequence[dict], units: Sequence[Unit], seed: int = 0, split_units: int | None = None
376
+ ) -> dict:
377
+ """The split's score: mean balanced accuracy over every unit, chance where missing.
378
+
379
+ A unit without an explanation scores :data:`CHANCE`, so leaving hard units out does not
380
+ raise the mean. The interval is a bootstrap over units. ``split_units`` is the size of
381
+ the whole split, when ``units`` are only part of it.
382
+
383
+ ``listed_nearly_all`` names the units whose judge listed at least
384
+ :data:`LISTED_ALL_SHARE` of the texts. An explanation that tells the judge to list
385
+ every text would look like that. Rows from before ``listed_share`` existed are skipped.
386
+ """
387
+ scored = {r["unit"]: r["balanced_accuracy"] for r in rows}
388
+ values = [scored.get(u.unit, CHANCE) for u in units]
389
+ summary = {
390
+ "units": len(units),
391
+ "explained": sum(u.unit in scored for u in units),
392
+ "mean_balanced_accuracy": _mean(values),
393
+ "ci95": _bootstrap(values, seed),
394
+ "per_model": {},
395
+ "split_units": len(units) if split_units is None else split_units,
396
+ "listed_nearly_all": sorted(
397
+ r["unit"] for r in rows if (r.get("listed_share") or 0) >= LISTED_ALL_SHARE
398
+ ),
399
+ }
400
+ for model in sorted({u.model for u in units}):
401
+ mine = [scored.get(u.unit, CHANCE) for u in units if u.model == model]
402
+ summary["per_model"][model] = {"units": len(mine), "mean_balanced_accuracy": _mean(mine)}
403
+ return summary
404
+
405
+
406
+ def _mean(values: Sequence[float]) -> float | None:
407
+ return round(sum(values) / len(values), 6) if values else None
408
+
409
+
410
+ def _bootstrap(values: Sequence[float], seed: int) -> list[float] | None:
411
+ if not values:
412
+ return None
413
+ rng = random.Random(seed)
414
+ means = sorted(
415
+ sum(rng.choices(values, k=len(values))) / len(values) for _ in range(BOOTSTRAP_SAMPLES)
416
+ )
417
+ return [round(means[int(0.025 * len(means))], 6), round(means[int(0.975 * len(means))], 6)]
418
+
419
+
420
+ def score(
421
+ probes_by_unit: dict[str, list[dict]],
422
+ explanations: dict[str, str],
423
+ judge: Callable[[str, str, list[str]], list[bool] | None],
424
+ *,
425
+ done: dict[str, dict] | None = None,
426
+ cluster_threshold: float = ReviewPolicy().probe_cluster_threshold,
427
+ on_row: Callable[[dict], None] | None = None,
428
+ ) -> list[dict]:
429
+ """Score each explained unit; ``judge(unit, explanation, texts)`` says which texts fire.
430
+
431
+ Units in ``done`` (from an earlier, interrupted run) are not judged again. A judge that
432
+ has no answer yet returns ``None``. Then the unit is left out of the rows, and a later
433
+ run scores it.
434
+ """
435
+ rows = []
436
+ for slug in sorted(probes_by_unit):
437
+ if slug not in explanations:
438
+ continue
439
+ if done and slug in done:
440
+ rows.append(done[slug])
441
+ continue
442
+ probes = shuffled(probes_by_unit[slug], slug)
443
+ predicted = judge(slug, explanations[slug], [p["text"] for p in probes])
444
+ if predicted is None:
445
+ continue
446
+ row = {"unit": slug, **unit_score(probes, predicted, cluster_threshold)}
447
+ rows.append(row)
448
+ if on_row:
449
+ on_row(row)
450
+ return rows
openinterp/blends.py ADDED
@@ -0,0 +1,239 @@
1
+ """Fixed, versioned blends of corpus presets (`open-interpretability corpus --dataset mixed`).
2
+
3
+ A blend samples each of its presets for its share of the documents, with one seed. It
4
+ writes them in order as one corpus. Each document records its preset as ``source``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+
12
+ from openinterp.core.schemas import CorpusSource
13
+ from openinterp.corpus import PRESETS, _sample_hub, jsonl_bytes, write_corpus
14
+
15
+ # Fixed blends of presets, by versioned name. Each value is a preset's weight in the blend.
16
+ # A blend is never edited once released. A new mix is a new version, so a corpus's name
17
+ # says what it holds. `mixed` is the latest version and `open-interpretability corpus`'s default. It
18
+ # spreads random-selection statistics over domains, not one dataset's. They are web
19
+ # text, the Pile's mix of sources (papers, legal text, forums, code), long encyclopedic
20
+ # articles, source code and nine languages besides English. From v3, they include maths,
21
+ # from v4, chat transcripts, from v5, web text in 22 languages besides English, and from
22
+ # v6, scientific papers. From v7, a part without exact labels gets detected ones: its
23
+ # documents' languages (openinterp.langid) and programming languages (openinterp.codeid).
24
+ # From v8, they include passages from books.
25
+ BLENDS = {
26
+ "mixed-v1": {
27
+ "fineweb-edu": 4,
28
+ "c4": 4,
29
+ "pile-uncopyrighted": 4,
30
+ "wikitext": 2,
31
+ "github-code": 3,
32
+ "wikipedia-multilingual": 3,
33
+ },
34
+ # The same mix as v1. Its code documents also record their programming language.
35
+ "mixed-v2": {
36
+ "fineweb-edu": 4,
37
+ "c4": 4,
38
+ "pile-uncopyrighted": 4,
39
+ "wikitext": 2,
40
+ "github-code": 3,
41
+ "wikipedia-multilingual": 3,
42
+ },
43
+ # v2's mix and weights, plus mathematical text at the same weight as wikitext.
44
+ "mixed-v3": {
45
+ "fineweb-edu": 4,
46
+ "c4": 4,
47
+ "pile-uncopyrighted": 4,
48
+ "wikitext": 2,
49
+ "github-code": 3,
50
+ "wikipedia-multilingual": 3,
51
+ "open-web-math": 2,
52
+ },
53
+ # v3's mix and weights, plus chat transcripts at the same weight as wikitext, written
54
+ # with Qwen2.5's chat template, for instruct models.
55
+ "mixed-v4": {
56
+ "fineweb-edu": 4,
57
+ "c4": 4,
58
+ "pile-uncopyrighted": 4,
59
+ "wikitext": 2,
60
+ "github-code": 3,
61
+ "wikipedia-multilingual": 3,
62
+ "open-web-math": 2,
63
+ "ultrachat": 2,
64
+ },
65
+ # v4's mix and weights, plus multilingual web text at the weight of Wikipedia's
66
+ # languages, so non-English text is not only encyclopedic.
67
+ "mixed-v5": {
68
+ "fineweb-edu": 4,
69
+ "c4": 4,
70
+ "pile-uncopyrighted": 4,
71
+ "wikitext": 2,
72
+ "github-code": 3,
73
+ "wikipedia-multilingual": 3,
74
+ "open-web-math": 2,
75
+ "ultrachat": 2,
76
+ "fineweb-2": 3,
77
+ },
78
+ # v5's mix and weights, plus scientific papers (abstracts and full texts) at the weight
79
+ # of wikitext.
80
+ "mixed-v6": {
81
+ "fineweb-edu": 4,
82
+ "c4": 4,
83
+ "pile-uncopyrighted": 4,
84
+ "wikitext": 2,
85
+ "github-code": 3,
86
+ "wikipedia-multilingual": 3,
87
+ "open-web-math": 2,
88
+ "ultrachat": 2,
89
+ "fineweb-2": 3,
90
+ "pes2o": 2,
91
+ },
92
+ # v6's mix and weights. Each part without exact labels gets detected ones, so English
93
+ # web text records its language and the Pile's code and chat answers record theirs.
94
+ "mixed-v7": {
95
+ "fineweb-edu": 4,
96
+ "c4": 4,
97
+ "pile-uncopyrighted": 4,
98
+ "wikitext": 2,
99
+ "github-code": 3,
100
+ "wikipedia-multilingual": 3,
101
+ "open-web-math": 2,
102
+ "ultrachat": 2,
103
+ "fineweb-2": 3,
104
+ "pes2o": 2,
105
+ },
106
+ # v7's mix and weights, plus passages from public-domain books (PG-19) at the weight of
107
+ # wikitext, for narrative, dialogue and older English.
108
+ "mixed-v8": {
109
+ "fineweb-edu": 4,
110
+ "c4": 4,
111
+ "pile-uncopyrighted": 4,
112
+ "wikitext": 2,
113
+ "github-code": 3,
114
+ "wikipedia-multilingual": 3,
115
+ "open-web-math": 2,
116
+ "ultrachat": 2,
117
+ "fineweb-2": 3,
118
+ "pes2o": 2,
119
+ "pg19": 2,
120
+ },
121
+ }
122
+ BLEND_ALIASES = {"mixed": "mixed-v8"}
123
+ # Released before documents recorded a programming language. So their bytes stay without it.
124
+ NO_CODE_LANGUAGE = {"mixed-v1"}
125
+ # Released before parts without exact labels got detected ones. So their bytes stay without.
126
+ NO_DETECTION = {"mixed-v1", "mixed-v2", "mixed-v3", "mixed-v4", "mixed-v5", "mixed-v6"}
127
+ # Presets left out of `mixed`, and why. Every other preset is in its latest version, so
128
+ # adding a preset means adding a version of the blend (tests/test_corpus.py checks).
129
+ NOT_BLENDED = {
130
+ "gsm8k": "one benchmark's short word problems; open-web-math gives the blend its maths",
131
+ "pile-10k": "pile-uncopyrighted covers the Pile, without its copyrighted components",
132
+ "tinystories": "synthetic children's stories, a control corpus rather than natural text",
133
+ }
134
+ DEFAULT_DATASET = "mixed"
135
+
136
+
137
+ def blend_shares(name: str, documents: int) -> dict[str, int]:
138
+ """Split ``documents`` among the blend's presets by weight, rounded down.
139
+
140
+ The remainder goes one each to the presets listed first.
141
+ """
142
+ weights = BLENDS[BLEND_ALIASES.get(name, name)]
143
+ total = sum(weights.values())
144
+ shares = {preset: documents * w // total for preset, w in weights.items()}
145
+ for preset in list(shares)[: documents - sum(shares.values())]:
146
+ shares[preset] += 1
147
+ return shares
148
+
149
+
150
+ def _sample_preset(
151
+ preset: str,
152
+ documents: int,
153
+ seed: int,
154
+ max_chars: int,
155
+ code_language: bool,
156
+ revisions: dict[str, str | None],
157
+ detect: bool = False,
158
+ ):
159
+ """Return what :func:`openinterp.corpus._sample_hub` reads for a blend's ``preset``.
160
+
161
+ ``revisions`` overrides the preset's revision, by dataset, and its chat template's
162
+ model commit, by model. With ``detect``, a preset without exact languages or exact
163
+ programming languages gets detected ones.
164
+ """
165
+ p = PRESETS[preset]
166
+ return _sample_hub(
167
+ p.dataset,
168
+ files=p.files,
169
+ text_column=p.text_column,
170
+ revision=revisions.get(p.dataset, p.revision),
171
+ documents=documents,
172
+ seed=seed,
173
+ max_chars=max_chars,
174
+ docs_per_row_group=p.docs_per_row_group,
175
+ layout=p.layout,
176
+ lang_pattern=p.lang_pattern,
177
+ detect_lang=detect and p.lang_pattern is None,
178
+ code_language_column=p.code_language_column if code_language else None,
179
+ detect_code=detect and code_language and p.code_language_column is None,
180
+ chat_template=p.chat_template,
181
+ chat_template_revision=revisions.get(p.chat_template) if p.chat_template else None,
182
+ )
183
+
184
+
185
+ def build_blend(
186
+ name: str,
187
+ out: Path,
188
+ *,
189
+ documents: int = 500,
190
+ seed: int = 0,
191
+ max_chars: int = 3000,
192
+ ascii: bool = False,
193
+ revisions: dict[str, str | None] | None = None,
194
+ ) -> CorpusSource:
195
+ """Sample each of a blend's presets (:data:`BLENDS`) and write one corpus.
196
+
197
+ Each preset gives its share of ``documents``, with one seed, in the blend's order. Each
198
+ document records its preset as ``source``, and Wikipedia's documents record their
199
+ language as ``lang``. GitHub code's documents record theirs as ``code_language``, except
200
+ in the versions of :data:`NO_CODE_LANGUAGE`. Outside :data:`NO_DETECTION`, the other
201
+ presets' documents record a detected language and programming language, where the
202
+ detector is sure of one. The sidecar lists the source of every preset, as
203
+ ``open-interpretability corpus --dataset <preset>`` writes it alone. So you can rebuild
204
+ and check each part separately. ``revisions`` pins datasets to commits, by dataset, and
205
+ chat templates' models to commits, by model. Without it, each preset reads its latest
206
+ commits.
207
+ """
208
+ name = BLEND_ALIASES.get(name, name)
209
+ texts: list[str] = []
210
+ labels: dict[str, list[str | None]] = {"lang": [], "source": []}
211
+ code_language, detect = name not in NO_CODE_LANGUAGE, name not in NO_DETECTION
212
+ if code_language:
213
+ labels["code_language"] = []
214
+ components = []
215
+ for preset, share in blend_shares(name, documents).items():
216
+ part, part_labels, source = _sample_preset(
217
+ preset, share, seed, max_chars, code_language, revisions or {}, detect
218
+ )
219
+ sha = hashlib.sha256(jsonl_bytes(part, ascii, part_labels)).hexdigest()
220
+ components.append(CorpusSource(documents=len(part), sha256=sha, ascii=ascii, **source))
221
+ texts += part
222
+ labels["lang"] += (part_labels or {}).get("lang", [None] * len(part))
223
+ labels["source"] += [preset] * len(part)
224
+ if code_language:
225
+ labels["code_language"] += (part_labels or {}).get("code_language", [None] * len(part))
226
+ return write_corpus(
227
+ texts,
228
+ out,
229
+ ascii=ascii,
230
+ labels=labels,
231
+ dataset=name,
232
+ files=",".join(c.dataset for c in components),
233
+ seed=seed,
234
+ max_chars=max_chars,
235
+ docs_per_row_group=None,
236
+ license="; ".join(f"{c.dataset}: {c.license or 'not stated'}" for c in components),
237
+ blend=name,
238
+ components=components,
239
+ )