tokenbill 0.1.0__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.
tokenbill/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Token Bill: token economics and prompt-cache profiling for LLM agents.
2
+
3
+ Answers "why is our agent bill so high" with receipts: parses agent traces,
4
+ computes per-call token waterfalls from real billed usage, measures what share
5
+ of billed input tokens re-sent bytes the model had already seen, simulates what
6
+ prompt caching would actually save under the provider's documented rules, and
7
+ pinpoints the exact orchestration choices (a timestamp in the system prompt, a
8
+ reordered tool list) that break cache hits — each with a concrete fix and the
9
+ dollars it recovers.
10
+ """
11
+
12
+ __version__ = "0.1.0"
tokenbill/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m tokenbill`` entry point."""
2
+
3
+ from tokenbill.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
tokenbill/analyzer.py ADDED
@@ -0,0 +1,187 @@
1
+ """Waterfalls, redundancy, and segment attribution over recorded runs.
2
+
3
+ Honesty rules (see docs/SPEC.md):
4
+
5
+ - Dollar figures come from real billed ``usage`` via :mod:`tokenbill.pricing`.
6
+ Exact.
7
+ - Char-based numbers (segment attribution, repeated-prefix fractions, the
8
+ redundancy fraction) are **approximate**, used only to apportion a call's
9
+ billed totals, and every approximate token figure is scaled so segments sum
10
+ to the call's billed ``total_input``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from tokenbill.pricing import cost_breakdown
19
+ from tokenbill.trace import Call, Run, common_prefix_chars, render_segments, rendered_text
20
+
21
+ _DOLLAR_KEYS = ("uncached", "write", "read", "output")
22
+ _TOKEN_KEYS = ("uncached", "cache_write", "cache_read", "output", "total_input")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SegmentShare:
27
+ """One rendered segment's approximate share of a call's billed input.
28
+
29
+ ``approx_tokens`` is the call's billed ``total_input`` scaled by this
30
+ segment's char fraction — approximate by construction, but the shares of
31
+ one call always sum to the billed total.
32
+ """
33
+
34
+ kind: str
35
+ label: str
36
+ chars: int
37
+ char_fraction: float
38
+ approx_tokens: float
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CallProfile:
43
+ """Per-call profile: exact billed dollars plus approximate attribution."""
44
+
45
+ call: Call
46
+ dollars: dict[str, float] | None # pricing.cost_breakdown, None if model unknown
47
+ segments: list[SegmentShare] # per-segment approx attribution
48
+ repeated_prefix_chars: int # LCP with previous call (0 for first)
49
+ repeated_fraction_of_input: float # approx: repeated chars / rendered chars
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RunTotals:
54
+ """Run-level aggregates.
55
+
56
+ ``tokens`` (exact, from billed usage): ``uncached``, ``cache_write``,
57
+ ``cache_read``, ``output``, ``total_input``.
58
+
59
+ ``dollars`` (exact, billed): ``uncached``, ``write``, ``read``,
60
+ ``output``, ``total`` — or ``None`` when any call's model has no pricing
61
+ entry (a partial dollar total would be misleading; tokens stay exact).
62
+
63
+ ``redundancy_fraction`` is the headline number: the share of cumulative
64
+ billed input tokens attributable to re-sent byte-identical prefix that
65
+ was NOT served from cache. ``redundancy_is_approx`` is always ``True``:
66
+ the fraction rests on char-based prefix attribution.
67
+ """
68
+
69
+ tokens: dict[str, int]
70
+ dollars: dict[str, float] | None
71
+ redundancy_fraction: float
72
+ redundancy_is_approx: bool = True
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class RunProfile:
77
+ """Everything the report needs about one run."""
78
+
79
+ run: Run
80
+ calls: list[CallProfile]
81
+ totals: RunTotals
82
+
83
+
84
+ def _segment_parts(segment: Any) -> tuple[str, str, str]:
85
+ """Normalize a trace Segment to ``(kind, label, text)``.
86
+
87
+ The SPEC writes Segment as a 3-tuple; tolerate an attribute-bearing
88
+ equivalent so a NamedTuple or dataclass from trace.py also works.
89
+ """
90
+ try:
91
+ kind, label, text = segment
92
+ except (TypeError, ValueError):
93
+ kind, label, text = segment.kind, segment.label, segment.text
94
+ return str(kind), str(label), str(text)
95
+
96
+
97
+ def _profile_call(call: Call, prev: Call | None) -> tuple[CallProfile, float]:
98
+ """Build one CallProfile; also return this call's wasted-input tokens."""
99
+ rendered_chars = len(rendered_text(call))
100
+ total_input = call.usage.total_input
101
+
102
+ # Millions of shares are built for a large run; keep the loop body lean
103
+ # (positional construction, hoisted append, one division).
104
+ segments: list[SegmentShare] = []
105
+ append = segments.append
106
+ inv_chars = (1.0 / rendered_chars) if rendered_chars else 0.0
107
+ for segment in render_segments(call):
108
+ kind, label, text = _segment_parts(segment)
109
+ chars = len(text)
110
+ fraction = chars * inv_chars
111
+ append(SegmentShare(kind, label, chars, fraction, total_input * fraction))
112
+
113
+ if prev is None:
114
+ repeated_chars = 0
115
+ else:
116
+ repeated_chars = common_prefix_chars(prev, call)
117
+ repeated_fraction = (repeated_chars / rendered_chars) if rendered_chars else 0.0
118
+
119
+ # Redundancy contribution (see profile_run docstring): repeated prefix
120
+ # char fraction x billed total_input, minus tokens already served from
121
+ # cache; clamped at 0 per call.
122
+ wasted = max(0.0, total_input * repeated_fraction - call.usage.cache_read_input_tokens)
123
+
124
+ profile = CallProfile(
125
+ call=call,
126
+ dollars=cost_breakdown(call.model, call.usage),
127
+ segments=segments,
128
+ repeated_prefix_chars=repeated_chars,
129
+ repeated_fraction_of_input=repeated_fraction,
130
+ )
131
+ return profile, wasted
132
+
133
+
134
+ def profile_run(run: Run) -> RunProfile:
135
+ """Profile one run: per-call waterfalls plus the run redundancy fraction.
136
+
137
+ Redundancy definition (the headline number; also in DESIGN.md): for call
138
+ ``i > 0`` the re-sent prefix is ``common_prefix_chars(call[i-1],
139
+ call[i])`` of the canonical rendered text. Its token value is the call's
140
+ billed ``total_input`` scaled by the repeated char fraction; the portion
141
+ already served as ``cache_read_input_tokens`` is subtracted (cache reads
142
+ are cheap — they are not waste), and each call's contribution is clamped
143
+ at 0. The fraction is the sum of those contributions divided by the sum
144
+ of billed ``total_input`` over all calls. Approximate by construction
145
+ (char-based attribution); labeled via ``redundancy_is_approx``.
146
+ """
147
+ call_profiles: list[CallProfile] = []
148
+ wasted_total = 0.0
149
+ prev: Call | None = None
150
+ for call in run.calls:
151
+ profile, wasted = _profile_call(call, prev)
152
+ call_profiles.append(profile)
153
+ wasted_total += wasted
154
+ prev = call
155
+
156
+ tokens = dict.fromkeys(_TOKEN_KEYS, 0)
157
+ for call in run.calls:
158
+ tokens["uncached"] += call.usage.input_tokens
159
+ tokens["cache_write"] += call.usage.cache_creation_input_tokens
160
+ tokens["cache_read"] += call.usage.cache_read_input_tokens
161
+ tokens["output"] += call.usage.output_tokens
162
+ tokens["total_input"] += call.usage.total_input
163
+
164
+ dollars: dict[str, float] | None
165
+ if any(p.dollars is None for p in call_profiles):
166
+ dollars = None
167
+ else:
168
+ dollars = dict.fromkeys(_DOLLAR_KEYS, 0.0)
169
+ for profile in call_profiles:
170
+ assert profile.dollars is not None # narrowed by the branch above
171
+ for key in _DOLLAR_KEYS:
172
+ dollars[key] += profile.dollars[key]
173
+ dollars["total"] = sum(dollars[key] for key in _DOLLAR_KEYS)
174
+
175
+ billed_input = tokens["total_input"]
176
+ redundancy = (wasted_total / billed_input) if billed_input else 0.0
177
+
178
+ return RunProfile(
179
+ run=run,
180
+ calls=call_profiles,
181
+ totals=RunTotals(tokens=tokens, dollars=dollars, redundancy_fraction=redundancy),
182
+ )
183
+
184
+
185
+ def profile_trace(runs: list[Run]) -> list[RunProfile]:
186
+ """Profile every run in a trace, preserving order."""
187
+ return [profile_run(run) for run in runs]
tokenbill/breakers.py ADDED
@@ -0,0 +1,369 @@
1
+ """Cache-breaker detection: divergence classification, fixes, and repair.
2
+
3
+ For each pair of consecutive calls the first matching rule wins (SPEC priority
4
+ order):
5
+
6
+ 1. ``model`` differs from the previous call → ``model-switch``.
7
+ 2. diverging segment is tools → ``tool-churn``.
8
+ 3. diverging segment is system → ``volatile-system`` when the changed span
9
+ strictly overlaps a :data:`VOLATILE_PATTERNS` match (ISO dates/times, unix
10
+ timestamps, UUIDs, monotonic counters) AND substituting every volatile
11
+ match makes the two system prompts byte-identical — i.e. the volatile
12
+ values fully explain the divergence. Otherwise ``system-edit`` — the
13
+ non-volatile variant with its own fix sentence (whose repair, pinning the
14
+ system text, also covers mixed volatile-plus-edit changes).
15
+ 4. diverging segment is a message that already existed in the previous call →
16
+ ``history-rewrite``.
17
+ 5. no divergence, shared prefix at least the model's minimum cacheable length
18
+ (approx tokens), but ``cache_breakpoints == 0`` and billed cache activity
19
+ is zero → ``missing-breakpoint``.
20
+
21
+ :func:`detect` reports one :class:`Breaker` per distinct cause with the first
22
+ call index where it bites (the demo's ``timestamp`` scenario diverges on every
23
+ call but yields exactly one ``volatile-system`` breaker at call 1).
24
+ ``est_recovered_usd`` isolates each cause: the run is re-simulated with only
25
+ that one breaker repaired, and the value is as-billed minus fixed-cache
26
+ dollars, floored at 0 (positive = money recovered by the fix; approx, since
27
+ the fixed-cache scenario is simulated; 0 when billed caching already beats
28
+ the simulated single-breakpoint policy — the report words that case
29
+ explicitly rather than showing a negative "recovery"). Causes with no
30
+ mechanical repair (``model-switch``, ``history-rewrite``) get ``None`` — a
31
+ billed-minus-fixed number there would price the optimal replay of the
32
+ still-broken run, which is not attributable to the displayed fix.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from dataclasses import dataclass, replace
39
+
40
+ from tokenbill.common import canonical_json
41
+ from tokenbill.pricing import PRICING, ModelPricing
42
+
43
+ # _as_billed/_replay are simulator internals shared within the package: the
44
+ # per-breaker estimate needs exactly one as-billed total per run and one
45
+ # fixed-cache replay per breaker — calling the full simulate() here would
46
+ # redundantly re-replay optimal-cache for every detected breaker.
47
+ from tokenbill.simulator import _as_billed, _replay
48
+ from tokenbill.trace import Call, Run, approx_tokens, diverging_segment, rendered_text
49
+
50
+ # Heuristic patterns for content that legitimately changes every call and
51
+ # therefore must not live in the cached prefix. Tested as a module constant
52
+ # (SPEC). Order matters for :func:`repaired_calls`: the combined ISO datetime
53
+ # comes first so one substitution covers the whole stamp.
54
+ VOLATILE_PATTERNS: tuple[re.Pattern[str], ...] = (
55
+ # ISO datetime, e.g. "2026-07-26T14:03:05Z" or "2026-07-26 14:03:05".
56
+ re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b"),
57
+ # ISO date, e.g. "2026-07-26".
58
+ re.compile(r"\b\d{4}-\d{2}-\d{2}\b"),
59
+ # Clock time, e.g. "14:03" or "14:03:05".
60
+ re.compile(r"\b\d{1,2}:\d{2}(?::\d{2})?\b"),
61
+ # Unix timestamp in seconds (~2017..2051 range guard against arbitrary
62
+ # 10-digit numbers).
63
+ re.compile(r"\b(?:1[5-9]|2[0-5])\d{8}\b"),
64
+ # UUID.
65
+ re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"),
66
+ # Monotonic counters, e.g. "attempt 3", "seq=42", "turn #7".
67
+ re.compile(
68
+ r"\b(?:seq|counter|attempt|turn|step|iteration|request|call|run)[ _#:=-]{1,3}\d+\b",
69
+ re.IGNORECASE,
70
+ ),
71
+ )
72
+
73
+ #: What repaired volatile spans are replaced with (stable across all calls).
74
+ STABLE_PLACEHOLDER = "<volatile>"
75
+
76
+ _DEFAULT_LIMITS = ModelPricing(input_per_mtok=0.0, output_per_mtok=0.0)
77
+
78
+ _FIXES: dict[str, str] = {
79
+ "model-switch": (
80
+ "prompt caches are per-model: keep one model for the whole run, or expect a "
81
+ "cold cache after every switch"
82
+ ),
83
+ "tool-churn": (
84
+ "send tool definitions in one fixed order on every call (sort them once at "
85
+ "startup); reordering rewrites the cached prefix"
86
+ ),
87
+ "volatile-system": (
88
+ "move the volatile value (timestamp/UUID/counter) out of the system prompt — "
89
+ "inject it in the latest user message instead"
90
+ ),
91
+ "system-edit": (
92
+ "keep the system prompt byte-stable for the whole run; put per-turn context in "
93
+ "the latest user message instead of editing the system text"
94
+ ),
95
+ "history-rewrite": (
96
+ "append new messages instead of rewriting earlier ones; any edit above the "
97
+ "cache breakpoint invalidates the cached prefix"
98
+ ),
99
+ "missing-breakpoint": (
100
+ "add a cache_control breakpoint (for example on the last message); the stable "
101
+ "prefix already meets the minimum cacheable length"
102
+ ),
103
+ }
104
+
105
+ # SPEC rule order, used only to order breakers that first bite at the same call.
106
+ _KIND_PRIORITY = {
107
+ "model-switch": 0,
108
+ "tool-churn": 1,
109
+ "volatile-system": 2,
110
+ "system-edit": 2,
111
+ "history-rewrite": 3,
112
+ "missing-breakpoint": 4,
113
+ }
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Breaker:
118
+ """One detected cache-breaking cause with a concrete, priced fix.
119
+
120
+ ``kind`` is one of ``model-switch``, ``tool-churn``, ``volatile-system``,
121
+ ``system-edit`` (the non-volatile system-divergence variant),
122
+ ``history-rewrite``, ``missing-breakpoint``. ``evidence`` shows the exact
123
+ changed span, truncated, with char offsets. ``est_recovered_usd`` is
124
+ as-billed minus fixed-cache dollars with only this breaker repaired,
125
+ floored at 0 (positive = recovered; 0 = billed caching already beats the
126
+ simulated repair; ``None`` when pricing is unknown or when the kind has
127
+ no mechanical repair — ``model-switch`` and ``history-rewrite`` — so no
128
+ honest per-fix dollar figure exists).
129
+ """
130
+
131
+ kind: str
132
+ first_call_index: int
133
+ evidence: str
134
+ fix: str
135
+ est_recovered_usd: float | None
136
+
137
+
138
+ def _truncate(text: str, limit: int = 80) -> str:
139
+ if len(text) <= limit:
140
+ return text
141
+ keep = (limit - 3) // 2
142
+ return f"{text[:keep]}...{text[-keep:]}"
143
+
144
+
145
+ def _changed_window(prev: str, cur: str) -> tuple[int, int, int]:
146
+ """``(lo, hi_prev, hi_cur)``: minimal spans ``prev[lo:hi_prev]`` vs ``cur[lo:hi_cur]``."""
147
+ lo = 0
148
+ limit = min(len(prev), len(cur))
149
+ while lo < limit and prev[lo] == cur[lo]:
150
+ lo += 1
151
+ hi_prev, hi_cur = len(prev), len(cur)
152
+ while hi_prev > lo and hi_cur > lo and prev[hi_prev - 1] == cur[hi_cur - 1]:
153
+ hi_prev -= 1
154
+ hi_cur -= 1
155
+ return lo, hi_prev, hi_cur
156
+
157
+
158
+ def _volatile_overlap(text: str, lo: int, hi: int) -> re.Match[str] | None:
159
+ """First volatile-pattern match strictly overlapping ``text[lo:hi]``.
160
+
161
+ Strict means at least one shared character: a match that merely touches
162
+ the changed span (e.g. a stable date immediately before an edited
163
+ punctuation mark) must not classify the change as volatile.
164
+ """
165
+ for pattern in VOLATILE_PATTERNS:
166
+ for match in pattern.finditer(text):
167
+ if match.start() < hi and match.end() > lo:
168
+ return match
169
+ return None
170
+
171
+
172
+ def _excerpt(text: str, lo: int, hi: int, context: int = 24) -> str:
173
+ start = max(0, lo - context)
174
+ end = min(len(text), hi + context)
175
+ prefix = "..." if start > 0 else ""
176
+ suffix = "..." if end < len(text) else ""
177
+ return prefix + _truncate(text[start:end]) + suffix
178
+
179
+
180
+ def _tool_names(call: Call) -> list[str]:
181
+ return [str(tool.get("name", "?")) for tool in call.tools]
182
+
183
+
184
+ def _classify_system(prev: Call, cur: Call) -> tuple[str, str]:
185
+ lo, hi_prev, hi_cur = _changed_window(prev.system, cur.system)
186
+ match_cur = _volatile_overlap(cur.system, lo, hi_cur)
187
+ match_prev = _volatile_overlap(prev.system, lo, hi_prev)
188
+ # Volatile only when the volatile values fully explain the divergence:
189
+ # after substituting every volatile match the systems must be identical.
190
+ # Otherwise the volatile repair would leave the run still diverging and
191
+ # the honest classification (and fix) is system-edit.
192
+ volatile = (match_cur is not None or match_prev is not None) and _stabilized(
193
+ prev.system
194
+ ) == _stabilized(cur.system)
195
+ if volatile:
196
+ # Widen each excerpt to cover the volatile token so the evidence shows
197
+ # the full stamp, not just the digits that changed.
198
+ prev_lo = min(lo, match_prev.start()) if match_prev else lo
199
+ prev_hi = max(hi_prev, match_prev.end()) if match_prev else hi_prev
200
+ cur_lo = min(lo, match_cur.start()) if match_cur else lo
201
+ cur_hi = max(hi_cur, match_cur.end()) if match_cur else hi_cur
202
+ evidence = (
203
+ f"system chars [{cur_lo}:{cur_hi}] at call {cur.index}: "
204
+ f"{_excerpt(prev.system, prev_lo, prev_hi)!r} -> "
205
+ f"{_excerpt(cur.system, cur_lo, cur_hi)!r}"
206
+ )
207
+ return "volatile-system", evidence
208
+ evidence = (
209
+ f"system chars [{lo}:{hi_cur}] at call {cur.index}: "
210
+ f"{_excerpt(prev.system, lo, hi_prev)!r} -> {_excerpt(cur.system, lo, hi_cur)!r}"
211
+ )
212
+ return "system-edit", evidence
213
+
214
+
215
+ def _classify_pair(prev: Call, cur: Call, first_seen_tools: list[str]) -> tuple[str, str] | None:
216
+ """Classify one consecutive pair per the SPEC priority order."""
217
+ if cur.model != prev.model:
218
+ return "model-switch", (
219
+ f"model changed at call {cur.index}: {prev.model!r} -> {cur.model!r}"
220
+ )
221
+ divergence = diverging_segment(prev, cur)
222
+ if divergence is not None:
223
+ seg_index, seg_kind = divergence
224
+ if seg_kind == "tools":
225
+ return "tool-churn", (
226
+ f"tool order changed at call {cur.index}: first seen "
227
+ f"{first_seen_tools} -> {_tool_names(cur)}"
228
+ )
229
+ if seg_kind == "system":
230
+ return _classify_system(prev, cur)
231
+ # Message segments start after the tools and system segments.
232
+ msg_index = seg_index - 2
233
+ if 0 <= msg_index < len(prev.messages):
234
+ prev_text = canonical_json(prev.messages[msg_index])
235
+ cur_text = (
236
+ canonical_json(cur.messages[msg_index]) if msg_index < len(cur.messages) else ""
237
+ )
238
+ lo, hi_prev, hi_cur = _changed_window(prev_text, cur_text)
239
+ return "history-rewrite", (
240
+ f"messages[{msg_index}] rewritten at call {cur.index}, chars "
241
+ f"[{lo}:{hi_cur}]: {_excerpt(prev_text, lo, hi_prev)!r} -> "
242
+ f"{_excerpt(cur_text, lo, hi_cur)!r}"
243
+ )
244
+ return None
245
+ # Rule 5: byte-stable prefix that was never cached.
246
+ usage = cur.usage
247
+ if (
248
+ cur.cache_breakpoints == 0
249
+ and usage.cache_read_input_tokens == 0
250
+ and usage.cache_creation_input_tokens == 0
251
+ ):
252
+ limits = PRICING.get(cur.model) or _DEFAULT_LIMITS
253
+ prev_text = rendered_text(prev)
254
+ shared_chars = min(len(prev_text), len(rendered_text(cur)))
255
+ prefix_tokens = approx_tokens(prev_text[:shared_chars])
256
+ if prefix_tokens >= limits.min_cacheable_prefix_tokens:
257
+ return "missing-breakpoint", (
258
+ f"calls {prev.index}->{cur.index} share a byte-stable prefix of "
259
+ f"~{prefix_tokens:.0f} approx tokens (min cacheable "
260
+ f"{limits.min_cacheable_prefix_tokens}) but cache_breakpoints=0 "
261
+ "and billed cache activity is 0"
262
+ )
263
+ return None
264
+
265
+
266
+ def detect(run: Run) -> list[Breaker]:
267
+ """Detect cache breakers in *run*: one :class:`Breaker` per distinct cause.
268
+
269
+ Consecutive call pairs are classified per the SPEC priority order; events
270
+ of the same kind are collapsed to the first call index where the cause
271
+ bites. Each breaker's ``est_recovered_usd`` is computed by re-simulating
272
+ the run with only that breaker repaired (see :func:`repaired_calls`), so
273
+ the dollar estimate isolates each cause; kinds with no mechanical repair
274
+ report ``None`` instead of a number the fix cannot claim.
275
+ """
276
+ calls = run.calls
277
+ if len(calls) < 2:
278
+ return []
279
+ first_seen_tools = _tool_names(calls[0])
280
+ events: dict[str, tuple[int, str]] = {}
281
+ for i in range(1, len(calls)):
282
+ classified = _classify_pair(calls[i - 1], calls[i], first_seen_tools)
283
+ if classified is None:
284
+ continue
285
+ kind, evidence = classified
286
+ events.setdefault(kind, (i, evidence))
287
+
288
+ ordered = sorted(events.items(), key=lambda item: (item[1][0], _KIND_PRIORITY[item[0]]))
289
+ # As-billed dollars are a property of the run, not of any repair: price
290
+ # them once instead of once per breaker (replays dominate detect() time).
291
+ billed = _as_billed(calls).dollars if ordered else None
292
+ breakers: list[Breaker] = []
293
+ for kind, (index, evidence) in ordered:
294
+ breaker = Breaker(
295
+ kind=kind,
296
+ first_call_index=index,
297
+ evidence=evidence,
298
+ fix=_FIXES[kind],
299
+ est_recovered_usd=None,
300
+ )
301
+ breakers.append(replace(breaker, est_recovered_usd=_estimate(run, breaker, billed)))
302
+ return breakers
303
+
304
+
305
+ def _estimate(run: Run, breaker: Breaker, billed: float | None) -> float | None:
306
+ """As-billed minus fixed-cache dollars with only *breaker* repaired.
307
+
308
+ Floored at 0: billed usage can legitimately beat the simulated
309
+ single-breakpoint replay (e.g. real multi-breakpoint caching), and a
310
+ negative "recovery" would claim the fix costs money — the report words
311
+ that case explicitly instead. ``None`` when the repair left the calls
312
+ untouched (no mechanical repair exists — model-switch, history-rewrite):
313
+ billed minus fixed would then be the optimal replay of the still-broken
314
+ run, a number the displayed fix cannot claim. ``None`` also when any
315
+ call's model is unpriced (*billed* arrives as ``None``).
316
+ """
317
+ repaired = repaired_calls(run, [breaker])
318
+ if repaired == list(run.calls):
319
+ return None
320
+ if billed is None:
321
+ return None
322
+ fixed = _replay(repaired, "fixed-cache", "").dollars
323
+ if fixed is None:
324
+ return None
325
+ return max(0.0, billed - fixed)
326
+
327
+
328
+ def _stabilized(system: str) -> str:
329
+ for pattern in VOLATILE_PATTERNS:
330
+ system = pattern.sub(STABLE_PLACEHOLDER, system)
331
+ return system
332
+
333
+
334
+ def repaired_calls(run: Run, breakers: list[Breaker]) -> list[Call]:
335
+ """Return *run*'s calls with the given breakers neutralized for simulation.
336
+
337
+ Repairs (SPEC): volatile spans in the system prompt are replaced by the
338
+ stable :data:`STABLE_PLACEHOLDER`; a mid-run system edit is pinned to the
339
+ first call's system text; tool order is restored to first-seen order;
340
+ ``cache_breakpoints=1`` where it was 0. ``model-switch`` and
341
+ ``history-rewrite`` have no mechanical repair (rewriting content or
342
+ models would change semantics) and leave the calls untouched. Content
343
+ semantics are never mutated otherwise; billed ``usage`` is preserved so
344
+ the fixed-cache simulation still scales to real billed totals.
345
+ """
346
+ kinds = {breaker.kind for breaker in breakers}
347
+ calls = list(run.calls)
348
+ if "tool-churn" in kinds:
349
+ first_seen: dict[str, int] = {}
350
+ for call in calls:
351
+ for tool in call.tools:
352
+ first_seen.setdefault(canonical_json(tool), len(first_seen))
353
+ calls = [
354
+ replace(
355
+ call,
356
+ tools=tuple(sorted(call.tools, key=lambda t: first_seen[canonical_json(t)])),
357
+ )
358
+ for call in calls
359
+ ]
360
+ if "volatile-system" in kinds:
361
+ calls = [replace(call, system=_stabilized(call.system)) for call in calls]
362
+ if "system-edit" in kinds and calls:
363
+ calls = [replace(call, system=calls[0].system) for call in calls]
364
+ if "missing-breakpoint" in kinds:
365
+ calls = [
366
+ replace(call, cache_breakpoints=1) if call.cache_breakpoints == 0 else call
367
+ for call in calls
368
+ ]
369
+ return calls