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,169 @@
1
+ """Control explanations for ``open-interpretability score --controls``.
2
+
3
+ A detection or fuzzing score can be high for reasons that have nothing to do with the
4
+ explanation. The held-out windows can have structure that a judge picks up without it,
5
+ for example when active windows are longer or have more code. "Scoring Explanations
6
+ Without Reading Them" (arXiv 2610.37676) reports such shortcuts. So the same windows
7
+ are judged twice more:
8
+
9
+ - ``empty``: with no explanation.
10
+ - ``other_unit``: with the explanation of another unit of the same model, drawn with the
11
+ task's seed. A VERIFIED explanation is drawn when one exists, so the control is a real,
12
+ specific claim about a different unit.
13
+
14
+ The gap between the real score and each control is the part of the score that the
15
+ explanation earns. The controls are reported, not gated (docs/PROTOCOL.md §4).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import random
22
+ from pathlib import Path
23
+
24
+ from pydantic import BaseModel
25
+
26
+ from openinterp.core.explanation import ControlScore, ExplanationScores
27
+ from openinterp.core.paths import DEFAULT_WIKI_DIR, experiments_dir
28
+
29
+ KINDS = ("detection", "fuzzing")
30
+
31
+
32
+ class ControlTask(BaseModel):
33
+ """One control explanation, as ``open-interpretability score --emit-task`` writes it."""
34
+
35
+ kind: str
36
+ explanation: str
37
+ source_slug: str | None = None
38
+
39
+
40
+ def other_unit_explanation(slug: str, seed: int, wiki_dir: Path = DEFAULT_WIKI_DIR):
41
+ """Draw another unit's explanation from the same model: ``(slug, explanation)``.
42
+
43
+ Draws among VERIFIED explanations when the model has any, else among all of them.
44
+ Returns ``None`` when no other unit of the model has a record.
45
+ """
46
+ model_dir = experiments_dir(Path(wiki_dir)) / slug.split("/", 1)[0]
47
+ latest: dict[str, tuple[str, bool]] = {}
48
+ for path in sorted(model_dir.rglob("*.json")): # sorted by time within a unit
49
+ unit = path.parent.relative_to(experiments_dir(Path(wiki_dir))).as_posix()
50
+ if unit == slug:
51
+ continue
52
+ record = json.loads(path.read_text())
53
+ verdict = record.get("verdict") or {}
54
+ explanation = record["submission"]["natural_language_explanation"]
55
+ latest[unit] = (explanation, verdict.get("status") == "VERIFIED")
56
+ pool = sorted(u for u, (_, ok) in latest.items() if ok) or sorted(latest)
57
+ if not pool:
58
+ return None
59
+ # The slug joins the seed, so units scored with one seed don't all draw the same decoy.
60
+ unit = random.Random(f"{seed}:{slug}").choice(pool)
61
+ return unit, latest[unit][0]
62
+
63
+
64
+ def control_tasks(slug: str, seed: int, wiki_dir: Path = DEFAULT_WIKI_DIR) -> list[ControlTask]:
65
+ """The controls for one unit: always ``empty``, and ``other_unit`` when one exists."""
66
+ controls = [ControlTask(kind="empty", explanation="")]
67
+ other = other_unit_explanation(slug, seed, wiki_dir)
68
+ if other is not None:
69
+ controls.append(ControlTask(kind="other_unit", explanation=other[1], source_slug=other[0]))
70
+ return controls
71
+
72
+
73
+ def prompt_path(task_path: Path, control: ControlTask, kind: str) -> Path:
74
+ """Where ``--emit-task`` writes a control's judge prompt, next to the task."""
75
+ return task_path.with_name(
76
+ task_path.name.split(".")[0] + f".control-{control.kind}.{kind}.prompt.md"
77
+ )
78
+
79
+
80
+ def answer_path(task_path: Path, control: ControlTask, kind: str) -> Path:
81
+ """Where the judge writes its answer to a control's prompt."""
82
+ return task_path.with_name(
83
+ task_path.name.split(".")[0] + f".control-{control.kind}.{kind}.json"
84
+ )
85
+
86
+
87
+ def unscored(control: ControlTask, detection: list[bool], fuzzing: list[bool]) -> ControlScore:
88
+ """A control with the judge's answers. ``finalize_scores`` adds its scores."""
89
+ return ControlScore(
90
+ kind=control.kind,
91
+ explanation=control.explanation,
92
+ source_slug=control.source_slug,
93
+ detection_judged=detection,
94
+ fuzzing_judged=fuzzing,
95
+ )
96
+
97
+
98
+ def gap_summary(scores: ExplanationScores) -> str:
99
+ """One line per control: the real score minus the control's, for each kind."""
100
+ lines = []
101
+ for c in scores.controls:
102
+ parts = []
103
+ for kind in KINDS:
104
+ real, ctrl = getattr(scores, kind), getattr(c, kind)
105
+ if real is not None and ctrl is not None:
106
+ gap = real.balanced_accuracy - ctrl.balanced_accuracy
107
+ parts.append(f"{kind} {ctrl.balanced_accuracy:.2f} (gap {gap:+.2f})")
108
+ source = f" from {c.source_slug}" if c.source_slug else ""
109
+ lines.append(f"Control {c.kind}{source}: " + (", ".join(parts) or "not judged"))
110
+ return "\n".join(lines)
111
+
112
+
113
+ def write_prompts(task, task_path: Path, noun: str) -> None:
114
+ """Write a judge prompt for each control and kind, next to the task."""
115
+ from openinterp.agents.judge import DetectionAnswer, ExplanationJudge, FuzzingAnswer
116
+
117
+ models = {"detection": DetectionAnswer, "fuzzing": FuzzingAnswer}
118
+ builders = {
119
+ "detection": ExplanationJudge.detection_prompt,
120
+ "fuzzing": ExplanationJudge.fuzzing_prompt,
121
+ }
122
+ for control in task.controls:
123
+ for kind in KINDS:
124
+ windows = getattr(task, kind)
125
+ if not windows:
126
+ continue
127
+ text = builders[kind](control.explanation, windows, noun)
128
+ answer = answer_path(task_path, control, kind)
129
+ rendered = ExplanationJudge.render_prompt(models[kind], text, answer)
130
+ prompt_path(task_path, control, kind).write_text(rendered)
131
+
132
+
133
+ def read_answers(task, task_path: Path) -> list[ControlScore]:
134
+ """Read the judge's answers to each control's prompts (step two of ``--emit-task``)."""
135
+ from openinterp.agents.judge import DetectionAnswer, FuzzingAnswer, answered
136
+
137
+ out = []
138
+ for control in task.controls:
139
+ judged = {}
140
+ for kind, model, field in (
141
+ ("detection", DetectionAnswer, "active"),
142
+ ("fuzzing", FuzzingAnswer, "correct"),
143
+ ):
144
+ windows = getattr(task, kind)
145
+ if not windows:
146
+ judged[kind] = []
147
+ continue
148
+ answer = model.model_validate_json(answer_path(task_path, control, kind).read_text())
149
+ judged[kind] = answered(getattr(answer, field), len(windows), f"{control.kind} {kind}")
150
+ out.append(unscored(control, judged["detection"], judged["fuzzing"]))
151
+ return out
152
+
153
+
154
+ def judge_controls(judge, task, noun: str) -> list[ControlScore]:
155
+ """Ask an LLM judge each control's questions (the one-step ``open-interpretability score``)."""
156
+ from openinterp.agents.judge import answered
157
+
158
+ out = []
159
+ for control in task.controls:
160
+ det = judge.detect(control.explanation, task.detection, noun)
161
+ fuz = judge.fuzz(control.explanation, task.fuzzing, noun) if task.fuzzing else None
162
+ out.append(
163
+ unscored(
164
+ control,
165
+ answered(det.active, len(task.detection), "detection"),
166
+ answered(fuz.correct, len(task.fuzzing), "fuzzing") if fuz else [],
167
+ )
168
+ )
169
+ return out
@@ -0,0 +1,243 @@
1
+ """Find personal data and offensive words in text, for scan labels and the site.
2
+
3
+ Experiments read this text unchanged. A scan labels what it finds
4
+ (:class:`~openinterp.harness.annotations.MatchTagger`). So a record can say how much of a
5
+ unit's activation personal data explains. Separately, the site build
6
+ removes personal data and masks offensive examples in what it publishes
7
+ (``openinterp/builder``).
8
+
9
+ The search for personal data uses Microsoft Presidio's pattern recognisers, with rules of
10
+ our own on top. They need no model and no network. It finds emails, phone numbers, payment
11
+ cards, IBANs, US SSNs and ITINs, UK NHS numbers, crypto wallets and public IP addresses. It
12
+ does NOT find names or postal addresses. Names and addresses are separate scan labels, which
13
+ the site does not remove: :mod:`openinterp.names` and :func:`address_matches`.
14
+
15
+ The search for offensive words is a short list (``openinterp/data/offensive_words.txt``),
16
+ not a classifier. It must be fast for a site build, and needs no model. The list holds only
17
+ words that are rarely innocent. It includes words for sexual violence, so news reports can
18
+ match. A general profanity list flagged 12% of distinct examples, on words such as "god",
19
+ "organ" and "stroke". This list flags 0.6%. The list is English only, and text can be
20
+ hateful with none of these words. So a match says that a listed word is there, not that the
21
+ text is toxic. No match does not mean that a text is safe.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import ipaddress
28
+ import re
29
+ from collections.abc import Iterator
30
+ from functools import cache
31
+ from importlib.metadata import version
32
+ from importlib.resources import as_file, files
33
+ from typing import Any
34
+
35
+ _SUFFIX_LIST = "public_suffix_list.dat" # publicsuffix.org, MPL 2.0
36
+
37
+ # Presidio's score of a match with no context words. Below this are its weak patterns, such
38
+ # as any nine digits for an SSN. In 1,000 sampled documents those matched only object IDs.
39
+ MIN_SCORE = 0.4
40
+ # Fewer digits match year ranges ("1800-1864") and plain numbers: of 30 matches in 1,000
41
+ # sampled documents, about half. _context_phones finds shorter numbers.
42
+ MIN_PHONE_DIGITS = 10
43
+ # A local number has too few digits for that rule, and a region outside Presidio's has no
44
+ # valid format. So a number of these lengths also counts when a phone word comes just before
45
+ # it. Over 1,000 documents and 3,990 published examples, this matched 20 texts. All the
46
+ # numbers looked real, but for one joke number.
47
+ _CONTEXT_PHONE_DIGITS = range(7, 13)
48
+ _PHONE_WORD_REACH = 40 # characters: "Media contact:\nA. Person, 8341 7141"
49
+ _PHONE_WORD = re.compile(
50
+ r"\b(?:tel|t[ée]l[ée]phone|telefon|tel[ée]fono|phon(?:e|es|ed|ing)|ph|fax|mobile|mob|cell|call|contact"
51
+ r"|whatsapp)\b",
52
+ re.IGNORECASE,
53
+ )
54
+ _PHONE_DIGITS = re.compile(r"(?<![\d.,/:-])\+?\(?\d(?:[ ().-]{0,2}\d){6,11}(?![\d]|[.,/:-]\d)")
55
+ # What such a number is not: a date range ("Call 1999-2000") or a decimal ("0.0612422").
56
+ _NOT_A_PHONE = re.compile(r"(?:1[5-9]|20)\d\d\b|\d+\.\d+$")
57
+ # Shapes that the phone recogniser accepts, but that are not phone numbers. Explanations hold
58
+ # many of them. They are decimals ("0.1644-0.17004"), table rows and lists ("0.96/0.89").
59
+ # They are also date ranges ("6/1999-2/2002"), DOIs ("1--2565"), dotted quads and counts.
60
+ _NOT_A_PHONE_SHAPE = re.compile(
61
+ r"(?<![\d.])0\.\d|\s{2,}|/|--|(?<![\d.])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])|(?:\b\d\s+){3}"
62
+ )
63
+ # RFC 2606 reserves these for examples, so an address here is nobody's. Reviewers' probes use them.
64
+ _RESERVED_DOMAINS = {"example.com", "example.net", "example.org"}
65
+ # A window of tokens can end inside an address, so its domain is cut or missing.
66
+ _EMAIL_CUT = re.compile(r"(?<![\w.+%-])[\w.+%-]*\w[\w.+%-]*@[\w.-]*$")
67
+
68
+ # US and UK formats only, on one line. A street's name words take no full stop, so a legal
69
+ # citation fails ("27 Mass. App. Ct."). "St" and "Dr" before a capital are a title ("4 Did St
70
+ # Joseph"), not a street. _COURT_NAME leaves out citations such as "2008 Supreme Court".
71
+ _STREET_TYPE = (
72
+ r"(?:(?:St|Dr)\b(?![ \t]+[A-Z])|(?:Street|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive"
73
+ r"|Court|Ct|Way|Place|Pl|Square|Sq|Terrace|Terr|Parkway|Pkwy|Highway|Hwy|Circle|Cir"
74
+ r"|Crescent|Cres|Close|Gardens|Mews)\b)"
75
+ )
76
+ _US_STATE = (
77
+ r"(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI"
78
+ r"|S[CD]|T[NX]|UT|V[AT]|W[AIVY])"
79
+ )
80
+ _ADDRESS_PATTERNS = {
81
+ "street": re.compile(
82
+ r"(?<![\d.#,-])\b\d{1,5}[A-Za-z]?[ \t]+"
83
+ r"(?:(?:[NSEW]\.?|North|South|East|West)[ \t]+)?"
84
+ rf"(?:[A-Z][\w'’-]*[ \t]+){{1,4}}{_STREET_TYPE}\.?"
85
+ ),
86
+ "po_box": re.compile(r"\bP\.?[ \t]?O\.?[ \t]?Box[ \t]+\d+", re.IGNORECASE),
87
+ "us_city_zip": re.compile(rf"\b[A-Z][a-z]+,?[ \t]+{_US_STATE}[ \t]+\d{{5}}(?:-\d{{4}})?\b"),
88
+ "uk_postcode": re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]?[ \t]\d[A-Z]{2}\b"),
89
+ }
90
+ _COURT_NAME = re.compile(r"\b(?:Supreme|Appeals?|Circuit|District|Superior|County)\b")
91
+
92
+ # The label of each kind -> Presidio's entity. These match with no context words. Presidio's
93
+ # others are left out. URLs, dates and UUIDs are not personal, and its medical licence
94
+ # pattern matched a clinical trial ID.
95
+ _PII_ENTITIES = {
96
+ "email": "EMAIL_ADDRESS",
97
+ "phone": "PHONE_NUMBER",
98
+ "payment_card": "CREDIT_CARD",
99
+ "iban": "IBAN_CODE",
100
+ "us_ssn": "US_SSN",
101
+ "us_itin": "US_ITIN",
102
+ "uk_nhs": "UK_NHS",
103
+ "crypto_wallet": "CRYPTO",
104
+ "ip_address": "IP_ADDRESS",
105
+ }
106
+
107
+
108
+ @cache
109
+ def _domains():
110
+ """Read email domains against our copy of the Public Suffix List, never a live one.
111
+
112
+ The live list changes, and a scan must give the same labels again. To update the copy,
113
+ replace the file: :func:`detector` records its SHA-256, so records say which one they used.
114
+ """
115
+ import tldextract
116
+
117
+ with as_file(files("openinterp.data").joinpath(_SUFFIX_LIST)) as path:
118
+ url = path.resolve().as_uri()
119
+ # fallback_to_snapshot=False: fail, not silently read the list in the installed package.
120
+ return tldextract.TLDExtract(
121
+ suffix_list_urls=(url,), cache_dir=None, fallback_to_snapshot=False
122
+ )
123
+
124
+
125
+ @cache
126
+ def _recognisers() -> dict[str, Any]:
127
+ """Each kind's Presidio recogniser. Importing Presidio loads spaCy, so only on first use."""
128
+ from presidio_analyzer import predefined_recognizers as recognisers
129
+
130
+ class OfflineEmail(recognisers.EmailRecognizer):
131
+ def validate_result(self, pattern_text: str) -> bool:
132
+ domain = pattern_text.rpartition("@")[2].lower()
133
+ return domain not in _RESERVED_DOMAINS and _domains()(domain).suffix != ""
134
+
135
+ return {
136
+ "email": OfflineEmail(),
137
+ "phone": recognisers.PhoneRecognizer(),
138
+ "payment_card": recognisers.CreditCardRecognizer(),
139
+ "iban": recognisers.IbanRecognizer(),
140
+ "us_ssn": recognisers.UsSsnRecognizer(),
141
+ "us_itin": recognisers.UsItinRecognizer(),
142
+ "uk_nhs": recognisers.NhsRecognizer(),
143
+ "crypto_wallet": recognisers.CryptoRecognizer(),
144
+ "ip_address": recognisers.IpRecognizer(),
145
+ }
146
+
147
+
148
+ def _is_public_ip(address: str) -> bool:
149
+ # "::a" is a valid address, but in text it is C++ or CSS: "std::a", ".x::after".
150
+ if ":" in address and sum(bool(group) for group in address.split(":")) < 3:
151
+ return False
152
+ try:
153
+ return ipaddress.ip_network(address, strict=False).is_global
154
+ except ValueError: # the pattern also matches other dotted and colon-separated numbers
155
+ return False
156
+
157
+
158
+ def _keeps(kind: str, match: str) -> bool:
159
+ """Our rules on top of Presidio's, from what it matched in the registry's documents."""
160
+ if kind == "phone":
161
+ enough_digits = sum(c.isdigit() for c in match) >= MIN_PHONE_DIGITS
162
+ return enough_digits and not _NOT_A_PHONE_SHAPE.search(match)
163
+ return kind != "ip_address" or _is_public_ip(match)
164
+
165
+
166
+ def _context_phones(text: str) -> Iterator[tuple[int, int, str]]:
167
+ for match in _PHONE_DIGITS.finditer(text):
168
+ digits = sum(c.isdigit() for c in match.group())
169
+ if digits not in _CONTEXT_PHONE_DIGITS or _NOT_A_PHONE.match(match.group()):
170
+ continue
171
+ if _NOT_A_PHONE_SHAPE.search(match.group()):
172
+ continue
173
+ if _PHONE_WORD.search(text, max(0, match.start() - _PHONE_WORD_REACH), match.start()):
174
+ yield (*match.span(), "phone")
175
+
176
+
177
+ def _cut_email(text: str) -> Iterator[tuple[int, int, str]]:
178
+ cut = _EMAIL_CUT.search(text)
179
+ if cut and cut.group().rpartition("@")[2].rstrip(".").lower() not in _RESERVED_DOMAINS:
180
+ yield (*cut.span(), "email")
181
+
182
+
183
+ def may_match(text: str) -> bool:
184
+ """Return False for a text too short for any match: most record strings are single tokens."""
185
+ return "@" in text or text.count(":") >= 2 or sum(map(str.isdigit, text)) >= 4
186
+
187
+
188
+ @cache
189
+ def pii_matches(text: str) -> tuple[tuple[int, int, str], ...]:
190
+ """Return ``(start, end, kind)`` of each match in ``text``, sorted; matches can overlap.
191
+
192
+ Many pages show the same text, so the result is cached.
193
+ """
194
+ if not may_match(text):
195
+ return ()
196
+ found = {*_cut_email(text), *_context_phones(text)}
197
+ for kind, recogniser in _recognisers().items():
198
+ for match in recogniser.analyze(text, [_PII_ENTITIES[kind]], None):
199
+ if match.score >= MIN_SCORE and _keeps(kind, text[match.start : match.end]):
200
+ found.add((match.start, match.end, kind))
201
+ return tuple(sorted(found)) # a set, because the phone recogniser repeats a number per region
202
+
203
+
204
+ def address_matches(text: str) -> tuple[tuple[int, int, str], ...]:
205
+ """Return ``(start, end, kind)`` of each postal address part in ``text``, sorted.
206
+
207
+ These are a scan label only. The site does not remove them. The patterns cannot tell a
208
+ home from a business, and most matches are organisations.
209
+ """
210
+ found = (
211
+ (*match.span(), kind)
212
+ for kind, pattern in _ADDRESS_PATTERNS.items()
213
+ for match in pattern.finditer(text)
214
+ if kind != "street" or not _COURT_NAME.search(match.group())
215
+ )
216
+ return tuple(sorted(found))
217
+
218
+
219
+ def _data(name: str) -> str:
220
+ return files("openinterp.data").joinpath(name).read_text(encoding="utf-8")
221
+
222
+
223
+ @cache
224
+ def _offensive_pattern() -> re.Pattern[str]:
225
+ alternatives = "|".join(re.escape(word) for word in _data("offensive_words.txt").split())
226
+ return re.compile(rf"\b(?:{alternatives})\b", re.IGNORECASE)
227
+
228
+
229
+ @cache
230
+ def is_offensive(text: str) -> bool:
231
+ """Return whether ``text`` holds a listed word; many pages show the same example."""
232
+ return _offensive_pattern().search(text) is not None
233
+
234
+
235
+ def detector() -> str:
236
+ """Name the package versions and the suffix list that fix what a search finds."""
237
+ names = ("presidio-analyzer", "phonenumbers", "tldextract")
238
+ packages = ", ".join(f"{p} {version(p)}" for p in names)
239
+ return f"openinterp.sensitive ({packages}; suffix list {_sha12(_data(_SUFFIX_LIST))})"
240
+
241
+
242
+ def _sha12(text: str) -> str:
243
+ return hashlib.sha256(text.encode()).hexdigest()[:12]
openinterp/show.py ADDED
@@ -0,0 +1,236 @@
1
+ """Compact summaries of records, submissions, scan evidence and a unit's history.
2
+
3
+ ``open-interpretability show`` prints these, so agents need not open a big file to learn a little.
4
+ A record can be 300 KB, an ``ev.json`` 500 KB and an experiment page 25 KB. An agent
5
+ often needs only a verdict, the failed gates and probes, or what a scan found. The files
6
+ stay the source of truth, and this only reads them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from openinterp.core.schemas import (
16
+ ACTIVATION_THRESHOLD,
17
+ AuditRecord,
18
+ NeuronAuditSubmission,
19
+ ScanEvidence,
20
+ )
21
+
22
+ PROMPT_CHARS = 160 # probes and examples are cut to this unless full=True
23
+ TOP_EXAMPLES = 5
24
+ CONTEXT_TOKENS = 8 # tokens shown before an example's peak
25
+
26
+
27
+ def show(path: Path, full: bool = False) -> str:
28
+ """Summarise ``path``: a record, submission or evidence JSON, or a unit's directory.
29
+
30
+ For a directory, it lists every experiment on the unit, oldest first, with each
31
+ page's Discussion.
32
+ """
33
+ if path.is_dir():
34
+ return show_unit(path, full)
35
+ text = path.read_text(encoding="utf-8")
36
+ data = json.loads(text) # to tell the kinds apart; strict models validate the text
37
+ if "submission" in data:
38
+ return show_record(AuditRecord.model_validate_json(text), path, full)
39
+ if "natural_language_explanation" in data:
40
+ return show_submission(NeuronAuditSubmission.model_validate_json(text), full)
41
+ if "top_examples" in data:
42
+ return show_evidence(ScanEvidence.model_validate_json(text), full)
43
+ raise ValueError(f"{path} is not a record, submission or scan evidence")
44
+
45
+
46
+ def show_unit(unit_dir: Path, full: bool = False) -> str:
47
+ """Every experiment on the unit in ``unit_dir``, oldest first, with each page's Discussion."""
48
+ records = sorted(
49
+ (
50
+ (AuditRecord.model_validate_json(p.read_text(encoding="utf-8")), p)
51
+ for p in unit_dir.glob("*.json")
52
+ ),
53
+ key=lambda rp: rp[0].verdict.timestamp if rp[0].verdict else 0,
54
+ )
55
+ if not records:
56
+ raise ValueError(f"no experiment records in {unit_dir}")
57
+ parts = [f"{len(records)} experiment(s) in {unit_dir}, oldest first"]
58
+ parts += [show_record(record, path, full) for record, path in records]
59
+ return "\n\n".join(parts)
60
+
61
+
62
+ def show_record(record: AuditRecord, path: Path | None = None, full: bool = False) -> str:
63
+ """A record's verdict, failed gates and probes, scores and hypothesis."""
64
+ sub, verdict = record.submission, record.verdict
65
+ name = f"{sub.identifier.slug}" + (f" · {path.stem}" if path else "")
66
+ if verdict is None:
67
+ return f"{name} · not reviewed\n{show_submission(sub, full)}"
68
+ status = f"{verdict.status} {verdict.confidence_score:.2f}"
69
+ protocol = verdict.policy.protocol_version if verdict.policy else "< 0.2"
70
+ lines = [
71
+ f"== {name} · {status} (protocol {protocol})"
72
+ + (" · self-reviewed" if record.self_reviewed else "")
73
+ ]
74
+ lines.append(
75
+ f"Proposer {sub.submitting_agent_model} · reviewer {verdict.reviewer_agent_model}"
76
+ + _selection(sub)
77
+ + (f" · supersedes {sub.supersedes}" if sub.supersedes else "")
78
+ )
79
+ lines += _hypothesis(sub)
80
+ lines += _metrics(verdict)
81
+ lines += _failed_probes(record, full)
82
+ if verdict.notes:
83
+ lines.append(f"Notes: {_clip(verdict.notes, full, 400)}")
84
+ if path is not None and (discussion := _discussion(path.with_suffix(".md"))):
85
+ lines.append(f"Discussion: {_clip(discussion, full, 1200)}")
86
+ return "\n".join(lines)
87
+
88
+
89
+ def _metrics(verdict) -> list[str]:
90
+ """Failed gates, the review's rates, and the scores recorded beside them."""
91
+ lines, m = [], verdict.metrics
92
+ if m is not None:
93
+ failed = [g for g, ok in m.gates.items() if not ok]
94
+ lines.append("Failed gates: " + (", ".join(failed) if failed else "none"))
95
+ rates = [
96
+ ("specificity", m.specificity),
97
+ ("sensitivity", m.sensitivity),
98
+ ("balanced accuracy", m.balanced_accuracy),
99
+ ("counterfactuals", m.counterfactual_reproduction),
100
+ ]
101
+ line = " · ".join(f"{k} {v:.2f}" for k, v in rates if v is not None)
102
+ if m.confound_baseline_accuracy is not None:
103
+ line += f" · best baseline ({m.confound_baseline}) {m.confound_baseline_accuracy:.2f}"
104
+ lines.append(line)
105
+ if (s := verdict.explanation_scores) is not None:
106
+ scores = [("detection", s.detection), ("fuzzing", s.fuzzing)]
107
+ lines.append(
108
+ "Explanation scores: "
109
+ + " · ".join(f"{k} {v.balanced_accuracy:.2f}" for k, v in scores if v is not None)
110
+ )
111
+ if (c := verdict.causal_metrics) is not None:
112
+ lines.append(
113
+ f"Causal: sufficiency {c.sufficiency_score:.2f} · necessity {c.necessity_score:.2f}"
114
+ )
115
+ return lines
116
+
117
+
118
+ def show_submission(sub: NeuronAuditSubmission, full: bool = False) -> str:
119
+ """A submission's unit, proposer, selection and hypothesis."""
120
+ lines = [
121
+ f"== {sub.identifier.slug} · submission by {sub.submitting_agent_model}{_selection(sub)}"
122
+ ]
123
+ lines += _hypothesis(sub)
124
+ lines.append(f"Counterfactuals: {len(sub.counterfactuals)}")
125
+ return "\n".join(lines)
126
+
127
+
128
+ def show_evidence(ev: ScanEvidence, full: bool = False) -> str:
129
+ """What a scan found: its corpus, peak and top examples."""
130
+ lines = [f"== {ev.identifier.slug} · scan evidence"]
131
+ if (p := ev.provenance) is not None:
132
+ window = f", {p.snippet_tokens}-token windows" if p.snippet_tokens else ""
133
+ lines.append(
134
+ f"Corpus {p.corpus}: {p.n_snippets} snippets, {p.tokens_scanned:,} tokens{window}"
135
+ + (f" · selection {p.selection} (seed {p.seed})" if p.seed is not None else "")
136
+ )
137
+ top = ev.top_examples[0].max_activation if ev.top_examples else 0.0
138
+ line = f"Max activation {top:.3f}"
139
+ if ev.activation_histogram is not None and top > 0:
140
+ share = ev.activation_histogram.share_at_least(ACTIVATION_THRESHOLD * top)
141
+ line += f" · ≥ {ACTIVATION_THRESHOLD:g}×max on at least {share:.1%} of tokens"
142
+ if ev.first_token_floor:
143
+ line += " · first-token floor"
144
+ lines.append(line)
145
+ shown = ev.top_examples if full else ev.top_examples[:TOP_EXAMPLES]
146
+ lines.append(
147
+ f"Top examples ({len(shown)} of {len(ev.top_examples)}), peak token in [brackets]:"
148
+ )
149
+ lines += [f" {ex.max_activation:7.3f} {_peak(ex, full)}" for ex in shown]
150
+ if (lens := ev.vocabulary_lens) is not None:
151
+ lines.append(f"Logit lens promotes: {_tokens(lens.logit_promoted)}")
152
+ lines.append(f"Logit lens suppresses: {_tokens(lens.logit_suppressed)}")
153
+ if ev.correlated_neurons:
154
+ sign = {"positive": "+", "negative": "−"}
155
+ lines.append(
156
+ "Co-activating poles: "
157
+ + ", ".join(
158
+ f"N{n.neuron_idx}{sign[n.pole]} (lift ≥ {n.coactivation_lift_lower:.1f})"
159
+ for n in ev.correlated_neurons[:5]
160
+ )
161
+ )
162
+ if (op := ev.opposite_pole) is not None and op.top_examples:
163
+ ex = op.top_examples[0]
164
+ lines.append(f"Opposite pole ({op.pole}) peak {ex.max_activation:.3f}: {_peak(ex, full)}")
165
+ return "\n".join(lines)
166
+
167
+
168
+ def _selection(sub) -> str:
169
+ p = sub.provenance
170
+ if p is None:
171
+ return ""
172
+ return f" · selection {p.selection}" + (f" (seed {p.seed})" if p.seed is not None else "")
173
+
174
+
175
+ def _hypothesis(sub) -> list[str]:
176
+ return [
177
+ f"Hypothesis: {sub.natural_language_explanation}",
178
+ f"Tokens: {_tokens(sub.activating_tokens)} · mechanism "
179
+ + ("lookahead" if sub.lookahead.is_lookahead else "retrospective")
180
+ + (f" · max activation {sub.max_activation:.3f}" if sub.max_activation else ""),
181
+ ]
182
+
183
+
184
+ def _failed_probes(record: AuditRecord, full: bool) -> list[str]:
185
+ verdict = record.verdict
186
+ results = verdict.adversarial_test_results
187
+ failed = [r for r in results if r.measured and not r.passed]
188
+ if not results:
189
+ return []
190
+ line = f"Failed probes: {len(failed)} of {len(results)}"
191
+ peak = record.submission.max_activation
192
+ if verdict.policy is not None and peak:
193
+ line += f" (active line {verdict.policy.activation_threshold * peak:.3f})"
194
+ lines = [line + (":" if failed else "")]
195
+ for r in failed:
196
+ if r.kind == "counterfactual":
197
+ got = f"Δ {_num(r.observed_delta)} vs expected {_num(r.expected_delta)}"
198
+ else:
199
+ expected = "active" if r.expected_active else "inactive"
200
+ got = f"{_num(r.activation)}, expected {expected}"
201
+ lines.append(f" ✗ {r.kind.replace('_', ' ')}: {got}: {_clip(r.prompt, full)}")
202
+ return lines
203
+
204
+
205
+ def _discussion(page: Path) -> str:
206
+ """The page's Discussion section without the template comment, or ''."""
207
+ if not page.exists():
208
+ return ""
209
+ text = page.read_text(encoding="utf-8").split("\n## Discussion", 1)
210
+ if len(text) < 2:
211
+ return ""
212
+ body = re.sub(r"<!--.*?-->", "", text[1], flags=re.S)
213
+ return " ".join(body.split())
214
+
215
+
216
+ def _peak(ex, full: bool) -> str:
217
+ i = ex.max_token_index
218
+ start = 0 if full else max(0, i - CONTEXT_TOKENS)
219
+ end = len(ex.tokens) if full else i + 3
220
+ before, peak, after = ex.tokens[start:i], ex.tokens[i : i + 1], ex.tokens[i + 1 : end]
221
+ text = "".join(before) + "[" + "".join(peak) + "]" + "".join(after)
222
+ return ("…" if start else "") + text.replace("\n", "⏎")
223
+
224
+
225
+ def _tokens(tokens: list[str], n: int = 10) -> str:
226
+ shown = ", ".join(repr(t) for t in tokens[:n])
227
+ return shown + (f" (+{len(tokens) - n})" if len(tokens) > n else "")
228
+
229
+
230
+ def _num(x: float | None) -> str:
231
+ return "n/a" if x is None else f"{x:.3f}"
232
+
233
+
234
+ def _clip(text: str, full: bool, chars: int = PROMPT_CHARS) -> str:
235
+ text = " ".join(text.split())
236
+ return text if full or len(text) <= chars else text[: chars - 1] + "…"