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