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,225 @@
1
+ """Ways to find superpositions: sparse directions over one layer's MLP neurons (issue #3686).
2
+
3
+ Two finders give definitions that ``open-interpretability audit --superposition`` reads.
4
+
5
+ - :func:`from_circuit` groups a traced circuit's neurons by layer. The weights are the
6
+ neurons' attributions.
7
+ - :func:`grammatical_number` fits a sparse probe for the number of the noun that the
8
+ verb agrees with, at one layer's last token.
9
+
10
+ ``open-interpretability superpositions`` runs them (``openinterp/cli_superpositions.py``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import random
17
+ from collections.abc import Iterable, Sequence
18
+
19
+ import torch
20
+
21
+ from openinterp.agreement import TEMPLATES, pairs
22
+ from openinterp.core.profiles import SuperpositionMember, SuperpositionProfile
23
+
24
+ NUMBER_SOURCE = "grammatical number from the agreement templates (#3686)"
25
+ TRAIN_SHARE = 0.8 # the share of examples the probe trains on. The rest is held out.
26
+ L1_DECAY = 0.7 # each step of the L1 path multiplies the strength by this
27
+ ISTA_STEPS = 200 # proximal-gradient steps at each L1 strength
28
+ L2_STRENGTH = 1e-2 # the ridge penalty of the refit on the kept neurons
29
+ BATCH = 64 # prompts in one forward pass
30
+
31
+
32
+ def from_circuit(
33
+ rows: Iterable[dict], *, top: int = 100, min_members: int = 2, source: str
34
+ ) -> list[tuple[int, SuperpositionProfile]]:
35
+ """One (layer, profile) for each layer with ``min_members`` of the circuit's ``top`` neurons.
36
+
37
+ ``rows`` are a circuit's rows, as ``scripts/sva_circuit.py`` writes them. Only rows
38
+ with ``"table": "neuron"`` count, in ``rank`` order. A member's weight is its
39
+ attribution. Members with a zero attribution are not used.
40
+ """
41
+ if min_members < 2:
42
+ raise ValueError("a superposition has at least 2 members, so min_members must be ≥ 2")
43
+ neurons = sorted((r for r in rows if r.get("table") == "neuron"), key=lambda r: r["rank"])
44
+ by_layer: dict[int, list[SuperpositionMember]] = {}
45
+ for row in neurons[:top]:
46
+ if row["attribution"] != 0:
47
+ member = SuperpositionMember(neuron_idx=row["neuron_idx"], weight=row["attribution"])
48
+ by_layer.setdefault(row["layer"], []).append(member)
49
+ return [
50
+ (layer, _profile(members, "circuit", source))
51
+ for layer, members in sorted(by_layer.items())
52
+ if len(members) >= min_members
53
+ ]
54
+
55
+
56
+ def _profile(members: list[SuperpositionMember], method: str, source: str):
57
+ ordered = sorted(members, key=lambda m: m.neuron_idx)
58
+ return SuperpositionProfile(members=ordered, method=method, source=source)
59
+
60
+
61
+ def split_by_group(groups: torch.Tensor, seed: int) -> tuple[torch.Tensor, torch.Tensor]:
62
+ """Train and held-out row indices. All rows of one group are on the same side.
63
+
64
+ A fixed shuffle of the groups puts the first 80% of them in training.
65
+ """
66
+ ids = groups.unique()
67
+ order = ids[torch.randperm(len(ids), generator=torch.Generator().manual_seed(seed))]
68
+ in_train = torch.isin(groups, order[: int(TRAIN_SHARE * len(ids))])
69
+ return in_train.nonzero().flatten(), (~in_train).nonzero().flatten()
70
+
71
+
72
+ def sparse_probe(
73
+ acts: torch.Tensor,
74
+ labels: torch.Tensor,
75
+ *,
76
+ k: int,
77
+ seed: int = 0,
78
+ groups: torch.Tensor | None = None,
79
+ ) -> tuple[list[int], list[float], float]:
80
+ """The ``k`` neurons that best predict ``labels``, their weights and the held-out accuracy.
81
+
82
+ ``acts`` is ``[n, width]`` and ``labels`` is ``[n]`` of 0 and 1. ``groups`` gives
83
+ each row a group id, and :func:`split_by_group` holds out 20% of the groups. With no
84
+ ``groups``, each row is a group. Each column is standardised on the training part.
85
+ An L1 logistic regression gets weaker until at least ``k`` weights are not zero. The
86
+ ``k`` largest weights choose the neurons. Then an L2 logistic regression is fitted
87
+ again on those neurons only. The weights are in the raw activation space.
88
+ """
89
+ x, y = acts.float().cpu(), labels.float().cpu()
90
+ groups = torch.arange(len(y)) if groups is None else groups.cpu()
91
+ train, test = split_by_group(groups, seed)
92
+ mean, std = x[train].mean(0), x[train].std(0)
93
+ std = torch.where(std > 1e-6, std, torch.ones_like(std)) # a constant column stays 0
94
+ z = (x - mean) / std
95
+ weights = _l1_path(z[train], y[train], k)
96
+ keep = weights.abs().topk(k).indices.sort().values
97
+ w, b = _ridge_logistic(z[train][:, keep], y[train])
98
+ predicted = (z[test][:, keep] @ w + b > 0).float()
99
+ accuracy = float((predicted == y[test]).float().mean()) if len(test) else math.nan
100
+ return keep.tolist(), (w / std[keep]).tolist(), accuracy
101
+
102
+
103
+ def _l1_path(z: torch.Tensor, y: torch.Tensor, k: int) -> torch.Tensor:
104
+ """L1 logistic weights at the strongest strength on the path with at least ``k`` non-zero."""
105
+ n = len(y)
106
+ strength = float((z.T @ (y - y.mean())).abs().max()) / n # all weights are 0 above this
107
+ step = 1.0 / (0.25 * _top_eigenvalue(z) / n) # 1 / the gradient's Lipschitz constant
108
+ w, b = torch.zeros(z.shape[1]), torch.zeros(())
109
+ while int((w != 0).sum()) < k and strength > 1e-8:
110
+ strength *= L1_DECAY
111
+ for _ in range(ISTA_STEPS):
112
+ residual = torch.sigmoid(z @ w + b) - y
113
+ w = _shrink(w - step * (z.T @ residual) / n, step * strength)
114
+ b = b - step * residual.mean()
115
+ return w
116
+
117
+
118
+ def _shrink(w: torch.Tensor, by: float) -> torch.Tensor:
119
+ """Soft thresholding: the proximal step of the L1 penalty."""
120
+ return torch.sign(w) * torch.clamp(w.abs() - by, min=0.0)
121
+
122
+
123
+ def _top_eigenvalue(z: torch.Tensor, steps: int = 50) -> float:
124
+ """The largest eigenvalue of ``zᵀz``, by power iteration."""
125
+ v = torch.ones(z.shape[1]) / math.sqrt(z.shape[1])
126
+ for _ in range(steps):
127
+ v = z.T @ (z @ v)
128
+ v = v / v.norm().clamp_min(1e-12)
129
+ return float((z @ v).pow(2).sum()) or 1.0
130
+
131
+
132
+ def _ridge_logistic(z: torch.Tensor, y: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
133
+ """Weights and bias of an L2 logistic regression, fitted with L-BFGS."""
134
+ w = torch.zeros(z.shape[1], requires_grad=True)
135
+ b = torch.zeros((), requires_grad=True)
136
+ optimiser = torch.optim.LBFGS([w, b], max_iter=200, line_search_fn="strong_wolfe")
137
+
138
+ def closure():
139
+ optimiser.zero_grad()
140
+ loss = torch.nn.functional.binary_cross_entropy_with_logits(z @ w + b, y)
141
+ loss = loss + L2_STRENGTH * w.pow(2).sum() / 2
142
+ loss.backward()
143
+ return loss
144
+
145
+ with torch.enable_grad():
146
+ optimiser.step(closure)
147
+ return w.detach(), b.detach()
148
+
149
+
150
+ def agreement_examples(n: int, seed: int) -> list[tuple[str, int]]:
151
+ """``2n`` (prompt, label) examples from ``n`` agreement pairs, spread over the templates.
152
+
153
+ The label is 1 when the noun that the verb agrees with is plural. Each pair gives
154
+ both of its prompts, so it gives one singular and one plural example.
155
+ """
156
+ rng = random.Random(seed)
157
+ out = []
158
+ for i, template in enumerate(TEMPLATES):
159
+ count = n // len(TEMPLATES) + (i < n % len(TEMPLATES))
160
+ for row in pairs(template, count, rng):
161
+ clean_plural = int(row["sign"] < 0)
162
+ out += [(row["clean"], clean_plural), (row["corrupted"], 1 - clean_plural)]
163
+ return out
164
+
165
+
166
+ def last_token_activations(runner, texts: Sequence[str], layers: Sequence[int]):
167
+ """``{layer: [len(texts), width]}``: each layer's MLP activations at each text's last token.
168
+
169
+ Texts with the same number of tokens run together, so no text needs padding.
170
+ """
171
+ ids = [runner.tokenizer.encode(t) for t in texts]
172
+ by_length: dict[int, list[int]] = {}
173
+ for i, row in enumerate(ids):
174
+ by_length.setdefault(len(row), []).append(i)
175
+ out = {layer: torch.zeros(len(texts), runner.mlp_width(layer)) for layer in layers}
176
+ for group in by_length.values():
177
+ for start in range(0, len(group), BATCH):
178
+ part = group[start : start + BATCH]
179
+ batch = torch.tensor([ids[i] for i in part])
180
+ cache = runner.layer_activations({"input_ids": batch}, list(layers))
181
+ for layer in layers:
182
+ out[layer][part] = cache[layer][:, -1].float().cpu()
183
+ return out
184
+
185
+
186
+ def grammatical_number(
187
+ runner, layers: Sequence[int], *, k: int, n: int, seed: int
188
+ ) -> list[tuple[int, SuperpositionProfile, float]]:
189
+ """One (layer, profile, held-out accuracy) per layer: a ``k``-neuron probe for noun number.
190
+
191
+ It uses ``n`` agreement pairs (:func:`agreement_examples`). The probe reads the
192
+ layer's MLP activations at the last token, where the verb comes next. The two
193
+ prompts of a pair are always on the same side of the held-out split.
194
+ """
195
+ examples = agreement_examples(n, seed)
196
+ labels = torch.tensor([label for _, label in examples])
197
+ pair_ids = torch.arange(len(examples)) // 2 # agreement_examples puts a pair's prompts together
198
+ acts = last_token_activations(runner, [text for text, _ in examples], layers)
199
+ out = []
200
+ for layer in layers:
201
+ neurons, weights, accuracy = sparse_probe(
202
+ acts[layer], labels, k=k, seed=seed, groups=pair_ids
203
+ )
204
+ members = [
205
+ SuperpositionMember(neuron_idx=i, weight=w)
206
+ for i, w in zip(neurons, weights, strict=True)
207
+ if w != 0
208
+ ]
209
+ out.append((layer, _profile(members, "sparse-probe", NUMBER_SOURCE), accuracy))
210
+ return out
211
+
212
+
213
+ def definition(model: str, layer: int, profile: SuperpositionProfile) -> dict:
214
+ """The definition that ``open-interpretability audit --superposition`` reads."""
215
+ return {"model": model, "layer": layer, **profile.model_dump()}
216
+
217
+
218
+ def compare(a: SuperpositionProfile, b: SuperpositionProfile) -> tuple[list[int], float]:
219
+ """The neurons that ``a`` and ``b`` share, and the cosine of their two directions."""
220
+ wa = {m.neuron_idx: m.weight for m in a.members}
221
+ wb = {m.neuron_idx: m.weight for m in b.members}
222
+ shared = sorted(wa.keys() & wb.keys())
223
+ dot = sum(wa[i] * wb[i] for i in shared)
224
+ norms = math.sqrt(sum(v * v for v in wa.values())) * math.sqrt(sum(v * v for v in wb.values()))
225
+ return shared, dot / norms
@@ -0,0 +1,5 @@
1
+ """Deploy the built site to Cloudflare R2 (``open-interpretability deploy``)."""
2
+
3
+ from openinterp.sync.r2 import R2Config, SyncPlan, cache_control_for, make_client, sync_directory
4
+
5
+ __all__ = ["R2Config", "SyncPlan", "cache_control_for", "make_client", "sync_directory"]
openinterp/sync/r2.py ADDED
@@ -0,0 +1,280 @@
1
+ """Delta-sync a built ``dist/`` directory to a Cloudflare R2 bucket over the S3 API.
2
+
3
+ Only files whose MD5 differs from the remote ETag are uploaded (``put_object``
4
+ always produces single-part uploads, whose ETag is the content MD5). Because the
5
+ builder is deterministic, an unchanged registry syncs as a no-op: nothing is
6
+ uploaded, deleted or purged.
7
+
8
+ * Upload order: content-addressed files, then other assets, then HTML, so a page
9
+ never references an object that has not landed yet.
10
+ * Derived prefixes (``pagefind/``) are only synced when a source file changed. The
11
+ Pagefind index is not byte-stable across runs, so syncing it alone would churn.
12
+ * Protected prefixes (``data/snapshots/``) are never deleted: snapshot URLs are
13
+ permanent.
14
+ * With a Cloudflare zone configured, only changed short-cache URLs are purged.
15
+
16
+ Credentials come from ``CF_ACCOUNT_ID``, ``R2_ACCESS_KEY_ID`` and
17
+ ``R2_SECRET_ACCESS_KEY``; purging uses ``CF_ZONE_ID`` and ``CF_API_TOKEN``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import mimetypes
25
+ import os
26
+ import re
27
+ import urllib.request
28
+ from collections.abc import Callable, Mapping, Sequence
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path, PurePosixPath
31
+ from typing import Any
32
+
33
+ from openinterp.core.exceptions import SyncError
34
+
35
+ SHORT_CACHE = "public, max-age=3600"
36
+ IMMUTABLE_CACHE = "public, max-age=31536000, immutable"
37
+ DERIVED_PREFIXES = ("pagefind/",)
38
+ PROTECTED_PREFIXES = ("data/snapshots/",)
39
+ PURGE_BATCH = 30 # Cloudflare's per-request limit for purge-by-URL
40
+
41
+ CONTENT_TYPES = {
42
+ ".html": "text/html; charset=utf-8",
43
+ ".json": "application/json",
44
+ ".parquet": "application/vnd.apache.parquet",
45
+ ".wasm": "application/wasm",
46
+ ".js": "text/javascript",
47
+ ".css": "text/css",
48
+ ".svg": "image/svg+xml",
49
+ }
50
+ # Pagefind's index shards are content-addressed. Its entry files are not, so they must
51
+ # stay on the short cache: pagefind.js, pagefind-entry.json and pagefind-ui.*.
52
+ HASHED_NAME = re.compile(r"[_.-][0-9a-f]{7,}\.[A-Za-z0-9_]+$")
53
+ PAGEFIND_SHARD_SUFFIXES = {".pf_fragment", ".pf_index", ".pf_filter", ".pf_meta", ".pagefind"}
54
+
55
+
56
+ def cache_control_for(key: str) -> str:
57
+ """Cache policy: immutable only for content-addressed files, one hour for everything else."""
58
+ path = PurePosixPath(key)
59
+ if key.startswith("data/snapshots/") or path.suffix in PAGEFIND_SHARD_SUFFIXES:
60
+ return IMMUTABLE_CACHE
61
+ if path.suffix not in {".html", ".json"} and HASHED_NAME.search(path.name):
62
+ return IMMUTABLE_CACHE
63
+ return SHORT_CACHE
64
+
65
+
66
+ def content_type_for(key: str) -> str:
67
+ """The Content-Type to upload ``key`` with, from its suffix."""
68
+ suffix = PurePosixPath(key).suffix
69
+ return CONTENT_TYPES.get(suffix) or mimetypes.guess_type(key)[0] or "application/octet-stream"
70
+
71
+
72
+ def file_md5(path: Path) -> str:
73
+ """The MD5 of ``path``, which equals a single-part upload's ETag."""
74
+ digest = hashlib.md5(usedforsecurity=False)
75
+ with path.open("rb") as fh:
76
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
77
+ digest.update(chunk)
78
+ return digest.hexdigest()
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class R2Config:
83
+ """The R2 account and credentials to sync with."""
84
+
85
+ account_id: str
86
+ access_key_id: str
87
+ secret_access_key: str
88
+
89
+ @property
90
+ def endpoint_url(self) -> str:
91
+ """The account's S3 API endpoint."""
92
+ return f"https://{self.account_id}.r2.cloudflarestorage.com"
93
+
94
+ @classmethod
95
+ def from_env(cls, env: Mapping[str, str] = os.environ) -> R2Config:
96
+ """Read the config from the environment; raise SyncError when a variable is missing."""
97
+ names = ("CF_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY")
98
+ missing = [n for n in names if not env.get(n)]
99
+ if missing:
100
+ raise SyncError(f"Missing environment variables: {', '.join(missing)}")
101
+ return cls(*(env[n] for n in names))
102
+
103
+
104
+ def make_client(config: R2Config) -> Any:
105
+ """An S3 client for R2, made with ``config``."""
106
+ import boto3
107
+
108
+ return boto3.client(
109
+ "s3",
110
+ endpoint_url=config.endpoint_url,
111
+ aws_access_key_id=config.access_key_id,
112
+ aws_secret_access_key=config.secret_access_key,
113
+ region_name="auto",
114
+ )
115
+
116
+
117
+ @dataclass
118
+ class SyncPlan:
119
+ """What a sync uploads, deletes, leaves alone and skips."""
120
+
121
+ uploads: list[str] = field(default_factory=list)
122
+ deletes: list[str] = field(default_factory=list)
123
+ unchanged: list[str] = field(default_factory=list)
124
+ skipped_derived: list[str] = field(default_factory=list)
125
+
126
+ @property
127
+ def changed(self) -> bool:
128
+ """True when the sync uploads or deletes anything."""
129
+ return bool(self.uploads or self.deletes)
130
+
131
+
132
+ def local_files(dist_dir: Path, prefix: str = "") -> dict[str, Path]:
133
+ """Every file in ``dist_dir`` by its bucket key, except dotfiles."""
134
+ dist_dir = Path(dist_dir)
135
+ return {
136
+ prefix + path.relative_to(dist_dir).as_posix(): path
137
+ for path in sorted(dist_dir.rglob("*"))
138
+ if path.is_file() and not path.name.startswith(".")
139
+ }
140
+
141
+
142
+ def remote_etags(client: Any, bucket: str, prefix: str = "") -> dict[str, str]:
143
+ """The ETag of every object in ``bucket`` under ``prefix``, by key."""
144
+ etags: dict[str, str] = {}
145
+ for page in client.get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=prefix):
146
+ for obj in page.get("Contents", []):
147
+ etags[obj["Key"]] = obj["ETag"].strip('"')
148
+ return etags
149
+
150
+
151
+ def _upload_order(key: str) -> tuple[int, str]:
152
+ if key.endswith(".html"):
153
+ return (2, key)
154
+ return (0 if cache_control_for(key) == IMMUTABLE_CACHE else 1, key)
155
+
156
+
157
+ def plan_sync(
158
+ local: Mapping[str, Path],
159
+ remote: Mapping[str, str],
160
+ *,
161
+ delete: bool = False,
162
+ prefix: str = "",
163
+ derived: Sequence[str] = DERIVED_PREFIXES,
164
+ protected: Sequence[str] = PROTECTED_PREFIXES,
165
+ ) -> SyncPlan:
166
+ """Compare local files with the bucket, and decide what to upload and delete.
167
+
168
+ The module docstring gives the rules.
169
+ """
170
+ plan = SyncPlan()
171
+
172
+ def rel(key: str) -> str:
173
+ return key.removeprefix(prefix)
174
+
175
+ for key, path in local.items():
176
+ (plan.unchanged if remote.get(key) == file_md5(path) else plan.uploads).append(key)
177
+ if delete:
178
+ plan.deletes = sorted(
179
+ k for k in set(remote) - set(local) if not rel(k).startswith(tuple(protected))
180
+ )
181
+
182
+ # Derived files only follow a change to a source file; on their own they are churn.
183
+ is_derived = lambda k: rel(k).startswith(tuple(derived)) # noqa: E731
184
+ if not any(not is_derived(k) for k in plan.uploads + plan.deletes):
185
+ plan.skipped_derived = sorted(plan.uploads + plan.deletes)
186
+ plan.unchanged += plan.uploads
187
+ plan.uploads, plan.deletes = [], []
188
+
189
+ plan.uploads.sort(key=lambda k: _upload_order(rel(k)))
190
+ return plan
191
+
192
+
193
+ def sync_directory(
194
+ dist_dir: Path,
195
+ bucket: str,
196
+ client: Any,
197
+ *,
198
+ prefix: str = "",
199
+ delete: bool = False,
200
+ dry_run: bool = False,
201
+ log: Callable[[str], None] = lambda _msg: None,
202
+ ) -> SyncPlan:
203
+ """Sync ``dist_dir`` to ``bucket``; with ``dry_run``, only log what would change."""
204
+ dist_dir = Path(dist_dir)
205
+ if not dist_dir.is_dir():
206
+ raise SyncError(f"{dist_dir} is not a directory; run `open-interpretability build` first")
207
+ if prefix and not prefix.endswith("/"):
208
+ prefix += "/"
209
+
210
+ local = local_files(dist_dir, prefix)
211
+ try:
212
+ plan = plan_sync(local, remote_etags(client, bucket, prefix), delete=delete, prefix=prefix)
213
+ for key in plan.skipped_derived:
214
+ log(f"skip {key} (derived; no source file changed)")
215
+ for key in plan.uploads:
216
+ log(f"upload {key} [{cache_control_for(key.removeprefix(prefix))}]")
217
+ if not dry_run:
218
+ with local[key].open("rb") as body:
219
+ client.put_object(
220
+ Bucket=bucket,
221
+ Key=key,
222
+ Body=body,
223
+ ContentType=content_type_for(key),
224
+ CacheControl=cache_control_for(key.removeprefix(prefix)),
225
+ )
226
+ for start in range(0, len(plan.deletes), 1000): # DeleteObjects takes <= 1000 keys
227
+ batch = plan.deletes[start : start + 1000]
228
+ for key in batch:
229
+ log(f"delete {key}")
230
+ if not dry_run:
231
+ client.delete_objects(
232
+ Bucket=bucket, Delete={"Objects": [{"Key": k} for k in batch], "Quiet": True}
233
+ )
234
+ except SyncError:
235
+ raise
236
+ except Exception as exc: # botocore raises ClientError, EndpointConnectionError, ...
237
+ raise SyncError(f"Sync to bucket {bucket!r} failed: {exc}") from exc
238
+ return plan
239
+
240
+
241
+ def purge_targets(plan: SyncPlan, site_url: str, prefix: str = "") -> list[str]:
242
+ """Public URLs whose cached copies are stale after ``plan`` ran.
243
+
244
+ Content-addressed (immutable) objects get new names when they change, so only
245
+ short-cache objects need purging.
246
+ """
247
+ base = site_url.rstrip("/") + "/"
248
+ urls = []
249
+ for key in plan.uploads + plan.deletes:
250
+ rel = key.removeprefix(prefix.rstrip("/") + "/" if prefix else "")
251
+ if cache_control_for(rel) == IMMUTABLE_CACHE:
252
+ continue
253
+ urls.append(base + rel)
254
+ if rel == "index.html" or rel.endswith("/index.html"):
255
+ urls.append(base + rel.removesuffix("index.html"))
256
+ return urls
257
+
258
+
259
+ def purge_cloudflare_cache(
260
+ zone_id: str,
261
+ api_token: str,
262
+ urls: Sequence[str],
263
+ *,
264
+ opener: Callable[..., Any] = urllib.request.urlopen,
265
+ ) -> None:
266
+ """Purge ``urls`` from Cloudflare's edge cache (no-op for an empty list)."""
267
+ for start in range(0, len(urls), PURGE_BATCH):
268
+ request = urllib.request.Request(
269
+ f"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache",
270
+ data=json.dumps({"files": list(urls[start : start + PURGE_BATCH])}).encode(),
271
+ headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
272
+ method="POST",
273
+ )
274
+ try:
275
+ with opener(request, timeout=30) as response:
276
+ body = json.load(response)
277
+ except Exception as exc:
278
+ raise SyncError(f"Cloudflare cache purge failed: {exc}") from exc
279
+ if not body.get("success"):
280
+ raise SyncError(f"Cloudflare cache purge failed: {body.get('errors')}")