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,430 @@
1
+ """What an experiment cost to run: wall time per stage and usage per LLM call.
2
+
3
+ Reported, never gated. Each command measures its own stages in a per-process collector
4
+ (:func:`stage`, :func:`add_call`). The files that it writes carry the costs forward:
5
+ evidence → submission → record, and score task → scores → record. So a record holds the
6
+ cost of every step of the experiment that produced it.
7
+
8
+ When ``OPENINTERP_COSTS_LOG`` is set, every command also appends its measurements to
9
+ that JSON-lines ledger. ``open-interpretability costs`` summarises the ledger.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from collections.abc import Callable, Iterable, Iterator
19
+ from contextlib import contextmanager
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+
23
+ from pydantic import Field
24
+
25
+ from openinterp.core import pricing
26
+ from openinterp.core.strict import StrictModel
27
+
28
+ LEDGER_ENV = "OPENINTERP_COSTS_LOG"
29
+
30
+
31
+ class StageTiming(StrictModel):
32
+ """Wall-clock seconds that one stage of a command took, and the memory it reached."""
33
+
34
+ stage: str = Field(
35
+ min_length=1,
36
+ examples=["model_load", "scan", "lenses", "proposer", "probe_measurement", "gates"],
37
+ )
38
+ seconds: float = Field(ge=0.0)
39
+ peak_rss_mb: int | None = Field(
40
+ default=None,
41
+ ge=0,
42
+ description="The process's peak resident memory so far, when the stage ended. It "
43
+ "never falls, so the stage that raises it set the peak. On Apple silicon it leaves "
44
+ "out MPS memory. None in records from before it was measured.",
45
+ )
46
+ peak_cuda_mb: int | None = Field(
47
+ default=None,
48
+ ge=0,
49
+ description="Peak CUDA memory that PyTorch allocated during the stage. None without CUDA.",
50
+ )
51
+
52
+
53
+ class LLMCall(StrictModel):
54
+ """One structured LLM request, counting every retry.
55
+
56
+ Token counts and cost are what
57
+ LiteLLM reported, or, for an external answerer, what its usage file says (priced from
58
+ :mod:`openinterp.core.pricing`). ``None`` when nobody reported them.
59
+ """
60
+
61
+ agent: str = Field(min_length=1, examples=["proposer", "reviewer", "judge"])
62
+ model: str = Field(min_length=1)
63
+ external: bool = Field(
64
+ default=False,
65
+ description="Answered through a prompt file; the stage's time then runs from the "
66
+ "prompt being written to the answer being written.",
67
+ )
68
+ attempts: int = Field(default=1, ge=0)
69
+ prompt_chars: int | None = Field(default=None, ge=0)
70
+ prompt_tokens: int | None = Field(default=None, ge=0)
71
+ completion_tokens: int | None = Field(default=None, ge=0)
72
+ cached_tokens: int | None = Field(default=None, ge=0, description="Prompt tokens cached.")
73
+ cache_write_tokens: int | None = Field(
74
+ default=None, ge=0, description="Prompt tokens written to the cache."
75
+ )
76
+ cost_usd: float | None = Field(default=None, ge=0.0)
77
+ price_table: str | None = Field(
78
+ default=None,
79
+ description="List-price table cost_usd was computed from (pricing.TABLE); None when "
80
+ "the provider reported the cost.",
81
+ )
82
+
83
+
84
+ class RequestUsage(StrictModel):
85
+ """One API request's tokens. ``input_tokens`` excludes cache reads and writes."""
86
+
87
+ at: datetime | None = Field(default=None, description="When it was made, if known.")
88
+ input_tokens: int = Field(default=0, ge=0)
89
+ cache_read_tokens: int = Field(default=0, ge=0)
90
+ cache_write_5m_tokens: int = Field(default=0, ge=0)
91
+ cache_write_1h_tokens: int = Field(default=0, ge=0)
92
+ output_tokens: int = Field(default=0, ge=0)
93
+
94
+
95
+ class ExternalUsage(RequestUsage):
96
+ """Tokens an external answerer used.
97
+
98
+ ``open-interpretability usage`` writes it next to the answer as ``<answer>.usage.json``. It
99
+ holds totals, plus each request when known,
100
+ since prices can depend on a request's size and time. ``cost_usd`` is for a provider
101
+ that reports its own cost; otherwise the requests are priced at list price for the
102
+ model the command records.
103
+ """
104
+
105
+ model: str | None = Field(default=None, description="The model the transcript reports.")
106
+ requests: int = Field(default=0, ge=0)
107
+ per_request: list[RequestUsage] = Field(default_factory=list)
108
+ cost_usd: float | None = Field(default=None, ge=0.0)
109
+ source: str | None = Field(default=None, examples=["claude-code:agent-a1b2c3"])
110
+ forbidden_reads: list[str] = Field(
111
+ default_factory=list,
112
+ description="Blocked paths that a reviewer's transcript shows it read "
113
+ "(openinterp.core.isolation). `review --probes` refuses a probe set with any.",
114
+ )
115
+
116
+ @classmethod
117
+ def of(cls, requests: list[RequestUsage], **kw) -> ExternalUsage:
118
+ """Totals over ``requests``, keeping each one."""
119
+ keys = [k for k in RequestUsage.model_fields if k != "at"]
120
+ totals = {k: sum(getattr(r, k) for r in requests) for k in keys}
121
+ return cls(requests=len(requests), per_request=requests, **totals, **kw)
122
+
123
+ def call_fields(self, model: str) -> dict:
124
+ """Token, cost and price-table fields of an :class:`LLMCall` answered by ``model``."""
125
+ writes = self.cache_write_5m_tokens + self.cache_write_1h_tokens
126
+ fields = {
127
+ "prompt_tokens": self.input_tokens + self.cache_read_tokens + writes,
128
+ "completion_tokens": self.output_tokens,
129
+ "cached_tokens": self.cache_read_tokens,
130
+ "cache_write_tokens": writes,
131
+ }
132
+ if self.cost_usd is not None:
133
+ return {**fields, "cost_usd": self.cost_usd}
134
+ if self.per_request:
135
+ cost = pricing.cost_usd(model, self.per_request)
136
+ else: # totals only: made now, each request with the average prompt
137
+ prompt = fields["prompt_tokens"] / max(self.requests, 1)
138
+ cost = pricing.cost_usd(model, [self], prompt=prompt)
139
+ table = pricing.TABLE if cost is not None else None
140
+ return {**fields, "cost_usd": cost, "price_table": table}
141
+
142
+
143
+ class ExperimentCosts(StrictModel):
144
+ """Wall time and LLM usage of the run that produced a record. Reported, never gated."""
145
+
146
+ stages: list[StageTiming] = Field(default_factory=list)
147
+ llm_calls: list[LLMCall] = Field(default_factory=list)
148
+
149
+ @property
150
+ def seconds(self) -> float:
151
+ """Total seconds over every stage."""
152
+ return sum(s.seconds for s in self.stages)
153
+
154
+ @property
155
+ def cost_usd(self) -> float | None:
156
+ """Total cost of the calls that report one, or ``None`` when none does."""
157
+ known = [c.cost_usd for c in self.llm_calls if c.cost_usd is not None]
158
+ return sum(known) if known else None
159
+
160
+ def tokens(self) -> tuple[int, int]:
161
+ """(prompt, completion) tokens over the calls that reported them."""
162
+ return (
163
+ sum(c.prompt_tokens or 0 for c in self.llm_calls),
164
+ sum(c.completion_tokens or 0 for c in self.llm_calls),
165
+ )
166
+
167
+ def summary(self) -> str:
168
+ """One line for experiment pages.
169
+
170
+ For example ``24.5 min (scan 13.9) · peak 5.4 GB RAM · 4 LLM calls · $0.31``. The
171
+ peak is the largest of the commands that made the record, each in its own process.
172
+ """
173
+ by_stage: dict[str, float] = {}
174
+ for s in self.stages:
175
+ by_stage[s.stage] = by_stage.get(s.stage, 0.0) + s.seconds
176
+ parts = [f"{self.seconds / 60:.1f} min"]
177
+ if by_stage:
178
+ slowest = max(by_stage, key=by_stage.__getitem__)
179
+ parts[0] += f" (slowest: {slowest}, {by_stage[slowest] / 60:.1f} min)"
180
+ peak = max((s.peak_rss_mb for s in self.stages if s.peak_rss_mb), default=None)
181
+ if peak:
182
+ parts.append(f"peak {peak / 1024:.1f} GB RAM")
183
+ if self.llm_calls:
184
+ n = len(self.llm_calls)
185
+ parts.append(f"{n} LLM call{'s' * (n != 1)}")
186
+ prompt, completion = self.tokens()
187
+ if prompt or completion:
188
+ parts.append(f"{prompt:,} + {completion:,} tokens")
189
+ if self.cost_usd is not None:
190
+ parts.append(f"${self.cost_usd:.2f}")
191
+ return " · ".join(parts)
192
+
193
+
194
+ def merge(*parts: ExperimentCosts | None) -> ExperimentCosts | None:
195
+ """Join the costs of several commands of one experiment, or ``None`` when all are ``None``."""
196
+ parts = tuple(p for p in parts if p is not None)
197
+ if not parts:
198
+ return None
199
+ return ExperimentCosts(
200
+ stages=[s for p in parts for s in p.stages],
201
+ llm_calls=[c for p in parts for c in p.llm_calls],
202
+ )
203
+
204
+
205
+ # This process's measurements. Commands are short-lived, so module state is enough; the
206
+ # CLI resets it when each command starts.
207
+ _stages: list[StageTiming] = []
208
+ _calls: list[LLMCall] = []
209
+
210
+
211
+ def reset() -> None:
212
+ """Clear this process's measurements, so that one command does not count another's."""
213
+ _stages.clear()
214
+ _calls.clear()
215
+
216
+
217
+ def add_stage(name: str, seconds: float) -> None:
218
+ """Record that stage ``name`` took ``seconds``, rounded to the millisecond, and its memory.
219
+
220
+ The CUDA peak is reset for the next stage, and MPS's cache is released.
221
+ """
222
+ rss, cuda = _peak_memory_mb()
223
+ timing = StageTiming(
224
+ stage=name, seconds=round(max(seconds, 0.0), 3), peak_rss_mb=rss, peak_cuda_mb=cuda
225
+ )
226
+ _stages.append(timing)
227
+ _reset_cuda_peak()
228
+ _release_mps_cache()
229
+
230
+
231
+ def _peak_memory_mb() -> tuple[int | None, int | None]:
232
+ """(peak resident memory of this process, peak CUDA allocation since the last reset)."""
233
+ rss = None
234
+ try:
235
+ import resource
236
+
237
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
238
+ rss = round(peak / 2**20) if sys.platform == "darwin" else round(peak / 2**10)
239
+ except (ImportError, OSError): # no resource module (Windows)
240
+ pass
241
+ return rss, _cuda_peak_mb()
242
+
243
+
244
+ def _cuda_peak_mb() -> int | None:
245
+ torch = sys.modules.get("torch") # never import torch just to ask
246
+ if torch is None or not torch.cuda.is_available() or not torch.cuda.is_initialized():
247
+ return None
248
+ return round(torch.cuda.max_memory_allocated() / 2**20)
249
+
250
+
251
+ def _release_mps_cache() -> None:
252
+ """Give memory that PyTorch's MPS allocator keeps for reuse back to the system.
253
+
254
+ On Apple silicon the GPU uses the same memory as the CPU. The allocator keeps every
255
+ freed block, so over an audit's stages it held 2.8 GB above the weights for
256
+ Qwen2.5-0.5B. Each stage needs different blocks, so the next stage loses little.
257
+ """
258
+ torch = sys.modules.get("torch")
259
+ if torch is None or not torch.backends.mps.is_available():
260
+ return
261
+ if torch.mps.current_allocated_memory(): # a CPU run never touches Metal
262
+ torch.mps.empty_cache()
263
+
264
+
265
+ def _reset_cuda_peak() -> None:
266
+ torch = sys.modules.get("torch")
267
+ if torch is not None and torch.cuda.is_available() and torch.cuda.is_initialized():
268
+ torch.cuda.reset_peak_memory_stats()
269
+
270
+
271
+ def add_call(call: LLMCall) -> None:
272
+ """Record one LLM request of this process."""
273
+ _calls.append(call)
274
+
275
+
276
+ @contextmanager
277
+ def stage(name: str) -> Iterator[None]:
278
+ """Time the ``with`` block as stage ``name``, also when it raises."""
279
+ _reset_cuda_peak()
280
+ start = time.perf_counter()
281
+ try:
282
+ yield
283
+ finally:
284
+ add_stage(name, time.perf_counter() - start)
285
+
286
+
287
+ def laps() -> Callable[[str], None]:
288
+ """A timer: each ``lap(name)`` records the time since the previous lap (or start)."""
289
+ _reset_cuda_peak()
290
+ last = time.perf_counter()
291
+
292
+ def lap(name: str) -> None:
293
+ nonlocal last
294
+ now = time.perf_counter()
295
+ add_stage(name, now - last)
296
+ last = now
297
+
298
+ return lap
299
+
300
+
301
+ def measured() -> ExperimentCosts:
302
+ """What this process has measured so far."""
303
+ return ExperimentCosts(stages=list(_stages), llm_calls=list(_calls))
304
+
305
+
306
+ def usage_path(answer: Path) -> Path:
307
+ """Where an answer's usage goes: ``ev.hypothesis.json`` → ``ev.hypothesis.usage.json``."""
308
+ return answer.with_name(answer.name.removesuffix(".json") + ".usage.json")
309
+
310
+
311
+ def add_external(agent: str, model: str, prompt: Path, answer: Path) -> None:
312
+ """Record a prompt answered from a file.
313
+
314
+ Records the prompt's size and the time between the prompt and the answer being
315
+ written (no stage when the prompt file is gone). Also records the answerer's tokens
316
+ and cost when ``usage_path(answer)`` exists.
317
+ """
318
+ if prompt.exists() and answer.exists():
319
+ add_stage(agent, answer.stat().st_mtime - prompt.stat().st_mtime)
320
+ chars = len(prompt.read_text()) if prompt.exists() else None
321
+ fields = {}
322
+ if (usage_file := usage_path(answer)).exists():
323
+ usage = ExternalUsage.model_validate_json(usage_file.read_text())
324
+ _check_model(usage, model, usage_file)
325
+ fields = usage.call_fields(model)
326
+ add_call(LLMCall(agent=agent, model=model, external=True, prompt_chars=chars, **fields))
327
+
328
+
329
+ def _check_model(usage: ExternalUsage, model: str, usage_file: Path) -> None:
330
+ """Warn when the transcript names a different model from the recorded one.
331
+
332
+ No warning when ``$OPENINTERP_MODEL_ALIASES`` says a proxy renamed the model. The
333
+ recorded model is the one priced.
334
+ """
335
+ from openinterp.agents.base import MODEL_ALIASES_ENV
336
+
337
+ reported = usage.model and {pricing.price_key(m) for m in usage.model.split(",")}
338
+ if (
339
+ reported
340
+ and pricing.price_key(model) not in reported
341
+ and not os.environ.get(MODEL_ALIASES_ENV)
342
+ ):
343
+ print(
344
+ f"warning: {usage_file} was answered by {usage.model}, but {model} is being "
345
+ "recorded and priced.",
346
+ file=sys.stderr,
347
+ )
348
+
349
+
350
+ def usage_of(responses: Iterable[object]) -> dict:
351
+ """Token counts and cost summed over raw LiteLLM responses (one per attempt)."""
352
+ totals: dict = {"attempts": 0}
353
+ for response in responses:
354
+ totals["attempts"] += 1
355
+ usage = getattr(response, "usage", None)
356
+ details = getattr(usage, "prompt_tokens_details", None)
357
+ values = {
358
+ "prompt_tokens": getattr(usage, "prompt_tokens", None),
359
+ "completion_tokens": getattr(usage, "completion_tokens", None),
360
+ "cached_tokens": getattr(details, "cached_tokens", None),
361
+ "cache_write_tokens": getattr(details, "cache_creation_tokens", None),
362
+ "cost_usd": _cost(response),
363
+ }
364
+ for key, value in values.items():
365
+ if isinstance(value, int | float):
366
+ totals[key] = totals.get(key, 0) + value
367
+ return totals
368
+
369
+
370
+ def _cost(response: object) -> float | None:
371
+ hidden = getattr(response, "_hidden_params", None) or {}
372
+ if hidden.get("response_cost") is not None:
373
+ return float(hidden["response_cost"])
374
+ try:
375
+ import litellm
376
+
377
+ return float(litellm.completion_cost(completion_response=response))
378
+ except Exception: # an unpriced model, or not a LiteLLM response
379
+ return None
380
+
381
+
382
+ def append_ledger(command: str, seconds: float) -> None:
383
+ """Append this process's measurements to ``$OPENINTERP_COSTS_LOG``, if set."""
384
+ path = os.environ.get(LEDGER_ENV)
385
+ if not path:
386
+ return
387
+ entry = {"command": command, "seconds": round(seconds, 3), **measured().model_dump()}
388
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
389
+ with open(path, "a", encoding="utf-8") as fh:
390
+ fh.write(json.dumps(entry) + "\n")
391
+
392
+
393
+ ROLE_ORDER = ("proposer", "reviewer", "judge")
394
+
395
+
396
+ def by_agent(
397
+ experiments: Iterable[tuple[str | None, ExperimentCosts | None]],
398
+ name: Callable[[str], str] = str,
399
+ ) -> list[dict]:
400
+ """LLM cost per experiment for each (role, model), from (verdict status, costs) pairs.
401
+
402
+ An experiment counts as priced for a role and model when every one of its calls there
403
+ has a cost. ``mean_usd`` averages over priced experiments, and ``usd_per_verified``
404
+ divides their total by how many of them were VERIFIED. ``name`` maps a call's model to
405
+ the name it is grouped under (e.g. ``openinterp.core.agent_names.canonical_agent``).
406
+ """
407
+ groups: dict[tuple[str, str], dict] = {}
408
+ for status, run in experiments:
409
+ calls: dict[tuple[str, str], list[LLMCall]] = {}
410
+ for call in run.llm_calls if run else ():
411
+ calls.setdefault((call.agent, name(call.model)), []).append(call)
412
+ for key, cs in calls.items():
413
+ g = groups.setdefault(key, {"experiments": 0, "priced": 0, "usd": 0.0, "verified": 0})
414
+ g["experiments"] += 1
415
+ if all(c.cost_usd is not None for c in cs):
416
+ g["priced"] += 1
417
+ g["usd"] += sum(c.cost_usd for c in cs)
418
+ g["verified"] += status == "VERIFIED"
419
+ rows = [
420
+ {
421
+ "agent": agent,
422
+ "model": model,
423
+ **g,
424
+ "mean_usd": g["usd"] / g["priced"] if g["priced"] else None,
425
+ "usd_per_verified": g["usd"] / g["verified"] if g["verified"] else None,
426
+ }
427
+ for (agent, model), g in groups.items()
428
+ ]
429
+ order = {r: i for i, r in enumerate(ROLE_ORDER)}
430
+ return sorted(rows, key=lambda r: (order.get(r["agent"], len(order)), -r["priced"], r["model"]))
@@ -0,0 +1,109 @@
1
+ """Random draws that could not be audited, kept as outcomes of random selection.
2
+
3
+ A random draw is one of the registry's unbiased samples. Statistics over random units
4
+ (share VERIFIED, share dead) need every draw, not only the draws that could be audited.
5
+ Two kinds of unit cannot be audited:
6
+
7
+ - *dead*: a unit that never fires on the scan. For a neuron pole, the scan then checks
8
+ the weights (``openinterp.harness.dead``). When they prove that no input can make the
9
+ pole fire, the draw is ``proven_dead``, and the planner counts the pole as verified.
10
+ - *default-on* (protocol 0.8): a unit active on at least half its tokens.
11
+
12
+ The scan stops on both. For a random draw, it writes an :class:`UnauditableDraw` instead
13
+ of evidence, once only, at ``wiki/draws/<slug>/<timestamp>.json`` (``open-interpretability audit
14
+ --random``).
15
+
16
+ For a chosen unit, such as a revise, it writes a :class:`ChosenStop` at
17
+ ``wiki/stops/<slug>/<timestamp>.json``. A chosen unit is not a sample, and it can already
18
+ count as a random draw. So stops have their own directory, and :func:`load_draws` does not
19
+ read them. A proof by the weights does not depend on selection, so a ``proven_dead`` stop
20
+ also counts its pole as verified (:func:`openinterp.planner.dead`).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Iterator
26
+ from pathlib import Path
27
+ from typing import Literal
28
+
29
+ from pydantic import AwareDatetime
30
+
31
+ from openinterp.core.paths import draws_dir, stops_dir
32
+ from openinterp.core.schemas import (
33
+ PROTOCOL_VERSION,
34
+ ComponentIdentifier,
35
+ ExperimentProvenance,
36
+ )
37
+ from openinterp.core.strict import StrictModel, UnitFloat
38
+
39
+ DrawOutcome = Literal["dead", "default_on"]
40
+
41
+
42
+ class UnauditableDraw(StrictModel):
43
+ """One random draw that the scan stopped on.
44
+
45
+ It is kept because rates over random units are biased without every draw.
46
+ """
47
+
48
+ identifier: ComponentIdentifier
49
+ outcome: DrawOutcome
50
+ active_share: UnitFloat | None = None # default-on: the share the scan stopped on
51
+ proven_dead: bool = False # dead: the weights prove that no input makes it fire
52
+ dead_bound: float | None = None # dead: the largest pre-activation any input gives
53
+ provenance: ExperimentProvenance
54
+ protocol_version: str = PROTOCOL_VERSION
55
+ timestamp: AwareDatetime
56
+
57
+
58
+ class ChosenStop(UnauditableDraw):
59
+ """One chosen unit that the scan stopped on.
60
+
61
+ It is kept so that the registry shows the attempt. No random-selection statistic reads it.
62
+ """
63
+
64
+ latest_record: str | None = None # the unit's newest experiment when the scan stopped
65
+
66
+
67
+ def draw_path(wiki_dir: Path, draw: UnauditableDraw) -> Path:
68
+ """Where ``draw`` is stored: ``draws/<slug>/<timestamp>.json`` under ``wiki_dir``."""
69
+ stamp = draw.timestamp.strftime("%Y%m%dT%H%M%SZ")
70
+ return draws_dir(wiki_dir) / draw.identifier.slug / f"{stamp}.json"
71
+
72
+
73
+ def write_draw(draw: UnauditableDraw, wiki_dir: Path) -> Path:
74
+ """Store ``draw`` (write-once: an existing file is left as it is)."""
75
+ path = draw_path(wiki_dir, draw)
76
+ if not path.exists():
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ path.write_text(draw.model_dump_json() + "\n", encoding="utf-8")
79
+ return path
80
+
81
+
82
+ def draw_files(wiki_dir: Path) -> Iterator[Path]:
83
+ """Every draw file under ``wiki_dir``, sorted by path; none without a draws directory."""
84
+ root = draws_dir(wiki_dir)
85
+ yield from sorted(root.rglob("*.json")) if root.is_dir() else ()
86
+
87
+
88
+ def load_draws(wiki_dir: Path) -> list[UnauditableDraw]:
89
+ """Every recorded draw, oldest first."""
90
+ draws = [UnauditableDraw.model_validate_json(p.read_text()) for p in draw_files(wiki_dir)]
91
+ return sorted(draws, key=lambda d: d.timestamp)
92
+
93
+
94
+ def stop_path(wiki_dir: Path, stop: ChosenStop) -> Path:
95
+ """Where ``stop`` is stored: ``stops/<slug>/<timestamp>.json`` under ``wiki_dir``."""
96
+ stamp = stop.timestamp.strftime("%Y%m%dT%H%M%SZ")
97
+ return stops_dir(wiki_dir) / stop.identifier.slug / f"{stamp}.json"
98
+
99
+
100
+ def stop_files(wiki_dir: Path) -> Iterator[Path]:
101
+ """Every stop file under ``wiki_dir``, sorted by path; none without a stops directory."""
102
+ root = stops_dir(wiki_dir)
103
+ yield from sorted(root.rglob("*.json")) if root.is_dir() else ()
104
+
105
+
106
+ def load_stops(wiki_dir: Path) -> list[ChosenStop]:
107
+ """Every recorded stop of a chosen unit, oldest first."""
108
+ stops = [ChosenStop.model_validate_json(p.read_text()) for p in stop_files(wiki_dir)]
109
+ return sorted(stops, key=lambda s: s.timestamp)