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,357 @@
1
+ """``open-interpretability loop``: run experiments back to back with API agents (issue #3246).
2
+
3
+ Each iteration resets a detached worktree to ``origin/main`` and draws with the planner.
4
+ It claims the draw as ``choose-next-experiment`` does, then runs the draw's steps
5
+ (``draw_steps.steps``, the commands ``open-interpretability next --steps`` prints). Then it
6
+ posts the result, and lands it as the ``land-experiment`` skill does.
7
+ ``open-interpretability`` commands run in this process, so a model loads once
8
+ (``runner_cache``). git and gh run as subprocesses.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from openinterp.core.paths import DEFAULT_WIKI_DIR, draws_dir, experiments_dir, stops_dir
20
+ from openinterp.draw_steps import REVIEW_KINDS, VERBS, draw_slug, steps
21
+ from openinterp.planner import claim_key
22
+
23
+ MERGE_PERMISSIONS = ("ADMIN", "MAINTAIN", "WRITE")
24
+ CHECKS_TIMEOUT = 45 * 60 # seconds; the land-experiment skill gives up after 45 minutes
25
+ OPENINTERP = ("uv", "run", "open-interpretability")
26
+
27
+
28
+ class LoopError(Exception):
29
+ """One iteration failed. Its message is the reason that the loop gives."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Options:
34
+ """What every iteration of a loop shares."""
35
+
36
+ proposer: str
37
+ reviewer: str
38
+ epsilon: float = 0.7
39
+ corpus: str | None = None # None: each new scan builds a fresh corpus from its seed
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Result:
44
+ """The outcome of one iteration, as the RESULT line reports it."""
45
+
46
+ slug: str | None
47
+ verdict: str
48
+ url: str | None = None
49
+ merged: bool = False
50
+
51
+ def line(self) -> str:
52
+ """``RESULT: <slug> <verdict> <PR url> <merged|open>``, or ``RESULT: none <why>``."""
53
+ if self.slug is None:
54
+ return f"RESULT: none {self.verdict}"
55
+ return (
56
+ f"RESULT: {self.slug} {self.verdict} {self.url} {'merged' if self.merged else 'open'}"
57
+ )
58
+
59
+
60
+ def sh(args: list[str], check: bool = True, timeout: float | None = None) -> str:
61
+ """Run ``args`` in the current directory and return its stdout; echo both streams."""
62
+ try:
63
+ done = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
64
+ except subprocess.TimeoutExpired as exc:
65
+ raise LoopError(f"`{' '.join(args[:3])}` timed out") from exc
66
+ sys.stdout.write(f"$ {shlex.join(args)}\n{done.stdout}{done.stderr}")
67
+ if check and done.returncode != 0:
68
+ tail = (done.stderr or done.stdout).strip().splitlines()[-1:] or ["no output"]
69
+ raise LoopError(f"`{' '.join(args[:3])}` failed: {tail[0]}")
70
+ return done.stdout.strip()
71
+
72
+
73
+ def openinterp(args: list[str]) -> None:
74
+ """Run one ``open-interpretability`` command in this process.
75
+
76
+ A non-zero exit raises LoopError.
77
+ """
78
+ from openinterp.cli import cli
79
+
80
+ sys.stdout.write(f"$ openinterp {shlex.join(args)}\n")
81
+ try:
82
+ cli.main(args=args, prog_name="open-interpretability", standalone_mode=False)
83
+ except SystemExit as exc:
84
+ if exc.code not in (0, None):
85
+ raise LoopError(f"`openinterp {args[0]}` exited {exc.code}") from exc
86
+
87
+
88
+ def run_command(command: str) -> None:
89
+ """Run one command of a step: ``uv run openinterp …`` in-process, anything else in a shell."""
90
+ args = shlex.split(command)
91
+ if tuple(args[:3]) == OPENINTERP:
92
+ openinterp(args[3:])
93
+ else:
94
+ sh(args)
95
+
96
+
97
+ def reset() -> None:
98
+ """Detach the current worktree at a fresh ``origin/main`` and drop old experiment branches."""
99
+ sh(["git", "fetch", "--quiet", "origin", "main"])
100
+ sh(["git", "checkout", "--quiet", "--force", "--detach", "origin/main"])
101
+ sh(["git", "clean", "-fdq"])
102
+ branches = sh(["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/experiment/"])
103
+ for branch in branches.split():
104
+ sh(["git", "branch", "-D", branch])
105
+
106
+
107
+ def proposer_of(record_path: str | None) -> str | None:
108
+ """The proposer model of the record at ``record_path``, if there is one."""
109
+ from openinterp.core.schemas import AuditRecord
110
+
111
+ if not record_path or not Path(record_path).exists():
112
+ return None
113
+ record = AuditRecord.model_validate_json(Path(record_path).read_text())
114
+ return record.submission.submitting_agent_model
115
+
116
+
117
+ def draw(options: Options, plan_next, claims):
118
+ """Draw with the planner. A review the reviewer can't do independently is drawn again once.
119
+
120
+ ``plan_next`` and ``claims`` are passed in so that tests can replace them.
121
+ """
122
+ for _ in range(2):
123
+ plan = plan_next(DEFAULT_WIKI_DIR, epsilon=options.epsilon, claims=claims())
124
+ c = plan.choice
125
+ if c.kind not in REVIEW_KINDS or proposer_of(c.record) != options.reviewer:
126
+ return plan
127
+ raise LoopError(f"twice drew a review of {options.reviewer}'s own record")
128
+
129
+
130
+ def pr_url() -> str:
131
+ """The URL of the pull request for the branch that is checked out."""
132
+ return sh(["gh", "pr", "view", "--json", "url", "-q", ".url"])
133
+
134
+
135
+ def new_files(prefix: Path) -> list[Path]:
136
+ """Files under ``prefix`` that git does not track yet."""
137
+ out = sh(["git", "ls-files", "--others", "--exclude-standard", str(prefix)])
138
+ return [Path(line) for line in out.splitlines() if line]
139
+
140
+
141
+ def verdict_of(kind: str, key: str) -> tuple[str, str]:
142
+ """The new record's verdict and the commit message that posts it.
143
+
144
+ A scan that stopped on its unit writes a draw or stop record and no experiment, so
145
+ its verdict is STOPPED.
146
+ """
147
+ from openinterp.core.schemas import AuditRecord
148
+
149
+ records = [p for p in new_files(experiments_dir(DEFAULT_WIKI_DIR)) if p.suffix == ".json"]
150
+ if not records:
151
+ if new_files(draws_dir(DEFAULT_WIKI_DIR)) or new_files(stops_dir(DEFAULT_WIKI_DIR)):
152
+ return "STOPPED", f"{VERBS[kind]} {key} (STOPPED)"
153
+ raise LoopError("the draw's steps wrote no record")
154
+ verdict = AuditRecord.model_validate_json(records[0].read_text()).verdict
155
+ accuracy = verdict.metrics.balanced_accuracy if verdict.metrics else None
156
+ shown = "n/a" if accuracy is None else f"{accuracy:.2f}"
157
+ return verdict.status, f"{VERBS[kind]} {key} ({verdict.status}, {shown})"
158
+
159
+
160
+ def post(kind: str, key: str) -> str:
161
+ """Write the pages, check the registry, commit ``wiki/``, push and mark the PR ready."""
162
+ openinterp(["wiki"])
163
+ openinterp(["check", "--base-ref", "origin/main"])
164
+ status, message = verdict_of(kind, key)
165
+ sh(["git", "add", str(DEFAULT_WIKI_DIR)])
166
+ sh(["git", "commit", "--quiet", "-m", message])
167
+ sh(["git", "push", "--quiet"])
168
+ sh(["gh", "pr", "ready"])
169
+ return status
170
+
171
+
172
+ def merge_blockers(view: dict, permission: str) -> list[str]:
173
+ """Why the PR described by ``view`` (``gh pr view --json``) may not be merged, if at all."""
174
+ files = [f["path"] for f in view.get("files", [])]
175
+ wiki = f"{DEFAULT_WIKI_DIR}/"
176
+ reasons = [f"it changes {p}, outside {wiki}" for p in files if not p.startswith(wiki)]
177
+ if permission not in MERGE_PERMISSIONS:
178
+ reasons.append(f"the token's permission is {permission or 'unknown'}")
179
+ if view.get("isDraft"):
180
+ reasons.append("it is a draft")
181
+ if view.get("mergeable") != "MERGEABLE":
182
+ reasons.append(f"GitHub says it is {view.get('mergeable', 'not mergeable')}")
183
+ return reasons
184
+
185
+
186
+ def land(url: str) -> bool:
187
+ """Wait for the checks, then merge the PR if every merge condition holds. True if merged."""
188
+ import json
189
+
190
+ try:
191
+ sh(
192
+ ["gh", "pr", "checks", url, "--watch", "--fail-fast", "--interval", "30"],
193
+ timeout=CHECKS_TIMEOUT,
194
+ )
195
+ except LoopError as exc:
196
+ sh(["gh", "pr", "comment", url, "--body", f"Left open: the checks didn't pass ({exc})."])
197
+ return False
198
+ fields = "state,isDraft,mergeable,files,headRefOid"
199
+ view = json.loads(sh(["gh", "pr", "view", url, "--json", fields]))
200
+ if view.get("state") == "MERGED": # the auto-merge workflow got there first
201
+ return True
202
+ permission = sh(["gh", "repo", "view", "--json", "viewerPermission", "-q", ".viewerPermission"])
203
+ if reasons := merge_blockers(view, permission):
204
+ sh(["gh", "pr", "comment", url, "--body", "Left open: " + "; ".join(reasons) + "."])
205
+ return False
206
+ sh(["gh", "pr", "merge", url, "--squash", "--match-head-commit", view["headRefOid"]])
207
+ delete_branch()
208
+ return True
209
+
210
+
211
+ def delete_branch() -> None:
212
+ """Delete the checked-out branch on origin, so that a later draw can claim it again."""
213
+ branch = sh(["git", "rev-parse", "--abbrev-ref", "HEAD"])
214
+ sh(["git", "push", "--quiet", "origin", "--delete", branch], check=False)
215
+
216
+
217
+ def iteration(options: Options, plan_next, claims) -> Result:
218
+ """Run one experiment end to end: reset, draw, claim, run, post and land."""
219
+ reset()
220
+ plan = draw(options, plan_next, claims)
221
+ slug = draw_slug(plan.choice)
222
+ if slug is None:
223
+ raise LoopError(f"the unit of a {plan.choice.kind} draw on {plan.choice.model} is unknown")
224
+ corpus = options.corpus or "corpus.jsonl"
225
+ claim, *work, _ = steps(
226
+ plan, corpus, options.proposer, options.reviewer, options.corpus is None
227
+ )
228
+ for command in claim.commands:
229
+ run_command(command)
230
+ url = pr_url()
231
+ try:
232
+ status = run_work(work, plan.choice.kind, claim_key(slug))
233
+ except Exception as exc:
234
+ sh(
235
+ [
236
+ "gh",
237
+ "pr",
238
+ "close",
239
+ url,
240
+ "--comment",
241
+ f"Abandoned by `open-interpretability loop`: {exc}",
242
+ ],
243
+ check=False,
244
+ )
245
+ delete_branch()
246
+ raise
247
+ return Result(slug, status, url, land(url))
248
+
249
+
250
+ def run_work(work, kind: str, key: str) -> str:
251
+ """Run every step after the claim, then post. A scan that stops on its unit posts at once."""
252
+ from openinterp.evidence import Unauditable
253
+
254
+ try:
255
+ for step in work:
256
+ for command in step.commands:
257
+ run_command(command)
258
+ except Unauditable as exc:
259
+ sys.stdout.write(f"The scan stopped on the unit: {exc}\n")
260
+ return post(kind, key)
261
+
262
+
263
+ BACKOFF_MINUTES = (1, 5, 15) # waits after a provider's rate or usage limit; then stop
264
+ _LIMIT_WORDS = ("rate limit", "ratelimit", "usage limit", "429", "overloaded", "quota")
265
+
266
+
267
+ def is_rate_limit(exc: BaseException) -> bool:
268
+ """True if ``exc``, or an exception it was raised from, is a provider's rate or usage limit."""
269
+ seen: BaseException | None = exc
270
+ while seen is not None:
271
+ text = f"{type(seen).__name__} {seen}".lower()
272
+ if any(word in text for word in _LIMIT_WORDS):
273
+ return True
274
+ seen = seen.__cause__ or seen.__context__
275
+ return False
276
+
277
+
278
+ def stop_file() -> Path:
279
+ """The kill switch that ``scripts/experiment-loop.sh`` also honours."""
280
+ import os
281
+
282
+ cache = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
283
+ return Path(os.environ.get("OPENINTERP_STOP_FILE") or Path(cache) / "openinterp" / "stop")
284
+
285
+
286
+ @dataclass
287
+ class Loop:
288
+ """The loop's counters, and the pieces that tests replace."""
289
+
290
+ options: Options
291
+ log_dir: Path
292
+ max_experiments: int = 0 # 0: until stopped
293
+ max_failures: int = 3
294
+ plan_next: object = None
295
+ claims: object = None
296
+ sleep: object = None
297
+ done: int = 0
298
+ failures: int = 0
299
+ backoffs: int = 0
300
+
301
+ def run(self) -> int:
302
+ """Run until the limit, the stop file or too many failures; return the exit code."""
303
+ while not self.max_experiments or self.done < self.max_experiments:
304
+ if stop_file().exists():
305
+ print(f"Stopping: {stop_file()} exists.")
306
+ return 0
307
+ self._wait_while_paused()
308
+ if (code := self._one()) is not None:
309
+ return code
310
+ return 0
311
+
312
+ def _one(self) -> int | None:
313
+ """Run one iteration and count it. Return an exit code when the loop must stop."""
314
+ import contextlib
315
+ from datetime import UTC, datetime
316
+
317
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
318
+ self.log_dir.mkdir(parents=True, exist_ok=True)
319
+ log = self.log_dir / f"{stamp}.log"
320
+ try:
321
+ with (
322
+ open(log, "w") as out,
323
+ contextlib.redirect_stdout(out),
324
+ contextlib.redirect_stderr(out),
325
+ ):
326
+ result = iteration(self.options, self.plan_next, self.claims)
327
+ except Exception as exc: # any failure of one experiment; the loop decides what next
328
+ return self._failed(exc, log)
329
+ self.done, self.failures, self.backoffs = self.done + 1, 0, 0
330
+ print(result.line(), flush=True)
331
+ return None
332
+
333
+ def _failed(self, exc: Exception, log: Path) -> int | None:
334
+ """Back off on a rate limit, count any other failure, and say when to stop."""
335
+ if is_rate_limit(exc):
336
+ if self.backoffs == len(BACKOFF_MINUTES):
337
+ print(f"RESULT: none rate limited after {self.backoffs} waits ({log})")
338
+ return 1
339
+ minutes = BACKOFF_MINUTES[self.backoffs]
340
+ self.backoffs += 1
341
+ print(f"Rate limited; waiting {minutes} min ({log})", flush=True)
342
+ self.sleep(minutes * 60)
343
+ return None
344
+ self.failures += 1
345
+ print(f"RESULT: none {exc} ({log})", flush=True)
346
+ return 1 if self.failures >= self.max_failures else None
347
+
348
+ def _wait_while_paused(self) -> None:
349
+ """Wait, a minute at a time, while ``open-interpretability pause`` is in force."""
350
+ from openinterp.core import pause
351
+
352
+ said = False
353
+ while (paused := pause.read()) is not None and not stop_file().exists():
354
+ if not said:
355
+ print(paused.describe(), flush=True)
356
+ said = True
357
+ self.sleep(60)
openinterp/explain.py ADDED
@@ -0,0 +1,252 @@
1
+ """Explain one answer of a model as fully as the registry allows (issue #3250).
2
+
3
+ ``open-interpretability explain "<prompt>"`` attributes the logit of the model's answer to every
4
+ MLP neuron pole. It labels the poles from the registry, and says how much of the answer is
5
+ explained. A sparse registry is fine: the output's job is also to show how little is
6
+ explained so far.
7
+
8
+ - *Attribute.* One prompt has no corrupted twin, so the baseline is each neuron's mean
9
+ activation over a corpus sample. At each position, a neuron's contribution is
10
+ (a − mean) · ∂logit/∂a. That is first order, like ``attribution_patching``. Each
11
+ contribution goes to the pole that the sign of ``a`` names at that position. The
12
+ first token, an attention sink, is left out, as scans leave it out.
13
+ - *Explain.* Each pole gets its newest record's status and explanation, or *stopped*
14
+ (a scan found it dead or default-on), or *unstudied*. Only a VERIFIED explanation is
15
+ shown as one.
16
+ - *Gaps.* The share of the total |attribution| that VERIFIED poles hold.
17
+ - *Check.* Mean-ablate single top poles, then the VERIFIED ones together, and measure
18
+ the change in the answer's logit.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ from collections.abc import Sequence
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ import torch
29
+
30
+ from openinterp.core.cache_dirs import cache_root
31
+ from openinterp.harness.hooks import NeuronHookRunner
32
+
33
+ MEANS_ENV = "OPENINTERP_MEANS" # the cache of per-neuron means, or "off"
34
+ WINDOW = 128 # tokens per window of the mean's corpus sample
35
+ MEANS_VERSION = 2 # in the cache key: 2 leaves each window's first token out
36
+
37
+
38
+ @dataclass
39
+ class PoleAttribution:
40
+ """One pole's attribution to the answer, with what the registry says of it."""
41
+
42
+ layer: int
43
+ neuron: int
44
+ pole: str
45
+ attribution: float # summed (a − mean) · grad over the positions where it is this pole
46
+ strength: float # the pole's peak strength on the prompt
47
+ status: str = "unstudied" # VERIFIED, NEEDS_REVISION, REJECTED, stopped, unstudied
48
+ explanation: str | None = None
49
+ max_activation: float | None = None
50
+ ablation_change: float | None = None # target logit change when mean-ablated alone
51
+
52
+ @property
53
+ def label(self) -> str:
54
+ """``L3/N7+``: the pole within its model."""
55
+ return f"L{self.layer}/N{self.neuron}{'+' if self.pole == 'positive' else '−'}"
56
+
57
+
58
+ @dataclass
59
+ class Explanation:
60
+ """Everything ``open-interpretability explain`` reports for one prompt."""
61
+
62
+ prompt: str
63
+ position: int
64
+ token: str
65
+ logit: float
66
+ poles: list[PoleAttribution] # the top poles, by |attribution|
67
+ total: float # the sum of |attribution| over every pole of every layer
68
+ verified: float # the same sum over VERIFIED poles only
69
+ joint: dict = field(default_factory=dict) # the VERIFIED poles ablated together
70
+
71
+ @property
72
+ def verified_share(self) -> float:
73
+ """The share of the total |attribution| that VERIFIED poles hold."""
74
+ return self.verified / self.total if self.total else 0.0
75
+
76
+
77
+ def _layer_acts(runner: NeuronHookRunner, encoded, layers) -> dict[int, torch.Tensor]:
78
+ _, cache = runner.run_with_cache(encoded, layers)
79
+ return cache
80
+
81
+
82
+ def neuron_means(runner: NeuronHookRunner, model: str, texts: Sequence[str], tokens: int):
83
+ """Each layer's mean MLP activation per neuron, ``{layer: tensor[d_mlp]}``, cached.
84
+
85
+ The sample is the first ``tokens`` tokens of ``texts``, in windows of :data:`WINDOW`.
86
+ Each window's first token is left out, as the attribution leaves out the prompt's.
87
+ """
88
+ tag = f"{model}|{tokens}|{WINDOW}|{MEANS_VERSION}|"
89
+ key = hashlib.sha256(tag.encode() + "\n".join(texts).encode())
90
+ root = cache_root(MEANS_ENV, "means")
91
+ path = root / f"{key.hexdigest()[:24]}.pt" if root else None
92
+ if path is not None and path.exists():
93
+ return torch.load(path)
94
+ ids = [i for t in texts for i in runner.tokenizer.encode(t)][:tokens]
95
+ windows = [ids[i : i + WINDOW] for i in range(0, len(ids) - WINDOW + 1, WINDOW)]
96
+ layers = list(range(runner.n_layers))
97
+ sums = {la: torch.zeros(runner.mlp_width(la)) for la in layers}
98
+ count = 0
99
+ for window in windows:
100
+ acts = _layer_acts(runner, torch.tensor([window]), layers)
101
+ for la in layers: # a window's first token is an attention sink: leave it out
102
+ sums[la] += acts[la][0, 1:].float().sum(0).cpu()
103
+ count += len(window) - 1
104
+ means = {la: s / max(count, 1) for la, s in sums.items()}
105
+ if path is not None:
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ torch.save(means, path)
108
+ return means
109
+
110
+
111
+ def attribute(runner: NeuronHookRunner, prompt: str, means: dict, token: int, position: int):
112
+ """Per pole, ``{(layer, neuron, pole): (attribution, peak strength)}``, and the logit.
113
+
114
+ The metric is the logit of ``token`` at ``position``. The prompt's first token is left out.
115
+ """
116
+ layers = sorted(means)
117
+ captured: dict[int, torch.Tensor] = {}
118
+
119
+ def capture(layer: int):
120
+ def hook(act: torch.Tensor) -> None:
121
+ captured[layer] = act
122
+
123
+ return hook
124
+
125
+ def ensure_grad(_module, _args, output):
126
+ return output if output.requires_grad else output.detach().requires_grad_(True)
127
+
128
+ encoded = runner.encode(prompt)
129
+ handle = runner.model.get_input_embeddings().register_forward_hook(ensure_grad)
130
+ try:
131
+ with torch.enable_grad(), runner.hooks({la: capture(la) for la in layers}):
132
+ logit = runner.forward(encoded)[0, position, token]
133
+ grads = torch.autograd.grad(logit, [captured[la] for la in layers])
134
+ finally:
135
+ handle.remove()
136
+ out = {}
137
+ for la, grad in zip(layers, grads, strict=True):
138
+ # The first token is an attention sink, active far beyond any scan's scale whatever
139
+ # the prompt. The protocol leaves it out of scans, and so does this.
140
+ act = captured[la].detach()[0, 1:].float().cpu()
141
+ contribution = (act - means[la]) * grad[0, 1:].float().cpu()
142
+ for pole, mask in (("positive", act > 0), ("negative", act < 0)):
143
+ score = (contribution * mask).sum(0)
144
+ peak = (act.abs() * mask).amax(0)
145
+ for n in torch.nonzero(mask.any(0)).flatten().tolist():
146
+ out[(la, n, pole)] = (float(score[n]), float(peak[n]))
147
+ return out, float(logit.detach())
148
+
149
+
150
+ def mean_ablation(poles: Sequence[tuple[int, int, str]], means: dict):
151
+ """``interventions`` that set each pole to its neuron's mean where the pole is active.
152
+
153
+ The first token keeps its value, as the attribution leaves it out.
154
+ """
155
+ by_layer: dict[int, list[tuple[int, str]]] = {}
156
+ for layer, neuron, pole in poles:
157
+ by_layer.setdefault(layer, []).append((neuron, pole))
158
+
159
+ def at(layer: int):
160
+ def intervene(act: torch.Tensor) -> torch.Tensor:
161
+ act = act.clone()
162
+ for neuron, pole in by_layer[layer]:
163
+ a = act[..., neuron]
164
+ active = a > 0 if pole == "positive" else a < 0
165
+ active[..., 0] = False # the first token stays, as in the attribution
166
+ mean = means[layer][neuron].to(act.dtype)
167
+ act[..., neuron] = torch.where(active, mean, a)
168
+ return act
169
+
170
+ return intervene
171
+
172
+ return {layer: at(layer) for layer in by_layer}
173
+
174
+
175
+ @torch.no_grad()
176
+ def logits_with(runner: NeuronHookRunner, prompt: str, interventions, position: int):
177
+ """The logits at ``position`` with ``interventions`` applied."""
178
+ with runner.hooks(interventions):
179
+ return runner.forward(runner.encode(prompt))[0, position].float().cpu()
180
+
181
+
182
+ def registry_labels(wiki_dir: Path, model: str) -> dict[str, tuple[str, str | None, float | None]]:
183
+ """``{pole slug: (status, explanation, max_activation)}`` for ``model``'s poles.
184
+
185
+ The status is the newest record's verdict. A pole that a scan stopped on, with no
186
+ record, is ``stopped``.
187
+ """
188
+ from openinterp.planner import load_states, stopped
189
+
190
+ out: dict[str, tuple[str, str | None, float | None]] = {}
191
+ for ident in stopped(wiki_dir):
192
+ if ident.model_name == model:
193
+ out[ident.slug] = ("stopped", None, None)
194
+ for slug, state in load_states(wiki_dir).items():
195
+ sub = state.record.submission
196
+ if sub.identifier.model_name == model:
197
+ out[slug] = (
198
+ state.record.verdict.status,
199
+ sub.natural_language_explanation,
200
+ sub.max_activation,
201
+ )
202
+ return out
203
+
204
+
205
+ def explain(
206
+ runner: NeuronHookRunner,
207
+ model: str,
208
+ prompt: str,
209
+ means: dict,
210
+ labels: dict,
211
+ *,
212
+ token: int | None = None,
213
+ position: int = -1,
214
+ top: int = 20,
215
+ confirm: int = 10,
216
+ ) -> Explanation:
217
+ """Attribute, label, measure the VERIFIED share and check by ablation (module docstring)."""
218
+ from openinterp.core.identifiers import NeuronIdentifier
219
+
220
+ base = logits_with(runner, prompt, {}, position)
221
+ token = int(base.argmax()) if token is None else token
222
+ scores, logit = attribute(runner, prompt, means, token, position)
223
+ ranked = sorted(scores.items(), key=lambda kv: abs(kv[1][0]), reverse=True)
224
+ total = verified = 0.0
225
+ poles = []
226
+ for rank, ((layer, neuron, pole), (score, strength)) in enumerate(ranked):
227
+ slug = NeuronIdentifier(model_name=model, layer=layer, neuron_idx=neuron, pole=pole).slug
228
+ status, text, max_act = labels.get(slug, ("unstudied", None, None))
229
+ total += abs(score)
230
+ verified += abs(score) if status == "VERIFIED" else 0.0
231
+ if rank < top:
232
+ shown = text if status == "VERIFIED" else None
233
+ poles.append(
234
+ PoleAttribution(layer, neuron, pole, score, strength, status, shown, max_act)
235
+ )
236
+ for p in poles[:confirm]:
237
+ ablated = logits_with(
238
+ runner, prompt, mean_ablation([(p.layer, p.neuron, p.pole)], means), position
239
+ )
240
+ p.ablation_change = float(ablated[token] - base[token])
241
+ chosen = [(p.layer, p.neuron, p.pole) for p in poles if p.status == "VERIFIED"]
242
+ joint = {"poles": len(chosen)}
243
+ if chosen:
244
+ ablated = logits_with(runner, prompt, mean_ablation(chosen, means), position)
245
+ joint |= {
246
+ "logit_change": float(ablated[token] - base[token]),
247
+ "top_token_changed": int(ablated.argmax()) != token,
248
+ "new_top_token": runner.tokenizer.decode([int(ablated.argmax())]),
249
+ }
250
+ return Explanation(
251
+ prompt, position, runner.tokenizer.decode([token]), logit, poles, total, verified, joint
252
+ )
@@ -0,0 +1,79 @@
1
+ """Reproducing records made in a 16-bit dtype (bfloat16 or float16), issue #2995.
2
+
3
+ 16-bit rounding moves a number by a few percent from one device to another. So a 16-bit
4
+ record is reproduced in float32, and compared within ``reproduce.HALF_TOLERANCE``
5
+ (``docs/analyses/half-precision-reproduce.md``). A pass/fail can then flip where rounding
6
+ moves a number across its threshold. These helpers check that the record's own results
7
+ follow from its own numbers, and its decision from its results.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ HALF = frozenset({"bfloat16", "float16"})
13
+ ROUNDING = 1e-6 # records round activations and deltas to 6 decimals
14
+
15
+
16
+ def reproduction_dtype(dtype: str) -> str:
17
+ """The dtype to reproduce a record in: float32 for a 16-bit record, else its own."""
18
+ return "float32" if dtype in HALF else dtype
19
+
20
+
21
+ def recorded_pass(result, policy, scale: float) -> bool | None:
22
+ """What the decision rule gives for ``result``'s own recorded number, or None.
23
+
24
+ A probe passes when its activation is at or above the threshold exactly when it is
25
+ expected to be active. A counterfactual passes when its Δ agrees with the expected Δ.
26
+ Other kinds (lookahead) have no single number, so they get None. Records round
27
+ numbers to 6 decimals, so a number within that rounding of its boundary gets None too.
28
+ """
29
+ from openinterp.agents.review_measurements import _delta_agrees
30
+
31
+ if not result.measured or scale <= 0:
32
+ return None
33
+ if result.kind in {"hard_negative", "positive_holdout"}:
34
+ if result.activation is None:
35
+ return False
36
+
37
+ def rule(x):
38
+ return (x >= policy.activation_threshold * scale) == result.expected_active
39
+
40
+ number = result.activation
41
+ elif result.kind == "counterfactual":
42
+ if result.observed_delta is None or result.expected_delta is None:
43
+ return False
44
+
45
+ def rule(x):
46
+ return _delta_agrees(result.expected_delta, x, policy)
47
+
48
+ number = result.observed_delta
49
+ else:
50
+ return None
51
+ low, high = rule(number - ROUNDING), rule(number + ROUNDING)
52
+ return low if low == high else None
53
+
54
+
55
+ def rederived(verdict):
56
+ """``verdict`` with its metrics and status computed again from its own recorded results."""
57
+ from openinterp.agents.reviewer import decide, review_metrics
58
+
59
+ m = verdict.metrics
60
+ baseline = (
61
+ (m.confound_baseline, m.confound_baseline_accuracy)
62
+ if m.confound_baseline is not None
63
+ else None
64
+ )
65
+ metrics = review_metrics(
66
+ verdict.adversarial_test_results,
67
+ verdict.policy,
68
+ independent=m.independent_review,
69
+ baseline=baseline,
70
+ active_share=m.active_share,
71
+ first_token_floor=m.first_token_floor,
72
+ )
73
+ return verdict.model_copy(
74
+ update={
75
+ "metrics": metrics,
76
+ "status": decide(metrics, verdict.policy),
77
+ "confidence_score": metrics.balanced_accuracy,
78
+ }
79
+ )