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,134 @@
1
+ """The experts forward that openinterp runs mixture-of-experts models with (docs/moe.md).
2
+
3
+ transformers 5 runs the experts of about 60 MoE architectures through
4
+ ``ExpertsInterface``: an experts class decorated with ``use_experts_implementation`` looks
5
+ its forward up by name. :func:`experts_forward` is registered there as ``openinterp``,
6
+ and :func:`use_openinterp_experts` selects it when a model loads. It is the ``eager``
7
+ forward written out once for every layout the decorator describes, with two additions:
8
+
9
+ * Each expert's hidden activation passes through the hook of :func:`expert_hook`
10
+ before the down projection. That is where an expert neuron is read and edited.
11
+ * In a streamed model (``--memory stream``) the experts' tensors stay on disk, and the
12
+ forward reads only the routed experts' slices of them (:func:`expert_weight`). One
13
+ expert of Mistral Large 4 is about 113 MB in FP8, and all 128 of a layer 14.5 GB.
14
+
15
+ ``scripts/moe_experts_check.py`` and ``tests/test_moe.py`` compare it with ``eager``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import threading
21
+ from collections.abc import Callable, Iterator
22
+ from contextlib import contextmanager
23
+
24
+ import torch
25
+ from torch import nn
26
+
27
+ EXPERTS_IMPLEMENTATION = "openinterp"
28
+
29
+ # fn(act [tokens, d_expert], expert index, token indices, routing weights) -> act
30
+ ExpertHook = Callable[[torch.Tensor, int, torch.Tensor, torch.Tensor], torch.Tensor]
31
+
32
+ _hooks = threading.local() # per thread, as the layer-major review needs
33
+
34
+
35
+ def _linear(x: torch.Tensor, weight: torch.Tensor, bias, transposed: bool) -> torch.Tensor:
36
+ weight = weight.to(x.device, x.dtype) # a streamed slice is read to the CPU
37
+ y = x @ weight if transposed else x @ weight.T
38
+ return y if bias is None else y + bias.to(x.device, x.dtype)
39
+
40
+
41
+ def experts_forward(self, hidden, top_k_index, top_k_weights) -> torch.Tensor:
42
+ """The routed experts' summed, weighted outputs ``[tokens, d_model]``.
43
+
44
+ ``top_k_index`` and ``top_k_weights`` are each token's chosen experts and their
45
+ routing weights ``[tokens, k]``. An index of ``num_experts`` or more is an
46
+ expert-parallel placeholder, and is skipped.
47
+ """
48
+ out = torch.zeros_like(hidden)
49
+ hook = getattr(_hooks, "by_module", {}).get(id(self))
50
+ up = "gate_up_proj" if self.has_gate else "up_proj"
51
+ for e in torch.unique(top_k_index).tolist():
52
+ if e >= self.num_experts:
53
+ continue
54
+ tokens, slot = torch.where(top_k_index == e)
55
+ pre = _linear(
56
+ hidden[tokens], expert_weight(self, up, e), _bias(self, up, e), self.is_transposed
57
+ )
58
+ act = self._apply_gate(pre) if self.has_gate else self.act_fn(pre)
59
+ weights = top_k_weights[tokens, slot]
60
+ if hook is not None:
61
+ act = hook(act, e, tokens, weights)
62
+ down = expert_weight(self, "down_proj", e)
63
+ y = _linear(act, down, _bias(self, "down_proj", e), self.is_transposed)
64
+ if self.has_post_expert_norm:
65
+ y = self.post_expert_norm(y)
66
+ out.index_add_(0, tokens, (y * weights[:, None]).to(out.dtype))
67
+ return out
68
+
69
+
70
+ def _bias(experts: nn.Module, name: str, e: int) -> torch.Tensor | None:
71
+ return expert_weight(experts, f"{name}_bias", e) if experts.has_bias else None
72
+
73
+
74
+ def expert_weight(experts: nn.Module, name: str, e: int) -> torch.Tensor:
75
+ """Expert ``e``'s slice of the stacked tensor ``name``, read from disk if streamed."""
76
+ param = getattr(experts, name)
77
+ if param.device.type != "meta":
78
+ return param[e]
79
+ weights = experts._hf_hook.weights_map # streamed: the tensor stays on disk
80
+ return weights.dataset.slice(weights.prefix + name, e).to(param.dtype)
81
+
82
+
83
+ @contextmanager
84
+ def expert_hook(experts: nn.Module, fn: ExpertHook) -> Iterator[None]:
85
+ """Pass every expert's hidden activation in ``experts`` through ``fn``, in this thread."""
86
+ by_module = getattr(_hooks, "by_module", None)
87
+ if by_module is None:
88
+ by_module = _hooks.by_module = {}
89
+ previous = by_module.get(id(experts))
90
+ by_module[id(experts)] = fn
91
+ try:
92
+ yield
93
+ finally:
94
+ if previous is None:
95
+ del by_module[id(experts)]
96
+ else:
97
+ by_module[id(experts)] = previous
98
+
99
+
100
+ def experts_modules(model: nn.Module) -> list[nn.Module]:
101
+ """The experts modules that ``use_experts_implementation`` decorated, in model order."""
102
+ return [m for m in model.modules() if hasattr(m, "has_gate") and hasattr(m, "down_proj")]
103
+
104
+
105
+ def use_openinterp_experts(model: nn.Module) -> bool:
106
+ """Run ``model``'s experts with :func:`experts_forward`; False if it has none.
107
+
108
+ In a streamed model each experts module's tensors then stay on disk between passes.
109
+ Each pass reads only the routed experts' slices of them.
110
+ """
111
+ experts = experts_modules(model)
112
+ if not experts:
113
+ return False
114
+ _register()
115
+ model.set_experts_implementation(EXPERTS_IMPLEMENTATION)
116
+ for module in experts:
117
+ hook = getattr(module, "_hf_hook", None)
118
+ if hook is not None and hook.offload:
119
+ hook.offload = False # the forward reads slices: don't load the whole stack
120
+ return True
121
+
122
+
123
+ def experts_implementation(model: nn.Module) -> str | None:
124
+ """The experts forward ``model`` runs with, or ``None`` if it has no experts."""
125
+ if not experts_modules(model):
126
+ return None
127
+ return getattr(model.config, "_experts_implementation", None)
128
+
129
+
130
+ def _register() -> None:
131
+ from transformers.integrations.moe import ALL_EXPERTS_FUNCTIONS, ExpertsInterface
132
+
133
+ if EXPERTS_IMPLEMENTATION not in ALL_EXPERTS_FUNCTIONS:
134
+ ExpertsInterface.register(EXPERTS_IMPLEMENTATION, experts_forward)
@@ -0,0 +1,322 @@
1
+ """Activation patching on single neuron poles.
2
+
3
+ Two directions are supported:
4
+
5
+ * **Denoising** (clean -> corrupted): run the corrupted prompt but inject the
6
+ neuron's activation cached from the clean run. The normalised recovery of the
7
+ clean behaviour measures *sufficiency*.
8
+ * **Noising** (corrupted -> clean): run the clean prompt but inject the activation
9
+ from the corrupted run (a resample ablation). The normalised movement towards
10
+ the corrupted behaviour measures *necessity*.
11
+
12
+ Only the requested pole is swapped. The opposite pole keeps its own value. With
13
+ ``freeze_downstream=True``, the outputs of every later MLP are pinned to their values
14
+ from the unpatched run. This blocks MLP-mediated self-repair (the "hydra effect"), so
15
+ the measured effect is closer to the neuron's direct effect.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass, field
23
+ from typing import Literal, Protocol
24
+
25
+ import torch
26
+
27
+ from openinterp.core.exceptions import ShapeMismatchError
28
+ from openinterp.core.schemas import Pole
29
+ from openinterp.harness.hooks import (
30
+ Inputs,
31
+ Intervention,
32
+ NeuronHookRunner,
33
+ _recorders,
34
+ replace_pole,
35
+ )
36
+ from openinterp.harness.memory import stream_rows
37
+
38
+ Metric = Callable[[torch.Tensor], torch.Tensor]
39
+ # (patched logits [1, seq, vocab], unpatched target logits [1, seq, vocab]) -> effect
40
+ Effect = Callable[[torch.Tensor, torch.Tensor], float]
41
+ Positions = Literal["all", "last"]
42
+
43
+
44
+ class NeuronRef(Protocol):
45
+ """Anything that names one neuron pole."""
46
+
47
+ layer: int
48
+ neuron_idx: int
49
+ pole: Pole
50
+
51
+
52
+ def token_logit_metric(token_id: int, position: int = -1) -> Metric:
53
+ """Logit of ``token_id`` at ``position`` (averaged over the batch)."""
54
+ return lambda logits: logits[:, position, token_id].mean()
55
+
56
+
57
+ def logit_diff_metric(correct_id: int, incorrect_id: int, position: int = -1) -> Metric:
58
+ """Logit of ``correct_id`` minus logit of ``incorrect_id`` at ``position`` (batch mean)."""
59
+ return lambda logits: (
60
+ logits[:, position, correct_id] - logits[:, position, incorrect_id]
61
+ ).mean()
62
+
63
+
64
+ def logit_direction_metric(weights: torch.Tensor, position: int = -1) -> Metric:
65
+ """Projection of the logits at ``position`` onto a vocabulary direction (batch mean).
66
+
67
+ ``weights`` is centred first, so the shared logit offset (which softmax ignores)
68
+ cannot move the metric. With weights 1/k on k tokens this is the mean logit of
69
+ those tokens minus the mean logit over the vocabulary.
70
+ """
71
+ w = weights.float()
72
+ w = w - w.mean()
73
+ return lambda logits: (logits[:, position].float() @ w.to(logits.device)).mean()
74
+
75
+
76
+ def metric_change(metric: Metric) -> Effect:
77
+ """Signed change in ``metric`` caused by the patch."""
78
+ return lambda patched, target: float(metric(patched)) - float(metric(target))
79
+
80
+
81
+ def next_token_kl(position: int = -1) -> Effect:
82
+ """KL(unpatched ‖ patched) between next-token distributions at ``position``."""
83
+
84
+ def effect(patched: torch.Tensor, target: torch.Tensor) -> float:
85
+ p = torch.log_softmax(target[:, position].float(), dim=-1)
86
+ q = torch.log_softmax(patched[:, position].float(), dim=-1)
87
+ return max(0.0, float((p.exp() * (p - q)).sum(-1).mean()))
88
+
89
+ return effect
90
+
91
+
92
+ def _constant(value: torch.Tensor) -> Intervention:
93
+ return lambda _out: value
94
+
95
+
96
+ def _normalised(patched: float, baseline: float, reference: float, eps: float = 1e-6) -> float:
97
+ denom = reference - baseline
98
+ return math.nan if abs(denom) < eps else (patched - baseline) / denom
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class PatchResult:
103
+ """Outcome of patching a neuron pole from a source run into a target run.
104
+
105
+ ``baseline`` is the target run's metric, ``reference`` the source run's, and
106
+ ``patched`` the target run with the source activation injected.
107
+ """
108
+
109
+ baseline_metric: float
110
+ reference_metric: float
111
+ patched_metric: float
112
+ logit_delta: torch.Tensor # patched - baseline logits at the final position, [vocab]
113
+
114
+ @property
115
+ def metric_delta(self) -> float:
116
+ """The patched metric minus the baseline metric."""
117
+ return self.patched_metric - self.baseline_metric
118
+
119
+ @property
120
+ def recovery(self) -> float:
121
+ """Fraction of the source-vs-target metric gap closed by the patch (NaN if no gap)."""
122
+ return _normalised(self.patched_metric, self.baseline_metric, self.reference_metric)
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class AblationResult:
127
+ """The patch results of one ablation over several source prompts."""
128
+
129
+ per_source: list[PatchResult] = field(default_factory=list)
130
+
131
+ @property
132
+ def mean_metric_delta(self) -> float:
133
+ """The mean metric delta over the source prompts."""
134
+ return sum(r.metric_delta for r in self.per_source) / len(self.per_source)
135
+
136
+ @property
137
+ def mean_recovery(self) -> float:
138
+ """The mean recovery over the prompts where it is defined; NaN when none is."""
139
+ finite = [r.recovery for r in self.per_source if math.isfinite(r.recovery)]
140
+ return sum(finite) / len(finite) if finite else math.nan
141
+
142
+
143
+ class ActivationPatcher:
144
+ """Patch one neuron pole's activation between two runs (see the module docstring)."""
145
+
146
+ def __init__(self, runner: NeuronHookRunner) -> None:
147
+ self.runner = runner
148
+
149
+ def patch(
150
+ self,
151
+ source: Inputs,
152
+ target: Inputs,
153
+ neuron: NeuronRef,
154
+ *,
155
+ metric: Metric | None = None,
156
+ positions: Positions = "all",
157
+ freeze_downstream: bool = False,
158
+ ) -> PatchResult:
159
+ """Inject ``neuron``'s pole activation from the ``source`` run into the ``target`` run.
160
+
161
+ If ``metric`` is omitted it defaults to the logit of the source run's
162
+ top-1 next token at the final position.
163
+ """
164
+ runner = self.runner
165
+ layer, idx, pole = neuron.layer, neuron.neuron_idx, neuron.pole
166
+ downstream = list(range(layer + 1, runner.n_layers)) if freeze_downstream else []
167
+
168
+ source_logits, source_cache = runner.run_with_cache(source, [layer])
169
+ target_enc = runner.encode(target)
170
+ target_logits, target_cache, downstream_out = self._target_pass(
171
+ target_enc, downstream, [layer]
172
+ )
173
+
174
+ src_act, tgt_act = source_cache[layer], target_cache[layer]
175
+ if positions == "all" and src_act.shape[:2] != tgt_act.shape[:2]:
176
+ raise ShapeMismatchError(
177
+ f"source {tuple(src_act.shape[:2])} and target {tuple(tgt_act.shape[:2])} "
178
+ "differ in batch/sequence length; use positions='last' or align the prompts."
179
+ )
180
+
181
+ def inject(act: torch.Tensor) -> torch.Tensor:
182
+ act = act.clone()
183
+ if positions == "all":
184
+ act[..., idx] = replace_pole(act[..., idx], src_act[..., idx], pole)
185
+ else:
186
+ act[:, -1, idx] = replace_pole(act[:, -1, idx], src_act[:, -1, idx], pole)
187
+ return act
188
+
189
+ frozen: dict[int, Intervention] = {
190
+ i: (lambda _out, cached=cached: cached) for i, cached in downstream_out.items()
191
+ }
192
+ patched_logits = runner.run_with_interventions(target_enc, {layer: inject}, frozen)
193
+
194
+ if metric is None:
195
+ metric = token_logit_metric(int(source_logits[0, -1].argmax()))
196
+ return PatchResult(
197
+ baseline_metric=float(metric(target_logits)),
198
+ reference_metric=float(metric(source_logits)),
199
+ patched_metric=float(metric(patched_logits)),
200
+ logit_delta=(patched_logits[0, -1] - target_logits[0, -1]).detach().cpu(),
201
+ )
202
+
203
+ def patch_many(
204
+ self,
205
+ source: Inputs,
206
+ target: Inputs,
207
+ layer: int,
208
+ neuron_indices: Sequence[int],
209
+ pole: Pole,
210
+ *,
211
+ effect: Effect,
212
+ positions: Positions = "all",
213
+ freeze_downstream: bool = False,
214
+ batch_size: int = 8,
215
+ ) -> list[float]:
216
+ """``effect`` of patching each neuron in ``layer``, one at a time, from source to target.
217
+
218
+ With ``effect=metric_change(metric)`` this is :meth:`patch`'s ``metric_delta``.
219
+
220
+ Each neuron gets its own row of a batch, so ``k`` neurons cost ``k / batch_size``
221
+ forward passes instead of ``k``. A streamed model runs more rows per pass
222
+ (:func:`~openinterp.harness.memory.stream_rows`). ``source`` and ``target`` must be
223
+ single prompts.
224
+ """
225
+ runner = self.runner
226
+ downstream = list(range(layer + 1, runner.n_layers)) if freeze_downstream else []
227
+ src_act = runner.layer_activations(source, [layer])[layer] # stops at the layer
228
+ target_enc = runner.encode(target)
229
+ target_logits, target_cache, downstream_out = self._target_pass(
230
+ target_enc, downstream, [layer]
231
+ )
232
+ batch_size = stream_rows(runner, target_enc["input_ids"].shape[1], batch_size, logits=True)
233
+ if src_act.shape[0] != 1 or target_cache[layer].shape[0] != 1:
234
+ raise ValueError("patch_many takes one source and one target prompt")
235
+ if positions == "all" and src_act.shape[:2] != target_cache[layer].shape[:2]:
236
+ raise ShapeMismatchError(
237
+ f"source {tuple(src_act.shape[:2])} and target "
238
+ f"{tuple(target_cache[layer].shape[:2])} differ in sequence length; "
239
+ "use positions='last' or align the prompts."
240
+ )
241
+ span = slice(None) if positions == "all" else slice(-1, None)
242
+
243
+ deltas: list[float] = []
244
+ for start in range(0, len(neuron_indices), batch_size):
245
+ chunk = list(neuron_indices[start : start + batch_size])
246
+ batch = {k: v.repeat(len(chunk), *[1] * (v.dim() - 1)) for k, v in target_enc.items()}
247
+
248
+ def inject(act: torch.Tensor, chunk=chunk) -> torch.Tensor:
249
+ act = act.clone()
250
+ for row, idx in enumerate(chunk):
251
+ act[row, span, idx] = replace_pole(
252
+ act[row, span, idx], src_act[0, span, idx], pole
253
+ )
254
+ return act
255
+
256
+ n = len(chunk)
257
+ frozen = {i: _constant(cached.repeat(n, 1, 1)) for i, cached in downstream_out.items()}
258
+ patched = runner.run_with_interventions(batch, {layer: inject}, frozen)
259
+ deltas += [effect(patched[row : row + 1], target_logits) for row in range(len(chunk))]
260
+ return deltas
261
+
262
+ def _target_pass(
263
+ self, encoded, downstream: Sequence[int], layers: Sequence[int] = ()
264
+ ) -> tuple[torch.Tensor, dict[int, torch.Tensor], dict[int, torch.Tensor]]:
265
+ """One pass over the target: its logits, ``layers``' activations, ``downstream``'s outputs.
266
+
267
+ Patching freezes the downstream outputs at their target values. They were read in a
268
+ second pass over the target. One pass gives the same values for one read of the
269
+ layers, which is what a streamed model pays for.
270
+ """
271
+ acts: dict[int, torch.Tensor] = {}
272
+ outputs: dict[int, torch.Tensor] = {}
273
+
274
+ def recorder(layer: int) -> Intervention:
275
+ def record(out: torch.Tensor) -> None:
276
+ outputs[layer] = out.detach().clone()
277
+
278
+ return record
279
+
280
+ logits = self.runner.run_with_interventions(
281
+ encoded, _recorders(acts, layers), {i: recorder(i) for i in downstream}
282
+ )
283
+ return logits, acts, outputs
284
+
285
+ def denoise(self, clean: Inputs, corrupted: Inputs, neuron: NeuronRef, **kw) -> PatchResult:
286
+ """Clean -> corrupted patch. ``recovery`` is the neuron's sufficiency."""
287
+ return self.patch(clean, corrupted, neuron, **kw)
288
+
289
+ def noise(self, clean: Inputs, corrupted: Inputs, neuron: NeuronRef, **kw) -> PatchResult:
290
+ """Corrupted -> clean patch. ``recovery`` is the neuron's necessity."""
291
+ return self.patch(corrupted, clean, neuron, **kw)
292
+
293
+ def resample_ablation(
294
+ self,
295
+ target: Inputs,
296
+ resample_sources: Sequence[Inputs],
297
+ neuron: NeuronRef,
298
+ *,
299
+ metric: Metric | None = None,
300
+ positions: Positions = "last",
301
+ freeze_downstream: bool = False,
302
+ ) -> AblationResult:
303
+ """Replace the neuron pole in ``target`` with its value on each resample source.
304
+
305
+ The default metric is fixed from the *target* run, so every source is scored
306
+ against the same token.
307
+ """
308
+ if metric is None:
309
+ target_logits = self.runner.run_with_interventions(target)
310
+ metric = token_logit_metric(int(target_logits[0, -1].argmax()))
311
+ results = [
312
+ self.patch(
313
+ src,
314
+ target,
315
+ neuron,
316
+ metric=metric,
317
+ positions=positions,
318
+ freeze_downstream=freeze_downstream,
319
+ )
320
+ for src in resample_sources
321
+ ]
322
+ return AblationResult(per_source=results)
@@ -0,0 +1,177 @@
1
+ """Verbalise a unit's output direction with a Patchscope (Ghandeharioun et al., 2024).
2
+
3
+ Take the residual stream after layer ℓ's block, at a placeholder token of an inspection
4
+ prompt. It is replaced by the unit's output direction, scaled to a multiple of the norm
5
+ that it replaces. The model then continues greedily, and so describes the direction without
6
+ seeing a single example. A random direction injected the same way is the control: any
7
+ text the two share comes from the prompt, not the unit.
8
+
9
+ There are two inspection prompts: a few-shot "word: category" list and a definition
10
+ frame. Each is used at the unit's own layer ℓ (source layer = target layer). Each is also
11
+ used at an early layer, as SelfIE (Chen et al., 2024) does. On Qwen2.5-0.5B, the few-shot
12
+ list injected at layer 2 names the concept of several VERIFIED poles. It gives "crop",
13
+ "newspaper", "official" and "culture", where the control gives "animal", "emotion" or
14
+ "period".
15
+ Injected at their own layer, poles from layer 17 up mostly read back the control's
16
+ text. The decoding is greedy, so a reading is deterministic given the weights.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping, Sequence
22
+
23
+ import torch
24
+
25
+ from openinterp.core.exceptions import ArchitectureNotSupportedError
26
+ from openinterp.core.patchscope import PatchscopeReading, PatchscopeReadout
27
+ from openinterp.harness.hooks import NeuronHookRunner
28
+ from openinterp.harness.units import FeatureUnit, NeuronUnit, Unit
29
+
30
+ PROMPTS = {
31
+ "category": "banana: fruit\nhammer: tool\nviolin: music\nsenator: politics\n{x}:",
32
+ "meaning": 'The meaning of the word "{x}" is',
33
+ }
34
+ PLACEHOLDER = "?"
35
+ SCALES = (1.0, 2.0, 4.0)
36
+ EARLY_LAYER = 2
37
+ MAX_NEW_TOKENS = 12
38
+ CONTROL_SEED = 0
39
+
40
+
41
+ def write_direction(runner: NeuronHookRunner, unit: Unit) -> torch.Tensor | None:
42
+ """Return the vector that one unit of the unit's strength adds to the residual stream.
43
+
44
+ That is a pole's signed ``w_out``, or a dictionary latent's decoder row. A head writes
45
+ a subspace, not one direction, so it has none.
46
+ """
47
+ if isinstance(unit, NeuronUnit):
48
+ ident = unit.ident
49
+ direction = runner.neuron_output_direction(ident.layer, ident.neuron_idx).float()
50
+ return -direction if ident.pole == "negative" else direction
51
+ if isinstance(unit, FeatureUnit):
52
+ return unit.dictionary(runner).w_dec[unit.ident.feature_idx].float()
53
+ return None
54
+
55
+
56
+ def _prompt_ids(runner: NeuronHookRunner, template: str, placeholder: str) -> tuple[list[int], int]:
57
+ """Token ids of ``template`` with the placeholder as exactly one token, and its index."""
58
+ tok = runner.tokenizer
59
+ before, after = template.split("{x}")
60
+ marker = tok.encode(placeholder, add_special_tokens=False)
61
+ if len(marker) != 1:
62
+ raise ValueError(f"the placeholder {placeholder!r} must be one token, not {len(marker)}")
63
+ prefix = tok.encode(before, add_special_tokens=True) if before else []
64
+ return prefix + marker + tok.encode(after, add_special_tokens=False), len(prefix)
65
+
66
+
67
+ def _block_output(output) -> torch.Tensor:
68
+ return output[0] if isinstance(output, tuple) else output
69
+
70
+
71
+ @torch.no_grad()
72
+ def _residual_norm(runner: NeuronHookRunner, ids: list[int], layer: int, pos: int) -> float:
73
+ captured: list[float] = []
74
+
75
+ def capture(_module, _args, output):
76
+ captured.append(float(_block_output(output)[0, pos].float().norm()))
77
+
78
+ handle = runner.layers[layer].register_forward_hook(capture)
79
+ try:
80
+ runner.forward({"input_ids": torch.tensor([ids], device=runner.device)})
81
+ finally:
82
+ handle.remove()
83
+ if not captured:
84
+ raise ArchitectureNotSupportedError(f"layer {layer}'s block never ran as a module")
85
+ return captured[-1]
86
+
87
+
88
+ @torch.no_grad()
89
+ def _continue(
90
+ runner: NeuronHookRunner,
91
+ ids: list[int],
92
+ pos: int,
93
+ layer: int,
94
+ vectors: torch.Tensor,
95
+ max_new_tokens: int,
96
+ ) -> list[str]:
97
+ """Return greedy continuations of ``ids``, one per row of ``vectors``.
98
+
99
+ In each, the residual after ``layer``'s block at ``pos`` is replaced by that row. Each
100
+ stops at a line break or the end-of-text token.
101
+ """
102
+ tok = runner.tokenizer
103
+ eos = getattr(tok, "eos_token_id", None)
104
+
105
+ def patch(_module, _args, output):
106
+ hidden = _block_output(output).clone()
107
+ hidden[:, pos] = vectors.to(hidden.device, hidden.dtype)
108
+ return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
109
+
110
+ seq = torch.tensor([ids] * len(vectors), device=runner.device)
111
+ done = [False] * len(vectors)
112
+ handle = runner.layers[layer].register_forward_hook(patch)
113
+ try:
114
+ for _ in range(max_new_tokens):
115
+ nxt = runner.forward({"input_ids": seq})[:, -1].argmax(-1)
116
+ seq = torch.cat([seq, nxt[:, None]], dim=1)
117
+ for row, token in enumerate(nxt.tolist()):
118
+ done[row] |= token == eos or "\n" in tok.decode([token])
119
+ if all(done):
120
+ break
121
+ finally:
122
+ handle.remove()
123
+ texts = []
124
+ for row in seq[:, len(ids) :].tolist():
125
+ if eos in row:
126
+ row = row[: row.index(eos)]
127
+ texts.append(tok.decode(row).split("\n")[0].strip())
128
+ return texts
129
+
130
+
131
+ def patchscope(
132
+ runner: NeuronHookRunner,
133
+ layer: int,
134
+ direction: torch.Tensor,
135
+ *,
136
+ prompts: Mapping[str, str] = PROMPTS,
137
+ placeholder: str = PLACEHOLDER,
138
+ scales: Sequence[float] = SCALES,
139
+ early_layer: int = EARLY_LAYER,
140
+ max_new_tokens: int = MAX_NEW_TOKENS,
141
+ control_seed: int = CONTROL_SEED,
142
+ ) -> PatchscopeReadout:
143
+ """Read ``direction``, written after ``layer``'s MLP, through every inspection prompt.
144
+
145
+ It is read at ``layer``, and at ``early_layer`` when that is earlier, at each scale.
146
+ """
147
+ if runner.tokenizer is None:
148
+ raise ValueError("a Patchscope needs a tokenizer")
149
+ direction = direction.float().cpu()
150
+ generator = torch.Generator().manual_seed(control_seed)
151
+ control = torch.randn(direction.shape, generator=generator)
152
+ units = torch.stack([direction / direction.norm(), control / control.norm()])
153
+ readings = []
154
+ for name, template in prompts.items():
155
+ ids, pos = _prompt_ids(runner, template, placeholder)
156
+ for target in sorted({min(early_layer, layer), layer}):
157
+ norm = _residual_norm(runner, ids, target, pos)
158
+ # Rows: the unit at each scale, then the control at each scale.
159
+ vectors = torch.stack([units[i] * norm * s for i in (0, 1) for s in scales])
160
+ texts = _continue(runner, ids, pos, target, vectors, max_new_tokens)
161
+ for i, scale in enumerate(scales):
162
+ readings.append(
163
+ PatchscopeReading(
164
+ prompt=name,
165
+ layer=target,
166
+ scale=float(scale),
167
+ text=texts[i],
168
+ control_text=texts[len(scales) + i],
169
+ )
170
+ )
171
+ return PatchscopeReadout(
172
+ prompts=dict(prompts),
173
+ placeholder=placeholder,
174
+ max_new_tokens=max_new_tokens,
175
+ control_seed=control_seed,
176
+ readings=readings,
177
+ )