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.
- open_interpretability-0.0.20.dist-info/METADATA +157 -0
- open_interpretability-0.0.20.dist-info/RECORD +256 -0
- open_interpretability-0.0.20.dist-info/WHEEL +4 -0
- open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
- open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
- openinterp/__init__.py +6 -0
- openinterp/agents/__init__.py +25 -0
- openinterp/agents/base.py +229 -0
- openinterp/agents/judge.py +149 -0
- openinterp/agents/proposer.py +471 -0
- openinterp/agents/proposer_profiles.py +79 -0
- openinterp/agents/review_measurements.py +173 -0
- openinterp/agents/reviewer.py +498 -0
- openinterp/agreement.py +63 -0
- openinterp/audit_options.py +79 -0
- openinterp/baseline.py +254 -0
- openinterp/benchmark.py +450 -0
- openinterp/blends.py +239 -0
- openinterp/builder/__init__.py +6 -0
- openinterp/builder/agents.py +260 -0
- openinterp/builder/aligned.py +41 -0
- openinterp/builder/charts.py +411 -0
- openinterp/builder/circuit_pages.py +37 -0
- openinterp/builder/circuits.py +264 -0
- openinterp/builder/compiler.py +495 -0
- openinterp/builder/corpora.py +98 -0
- openinterp/builder/coverage.py +46 -0
- openinterp/builder/dashboard.py +255 -0
- openinterp/builder/disk_memo.py +65 -0
- openinterp/builder/experiment.py +105 -0
- openinterp/builder/heat.py +121 -0
- openinterp/builder/hrefs.py +71 -0
- openinterp/builder/learned_baseline.py +204 -0
- openinterp/builder/model_facts.py +138 -0
- openinterp/builder/neuronpedia_export.py +107 -0
- openinterp/builder/parallel.py +100 -0
- openinterp/builder/parquet_export.py +458 -0
- openinterp/builder/pieces.py +127 -0
- openinterp/builder/pii.py +211 -0
- openinterp/builder/registry_stats.py +144 -0
- openinterp/builder/research.py +109 -0
- openinterp/builder/sections.py +28 -0
- openinterp/builder/significance.py +103 -0
- openinterp/builder/sister_graph.py +180 -0
- openinterp/builder/standing.py +185 -0
- openinterp/builder/statuses.py +34 -0
- openinterp/builder/stopped.py +91 -0
- openinterp/builder/stops.py +30 -0
- openinterp/builder/superpositions.py +102 -0
- openinterp/builder/templates/_agent_role_cells.html +9 -0
- openinterp/builder/templates/_causal_chart.html +67 -0
- openinterp/builder/templates/_circuit.html +8 -0
- openinterp/builder/templates/_dashboard.html +133 -0
- openinterp/builder/templates/_experiment.html +500 -0
- openinterp/builder/templates/_macros.html +82 -0
- openinterp/builder/templates/_patchscope.html +33 -0
- openinterp/builder/templates/_prior_art.html +24 -0
- openinterp/builder/templates/_specificity.html +21 -0
- openinterp/builder/templates/_steering.html +34 -0
- openinterp/builder/templates/_superposition.html +62 -0
- openinterp/builder/templates/_vocab_chart.html +88 -0
- openinterp/builder/templates/_wiring.html +44 -0
- openinterp/builder/templates/about.html +95 -0
- openinterp/builder/templates/agent.html +69 -0
- openinterp/builder/templates/agents.html +80 -0
- openinterp/builder/templates/base.html +103 -0
- openinterp/builder/templates/circuit.html +64 -0
- openinterp/builder/templates/circuits.html +46 -0
- openinterp/builder/templates/experiment.html +21 -0
- openinterp/builder/templates/hub.html +16 -0
- openinterp/builder/templates/index.html +387 -0
- openinterp/builder/templates/layer.html +69 -0
- openinterp/builder/templates/learned-baseline.html +44 -0
- openinterp/builder/templates/model.html +151 -0
- openinterp/builder/templates/models.html +47 -0
- openinterp/builder/templates/neuron.html +234 -0
- openinterp/builder/templates/paper.html +39 -0
- openinterp/builder/templates/redirect.html +1 -0
- openinterp/builder/templates/research.html +31 -0
- openinterp/builder/templates/user.html +54 -0
- openinterp/builder/templates/users.html +39 -0
- openinterp/builder/templates/wiki/experiment.md +271 -0
- openinterp/builder/templates/wiki/index.md +32 -0
- openinterp/builder/templates/wiki/neuron.md +30 -0
- openinterp/builder/templates/wiki/stop.md +36 -0
- openinterp/builder/tree.py +470 -0
- openinterp/builder/users.py +142 -0
- openinterp/builder/wiki.py +427 -0
- openinterp/checks.py +429 -0
- openinterp/cli.py +494 -0
- openinterp/cli_baseline.py +332 -0
- openinterp/cli_benchmark.py +300 -0
- openinterp/cli_build.py +128 -0
- openinterp/cli_check.py +51 -0
- openinterp/cli_concepts.py +76 -0
- openinterp/cli_corpus.py +252 -0
- openinterp/cli_costs.py +257 -0
- openinterp/cli_coverage.py +76 -0
- openinterp/cli_crossref.py +86 -0
- openinterp/cli_errata.py +142 -0
- openinterp/cli_evidence.py +495 -0
- openinterp/cli_evidence_store.py +93 -0
- openinterp/cli_explain.py +101 -0
- openinterp/cli_loop.py +125 -0
- openinterp/cli_memory.py +28 -0
- openinterp/cli_neuronpedia.py +175 -0
- openinterp/cli_pause.py +45 -0
- openinterp/cli_pole_effects.py +133 -0
- openinterp/cli_probing.py +119 -0
- openinterp/cli_registry.py +348 -0
- openinterp/cli_reproduce.py +446 -0
- openinterp/cli_review.py +302 -0
- openinterp/cli_scan.py +223 -0
- openinterp/cli_score.py +446 -0
- openinterp/cli_sisters.py +86 -0
- openinterp/cli_submission.py +217 -0
- openinterp/cli_superpositions.py +118 -0
- openinterp/cli_wiring.py +54 -0
- openinterp/codeid.py +73 -0
- openinterp/concepts.py +400 -0
- openinterp/core/__init__.py +59 -0
- openinterp/core/agent_names.py +76 -0
- openinterp/core/attribution.py +101 -0
- openinterp/core/cache_dirs.py +15 -0
- openinterp/core/causal.py +82 -0
- openinterp/core/confounds.py +103 -0
- openinterp/core/contributors.py +26 -0
- openinterp/core/corpora.py +126 -0
- openinterp/core/costs.py +430 -0
- openinterp/core/draws.py +109 -0
- openinterp/core/errata.py +244 -0
- openinterp/core/evidence.py +160 -0
- openinterp/core/examples.py +93 -0
- openinterp/core/exceptions.py +29 -0
- openinterp/core/explanation.py +130 -0
- openinterp/core/fence.py +42 -0
- openinterp/core/heavy_lock.py +114 -0
- openinterp/core/identifiers.py +198 -0
- openinterp/core/isolation.py +149 -0
- openinterp/core/ledger.py +130 -0
- openinterp/core/licenses.py +28 -0
- openinterp/core/manifold.py +117 -0
- openinterp/core/partners.py +32 -0
- openinterp/core/patchscope.py +59 -0
- openinterp/core/paths.py +40 -0
- openinterp/core/pause.py +96 -0
- openinterp/core/pricing.py +178 -0
- openinterp/core/prior_art.py +44 -0
- openinterp/core/probing.py +146 -0
- openinterp/core/profiles.py +221 -0
- openinterp/core/project.py +28 -0
- openinterp/core/run_environment.py +38 -0
- openinterp/core/schemas.py +484 -0
- openinterp/core/specificity.py +49 -0
- openinterp/core/stats.py +172 -0
- openinterp/core/steering.py +49 -0
- openinterp/core/strict.py +19 -0
- openinterp/core/takedowns.py +136 -0
- openinterp/core/transcripts.py +130 -0
- openinterp/core/wiring.py +80 -0
- openinterp/corpus.py +476 -0
- openinterp/corpus_cache.py +145 -0
- openinterp/corpus_layouts.py +108 -0
- openinterp/corpus_presets.py +168 -0
- openinterp/corpus_rebuild.py +93 -0
- openinterp/coverage.py +155 -0
- openinterp/crossref.py +442 -0
- openinterp/data/__init__.py +9 -0
- openinterp/data/offensive_words.txt +58 -0
- openinterp/data/public_suffix_list.dat +15771 -0
- openinterp/data/sample_corpus.txt +40 -0
- openinterp/draw_steps.py +359 -0
- openinterp/errata_checks.py +129 -0
- openinterp/evidence.py +462 -0
- openinterp/evidence_branch.py +119 -0
- openinterp/evidence_lenses.py +75 -0
- openinterp/experiment_loop.py +357 -0
- openinterp/explain.py +252 -0
- openinterp/half_precision.py +79 -0
- openinterp/harness/__init__.py +37 -0
- openinterp/harness/ane/__init__.py +9 -0
- openinterp/harness/ane/backend.py +324 -0
- openinterp/harness/ane/cache.py +276 -0
- openinterp/harness/ane/chunks.py +163 -0
- openinterp/harness/ane/convert.py +116 -0
- openinterp/harness/ane/forward.py +258 -0
- openinterp/harness/ane/qualified.py +40 -0
- openinterp/harness/annotations.py +329 -0
- openinterp/harness/attribution.py +96 -0
- openinterp/harness/causal.py +264 -0
- openinterp/harness/confounds.py +184 -0
- openinterp/harness/covariance.py +301 -0
- openinterp/harness/crossref.py +195 -0
- openinterp/harness/cuda_devices.py +88 -0
- openinterp/harness/dead.py +124 -0
- openinterp/harness/devices.py +401 -0
- openinterp/harness/dictionaries.py +496 -0
- openinterp/harness/dreaming.py +116 -0
- openinterp/harness/environment.py +64 -0
- openinterp/harness/explanation_scores.py +371 -0
- openinterp/harness/features.py +276 -0
- openinterp/harness/first_position.py +137 -0
- openinterp/harness/heads.py +226 -0
- openinterp/harness/hooks.py +499 -0
- openinterp/harness/input_attribution.py +367 -0
- openinterp/harness/jlens.py +183 -0
- openinterp/harness/layer_cache.py +301 -0
- openinterp/harness/layer_major.py +274 -0
- openinterp/harness/lookahead.py +93 -0
- openinterp/harness/manifold.py +200 -0
- openinterp/harness/memory.py +471 -0
- openinterp/harness/moe.py +134 -0
- openinterp/harness/patcher.py +322 -0
- openinterp/harness/patchscope.py +177 -0
- openinterp/harness/probing.py +383 -0
- openinterp/harness/scan.py +462 -0
- openinterp/harness/scan_backend.py +201 -0
- openinterp/harness/sister_passes.py +463 -0
- openinterp/harness/sisters.py +363 -0
- openinterp/harness/sites.py +101 -0
- openinterp/harness/specificity.py +107 -0
- openinterp/harness/steering.py +159 -0
- openinterp/harness/superpositions.py +116 -0
- openinterp/harness/units.py +203 -0
- openinterp/harness/wiring.py +432 -0
- openinterp/harness/wiring_activations.py +285 -0
- openinterp/langid.py +73 -0
- openinterp/names.py +129 -0
- openinterp/neuronpedia_baseline.py +88 -0
- openinterp/neuronpedia_review.py +148 -0
- openinterp/planner.py +466 -0
- openinterp/planner_effects.py +89 -0
- openinterp/planner_heads.py +92 -0
- openinterp/planner_models.py +174 -0
- openinterp/planner_sisters.py +122 -0
- openinterp/planner_states.py +57 -0
- openinterp/planner_superpositions.py +114 -0
- openinterp/pole_effects.py +102 -0
- openinterp/prior_art.py +143 -0
- openinterp/reproduce.py +450 -0
- openinterp/revision.py +45 -0
- openinterp/runner_cache.py +35 -0
- openinterp/score_controls.py +169 -0
- openinterp/sensitive.py +243 -0
- openinterp/show.py +236 -0
- openinterp/signing.py +209 -0
- openinterp/sisters.py +476 -0
- openinterp/superposition_finders.py +225 -0
- openinterp/sync/__init__.py +5 -0
- openinterp/sync/r2.py +280 -0
- openinterp/syntax.py +399 -0
- openinterp/takedown_checks.py +76 -0
- openinterp/toxicity.py +158 -0
- openinterp/verdict_regression.py +79 -0
- openinterp/wiring_backfill.py +170 -0
- 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')}")
|