copela 0.1.0__tar.gz

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.
copela-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Santibanez-Leal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
copela-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: copela
3
+ Version: 0.1.0
4
+ Summary: Run narrative-to-formal translation across many language models and score it with oracles that are not language models: executable, structural and property layers, reported separately.
5
+ Author-email: Felipe Santibanez-Leal <fsantibanez@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/fsantibanezleal/CAOS_Copela
8
+ Project-URL: Source, https://github.com/fsantibanezleal/CAOS_Copela
9
+ Project-URL: Issues, https://github.com/fsantibanezleal/CAOS_Copela/issues
10
+ Project-URL: Changelog, https://github.com/fsantibanezleal/CAOS_Copela/blob/main/CHANGELOG.md
11
+ Keywords: evaluation,benchmark,optimization,formalization,autoformalization,metamorphic-testing,llm
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: planteo>=0.1.0
25
+ Provides-Extra: solvers
26
+ Requires-Dist: pyomo>=6.7; extra == "solvers"
27
+ Requires-Dist: highspy>=1.7; extra == "solvers"
28
+ Provides-Extra: anthropic
29
+ Requires-Dist: anthropic>=0.40; extra == "anthropic"
30
+ Provides-Extra: openai
31
+ Requires-Dist: openai>=1.50; extra == "openai"
32
+ Provides-Extra: all
33
+ Requires-Dist: pyomo>=6.7; extra == "all"
34
+ Requires-Dist: highspy>=1.7; extra == "all"
35
+ Requires-Dist: anthropic>=0.40; extra == "all"
36
+ Requires-Dist: openai>=1.50; extra == "all"
37
+ Provides-Extra: dev
38
+ Requires-Dist: pytest>=8.0; extra == "dev"
39
+ Requires-Dist: ruff>=0.6; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # copela
43
+
44
+ [![PyPI](https://img.shields.io/pypi/v/copela.svg)](https://pypi.org/project/copela/)
45
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
46
+
47
+ Run narrative-to-formal translation across many language models, and score the result with oracles
48
+ that are **not** language models.
49
+
50
+ The name is the cupel used in fire assay: the vessel that separates the metal from the lead. That is
51
+ the job here, separating a formalization that is faithful from one that merely runs.
52
+
53
+ ## The number this produces
54
+
55
+ ```
56
+ gap report
57
+ ============================================================
58
+
59
+ anthropic/claude-sonnet-5 [optimization] ran 0.940 [0.832, 0.981] n=50 faithful 0.720 [0.584, 0.826] n=50 gap +0.220
60
+ ```
61
+
62
+ **ran** is what the field reports. **faithful** is what was asked for. The gap between them is the
63
+ output, and no source found reports it across target families.
64
+
65
+ That gap is not hypothetical. Where it has been measured carefully, in natural-language to Lean
66
+ formalization, it runs [3.0 to 29.0 percentage points](https://arxiv.org/abs/2606.31002), and the
67
+ strongest system measured had the largest gap: 89.5% compiling, 60.5% faithful.
68
+
69
+ ## Four layers, never merged
70
+
71
+ | Layer | Asks | Strength |
72
+ |---|---|---|
73
+ | **executable** | did it run, solve, compile | necessary, weak, the layer the field over-reports |
74
+ | **structural** | is it the same model as the reference | strong where it applies |
75
+ | **property** | do the invariants of this class hold | strong, catches what structure misses |
76
+ | **judge** | what would a model say | a labelled screening aggregate, never truth |
77
+
78
+ There is deliberately **no combined score**, and a test fails if anyone adds one. A single number
79
+ lets a high "it ran" rate conceal a low "it was right" rate, which is the distance this exists to
80
+ show.
81
+
82
+ The judge layer is reported because the literature reports it and comparability matters. It carries
83
+ a label on every record saying it is not an oracle, because the study that calibrated a two-judge
84
+ consensus against human majority states exactly that.
85
+
86
+ ## The property layer
87
+
88
+ Metamorphic relations: instead of checking an exact output, check how the output **must** change
89
+ when the input changes in a controlled way.
90
+
91
+ | Relation | Guarantee |
92
+ |---|---|
93
+ | scale the objective by `k > 0` | the argmin cannot move |
94
+ | add a redundant constraint | the feasible set cannot change |
95
+ | tighten a constraint | the optimum cannot improve |
96
+
97
+ The standard objection to metamorphic testing is that the relations must be authored per problem
98
+ class and so do not generalise to arbitrary programs. That is the design here: the target families
99
+ are narrow typed classes, so the relations are written once per class.
100
+
101
+ A candidate that solves and then fails one of these is wrong in a way no solver would have reported.
102
+
103
+ ## Install
104
+
105
+ ```bash
106
+ pip install copela # the harness
107
+ pip install "copela[solvers]" # plus Pyomo and HiGHS
108
+ pip install "copela[all]" # plus the provider SDKs
109
+ ```
110
+
111
+ ## Use
112
+
113
+ ```python
114
+ from copela import Budget, Case, Ledger, Sweep, Target, build
115
+ from copela.providers import get
116
+ from copela.solvers.highs import make_solver
117
+
118
+ sweep = Sweep(
119
+ ledger=Ledger("runs.jsonl"),
120
+ budget=Budget(limit_usd=5.00, max_consecutive_failures=10),
121
+ providers={"anthropic": get("anthropic"), "ollama": get("ollama")},
122
+ build_prompt=my_prompt, # the prompt strategy is what a study varies
123
+ parse_response=my_parser,
124
+ solve=make_solver(),
125
+ repeats=5,
126
+ )
127
+
128
+ sweep.run(cases, [Target("anthropic", "claude-sonnet-5"),
129
+ Target("ollama", "qwen3:8b")])
130
+
131
+ print(build(Ledger("runs.jsonl")).to_text())
132
+ ```
133
+
134
+ From the shell:
135
+
136
+ ```bash
137
+ copela models # what each provider serves, and what it costs
138
+ copela solve problem.json # solve one formalization, no model involved
139
+ copela report runs.jsonl # the gap
140
+ ```
141
+
142
+ ## Why many models, not one
143
+
144
+ Because a benchmark of AI-assisted modelling and simulation reports that
145
+ [no single model dominates across engine types](https://arxiv.org/abs/2605.28994), with
146
+ task-specific tradeoffs between speed and accuracy. A ranking claimed from one model contradicts a
147
+ published result, so the provider seam is a requirement rather than tidiness: Anthropic, Groq and
148
+ local models through Ollama, behind one interface, with no vendor name anywhere outside
149
+ `copela/providers/`. A test enforces that.
150
+
151
+ ## What reproducibility means here
152
+
153
+ Temperature zero does not make hosted inference deterministic. The dominant cause is the batch-size
154
+ dependence of reduction kernels rather than floating-point non-associativity, and bitwise
155
+ determinism costs a third to two thirds of throughput and cannot be bought over a hosted API.
156
+
157
+ So the harness pins what it can (model, version, temperature, seed, provider fingerprint), records
158
+ `n` repeats, and reports a **rate with a Wilson interval**. It never presents a single run as the
159
+ result, and 5 of 5 is not reported as certainly 1.0.
160
+
161
+ ## The ledger
162
+
163
+ One append-only JSONL record per call, carrying its full provenance. A record is never edited,
164
+ because a ledger that can be rewritten is not evidence. It is also the resume mechanism: a sweep
165
+ reads it and skips the calls already done.
166
+
167
+ ## Cost
168
+
169
+ Every sweep declares a budget and a kill criterion before it runs, and the guard refuses the call
170
+ that **would** exceed the ceiling rather than noticing afterwards.
171
+
172
+ ## Documentation
173
+
174
+ The wiki is in [`docs/`](docs/). The design document, written before the code, is
175
+ [`docs/design/SDD.md`](docs/design/SDD.md); each of its twelve requirements names the test that
176
+ verifies it.
177
+
178
+ ## Related
179
+
180
+ [`planteo`](https://github.com/fsantibanezleal/CAOS_Planteo) is the representation this consumes: a
181
+ typed problem with dimensions on every quantity and provenance on every element.
182
+
183
+ ## License
184
+
185
+ MIT. See [LICENSE](LICENSE).
copela-0.1.0/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # copela
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/copela.svg)](https://pypi.org/project/copela/)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ Run narrative-to-formal translation across many language models, and score the result with oracles
7
+ that are **not** language models.
8
+
9
+ The name is the cupel used in fire assay: the vessel that separates the metal from the lead. That is
10
+ the job here, separating a formalization that is faithful from one that merely runs.
11
+
12
+ ## The number this produces
13
+
14
+ ```
15
+ gap report
16
+ ============================================================
17
+
18
+ anthropic/claude-sonnet-5 [optimization] ran 0.940 [0.832, 0.981] n=50 faithful 0.720 [0.584, 0.826] n=50 gap +0.220
19
+ ```
20
+
21
+ **ran** is what the field reports. **faithful** is what was asked for. The gap between them is the
22
+ output, and no source found reports it across target families.
23
+
24
+ That gap is not hypothetical. Where it has been measured carefully, in natural-language to Lean
25
+ formalization, it runs [3.0 to 29.0 percentage points](https://arxiv.org/abs/2606.31002), and the
26
+ strongest system measured had the largest gap: 89.5% compiling, 60.5% faithful.
27
+
28
+ ## Four layers, never merged
29
+
30
+ | Layer | Asks | Strength |
31
+ |---|---|---|
32
+ | **executable** | did it run, solve, compile | necessary, weak, the layer the field over-reports |
33
+ | **structural** | is it the same model as the reference | strong where it applies |
34
+ | **property** | do the invariants of this class hold | strong, catches what structure misses |
35
+ | **judge** | what would a model say | a labelled screening aggregate, never truth |
36
+
37
+ There is deliberately **no combined score**, and a test fails if anyone adds one. A single number
38
+ lets a high "it ran" rate conceal a low "it was right" rate, which is the distance this exists to
39
+ show.
40
+
41
+ The judge layer is reported because the literature reports it and comparability matters. It carries
42
+ a label on every record saying it is not an oracle, because the study that calibrated a two-judge
43
+ consensus against human majority states exactly that.
44
+
45
+ ## The property layer
46
+
47
+ Metamorphic relations: instead of checking an exact output, check how the output **must** change
48
+ when the input changes in a controlled way.
49
+
50
+ | Relation | Guarantee |
51
+ |---|---|
52
+ | scale the objective by `k > 0` | the argmin cannot move |
53
+ | add a redundant constraint | the feasible set cannot change |
54
+ | tighten a constraint | the optimum cannot improve |
55
+
56
+ The standard objection to metamorphic testing is that the relations must be authored per problem
57
+ class and so do not generalise to arbitrary programs. That is the design here: the target families
58
+ are narrow typed classes, so the relations are written once per class.
59
+
60
+ A candidate that solves and then fails one of these is wrong in a way no solver would have reported.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install copela # the harness
66
+ pip install "copela[solvers]" # plus Pyomo and HiGHS
67
+ pip install "copela[all]" # plus the provider SDKs
68
+ ```
69
+
70
+ ## Use
71
+
72
+ ```python
73
+ from copela import Budget, Case, Ledger, Sweep, Target, build
74
+ from copela.providers import get
75
+ from copela.solvers.highs import make_solver
76
+
77
+ sweep = Sweep(
78
+ ledger=Ledger("runs.jsonl"),
79
+ budget=Budget(limit_usd=5.00, max_consecutive_failures=10),
80
+ providers={"anthropic": get("anthropic"), "ollama": get("ollama")},
81
+ build_prompt=my_prompt, # the prompt strategy is what a study varies
82
+ parse_response=my_parser,
83
+ solve=make_solver(),
84
+ repeats=5,
85
+ )
86
+
87
+ sweep.run(cases, [Target("anthropic", "claude-sonnet-5"),
88
+ Target("ollama", "qwen3:8b")])
89
+
90
+ print(build(Ledger("runs.jsonl")).to_text())
91
+ ```
92
+
93
+ From the shell:
94
+
95
+ ```bash
96
+ copela models # what each provider serves, and what it costs
97
+ copela solve problem.json # solve one formalization, no model involved
98
+ copela report runs.jsonl # the gap
99
+ ```
100
+
101
+ ## Why many models, not one
102
+
103
+ Because a benchmark of AI-assisted modelling and simulation reports that
104
+ [no single model dominates across engine types](https://arxiv.org/abs/2605.28994), with
105
+ task-specific tradeoffs between speed and accuracy. A ranking claimed from one model contradicts a
106
+ published result, so the provider seam is a requirement rather than tidiness: Anthropic, Groq and
107
+ local models through Ollama, behind one interface, with no vendor name anywhere outside
108
+ `copela/providers/`. A test enforces that.
109
+
110
+ ## What reproducibility means here
111
+
112
+ Temperature zero does not make hosted inference deterministic. The dominant cause is the batch-size
113
+ dependence of reduction kernels rather than floating-point non-associativity, and bitwise
114
+ determinism costs a third to two thirds of throughput and cannot be bought over a hosted API.
115
+
116
+ So the harness pins what it can (model, version, temperature, seed, provider fingerprint), records
117
+ `n` repeats, and reports a **rate with a Wilson interval**. It never presents a single run as the
118
+ result, and 5 of 5 is not reported as certainly 1.0.
119
+
120
+ ## The ledger
121
+
122
+ One append-only JSONL record per call, carrying its full provenance. A record is never edited,
123
+ because a ledger that can be rewritten is not evidence. It is also the resume mechanism: a sweep
124
+ reads it and skips the calls already done.
125
+
126
+ ## Cost
127
+
128
+ Every sweep declares a budget and a kill criterion before it runs, and the guard refuses the call
129
+ that **would** exceed the ceiling rather than noticing afterwards.
130
+
131
+ ## Documentation
132
+
133
+ The wiki is in [`docs/`](docs/). The design document, written before the code, is
134
+ [`docs/design/SDD.md`](docs/design/SDD.md); each of its twelve requirements names the test that
135
+ verifies it.
136
+
137
+ ## Related
138
+
139
+ [`planteo`](https://github.com/fsantibanezleal/CAOS_Planteo) is the representation this consumes: a
140
+ typed problem with dimensions on every quantity and provenance on every element.
141
+
142
+ ## License
143
+
144
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,73 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "copela"
7
+ version = "0.1.0"
8
+ description = "Run narrative-to-formal translation across many language models and score it with oracles that are not language models: executable, structural and property layers, reported separately."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Felipe Santibanez-Leal", email = "fsantibanez@gmail.com" }]
13
+ keywords = [
14
+ "evaluation",
15
+ "benchmark",
16
+ "optimization",
17
+ "formalization",
18
+ "autoformalization",
19
+ "metamorphic-testing",
20
+ "llm",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 3 - Alpha",
24
+ "Intended Audience :: Science/Research",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
31
+ "Typing :: Typed",
32
+ ]
33
+ dependencies = ["planteo>=0.1.0"]
34
+
35
+ [project.optional-dependencies]
36
+ solvers = ["pyomo>=6.7", "highspy>=1.7"]
37
+ anthropic = ["anthropic>=0.40"]
38
+ openai = ["openai>=1.50"]
39
+ all = ["pyomo>=6.7", "highspy>=1.7", "anthropic>=0.40", "openai>=1.50"]
40
+ dev = ["pytest>=8.0", "ruff>=0.6"]
41
+
42
+ [project.scripts]
43
+ copela = "copela.cli:main"
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/fsantibanezleal/CAOS_Copela"
47
+ Source = "https://github.com/fsantibanezleal/CAOS_Copela"
48
+ Issues = "https://github.com/fsantibanezleal/CAOS_Copela/issues"
49
+ Changelog = "https://github.com/fsantibanezleal/CAOS_Copela/blob/main/CHANGELOG.md"
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
53
+
54
+ [tool.setuptools.package-data]
55
+ copela = ["py.typed"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ addopts = "-q"
60
+ pythonpath = ["."]
61
+
62
+ [tool.ruff]
63
+ line-length = 100
64
+ target-version = "py311"
65
+
66
+ [tool.ruff.lint]
67
+ select = ["E", "F", "I", "UP", "B"]
68
+ ignore = [
69
+ "E501",
70
+ # UP042 wants StrEnum. These enums inherit str deliberately: their .value is what lands in the
71
+ # JSONL ledger, and StrEnum changes str() behaviour in a way that would alter what is written.
72
+ "UP042",
73
+ ]
copela-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,61 @@
1
+ """copela: run narrative-to-formal translation across many models, and score it with oracles
2
+ that are not language models.
3
+
4
+ The name is the cupel used in fire assay, the vessel that separates the metal from the lead. That is
5
+ the job here: separating a formalization that is faithful from one that merely runs.
6
+
7
+ Four layers, reported separately and never merged into one score:
8
+
9
+ 1. executable, did it run, solve, compile
10
+ 2. structural, is it the same model as the reference
11
+ 3. property, do the invariants of this class hold
12
+ 4. judge, what a model says, recorded as a labelled screening aggregate and never as truth
13
+
14
+ The headline is the subtraction: how often the artifact ran, minus how often it was right.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .budget import Budget, BudgetExceeded, estimate
20
+ from .ledger import CallKey, Ledger, LedgerError, Record
21
+ from .providers import Provider, ProviderError, StubProvider
22
+ from .report import Cell, Report, build
23
+ from .sweep import Case, Sweep, Target
24
+ from .verdicts import (
25
+ JUDGE_LABEL,
26
+ CandidateVerdict,
27
+ Layer,
28
+ LayerResult,
29
+ Outcome,
30
+ Rate,
31
+ )
32
+
33
+ __version__ = "0.1.0"
34
+ __display_version__ = "0.01.000"
35
+
36
+ __all__ = [
37
+ "JUDGE_LABEL",
38
+ "Budget",
39
+ "BudgetExceeded",
40
+ "CallKey",
41
+ "CandidateVerdict",
42
+ "Case",
43
+ "Cell",
44
+ "Layer",
45
+ "LayerResult",
46
+ "Ledger",
47
+ "LedgerError",
48
+ "Outcome",
49
+ "Provider",
50
+ "ProviderError",
51
+ "Rate",
52
+ "Record",
53
+ "Report",
54
+ "StubProvider",
55
+ "Sweep",
56
+ "Target",
57
+ "__display_version__",
58
+ "__version__",
59
+ "build",
60
+ "estimate",
61
+ ]
@@ -0,0 +1,84 @@
1
+ """The budget guard: stop before the limit, not after it.
2
+
3
+ A sweep is cases times models times repeats, and each cell costs money. The guard exists because the
4
+ failure it prevents has happened on this account: an unattended job consumed a week of quota in
5
+ about a day.
6
+
7
+ The rule is that the guard refuses the call that *would* exceed the budget, rather than noticing
8
+ afterwards. A guard that reports an overrun is an accountant, not a guard.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ class BudgetExceeded(RuntimeError):
17
+ """Raised when a call would take the sweep past its declared budget."""
18
+
19
+
20
+ @dataclass
21
+ class Budget:
22
+ """A spend ceiling and a kill criterion, both declared before the sweep runs.
23
+
24
+ ``limit_usd`` is the hard ceiling. ``max_consecutive_failures`` is the kill criterion: a sweep
25
+ whose calls are all failing is buying nothing, and continuing to the ceiling is waste.
26
+ """
27
+
28
+ limit_usd: float
29
+ max_consecutive_failures: int = 10
30
+ spent_usd: float = 0.0
31
+ consecutive_failures: int = 0
32
+ calls: int = 0
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.limit_usd < 0:
36
+ raise ValueError("a budget cannot be negative")
37
+
38
+ @property
39
+ def remaining_usd(self) -> float:
40
+ return max(0.0, self.limit_usd - self.spent_usd)
41
+
42
+ def check(self, estimated_usd: float) -> None:
43
+ """Raise if this call would exceed the ceiling. Call BEFORE spending."""
44
+ if self.spent_usd + estimated_usd > self.limit_usd:
45
+ raise BudgetExceeded(
46
+ f"this call is estimated at {estimated_usd:.4f} USD and "
47
+ f"{self.spent_usd:.4f} of {self.limit_usd:.4f} is already spent; "
48
+ "stopping before the budget rather than after it"
49
+ )
50
+ if self.consecutive_failures >= self.max_consecutive_failures:
51
+ raise BudgetExceeded(
52
+ f"{self.consecutive_failures} consecutive failures reached the kill criterion; "
53
+ "a sweep that is failing every call is buying nothing"
54
+ )
55
+
56
+ def charge(self, actual_usd: float, *, failed: bool = False) -> None:
57
+ """Record what a completed call actually cost."""
58
+ self.spent_usd += actual_usd
59
+ self.calls += 1
60
+ self.consecutive_failures = self.consecutive_failures + 1 if failed else 0
61
+
62
+ def describe(self) -> str:
63
+ return (
64
+ f"{self.spent_usd:.4f} of {self.limit_usd:.4f} USD over {self.calls} call(s), "
65
+ f"{self.remaining_usd:.4f} remaining"
66
+ )
67
+
68
+
69
+ def estimate(
70
+ prompt: str,
71
+ expected_output_tokens: int,
72
+ input_per_mtok: float,
73
+ output_per_mtok: float,
74
+ ) -> float:
75
+ """A cost estimate before the call, from a crude token count.
76
+
77
+ Four characters per token is a rough English average and it is deliberately not refined: the
78
+ estimate exists to keep the guard conservative, and a guard that under-estimates is worse than
79
+ one that stops slightly early.
80
+ """
81
+ input_tokens = max(1, len(prompt) // 4)
82
+ return (
83
+ input_tokens * input_per_mtok + expected_output_tokens * output_per_mtok
84
+ ) / 1_000_000