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 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