astern 0.0.2__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.
Potentially problematic release.
This version of astern might be problematic. Click here for more details.
- astern/__init__.py +20 -0
- astern/__main__.py +13 -0
- astern/data/.gitkeep +0 -0
- astern/estimate.py +340 -0
- astern/judge.py +291 -0
- astern/ledger.py +118 -0
- astern/lenses/__init__.py +121 -0
- astern/lenses/commands.py +224 -0
- astern/lenses/cost.py +45 -0
- astern/lenses/friction.py +175 -0
- astern/lenses/hygiene.py +68 -0
- astern/lenses/stats.py +81 -0
- astern/lenses/synopsis.py +245 -0
- astern/lenses/timeline.py +62 -0
- astern/lenses/tooling.py +60 -0
- astern/report.py +403 -0
- astern/sources.py +188 -0
- astern/store.py +126 -0
- astern/tools.py +683 -0
- astern/turns.py +385 -0
- astern/views.py +269 -0
- astern-0.0.2.dist-info/METADATA +122 -0
- astern-0.0.2.dist-info/RECORD +26 -0
- astern-0.0.2.dist-info/WHEEL +4 -0
- astern-0.0.2.dist-info/entry_points.txt +2 -0
- astern-0.0.2.dist-info/licenses/LICENSE +21 -0
astern/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""astern — look astern: mine your past Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
The crow's nest (``crowsnest``) watches the sessions that are running now; astern
|
|
4
|
+
looks back at the wake they left. It reads the transcripts Claude Code already writes,
|
|
5
|
+
keeps its own store of turns and findings, and answers, without spending tokens twice:
|
|
6
|
+
what problems recur, where agents get stuck, what one-off code keeps being rewritten,
|
|
7
|
+
what words you and your agents do not share.
|
|
8
|
+
|
|
9
|
+
Core contract: :mod:`astern.tools` (plain functions, JSON in, JSON out) over
|
|
10
|
+
:mod:`astern.sources` → :mod:`astern.turns` → :mod:`astern.store` with
|
|
11
|
+
:mod:`astern.ledger` deciding what still needs analyzing, :mod:`astern.lenses`
|
|
12
|
+
answering the questions, and :mod:`astern.judge` the one LLM seam.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from astern.judge import Judgment, claude_judge, replay_judge # noqa: F401
|
|
16
|
+
from astern.lenses import LENSES, finding, lens # noqa: F401
|
|
17
|
+
from astern.store import MemoryStore, Store, mk_store # noqa: F401
|
|
18
|
+
from astern.tools import lenses, sessions, show, sync # noqa: F401
|
|
19
|
+
|
|
20
|
+
__version__ = "0.0.1"
|
astern/__main__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""``astern`` command line: ``cw`` over the functions :mod:`astern.tools` exposes."""
|
|
2
|
+
|
|
3
|
+
import cw
|
|
4
|
+
|
|
5
|
+
from astern.tools import _dispatch_funcs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
raise SystemExit(cw.dispatch(_dispatch_funcs))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
main()
|
astern/data/.gitkeep
ADDED
|
File without changes
|
astern/estimate.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""What will judging this batch cost? — features, a fitted model, a prediction.
|
|
2
|
+
|
|
3
|
+
The point of the whole package is to burn fewer tokens, so it must be able to say
|
|
4
|
+
what a run *will* burn before it burns it. Every judged session stores its
|
|
5
|
+
:func:`features` (all computable with no model at all) next to the usage the judge
|
|
6
|
+
actually reported, which makes the cost model a plain regression of a measured
|
|
7
|
+
quantity on free ones. :func:`fit` reads those pairs, :func:`predict` applies them,
|
|
8
|
+
and ``astern judge --dry-run`` prices a batch without calling anything.
|
|
9
|
+
|
|
10
|
+
Three decisions:
|
|
11
|
+
|
|
12
|
+
- **The regressor is ``view_chars``, not ``bytes``.** The judge never sees the
|
|
13
|
+
transcript; it sees the view :mod:`astern.views` renders, and the view has a
|
|
14
|
+
ceiling. Session bytes vary 100x, view chars vary ~3x, and the second is what is
|
|
15
|
+
actually sent. ``bytes`` stays in the feature dict as the fallback proxy for
|
|
16
|
+
sessions with no view yet.
|
|
17
|
+
- **Ordinary least squares, in stdlib.** ``numpy`` is not a dependency and three
|
|
18
|
+
columns do not justify one; the normal equations solved by Gaussian elimination
|
|
19
|
+
are twenty lines and exact enough for a token budget.
|
|
20
|
+
- **The intercept is real and must not be forced through zero.** A tool-less call
|
|
21
|
+
with our own system prompt still costs ~1.1k input tokens before the view is
|
|
22
|
+
added, so a proportional model under-prices short sessions by a factor of two.
|
|
23
|
+
- **Retries, not view size, are what the residual is made of.** Measured over ten
|
|
24
|
+
real sessions on haiku, two views within 6% of each other cost 4.8k and 20.8k
|
|
25
|
+
input tokens; the difference is the number of API round trips, because a
|
|
26
|
+
structured answer the schema rejects is retried with the whole conversation
|
|
27
|
+
resent. Restricted to the calls that took one round trip the same regression has
|
|
28
|
+
R² = 0.995 — so ``fit`` reports ``input_single_pass`` and ``retry_rate`` beside
|
|
29
|
+
the headline line, and a low overall R² has an explanation instead of a shrug.
|
|
30
|
+
:func:`predict` still uses the all-calls line, because retries are part of what a
|
|
31
|
+
batch actually costs.
|
|
32
|
+
|
|
33
|
+
>>> pts = [{'features': {'view_chars': c, 'n_turns': 2}, 'usage':
|
|
34
|
+
... {'input_tokens': 1000 + c // 4, 'output_tokens': 300}}
|
|
35
|
+
... for c in (2000, 6000, 12000, 20000)]
|
|
36
|
+
>>> m = fit(pts)
|
|
37
|
+
>>> m['n'], round(m['input']['per_1k_chars']), round(m['input']['r2'], 3)
|
|
38
|
+
(4, 250, 1.0)
|
|
39
|
+
>>> p = predict(m, {'view_chars': 8000, 'n_turns': 3})
|
|
40
|
+
>>> p['input_tokens'], p['output_tokens']
|
|
41
|
+
(3000, 300)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from statistics import mean, pstdev
|
|
47
|
+
|
|
48
|
+
from astern.judge import input_tokens, total_tokens
|
|
49
|
+
from astern.views import DFLT_MAX_CHARS
|
|
50
|
+
|
|
51
|
+
#: What one tool-less ``claude -p`` call costs before the view is added, and roughly
|
|
52
|
+
#: how many input tokens a char of view buys (measured 2026-09-07 on haiku). Used
|
|
53
|
+
#: only when nothing has been judged yet — a prior, replaced by the first real fit.
|
|
54
|
+
PRIOR_INPUT_INTERCEPT = 1100.0
|
|
55
|
+
PRIOR_INPUT_PER_CHAR = 0.27
|
|
56
|
+
PRIOR_OUTPUT_TOKENS = 900.0
|
|
57
|
+
|
|
58
|
+
#: Below this many observations a slope is noise; :func:`fit` reports a ratio model
|
|
59
|
+
#: instead and says so in ``method``.
|
|
60
|
+
MIN_POINTS_FOR_OLS = 3
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def features(session: dict, turns: list[dict], view_text: str) -> dict:
|
|
64
|
+
"""Everything about a session that costs no tokens to know.
|
|
65
|
+
|
|
66
|
+
``turns`` is the scope actually rendered (the new turns, on an incremental run),
|
|
67
|
+
so the features describe the same thing the usage will.
|
|
68
|
+
|
|
69
|
+
>>> f = features({'source': {'size': 4096}}, [{'user_prompt': 'hi', 'assistant_chars': 10,
|
|
70
|
+
... 'n_tool_calls': 2, 'n_errors': 1, 'tool_result_chars': 99, 'tools': []}], 'x' * 40)
|
|
71
|
+
>>> f['bytes'], f['n_turns'], f['prose_chars'], f['view_chars'], f['view_tokens_est']
|
|
72
|
+
(4096, 1, 12, 40, 10)
|
|
73
|
+
"""
|
|
74
|
+
prompt_chars = sum(len(t.get("user_prompt", "")) for t in turns)
|
|
75
|
+
assistant_chars = sum(int(t.get("assistant_chars") or 0) for t in turns)
|
|
76
|
+
return {
|
|
77
|
+
"bytes": int((session.get("source") or {}).get("size") or 0),
|
|
78
|
+
"n_turns": len(turns),
|
|
79
|
+
"n_tool_calls": sum(int(t.get("n_tool_calls") or 0) for t in turns),
|
|
80
|
+
"n_errors": sum(int(t.get("n_errors") or 0) for t in turns),
|
|
81
|
+
"prompt_chars": prompt_chars,
|
|
82
|
+
"assistant_chars": assistant_chars,
|
|
83
|
+
"prose_chars": prompt_chars + assistant_chars,
|
|
84
|
+
"tool_result_chars": sum(int(t.get("tool_result_chars") or 0) for t in turns),
|
|
85
|
+
"view_chars": len(view_text),
|
|
86
|
+
"view_tokens_est": len(view_text) // 4,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _solve(a: list[list[float]], b: list[float]) -> list[float] | None:
|
|
91
|
+
"""Gaussian elimination with partial pivoting; ``None`` if singular.
|
|
92
|
+
|
|
93
|
+
>>> _solve([[2.0, 0.0], [0.0, 4.0]], [2.0, 8.0])
|
|
94
|
+
[1.0, 2.0]
|
|
95
|
+
"""
|
|
96
|
+
n = len(b)
|
|
97
|
+
m = [row[:] + [b[i]] for i, row in enumerate(a)]
|
|
98
|
+
for col in range(n):
|
|
99
|
+
piv = max(range(col, n), key=lambda r: abs(m[r][col]))
|
|
100
|
+
if abs(m[piv][col]) < 1e-12:
|
|
101
|
+
return None
|
|
102
|
+
m[col], m[piv] = m[piv], m[col]
|
|
103
|
+
for r in range(n):
|
|
104
|
+
if r == col:
|
|
105
|
+
continue
|
|
106
|
+
f = m[r][col] / m[col][col]
|
|
107
|
+
for c in range(col, n + 1):
|
|
108
|
+
m[r][c] -= f * m[col][c]
|
|
109
|
+
return [m[i][n] / m[i][i] for i in range(n)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ols(rows: list[list[float]], y: list[float]) -> dict | None:
|
|
113
|
+
"""Least squares of ``y`` on ``rows`` (each row already carries its 1 intercept).
|
|
114
|
+
|
|
115
|
+
>>> r = _ols([[1.0, 0.0], [1.0, 1.0], [1.0, 2.0]], [1.0, 3.0, 5.0])
|
|
116
|
+
>>> [round(c, 6) for c in r['coef']], round(r['r2'], 6)
|
|
117
|
+
([1.0, 2.0], 1.0)
|
|
118
|
+
"""
|
|
119
|
+
n, k = len(rows), len(rows[0])
|
|
120
|
+
if n <= k:
|
|
121
|
+
return None
|
|
122
|
+
xtx = [
|
|
123
|
+
[sum(rows[i][a] * rows[i][b] for i in range(n)) for b in range(k)]
|
|
124
|
+
for a in range(k)
|
|
125
|
+
]
|
|
126
|
+
xty = [sum(rows[i][a] * y[i] for i in range(n)) for a in range(k)]
|
|
127
|
+
coef = _solve(xtx, xty)
|
|
128
|
+
if coef is None:
|
|
129
|
+
return None
|
|
130
|
+
pred = [sum(c * v for c, v in zip(coef, row)) for row in rows]
|
|
131
|
+
resid = [yi - pi for yi, pi in zip(y, pred)]
|
|
132
|
+
ybar = mean(y)
|
|
133
|
+
ss_tot = sum((yi - ybar) ** 2 for yi in y)
|
|
134
|
+
ss_res = sum(r * r for r in resid)
|
|
135
|
+
dof = max(1, n - k)
|
|
136
|
+
return {
|
|
137
|
+
"coef": coef,
|
|
138
|
+
"n": n,
|
|
139
|
+
"r2": 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0,
|
|
140
|
+
"resid_std": (ss_res / dof) ** 0.5,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _points(judgments) -> list[tuple[dict, dict]]:
|
|
145
|
+
"""(features, usage) pairs from stored judgment records; errored calls dropped."""
|
|
146
|
+
out = []
|
|
147
|
+
for j in judgments:
|
|
148
|
+
if not isinstance(j, dict) or j.get("error"):
|
|
149
|
+
continue
|
|
150
|
+
f, u = j.get("features") or {}, j.get("usage") or {}
|
|
151
|
+
if f.get("view_chars") and total_tokens(u):
|
|
152
|
+
out.append((f, u))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
#: A *non-zero* cache read does not mean a retry. Two consecutive judge calls that each
|
|
157
|
+
#: took exactly one round trip still read ~1.3k cached tokens: the system prompt and the
|
|
158
|
+
#: schema are identical between invocations and the CLI's cache window outlives one call.
|
|
159
|
+
#: Only a read comfortably above that block means the *conversation* was re-sent.
|
|
160
|
+
FIXED_CACHE_TOKENS = 2000
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def round_trips(judgment: dict) -> int:
|
|
164
|
+
"""How many API calls one judge call took — recorded, else inferred from the cache.
|
|
165
|
+
|
|
166
|
+
``n_iterations`` (recorded since the CLI's ``usage.iterations`` was captured) is
|
|
167
|
+
authoritative. Older records have only the cache read to go on, and the threshold
|
|
168
|
+
it is compared against is :data:`FIXED_CACHE_TOKENS`, not zero — see there.
|
|
169
|
+
|
|
170
|
+
>>> round_trips({'usage': {'n_iterations': 3}})
|
|
171
|
+
3
|
|
172
|
+
>>> round_trips({'usage': {'cache_read_input_tokens': 5197}})
|
|
173
|
+
2
|
|
174
|
+
>>> round_trips({'usage': {'cache_read_input_tokens': 1319}})
|
|
175
|
+
1
|
|
176
|
+
"""
|
|
177
|
+
n = (judgment.get("usage") or {}).get("n_iterations") or judgment.get("num_turns")
|
|
178
|
+
if n:
|
|
179
|
+
return int(n)
|
|
180
|
+
cached = (judgment.get("usage") or {}).get("cache_read_input_tokens") or 0
|
|
181
|
+
return 2 if cached > FIXED_CACHE_TOKENS else 1
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _spread(values: list[float]) -> dict:
|
|
185
|
+
return {
|
|
186
|
+
"mean": mean(values),
|
|
187
|
+
"std": pstdev(values) if len(values) > 1 else 0.0,
|
|
188
|
+
"n": len(values),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fit(judgments) -> dict:
|
|
193
|
+
"""Fit input tokens on view size; summarise output tokens, totals and cost.
|
|
194
|
+
|
|
195
|
+
``judgments`` are the records :mod:`astern.lenses.synopsis` stores (each with
|
|
196
|
+
``features`` and ``usage``). With fewer than :data:`MIN_POINTS_FOR_OLS` points
|
|
197
|
+
the slope is a ratio through the measured intercept, and ``method`` says so.
|
|
198
|
+
|
|
199
|
+
>>> fit([])['method']
|
|
200
|
+
'prior'
|
|
201
|
+
>>> fit([{'features': {'view_chars': 4000, 'n_turns': 2},
|
|
202
|
+
... 'usage': {'input_tokens': 2200, 'output_tokens': 800}}])['method']
|
|
203
|
+
'ratio'
|
|
204
|
+
"""
|
|
205
|
+
pts = _points(judgments)
|
|
206
|
+
n = len(pts)
|
|
207
|
+
xs = [float(f["view_chars"]) for f, _ in pts]
|
|
208
|
+
ins = [float(input_tokens(u)) for _, u in pts]
|
|
209
|
+
outs = [float(u.get("output_tokens") or 0) for _, u in pts]
|
|
210
|
+
tots = [i + o for i, o in zip(ins, outs)]
|
|
211
|
+
costs = [
|
|
212
|
+
float(j.get("cost_usd") or 0.0)
|
|
213
|
+
for j in judgments
|
|
214
|
+
if isinstance(j, dict) and not j.get("error") and j.get("cost_usd") is not None
|
|
215
|
+
]
|
|
216
|
+
model: dict = {
|
|
217
|
+
"n": n,
|
|
218
|
+
"method": "prior",
|
|
219
|
+
"models": sorted(
|
|
220
|
+
{
|
|
221
|
+
str(j.get("model"))
|
|
222
|
+
for j in judgments
|
|
223
|
+
if isinstance(j, dict) and j.get("model")
|
|
224
|
+
}
|
|
225
|
+
),
|
|
226
|
+
}
|
|
227
|
+
if n == 0:
|
|
228
|
+
b0, b1, resid = PRIOR_INPUT_INTERCEPT, PRIOR_INPUT_PER_CHAR, PRIOR_INPUT_INTERCEPT
|
|
229
|
+
r2 = None
|
|
230
|
+
elif n < MIN_POINTS_FOR_OLS:
|
|
231
|
+
model["method"] = "ratio"
|
|
232
|
+
b0 = PRIOR_INPUT_INTERCEPT
|
|
233
|
+
b1 = max(0.0, mean((i - b0) / x for i, x in zip(ins, xs)))
|
|
234
|
+
resid = pstdev(ins) if n > 1 else 0.25 * mean(ins)
|
|
235
|
+
r2 = None
|
|
236
|
+
else:
|
|
237
|
+
model["method"] = "ols"
|
|
238
|
+
simple = _ols([[1.0, x] for x in xs], ins)
|
|
239
|
+
b0, b1 = simple["coef"]
|
|
240
|
+
resid, r2 = simple["resid_std"], simple["r2"]
|
|
241
|
+
multi = _ols([[1.0, x, float(f["n_turns"])] for (f, _), x in zip(pts, xs)], ins)
|
|
242
|
+
if multi:
|
|
243
|
+
model["input_multi"] = {
|
|
244
|
+
"names": ["1", "view_chars", "n_turns"],
|
|
245
|
+
"coef": multi["coef"],
|
|
246
|
+
"r2": multi["r2"],
|
|
247
|
+
"resid_std": multi["resid_std"],
|
|
248
|
+
}
|
|
249
|
+
model["input"] = {
|
|
250
|
+
"intercept": b0,
|
|
251
|
+
"per_char": b1,
|
|
252
|
+
"per_1k_chars": b1 * 1000,
|
|
253
|
+
"r2": r2,
|
|
254
|
+
"resid_std": resid,
|
|
255
|
+
"reads_as": f"≈ {b1 * 1000:.0f} input tokens per 1k view chars + {b0:.0f}",
|
|
256
|
+
}
|
|
257
|
+
model["output"] = (
|
|
258
|
+
_spread(outs) if outs else {"mean": PRIOR_OUTPUT_TOKENS, "std": 0.0, "n": 0}
|
|
259
|
+
)
|
|
260
|
+
model["total"] = (
|
|
261
|
+
_spread(tots)
|
|
262
|
+
if tots
|
|
263
|
+
else {"mean": PRIOR_INPUT_INTERCEPT + PRIOR_OUTPUT_TOKENS, "std": 0.0, "n": 0}
|
|
264
|
+
)
|
|
265
|
+
model["cost_usd"] = _spread(costs) if costs else {"mean": 0.0, "std": 0.0, "n": 0}
|
|
266
|
+
# Round trips are the variance nobody sees: a structured answer the schema rejects
|
|
267
|
+
# is retried with the whole conversation resent, so one session's input can be four
|
|
268
|
+
# times the view it was built from. Reported separately, so a low overall R² has an
|
|
269
|
+
# explanation rather than a shrug — and so the fix (fewer retries) is measurable.
|
|
270
|
+
ok = [
|
|
271
|
+
j
|
|
272
|
+
for j in judgments
|
|
273
|
+
if isinstance(j, dict)
|
|
274
|
+
and not j.get("error")
|
|
275
|
+
and (j.get("features") or {}).get("view_chars")
|
|
276
|
+
and total_tokens(j.get("usage") or {})
|
|
277
|
+
]
|
|
278
|
+
if ok:
|
|
279
|
+
trips = [float(round_trips(j)) for j in ok]
|
|
280
|
+
model["iterations"] = _spread(trips)
|
|
281
|
+
model["retry_rate"] = sum(1 for t in trips if t > 1) / len(trips)
|
|
282
|
+
one = [(f, u) for (f, u), t in zip(pts, trips) if t <= 1]
|
|
283
|
+
if len(one) >= MIN_POINTS_FOR_OLS:
|
|
284
|
+
single = _ols(
|
|
285
|
+
[[1.0, float(f["view_chars"])] for f, _ in one],
|
|
286
|
+
[float(input_tokens(u)) for _, u in one],
|
|
287
|
+
)
|
|
288
|
+
if single:
|
|
289
|
+
b = single["coef"]
|
|
290
|
+
model["input_single_pass"] = {
|
|
291
|
+
"n": len(one),
|
|
292
|
+
"intercept": b[0],
|
|
293
|
+
"per_char": b[1],
|
|
294
|
+
"per_1k_chars": b[1] * 1000,
|
|
295
|
+
"r2": single["r2"],
|
|
296
|
+
"resid_std": single["resid_std"],
|
|
297
|
+
"reads_as": f"≈ {b[1] * 1000:.0f} input tokens per 1k view chars"
|
|
298
|
+
f" + {b[0]:.0f}, on calls that took one round trip",
|
|
299
|
+
}
|
|
300
|
+
tot_tokens = sum(tots)
|
|
301
|
+
model["cost_per_total_token"] = (
|
|
302
|
+
(sum(costs) / tot_tokens) if (costs and tot_tokens) else 0.0
|
|
303
|
+
)
|
|
304
|
+
# view_chars per source byte: the proxy for a session that has never been viewed.
|
|
305
|
+
src = [float(f.get("bytes") or 0) for f, _ in pts]
|
|
306
|
+
model["view_chars_per_byte"] = (sum(xs) / sum(src)) if sum(src) else 0.0
|
|
307
|
+
return model
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def predict(model: dict, features: dict) -> dict:
|
|
311
|
+
"""Predicted tokens (and cost) of judging one session, with a ±1σ band.
|
|
312
|
+
|
|
313
|
+
>>> m = fit([])
|
|
314
|
+
>>> p = predict(m, {'view_chars': 10000})
|
|
315
|
+
>>> p['input_tokens'], p['total_tokens'] == p['input_tokens'] + p['output_tokens']
|
|
316
|
+
(3800, True)
|
|
317
|
+
>>> p['low'] <= p['total_tokens'] <= p['high']
|
|
318
|
+
True
|
|
319
|
+
"""
|
|
320
|
+
x = float(features.get("view_chars") or 0)
|
|
321
|
+
if not x and model.get("view_chars_per_byte"):
|
|
322
|
+
# The bytes proxy must respect the ceiling the view itself has, or a 17 MB
|
|
323
|
+
# session is priced as if the judge would read all of it. It never does.
|
|
324
|
+
x = min(
|
|
325
|
+
float(features.get("bytes") or 0) * model["view_chars_per_byte"],
|
|
326
|
+
float(DFLT_MAX_CHARS),
|
|
327
|
+
)
|
|
328
|
+
inp = model["input"]
|
|
329
|
+
y_in = max(0.0, inp["intercept"] + inp["per_char"] * x)
|
|
330
|
+
y_out = max(0.0, float(model["output"]["mean"]))
|
|
331
|
+
total = y_in + y_out
|
|
332
|
+
band = float(inp["resid_std"] or 0.0) + float(model["output"]["std"] or 0.0)
|
|
333
|
+
return {
|
|
334
|
+
"input_tokens": round(y_in),
|
|
335
|
+
"output_tokens": round(y_out),
|
|
336
|
+
"total_tokens": round(total),
|
|
337
|
+
"low": round(max(0.0, total - band)),
|
|
338
|
+
"high": round(total + band),
|
|
339
|
+
"cost_usd": round(total * model.get("cost_per_total_token", 0.0), 6),
|
|
340
|
+
}
|
astern/judge.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""The ``judge=`` seam: an LLM call that returns text or JSON *and says what it cost*.
|
|
2
|
+
|
|
3
|
+
The default judge is the local ``claude`` CLI run headless (``claude -p``). That is a
|
|
4
|
+
deliberate choice, not a shortcut: astern is used from Claude Code, by someone on a
|
|
5
|
+
Claude subscription, so the subscription is the budget and the CLI is the only
|
|
6
|
+
client that draws on it. ``--output-format json`` reports the usage of every call,
|
|
7
|
+
which is exactly the dependent variable the cost model in :mod:`astern.estimate`
|
|
8
|
+
needs. ``--no-session-persistence`` keeps the judge's own calls out of
|
|
9
|
+
``~/.claude/projects`` (verified by counting transcripts before and after), so the
|
|
10
|
+
miner does not grow the corpus it mines.
|
|
11
|
+
|
|
12
|
+
Four things about the flags, each measured against the real CLI on 2026-09-07 and
|
|
13
|
+
each load-bearing:
|
|
14
|
+
|
|
15
|
+
- **Not** ``--bare``. Its own help says Anthropic auth is *strictly*
|
|
16
|
+
``ANTHROPIC_API_KEY`` or an ``apiKeyHelper`` — "OAuth and keychain are never
|
|
17
|
+
read" — so on a subscription every ``--bare`` call comes back
|
|
18
|
+
``"Not logged in · Please run /login"``. :data:`SANDBOX_FLAGS` uses
|
|
19
|
+
``--safe-mode`` instead, which disables the same customizations (CLAUDE.md,
|
|
20
|
+
skills, hooks, plugins, MCP) and leaves authentication alone.
|
|
21
|
+
- ``--tools ""`` really does empty the tool set: same prompt, same system prompt,
|
|
22
|
+
1,109 input tokens with it against 14,925 without. (Asking the model what tools
|
|
23
|
+
it has is not a test — it confidently lists six it does not have.)
|
|
24
|
+
- A failed call **exits 0**. The "not logged in" answer above came back on
|
|
25
|
+
``returncode == 0`` with ``is_error: true`` in the JSON, so the return code alone
|
|
26
|
+
is not a success check.
|
|
27
|
+
- ``--json-schema`` is honoured and the parsed object lands under
|
|
28
|
+
``structured_output``; ``result`` still holds the text. Passing our own
|
|
29
|
+
``--system-prompt`` also removes the ~4k-token default preamble from every call
|
|
30
|
+
and moves the whole input into ``input_tokens`` (nothing is prompt-cached), which
|
|
31
|
+
is what makes the regression in :mod:`astern.estimate` a straight line.
|
|
32
|
+
|
|
33
|
+
A replacement is any callable with the same signature returning a :class:`Judgment`
|
|
34
|
+
— an ``aix.prompt_func`` for API billing, or a recorded-replay judge for tests.
|
|
35
|
+
|
|
36
|
+
>>> j = replay_judge({'hello': '{"answer": 1}'})
|
|
37
|
+
>>> r = j('hello', schema={'type': 'object'})
|
|
38
|
+
>>> r.data, r.usage['input_tokens'] > 0, r.error
|
|
39
|
+
({'answer': 1}, True, None)
|
|
40
|
+
>>> total_tokens(r.usage) == r.usage['input_tokens'] + r.usage['output_tokens']
|
|
41
|
+
True
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess
|
|
49
|
+
from collections.abc import Callable, Mapping
|
|
50
|
+
from dataclasses import asdict, dataclass, field
|
|
51
|
+
|
|
52
|
+
DFLT_MODEL = "haiku"
|
|
53
|
+
DFLT_TIMEOUT_S = 900
|
|
54
|
+
|
|
55
|
+
#: Flags that make one headless call a stateless, tool-less, corpus-neutral judge.
|
|
56
|
+
#: ``--safe-mode`` (not ``--bare``: see the module docstring) drops CLAUDE.md, skills,
|
|
57
|
+
#: hooks, plugins and MCP; ``--tools ""`` empties the tool set; the persistence flag
|
|
58
|
+
#: keeps the call out of ``~/.claude/projects``.
|
|
59
|
+
SANDBOX_FLAGS = ("--safe-mode", "--no-session-persistence", "--tools", "")
|
|
60
|
+
|
|
61
|
+
#: The usage keys that are billed as input. ``input_tokens`` alone under-reports by
|
|
62
|
+
#: an order of magnitude whenever the default system prompt is cached.
|
|
63
|
+
INPUT_KEYS = ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Judgment:
|
|
68
|
+
"""What one judge call returned and what it cost."""
|
|
69
|
+
|
|
70
|
+
text: str
|
|
71
|
+
data: dict | list | None = None
|
|
72
|
+
usage: dict = field(default_factory=dict)
|
|
73
|
+
cost_usd: float | None = None
|
|
74
|
+
duration_ms: int | None = None
|
|
75
|
+
model: str = ""
|
|
76
|
+
prompt_chars: int = 0
|
|
77
|
+
#: How many API round trips the CLI made for this one call. It is normally 1, but a
|
|
78
|
+
#: structured answer the schema rejects is retried with the whole conversation
|
|
79
|
+
#: resent, so this is the multiplier on the input the view actually explains — the
|
|
80
|
+
#: single largest source of variance in the cost model, and invisible without it.
|
|
81
|
+
num_turns: int = 1
|
|
82
|
+
raw: dict | None = None
|
|
83
|
+
error: str | None = None
|
|
84
|
+
|
|
85
|
+
def as_record(self) -> dict:
|
|
86
|
+
d = asdict(self)
|
|
87
|
+
d.pop("raw", None)
|
|
88
|
+
d["input_tokens"] = input_tokens(self.usage)
|
|
89
|
+
d["output_tokens"] = int(self.usage.get("output_tokens") or 0)
|
|
90
|
+
d["total_tokens"] = total_tokens(self.usage)
|
|
91
|
+
return d
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Judge = Callable[..., Judgment]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def input_tokens(usage: Mapping) -> int:
|
|
98
|
+
"""Billed input of one call: fresh input plus both halves of the cache.
|
|
99
|
+
|
|
100
|
+
>>> input_tokens({'input_tokens': 9, 'cache_creation_input_tokens': 4241})
|
|
101
|
+
4250
|
|
102
|
+
"""
|
|
103
|
+
return sum(int(usage.get(k) or 0) for k in INPUT_KEYS)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def total_tokens(usage: Mapping) -> int:
|
|
107
|
+
"""Input (all three flavours) plus output.
|
|
108
|
+
|
|
109
|
+
>>> total_tokens({'input_tokens': 10, 'output_tokens': 5})
|
|
110
|
+
15
|
|
111
|
+
"""
|
|
112
|
+
return input_tokens(usage) + int(usage.get("output_tokens") or 0)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _usage_record(usage: Mapping) -> dict:
|
|
116
|
+
"""Keep the scalar usage fields (cache ones included); drop the nested detail.
|
|
117
|
+
|
|
118
|
+
The CLI nests ``iterations``, ``cache_creation`` and ``server_tool_use`` inside
|
|
119
|
+
``usage``; a cost model regresses on numbers, and a store keyed by session should
|
|
120
|
+
not carry a per-request log.
|
|
121
|
+
|
|
122
|
+
>>> sorted(_usage_record({'input_tokens': 1, 'iterations': [{'x': 2}], 'speed': 's'}))
|
|
123
|
+
['cache_creation_input_tokens', 'cache_read_input_tokens', 'input_tokens', 'n_iterations', 'output_tokens']
|
|
124
|
+
"""
|
|
125
|
+
out = {k: int(usage.get(k) or 0) for k in INPUT_KEYS}
|
|
126
|
+
out["output_tokens"] = int(usage.get("output_tokens") or 0)
|
|
127
|
+
for k, v in usage.items():
|
|
128
|
+
if isinstance(v, (int, float)) and k not in out:
|
|
129
|
+
out[k] = v
|
|
130
|
+
iters = usage.get("iterations")
|
|
131
|
+
if isinstance(iters, list):
|
|
132
|
+
out["n_iterations"] = len(iters)
|
|
133
|
+
details = usage.get("output_tokens_details")
|
|
134
|
+
if isinstance(details, Mapping) and isinstance(
|
|
135
|
+
details.get("thinking_tokens"), (int, float)
|
|
136
|
+
):
|
|
137
|
+
out["thinking_tokens"] = int(details["thinking_tokens"])
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_json_loose(text: str):
|
|
142
|
+
text = text.strip()
|
|
143
|
+
if text.startswith("```"):
|
|
144
|
+
text = text.strip("`").removeprefix("json")
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(text)
|
|
147
|
+
except ValueError:
|
|
148
|
+
start, end = text.find("{"), text.rfind("}")
|
|
149
|
+
if 0 <= start < end:
|
|
150
|
+
try:
|
|
151
|
+
return json.loads(text[start : end + 1])
|
|
152
|
+
except ValueError:
|
|
153
|
+
return None
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _model_name(raw: Mapping, fallback: str) -> str:
|
|
158
|
+
"""The model the CLI actually used, from ``modelUsage``'s single key."""
|
|
159
|
+
mu = raw.get("modelUsage")
|
|
160
|
+
if isinstance(mu, Mapping) and mu:
|
|
161
|
+
return str(next(iter(mu)))
|
|
162
|
+
return fallback
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def claude_judge(
|
|
166
|
+
prompt: str,
|
|
167
|
+
*,
|
|
168
|
+
schema: Mapping | None = None,
|
|
169
|
+
model: str = DFLT_MODEL,
|
|
170
|
+
effort: str | None = None,
|
|
171
|
+
system: str | None = None,
|
|
172
|
+
timeout_s: int = DFLT_TIMEOUT_S,
|
|
173
|
+
claude_bin: str = "claude",
|
|
174
|
+
) -> Judgment:
|
|
175
|
+
"""Run ``claude -p`` headless on ``prompt`` and return a :class:`Judgment`.
|
|
176
|
+
|
|
177
|
+
Tools are disabled and customizations skipped (:data:`SANDBOX_FLAGS`): the judge
|
|
178
|
+
reads what it is given and answers; it never explores. ``schema`` requests
|
|
179
|
+
structured output, which comes back under ``structured_output``.
|
|
180
|
+
"""
|
|
181
|
+
if shutil.which(claude_bin) is None:
|
|
182
|
+
return Judgment(
|
|
183
|
+
text="",
|
|
184
|
+
error=f"{claude_bin!r} not found on PATH",
|
|
185
|
+
model=model,
|
|
186
|
+
prompt_chars=len(prompt),
|
|
187
|
+
)
|
|
188
|
+
cmd = [claude_bin, "-p", *SANDBOX_FLAGS, "--output-format", "json", "--model", model]
|
|
189
|
+
if effort:
|
|
190
|
+
cmd += ["--effort", effort]
|
|
191
|
+
if system:
|
|
192
|
+
cmd += ["--system-prompt", system]
|
|
193
|
+
if schema is not None:
|
|
194
|
+
cmd += ["--json-schema", json.dumps(dict(schema))]
|
|
195
|
+
try:
|
|
196
|
+
# check=False: a failed judge is data (it becomes the `error` below), never an
|
|
197
|
+
# exception that takes the batch down with it.
|
|
198
|
+
proc = subprocess.run(
|
|
199
|
+
cmd,
|
|
200
|
+
input=prompt,
|
|
201
|
+
capture_output=True,
|
|
202
|
+
text=True,
|
|
203
|
+
timeout=timeout_s,
|
|
204
|
+
check=False,
|
|
205
|
+
)
|
|
206
|
+
except subprocess.TimeoutExpired:
|
|
207
|
+
return Judgment(
|
|
208
|
+
text="",
|
|
209
|
+
error=f"timeout after {timeout_s}s",
|
|
210
|
+
model=model,
|
|
211
|
+
prompt_chars=len(prompt),
|
|
212
|
+
)
|
|
213
|
+
raw = _parse_json_loose(proc.stdout)
|
|
214
|
+
if not isinstance(raw, dict):
|
|
215
|
+
detail = (proc.stderr or proc.stdout).strip()[:500]
|
|
216
|
+
return Judgment(
|
|
217
|
+
text=proc.stdout,
|
|
218
|
+
model=model,
|
|
219
|
+
prompt_chars=len(prompt),
|
|
220
|
+
error=f"exit {proc.returncode}: {detail}"
|
|
221
|
+
if proc.returncode
|
|
222
|
+
else "unparseable claude output",
|
|
223
|
+
)
|
|
224
|
+
usage = _usage_record(raw.get("usage") or {})
|
|
225
|
+
text = (
|
|
226
|
+
raw["result"]
|
|
227
|
+
if isinstance(raw.get("result"), str)
|
|
228
|
+
else json.dumps(raw.get("result"))
|
|
229
|
+
)
|
|
230
|
+
# The CLI exits 0 on an errored turn (an unauthenticated call returns
|
|
231
|
+
# `is_error: true` and returncode 0), so the flag in the payload is the check.
|
|
232
|
+
error = None
|
|
233
|
+
if raw.get("is_error") or proc.returncode != 0:
|
|
234
|
+
error = f"{raw.get('subtype') or 'error'}: {(text or '').strip()[:300]}"
|
|
235
|
+
data = raw.get("structured_output")
|
|
236
|
+
if data is None and schema is not None and not error:
|
|
237
|
+
data = _parse_json_loose(text or "")
|
|
238
|
+
return Judgment(
|
|
239
|
+
text=text or "",
|
|
240
|
+
data=data,
|
|
241
|
+
usage=usage,
|
|
242
|
+
cost_usd=raw.get("total_cost_usd"),
|
|
243
|
+
duration_ms=raw.get("duration_ms") or raw.get("duration_api_ms"),
|
|
244
|
+
model=_model_name(raw, model),
|
|
245
|
+
prompt_chars=len(prompt),
|
|
246
|
+
num_turns=int(
|
|
247
|
+
raw.get("num_turns") or len((raw.get("usage") or {}).get("iterations") or [1])
|
|
248
|
+
),
|
|
249
|
+
raw=raw,
|
|
250
|
+
error=error,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def replay_judge(answers: Mapping[str, str], *, default: str | None = None) -> Judge:
|
|
255
|
+
"""A judge that answers from a mapping of prompt → text; for tests and dry runs.
|
|
256
|
+
|
|
257
|
+
A prompt with no recorded answer is an *error* judgment unless ``default`` is
|
|
258
|
+
given — silently answering ``""`` would let a test mistake a missing recording
|
|
259
|
+
for a real (empty) verdict, which is the one thing a replay judge must not do.
|
|
260
|
+
|
|
261
|
+
>>> replay_judge({})('unseen').error
|
|
262
|
+
'no recorded answer'
|
|
263
|
+
>>> replay_judge({}, default='{}')('unseen').error is None
|
|
264
|
+
True
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def judge(prompt: str, *, schema=None, **_) -> Judgment:
|
|
268
|
+
text = answers.get(prompt, default)
|
|
269
|
+
if text is None:
|
|
270
|
+
return Judgment(
|
|
271
|
+
text="",
|
|
272
|
+
model="replay",
|
|
273
|
+
prompt_chars=len(prompt),
|
|
274
|
+
error="no recorded answer",
|
|
275
|
+
)
|
|
276
|
+
return Judgment(
|
|
277
|
+
text=text,
|
|
278
|
+
data=_parse_json_loose(text) if schema is not None else None,
|
|
279
|
+
usage={
|
|
280
|
+
"input_tokens": max(1, len(prompt) // 4),
|
|
281
|
+
"cache_creation_input_tokens": 0,
|
|
282
|
+
"cache_read_input_tokens": 0,
|
|
283
|
+
"output_tokens": max(1, len(text) // 4),
|
|
284
|
+
},
|
|
285
|
+
cost_usd=0.0,
|
|
286
|
+
duration_ms=0,
|
|
287
|
+
model="replay",
|
|
288
|
+
prompt_chars=len(prompt),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return judge
|