@oh-my-pi/omp-stats 18.16.0 → 18.17.0
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.
- package/CHANGELOG.md +7 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +102 -99
- package/dist/types/client/api.d.ts +4 -1
- package/dist/types/client/app/nav.d.ts +1 -1
- package/dist/types/client/charts/Chart.d.ts +16 -8
- package/dist/types/client/charts/DotWhisker.d.ts +50 -0
- package/dist/types/client/charts/Sparkline.d.ts +1 -1
- package/dist/types/client/charts/Waterfall.d.ts +26 -0
- package/dist/types/client/charts/index.d.ts +2 -0
- package/dist/types/client/charts/types.d.ts +8 -2
- package/dist/types/client/routes/SpotlightRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/client/spotlight/Compounding.d.ts +8 -0
- package/dist/types/client/spotlight/Coverage.d.ts +6 -0
- package/dist/types/client/spotlight/Economics.d.ts +10 -0
- package/dist/types/client/spotlight/FetchBack.d.ts +9 -0
- package/dist/types/client/spotlight/Hero.d.ts +12 -0
- package/dist/types/client/spotlight/Overhead.d.ts +6 -0
- package/dist/types/client/spotlight/Ranking.d.ts +6 -0
- package/dist/types/client/spotlight/ResultsFeed.d.ts +6 -0
- package/dist/types/client/spotlight/Savings.d.ts +10 -0
- package/dist/types/client/spotlight/ShareTrend.d.ts +8 -0
- package/dist/types/client/spotlight/Steps.d.ts +6 -0
- package/dist/types/client/spotlight/TimeCost.d.ts +6 -0
- package/dist/types/client/spotlight/chips.d.ts +16 -0
- package/dist/types/client/spotlight/meta.d.ts +64 -0
- package/dist/types/db.d.ts +26 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/parser.d.ts +4 -0
- package/dist/types/rollup.d.ts +22 -1
- package/dist/types/shared-types.d.ts +419 -0
- package/dist/types/spotlight-aggregator.d.ts +189 -0
- package/dist/types/spotlight-bootstrap.d.ts +32 -0
- package/dist/types/spotlight-ingest.d.ts +258 -0
- package/dist/types/spotlight-measures.d.ts +51 -0
- package/package.json +5 -5
- package/src/aggregator.ts +3 -1
- package/src/client/App.tsx +3 -0
- package/src/client/api.ts +21 -0
- package/src/client/app/nav.ts +4 -1
- package/src/client/charts/Chart.tsx +105 -22
- package/src/client/charts/DotWhisker.tsx +149 -0
- package/src/client/charts/Sparkline.tsx +3 -2
- package/src/client/charts/Waterfall.tsx +86 -0
- package/src/client/charts/index.ts +2 -0
- package/src/client/charts/types.ts +9 -2
- package/src/client/react-css.d.ts +1 -1
- package/src/client/routes/SpotlightRoute.tsx +138 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/spotlight/Compounding.tsx +222 -0
- package/src/client/spotlight/Coverage.tsx +96 -0
- package/src/client/spotlight/Economics.tsx +167 -0
- package/src/client/spotlight/FetchBack.tsx +222 -0
- package/src/client/spotlight/Hero.tsx +177 -0
- package/src/client/spotlight/Overhead.tsx +135 -0
- package/src/client/spotlight/Ranking.tsx +248 -0
- package/src/client/spotlight/ResultsFeed.tsx +354 -0
- package/src/client/spotlight/Savings.tsx +355 -0
- package/src/client/spotlight/ShareTrend.tsx +103 -0
- package/src/client/spotlight/Steps.tsx +225 -0
- package/src/client/spotlight/TimeCost.tsx +64 -0
- package/src/client/spotlight/chips.tsx +63 -0
- package/src/client/spotlight/meta.ts +206 -0
- package/src/client/spotlight/spotlight.css +1387 -0
- package/src/client/styles.css +289 -1
- package/src/db.ts +480 -2
- package/src/index.ts +10 -1
- package/src/live.ts +21 -9
- package/src/parser.ts +44 -8
- package/src/rollup.ts +203 -10
- package/src/server.ts +42 -0
- package/src/shared-types.ts +453 -0
- package/src/spotlight-aggregator.ts +1738 -0
- package/src/spotlight-bootstrap.ts +110 -0
- package/src/spotlight-ingest.ts +981 -0
- package/src/spotlight-measures.ts +667 -0
|
@@ -0,0 +1,1738 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Spotlight dashboard: what tool-output shaping saved, measured four ways (see the doc comments of
|
|
3
|
+
* {@link SpotlightDashboardStats} and the types it references), from the tables `spotlight-ingest.ts` fills.
|
|
4
|
+
*
|
|
5
|
+
* Approximations, all named in the UI's captions:
|
|
6
|
+
* - token counts of results without a Spotlight trace are result characters / 4;
|
|
7
|
+
* - what pi would have shown unshaped (`plain`), on read/bash/eval traces written before pi recorded it, is the input
|
|
8
|
+
* scaled to pi's inline cap (see {@link estimatedPlainTokens});
|
|
9
|
+
* - a compaction is a request whose prompt (input + cache read + cache write) is under 60% of the previous one's;
|
|
10
|
+
* - a rerun is a bash command equal to, extending (pipe, redirect, `&&`), or ≥ 60% token-similar to a recent one;
|
|
11
|
+
* for any other tool but `read`, a call of the same tool whose argument tokens (`argumentTokens`) are ≥ 60%
|
|
12
|
+
* similar.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Database, Statement } from "bun:sqlite";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { getTimeRangeConfig } from "./aggregator";
|
|
18
|
+
import { initDb } from "./db";
|
|
19
|
+
import { classifyAgentType } from "./parser";
|
|
20
|
+
import { getContextFactSeries, getSessionSpans, type RangeWindow } from "./rollup";
|
|
21
|
+
import type {
|
|
22
|
+
AgentType,
|
|
23
|
+
SpotlightBucket,
|
|
24
|
+
SpotlightClawback,
|
|
25
|
+
SpotlightContextPoint,
|
|
26
|
+
SpotlightCutEconomics,
|
|
27
|
+
SpotlightCutGroup,
|
|
28
|
+
SpotlightDashboardStats,
|
|
29
|
+
SpotlightEstimate,
|
|
30
|
+
SpotlightFetchBackCell,
|
|
31
|
+
SpotlightGapBin,
|
|
32
|
+
SpotlightGapCount,
|
|
33
|
+
SpotlightOverhead,
|
|
34
|
+
SpotlightRecovery,
|
|
35
|
+
SpotlightRecoveryKind,
|
|
36
|
+
SpotlightAgentScope,
|
|
37
|
+
SpotlightRecoveryTotals,
|
|
38
|
+
SpotlightResultRow,
|
|
39
|
+
SpotlightSessionContext,
|
|
40
|
+
SpotlightSessionSummary,
|
|
41
|
+
SpotlightSharePoint,
|
|
42
|
+
SpotlightTool,
|
|
43
|
+
SpotlightToolRow,
|
|
44
|
+
SpotlightToolTotals,
|
|
45
|
+
} from "./shared-types";
|
|
46
|
+
import { BOOTSTRAP_MIN_SESSIONS, bootstrapInterval, bootstrapRatio, quantile } from "./spotlight-bootstrap";
|
|
47
|
+
import { artifactIds, parseReadSelection, resolveReadPath, selectedLines } from "./spotlight-ingest";
|
|
48
|
+
import {
|
|
49
|
+
computeAdjusted,
|
|
50
|
+
computeCoverage,
|
|
51
|
+
computeRanking,
|
|
52
|
+
computeSteps,
|
|
53
|
+
computeTimeCost,
|
|
54
|
+
emptyAdjusted,
|
|
55
|
+
emptyRanking,
|
|
56
|
+
holdoutBaselines,
|
|
57
|
+
type SessionMoney,
|
|
58
|
+
} from "./spotlight-measures";
|
|
59
|
+
|
|
60
|
+
const CHARS_PER_TOKEN = 4;
|
|
61
|
+
/** A request whose prompt is under this share of the previous request's follows a compaction. */
|
|
62
|
+
const COMPACTION_RATIO = 0.6;
|
|
63
|
+
/** A rerun follows the result it repeats within this many tool calls. */
|
|
64
|
+
const RERUN_WINDOW = 12;
|
|
65
|
+
/**
|
|
66
|
+
* Token Jaccard similarity at which two commands of the same program, or two calls of another tool (their argument
|
|
67
|
+
* tokens), count as a rerun.
|
|
68
|
+
*/
|
|
69
|
+
const RERUN_JACCARD = 0.6;
|
|
70
|
+
const RECENT_LIMIT = 60;
|
|
71
|
+
const SESSION_LIST_LIMIT = 30;
|
|
72
|
+
const DISPLAY_TARGET_CHARS = 200;
|
|
73
|
+
/**
|
|
74
|
+
* What `plain` shows of an output at most, in bytes, for read/bash/eval traces without `plainTokens` (written before
|
|
75
|
+
* pi recorded it; every other tool's trace always has it): bash and eval inline up to pi's spill threshold
|
|
76
|
+
* (`tools.artifactSpillThreshold`, default 50 KiB) plus the 2 KiB notice slack of `resolveInlineByteCapBudget`
|
|
77
|
+
* (coding-agent `tools/output-meta.ts`); `read` pages at the spill threshold.
|
|
78
|
+
*/
|
|
79
|
+
const PLAIN_CAP_BYTES: Readonly<Record<string, number>> = { read: 50 * 1024, bash: 52 * 1024, eval: 52 * 1024 };
|
|
80
|
+
/** Upper bounds (tokens) of the original-size bins the fetch-back reference is standardized over; the last is open. */
|
|
81
|
+
const SIZE_BIN_BOUNDS: readonly number[] = [1000, 2000, 4000];
|
|
82
|
+
const GAP_BINS: readonly SpotlightGapBin[] = ["1", "2", "3", "4–6", "7–12"];
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Rows
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface SessionRow {
|
|
89
|
+
session_file: string;
|
|
90
|
+
folder: string;
|
|
91
|
+
marked_at: number;
|
|
92
|
+
cwd: string | null;
|
|
93
|
+
title: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface CallRow {
|
|
97
|
+
tool_call_id: string;
|
|
98
|
+
entry_id: string;
|
|
99
|
+
timestamp: number;
|
|
100
|
+
seq: number;
|
|
101
|
+
tool_name: string;
|
|
102
|
+
calls_in_turn: number;
|
|
103
|
+
target: string;
|
|
104
|
+
result_chars: number | null;
|
|
105
|
+
artifacts: string | null;
|
|
106
|
+
edits: string | null;
|
|
107
|
+
arg_tokens: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ResultRow {
|
|
111
|
+
tool_call_id: string;
|
|
112
|
+
timestamp: number;
|
|
113
|
+
tool: string;
|
|
114
|
+
in_chars: number;
|
|
115
|
+
out_chars: number;
|
|
116
|
+
in_tokens: number;
|
|
117
|
+
out_tokens: number;
|
|
118
|
+
steps: string;
|
|
119
|
+
holdout: number;
|
|
120
|
+
path: string | null;
|
|
121
|
+
start_line: number | null;
|
|
122
|
+
lines: number | null;
|
|
123
|
+
hidden: string | null;
|
|
124
|
+
hidden_lines: number;
|
|
125
|
+
artifacts: string | null;
|
|
126
|
+
sp_calls: number | null;
|
|
127
|
+
sp_failures: number | null;
|
|
128
|
+
sp_ms: number | null;
|
|
129
|
+
sp_cost: number | null;
|
|
130
|
+
shape_ms: number | null;
|
|
131
|
+
plain_chars: number | null;
|
|
132
|
+
plain_tokens: number | null;
|
|
133
|
+
capped_tokens: number | null;
|
|
134
|
+
score_free: number;
|
|
135
|
+
gated: number;
|
|
136
|
+
heat: string | null;
|
|
137
|
+
kept: string | null;
|
|
138
|
+
need_reread: string | null;
|
|
139
|
+
need_edit: string | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface RequestRow {
|
|
143
|
+
entry_id: string;
|
|
144
|
+
timestamp: number;
|
|
145
|
+
duration: number;
|
|
146
|
+
input_tokens: number;
|
|
147
|
+
cache_read_tokens: number;
|
|
148
|
+
cache_write_tokens: number;
|
|
149
|
+
cost_input: number;
|
|
150
|
+
cost_cache_read: number;
|
|
151
|
+
cost_cache_write: number;
|
|
152
|
+
cost_total: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Per-session analysis
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export interface Result {
|
|
160
|
+
timestamp: number;
|
|
161
|
+
tool: string;
|
|
162
|
+
inTokens: number;
|
|
163
|
+
outTokens: number;
|
|
164
|
+
/** What `plain` would have shown: the trace's count, else {@link estimatedPlainTokens}. */
|
|
165
|
+
plainTokens: number;
|
|
166
|
+
plainEstimated: boolean;
|
|
167
|
+
/** plain − out of a cut or would-be cut (held out), negative when the cut was wider than plain; 0 if unchanged. */
|
|
168
|
+
savedTokens: number;
|
|
169
|
+
/** in − out of a cut or would-be cut: the saving against the full output. */
|
|
170
|
+
fullSavedTokens: number;
|
|
171
|
+
steps: string[];
|
|
172
|
+
/** Shaping changed it and the model saw the changed text (an applied cut). */
|
|
173
|
+
shortened: boolean;
|
|
174
|
+
/** Shaping would have shortened it, but it was held out: the model saw it unshaped. */
|
|
175
|
+
holdout: boolean;
|
|
176
|
+
/** The score-free coin skipped Spotlight: the no-score fallback rules shaped it. */
|
|
177
|
+
scoreFree: boolean;
|
|
178
|
+
/**
|
|
179
|
+
* A read pi's former edit gate (removed) kept whole: the model saw `plain`'s page, the counts describe the
|
|
180
|
+
* would-be cut. Only in transcripts journaled while the gate ran.
|
|
181
|
+
*/
|
|
182
|
+
gated: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* The cut (shown, or not applied because pi capped it at `plain`'s text) was wider than what `plain` would have
|
|
185
|
+
* shown.
|
|
186
|
+
*/
|
|
187
|
+
widerThanPlain: boolean;
|
|
188
|
+
/** Some Spotlight call failed: the no-score fallback rules shaped it. */
|
|
189
|
+
failed: boolean;
|
|
190
|
+
/** Per-line heat (`0`–`9`, else unscored) and kept runs (1-based positions) of the original; see the trace. */
|
|
191
|
+
heat: string;
|
|
192
|
+
kept: [number, number][];
|
|
193
|
+
/** Original lines (1-based positions) later calls needed; null for results that collect none. */
|
|
194
|
+
needs: { reread: [number, number][]; edit: [number, number][] } | null;
|
|
195
|
+
path: string | null;
|
|
196
|
+
/** The original window (`details.spotlight.original`): first line and line count; 0 lines without one. */
|
|
197
|
+
start: number;
|
|
198
|
+
lines: number;
|
|
199
|
+
hidden: [number, number][];
|
|
200
|
+
hiddenLines: number;
|
|
201
|
+
spotlightCalls: number;
|
|
202
|
+
spotlightFailures: number;
|
|
203
|
+
spotlightMs: number;
|
|
204
|
+
spotlightUsd: number;
|
|
205
|
+
/** Wall time shaping took; null before its shape entry landed. */
|
|
206
|
+
shapeMs: number | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface Recovery {
|
|
210
|
+
kind: SpotlightRecoveryKind;
|
|
211
|
+
source: Call;
|
|
212
|
+
/** Tool calls between the source result and this call. */
|
|
213
|
+
after: number;
|
|
214
|
+
/** Every call of this call's request is a recovery. */
|
|
215
|
+
turn: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface Call {
|
|
219
|
+
id: string;
|
|
220
|
+
entryId: string;
|
|
221
|
+
timestamp: number;
|
|
222
|
+
seq: number;
|
|
223
|
+
tool: string;
|
|
224
|
+
target: string;
|
|
225
|
+
callsInTurn: number;
|
|
226
|
+
/** Tokens the result added to the context: the trace's `outTokens`, else result characters / 4. */
|
|
227
|
+
tokens: number;
|
|
228
|
+
artifacts: Set<number>;
|
|
229
|
+
result: Result | null;
|
|
230
|
+
recovery: Recovery | null;
|
|
231
|
+
/** Recoveries this call's result was the source of. */
|
|
232
|
+
recoveredBy: Call[];
|
|
233
|
+
/** Files the call edits, resolved against the session's working directory; empty for other calls. */
|
|
234
|
+
edits: string[];
|
|
235
|
+
/** The call's argument tokens (generic rerun rule); empty for `read`, `bash` and unrecorded calls. */
|
|
236
|
+
argTokens: Set<string>;
|
|
237
|
+
/** Calls from an evaluated result to the first call fetching more of it (the uniform rule); null for none. */
|
|
238
|
+
fetchBackGap: number | null;
|
|
239
|
+
/** Evaluated results this call is the first fetch-back of (the uniform rule). */
|
|
240
|
+
fetchesBack: Call[];
|
|
241
|
+
/** A held-out result's would-be recoveries: tokens of later calls that would have recovered the cut. */
|
|
242
|
+
baselineTokens: number;
|
|
243
|
+
/** A shortened result's saved tokens × the requests that carried them, and their price. */
|
|
244
|
+
savedCarry: number;
|
|
245
|
+
savedCarryUsd: number;
|
|
246
|
+
/** A recovery's tokens × the requests that carried them, and their price. */
|
|
247
|
+
recoveryCarry: number;
|
|
248
|
+
recoveryCarryUsd: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface Request {
|
|
252
|
+
entryId: string;
|
|
253
|
+
timestamp: number;
|
|
254
|
+
/** Model time of the request (ms). */
|
|
255
|
+
durationMs: number;
|
|
256
|
+
context: number;
|
|
257
|
+
costTotal: number;
|
|
258
|
+
compacted: boolean;
|
|
259
|
+
readRate: number;
|
|
260
|
+
writeRate: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface SessionAnalysis {
|
|
264
|
+
session: SessionRow;
|
|
265
|
+
cwd: string | null;
|
|
266
|
+
calls: Call[];
|
|
267
|
+
requests: Request[];
|
|
268
|
+
/** Request cost by assistant entry id (main-loop requests). */
|
|
269
|
+
costByEntry: Map<string, number>;
|
|
270
|
+
agentType: AgentType;
|
|
271
|
+
edits: EditLog;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseIds(json: string | null): number[] {
|
|
275
|
+
if (!json) return [];
|
|
276
|
+
try {
|
|
277
|
+
const value: unknown = JSON.parse(json);
|
|
278
|
+
return Array.isArray(value) ? value.filter((id): id is number => typeof id === "number") : [];
|
|
279
|
+
} catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseStrings(json: string | null): string[] {
|
|
285
|
+
if (!json) return [];
|
|
286
|
+
try {
|
|
287
|
+
const value: unknown = JSON.parse(json);
|
|
288
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parseRanges(json: string | null): [number, number][] {
|
|
295
|
+
if (!json) return [];
|
|
296
|
+
try {
|
|
297
|
+
const value: unknown = JSON.parse(json);
|
|
298
|
+
if (!Array.isArray(value)) return [];
|
|
299
|
+
return value.filter(
|
|
300
|
+
(pair): pair is [number, number] =>
|
|
301
|
+
Array.isArray(pair) && typeof pair[0] === "number" && typeof pair[1] === "number",
|
|
302
|
+
);
|
|
303
|
+
} catch {
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* What `plain` would have shown of a read/bash/eval result whose trace predates `plainTokens`: its input scaled to
|
|
310
|
+
* the inline cap ({@link PLAIN_CAP_BYTES}), in × min(1, cap / inChars). Null for any other tool: the generic path
|
|
311
|
+
* shapes the text `plain` shows, so its input is that text.
|
|
312
|
+
*/
|
|
313
|
+
export function estimatedPlainTokens(tool: string, inTokens: number, inChars: number): number | null {
|
|
314
|
+
const cap = PLAIN_CAP_BYTES[tool];
|
|
315
|
+
if (cap === undefined) return null;
|
|
316
|
+
return inChars > cap ? Math.round((inTokens * cap) / inChars) : inTokens;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseSteps(json: string): string[] {
|
|
320
|
+
try {
|
|
321
|
+
const value: unknown = JSON.parse(json);
|
|
322
|
+
return Array.isArray(value) ? value.filter((step): step is string => typeof step === "string") : [];
|
|
323
|
+
} catch {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function toResult(row: ResultRow): Result {
|
|
329
|
+
const hidden = parseRanges(row.hidden);
|
|
330
|
+
const steps = parseSteps(row.steps);
|
|
331
|
+
// A cut wider than plain pi did not apply: the model got plain's text, as for a result shaping kept.
|
|
332
|
+
const capped = row.capped_tokens !== null || steps.at(-1) === "plain-cap";
|
|
333
|
+
// Traces that log plain sizes give the model plain's text for a kept result: only a text unlike it is a cut.
|
|
334
|
+
const shown = row.plain_chars ?? row.in_chars;
|
|
335
|
+
const shortened = row.holdout === 0 && row.gated === 0 && !capped && (row.lines !== null || row.out_chars !== shown);
|
|
336
|
+
const holdout = row.holdout !== 0;
|
|
337
|
+
const gated = row.gated !== 0;
|
|
338
|
+
const estimated = row.plain_tokens === null ? estimatedPlainTokens(row.tool, row.in_tokens, row.in_chars) : null;
|
|
339
|
+
const plainTokens = row.plain_tokens ?? estimated ?? row.in_tokens;
|
|
340
|
+
const cut = shortened || holdout;
|
|
341
|
+
return {
|
|
342
|
+
timestamp: row.timestamp,
|
|
343
|
+
tool: row.tool,
|
|
344
|
+
inTokens: row.in_tokens,
|
|
345
|
+
outTokens: row.out_tokens,
|
|
346
|
+
plainTokens,
|
|
347
|
+
plainEstimated: estimated !== null,
|
|
348
|
+
savedTokens: cut ? plainTokens - row.out_tokens : 0,
|
|
349
|
+
fullSavedTokens: cut ? row.in_tokens - row.out_tokens : 0,
|
|
350
|
+
steps,
|
|
351
|
+
shortened,
|
|
352
|
+
holdout,
|
|
353
|
+
scoreFree: row.score_free !== 0,
|
|
354
|
+
gated,
|
|
355
|
+
widerThanPlain: capped || (shortened && row.out_tokens > plainTokens),
|
|
356
|
+
failed: (row.sp_failures ?? 0) > 0,
|
|
357
|
+
heat: row.heat ?? "",
|
|
358
|
+
kept: parseRanges(row.kept),
|
|
359
|
+
needs:
|
|
360
|
+
row.need_reread !== null || row.need_edit !== null
|
|
361
|
+
? { reread: parseRanges(row.need_reread), edit: parseRanges(row.need_edit) }
|
|
362
|
+
: null,
|
|
363
|
+
path: row.path,
|
|
364
|
+
start: row.start_line ?? 1,
|
|
365
|
+
lines: row.lines ?? 0,
|
|
366
|
+
hidden,
|
|
367
|
+
hiddenLines: row.hidden_lines,
|
|
368
|
+
spotlightCalls: row.sp_calls ?? 0,
|
|
369
|
+
spotlightFailures: row.sp_failures ?? 0,
|
|
370
|
+
spotlightMs: row.sp_ms ?? 0,
|
|
371
|
+
spotlightUsd: row.sp_cost ?? 0,
|
|
372
|
+
shapeMs: row.shape_ms,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Tokens a result added to the context: what the model saw (a held-out or gated result's `plain` text, not the
|
|
378
|
+
* would-be cut).
|
|
379
|
+
*/
|
|
380
|
+
function seenTokens(result: Result | null, resultChars: number | null): number {
|
|
381
|
+
if (result && !result.holdout && !result.gated) return result.outTokens;
|
|
382
|
+
if (resultChars !== null) return Math.round(resultChars / CHARS_PER_TOKEN);
|
|
383
|
+
return result?.plainTokens ?? 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function toRequest(row: RequestRow): Request {
|
|
387
|
+
const inputRate = row.input_tokens > 0 ? row.cost_input / row.input_tokens : 0;
|
|
388
|
+
return {
|
|
389
|
+
entryId: row.entry_id,
|
|
390
|
+
timestamp: row.timestamp,
|
|
391
|
+
durationMs: row.duration,
|
|
392
|
+
context: row.input_tokens + row.cache_read_tokens + row.cache_write_tokens,
|
|
393
|
+
costTotal: row.cost_total,
|
|
394
|
+
compacted: false,
|
|
395
|
+
readRate: row.cache_read_tokens > 0 ? row.cost_cache_read / row.cache_read_tokens : inputRate,
|
|
396
|
+
writeRate: row.cache_write_tokens > 0 ? row.cost_cache_write / row.cache_write_tokens : inputRate,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Loads and analyzes experiment sessions with one set of prepared statements. */
|
|
401
|
+
class SessionLoader {
|
|
402
|
+
readonly #calls: Statement<CallRow, [string]>;
|
|
403
|
+
readonly #results: Statement<ResultRow, [string]>;
|
|
404
|
+
readonly #requests: Statement<RequestRow, [string]>;
|
|
405
|
+
|
|
406
|
+
constructor(database: Database) {
|
|
407
|
+
this.#calls = database.query<CallRow, [string]>(
|
|
408
|
+
`SELECT tool_call_id, entry_id, timestamp, seq, tool_name, calls_in_turn, target, result_chars, artifacts,
|
|
409
|
+
edits, arg_tokens
|
|
410
|
+
FROM spotlight_calls WHERE session_file = ? ORDER BY seq`,
|
|
411
|
+
);
|
|
412
|
+
this.#results = database.query<ResultRow, [string]>(
|
|
413
|
+
`SELECT r.tool_call_id, r.timestamp, r.tool, r.in_chars, r.out_chars, r.in_tokens, r.out_tokens,
|
|
414
|
+
r.steps, r.holdout, r.path, r.start_line, r.lines, r.hidden, r.hidden_lines, r.artifacts, r.sp_calls,
|
|
415
|
+
r.sp_failures, r.sp_ms, r.sp_cost, r.shape_ms, r.plain_chars, r.plain_tokens, r.capped_tokens, r.score_free,
|
|
416
|
+
r.gated, r.heat, r.kept,
|
|
417
|
+
n.reread AS need_reread, n.edit AS need_edit
|
|
418
|
+
FROM spotlight_results r
|
|
419
|
+
LEFT JOIN spotlight_needs n ON n.session_file = r.session_file AND n.tool_call_id = r.tool_call_id
|
|
420
|
+
WHERE r.session_file = ?`,
|
|
421
|
+
);
|
|
422
|
+
this.#requests = database.query<RequestRow, [string]>(
|
|
423
|
+
`SELECT m.entry_id, m.timestamp, m.duration, m.input_tokens, m.cache_read_tokens, m.cache_write_tokens,
|
|
424
|
+
m.cost_input, m.cost_cache_read, m.cost_cache_write, m.cost_total
|
|
425
|
+
FROM spotlight_requests r JOIN messages m ON m.session_file = r.session_file AND m.entry_id = r.entry_id
|
|
426
|
+
WHERE r.session_file = ? ORDER BY m.timestamp, m.entry_id`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
analyze(session: SessionRow): SessionAnalysis {
|
|
431
|
+
const cwd = session.cwd;
|
|
432
|
+
const results = new Map<string, ResultRow>();
|
|
433
|
+
for (const row of this.#results.all(session.session_file)) results.set(row.tool_call_id, row);
|
|
434
|
+
const calls: Call[] = this.#calls.all(session.session_file).map(row => {
|
|
435
|
+
const resultRow = results.get(row.tool_call_id);
|
|
436
|
+
results.delete(row.tool_call_id);
|
|
437
|
+
const result = resultRow ? toResult(resultRow) : null;
|
|
438
|
+
const artifacts = new Set([...parseIds(row.artifacts), ...parseIds(resultRow?.artifacts ?? null)]);
|
|
439
|
+
return {
|
|
440
|
+
id: row.tool_call_id,
|
|
441
|
+
entryId: row.entry_id,
|
|
442
|
+
timestamp: row.timestamp,
|
|
443
|
+
seq: row.seq,
|
|
444
|
+
tool: row.tool_name,
|
|
445
|
+
target: row.target,
|
|
446
|
+
callsInTurn: row.calls_in_turn,
|
|
447
|
+
tokens: seenTokens(result, row.result_chars),
|
|
448
|
+
artifacts,
|
|
449
|
+
result,
|
|
450
|
+
recovery: null,
|
|
451
|
+
recoveredBy: [],
|
|
452
|
+
edits: parseStrings(row.edits).map(file => resolveReadPath(file, cwd)),
|
|
453
|
+
argTokens: new Set(parseStrings(row.arg_tokens)),
|
|
454
|
+
fetchBackGap: null,
|
|
455
|
+
fetchesBack: [],
|
|
456
|
+
baselineTokens: 0,
|
|
457
|
+
savedCarry: 0,
|
|
458
|
+
savedCarryUsd: 0,
|
|
459
|
+
recoveryCarry: 0,
|
|
460
|
+
recoveryCarryUsd: 0,
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
// Results whose call was not recorded (issued before the session's marker) still count as savings.
|
|
464
|
+
for (const row of results.values()) {
|
|
465
|
+
const result = toResult(row);
|
|
466
|
+
calls.push({
|
|
467
|
+
id: row.tool_call_id,
|
|
468
|
+
entryId: "",
|
|
469
|
+
timestamp: row.timestamp,
|
|
470
|
+
seq: Number.NEGATIVE_INFINITY,
|
|
471
|
+
tool: row.tool,
|
|
472
|
+
target: row.path ?? "",
|
|
473
|
+
callsInTurn: 1,
|
|
474
|
+
tokens: seenTokens(result, null),
|
|
475
|
+
artifacts: new Set(parseIds(row.artifacts)),
|
|
476
|
+
result,
|
|
477
|
+
recovery: null,
|
|
478
|
+
recoveredBy: [],
|
|
479
|
+
edits: [],
|
|
480
|
+
argTokens: new Set(),
|
|
481
|
+
fetchBackGap: null,
|
|
482
|
+
fetchesBack: [],
|
|
483
|
+
baselineTokens: 0,
|
|
484
|
+
savedCarry: 0,
|
|
485
|
+
savedCarryUsd: 0,
|
|
486
|
+
recoveryCarry: 0,
|
|
487
|
+
recoveryCarryUsd: 0,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
calls.sort((a, b) => a.seq - b.seq || a.timestamp - b.timestamp);
|
|
491
|
+
|
|
492
|
+
const requests: Request[] = [];
|
|
493
|
+
for (const row of this.#requests.all(session.session_file)) {
|
|
494
|
+
const request = toRequest(row);
|
|
495
|
+
// Failed requests report no prompt; they neither carry context nor mark a compaction. Requests before
|
|
496
|
+
// the marker (a resumed pre-experiment session) are not the experiment's.
|
|
497
|
+
if (request.context <= 0 || request.timestamp < session.marked_at) continue;
|
|
498
|
+
const previous = requests.at(-1);
|
|
499
|
+
request.compacted = previous !== undefined && request.context < previous.context * COMPACTION_RATIO;
|
|
500
|
+
requests.push(request);
|
|
501
|
+
}
|
|
502
|
+
const costByEntry = new Map(requests.map(request => [request.entryId, request.costTotal]));
|
|
503
|
+
const analysis: SessionAnalysis = {
|
|
504
|
+
session,
|
|
505
|
+
cwd: session.cwd,
|
|
506
|
+
calls,
|
|
507
|
+
requests,
|
|
508
|
+
costByEntry,
|
|
509
|
+
agentType: classifyAgentType(session.session_file),
|
|
510
|
+
edits: new EditLog(calls),
|
|
511
|
+
};
|
|
512
|
+
detectRecoveries(analysis);
|
|
513
|
+
detectHoldoutBaselines(analysis);
|
|
514
|
+
detectFetchBacks(analysis);
|
|
515
|
+
compound(analysis);
|
|
516
|
+
return analysis;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// Recovery detection
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
function overlaps(ranges: readonly [number, number][], hidden: readonly [number, number][]): boolean {
|
|
525
|
+
for (const [a, b] of ranges) {
|
|
526
|
+
for (const [h1, h2] of hidden) if (a <= h2 && h1 <= b) return true;
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_]\w*=/;
|
|
532
|
+
const COMMAND_EXTENSION = /^\s*(?:\||>|2>|&>|&&)/;
|
|
533
|
+
/** A leading `cd <dir> &&` / `cd <dir>;` says where, not what: similarity compares what follows. */
|
|
534
|
+
const CD_PREFIX = /^cd\s+\S+?\s*(?:&&|;)\s*/;
|
|
535
|
+
|
|
536
|
+
function commandTokens(command: string): string[] {
|
|
537
|
+
return command.trim().split(/\s+/).filter(Boolean);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function commandBody(command: string): string[] {
|
|
541
|
+
let body = command.trim();
|
|
542
|
+
while (CD_PREFIX.test(body)) body = body.replace(CD_PREFIX, "");
|
|
543
|
+
return commandTokens(body);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function commandProgram(tokens: readonly string[]): string | null {
|
|
547
|
+
const head = tokens.find(token => !ENV_ASSIGNMENT.test(token));
|
|
548
|
+
return head ? path.basename(head) : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Whether bash command `next` repeats `previous`: equal, extended by a pipe/redirect/`&&`, or similar enough. */
|
|
552
|
+
export function isRerun(previous: string, next: string): boolean {
|
|
553
|
+
const a = commandTokens(previous);
|
|
554
|
+
const b = commandTokens(next);
|
|
555
|
+
if (a.length === 0 || b.length === 0) return false;
|
|
556
|
+
const normalizedA = a.join(" ");
|
|
557
|
+
const normalizedB = b.join(" ");
|
|
558
|
+
if (normalizedA === normalizedB) return true;
|
|
559
|
+
if (normalizedB.startsWith(normalizedA) && COMMAND_EXTENSION.test(normalizedB.slice(normalizedA.length)))
|
|
560
|
+
return true;
|
|
561
|
+
const bodyA = commandBody(previous);
|
|
562
|
+
const bodyB = commandBody(next);
|
|
563
|
+
const program = commandProgram(bodyA);
|
|
564
|
+
if (program === null || program !== commandProgram(bodyB)) return false;
|
|
565
|
+
return jaccard(new Set(bodyA), new Set(bodyB)) >= RERUN_JACCARD;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const GLOB_CHARS = /[*?[{]/;
|
|
569
|
+
|
|
570
|
+
/** The session's file edits in call order: whether a file (or any file) changed between two calls. */
|
|
571
|
+
export class EditLog {
|
|
572
|
+
readonly #edits: { seq: number; exact: Set<string>; globs: Bun.Glob[] }[] = [];
|
|
573
|
+
|
|
574
|
+
/** `calls` sorted by seq, their `edits` resolved. */
|
|
575
|
+
constructor(calls: readonly Call[]) {
|
|
576
|
+
for (const call of calls) {
|
|
577
|
+
if (call.edits.length === 0) continue;
|
|
578
|
+
const exact = new Set<string>();
|
|
579
|
+
const globs: Bun.Glob[] = [];
|
|
580
|
+
for (const file of call.edits) {
|
|
581
|
+
if (GLOB_CHARS.test(file)) globs.push(new Bun.Glob(file));
|
|
582
|
+
else exact.add(file);
|
|
583
|
+
}
|
|
584
|
+
this.#edits.push({ seq: call.seq, exact, globs });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** An edit strictly between calls `after` and `before`, of `file` (resolved) when given, else of any file. */
|
|
589
|
+
between(after: number, before: number, file?: string): boolean {
|
|
590
|
+
let lo = 0;
|
|
591
|
+
let hi = this.#edits.length;
|
|
592
|
+
while (lo < hi) {
|
|
593
|
+
const mid = (lo + hi) >> 1;
|
|
594
|
+
if (this.#edits[mid].seq > after) hi = mid;
|
|
595
|
+
else lo = mid + 1;
|
|
596
|
+
}
|
|
597
|
+
for (let i = lo; i < this.#edits.length && this.#edits[i].seq < before; i++) {
|
|
598
|
+
const edit = this.#edits[i];
|
|
599
|
+
if (file === undefined || edit.exact.has(file) || edit.globs.some(glob => glob.match(file))) return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Whether `call` re-reads the file `source` read, unless that file was edited in between: `hidden` given, the
|
|
607
|
+
* later selection must overlap it (a recovery of a cut); else it must overlap the source's own window (the
|
|
608
|
+
* fetch-back rule, where a whole-file read overlaps everything).
|
|
609
|
+
*/
|
|
610
|
+
function rereads(call: Call, source: Call, analysis: SessionAnalysis, hidden?: [number, number][]): boolean {
|
|
611
|
+
const result = source.result;
|
|
612
|
+
const sourceSelection = parseReadSelection(source.target);
|
|
613
|
+
const sourcePath = result?.path ?? sourceSelection.path;
|
|
614
|
+
const selection = parseReadSelection(call.target);
|
|
615
|
+
if (!sourcePath) return false;
|
|
616
|
+
const file = resolveReadPath(selection.path, analysis.cwd);
|
|
617
|
+
if (file !== resolveReadPath(sourcePath, analysis.cwd)) return false;
|
|
618
|
+
if (analysis.edits.between(source.seq, call.seq, file)) return false;
|
|
619
|
+
const later = selectedLines(selection);
|
|
620
|
+
if (later === null) return true;
|
|
621
|
+
if (hidden) {
|
|
622
|
+
if (!result) return false;
|
|
623
|
+
const end = result.start + result.lines - 1;
|
|
624
|
+
const ranges = selection.tail === null ? later : [...later, [end - selection.tail + 1, end] as [number, number]];
|
|
625
|
+
return overlaps(ranges, hidden);
|
|
626
|
+
}
|
|
627
|
+
// A tail read lands somewhere unknown in a windowed read.
|
|
628
|
+
const window = selectedLines(sourceSelection);
|
|
629
|
+
if (window === null) return true;
|
|
630
|
+
const own: [number, number][] =
|
|
631
|
+
result && result.lines > 0 ? [[result.start, result.start + result.lines - 1]] : window;
|
|
632
|
+
return overlaps(later, own);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Jaccard similarity of two token sets; 0 when both are empty. */
|
|
636
|
+
function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
|
|
637
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
638
|
+
let shared = 0;
|
|
639
|
+
for (const token of a) if (b.has(token)) shared++;
|
|
640
|
+
return shared / (a.size + b.size - shared);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Whether `call` repeats `source`, a call of the same tool, within the rerun window, with no file edited in between:
|
|
645
|
+
* bash by {@link isRerun}; any other tool but `read` (which re-reads instead) by its argument tokens' Jaccard
|
|
646
|
+
* similarity.
|
|
647
|
+
*/
|
|
648
|
+
function reruns(call: Call, source: Call, analysis: SessionAnalysis): boolean {
|
|
649
|
+
if (call.tool !== source.tool || call.tool === "read" || call.seq - source.seq > RERUN_WINDOW) return false;
|
|
650
|
+
const similar =
|
|
651
|
+
call.tool === "bash"
|
|
652
|
+
? isRerun(source.target, call.target)
|
|
653
|
+
: jaccard(source.argTokens, call.argTokens) >= RERUN_JACCARD;
|
|
654
|
+
return similar && !analysis.edits.between(source.seq, call.seq);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function namesArtifactOf(call: Call, source: Call): boolean {
|
|
658
|
+
if (source.artifacts.size === 0) return false;
|
|
659
|
+
for (const id of artifactIds(call.target)) if (source.artifacts.has(id)) return true;
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The kind of recovery `call` is of the earlier `source`, if any; rules in order, first wins. With `wouldBe`, a
|
|
665
|
+
* held-out source is judged as its would-be cut (its hidden lines), for {@link detectHoldoutBaselines}.
|
|
666
|
+
*/
|
|
667
|
+
function recoveryKind(
|
|
668
|
+
call: Call,
|
|
669
|
+
source: Call,
|
|
670
|
+
analysis: SessionAnalysis,
|
|
671
|
+
wouldBe = false,
|
|
672
|
+
): SpotlightRecoveryKind | null {
|
|
673
|
+
if (namesArtifactOf(call, source)) return "artifact";
|
|
674
|
+
const result = source.result;
|
|
675
|
+
if (
|
|
676
|
+
call.tool === "read" &&
|
|
677
|
+
(result?.shortened || (wouldBe && (result?.holdout || result?.gated))) &&
|
|
678
|
+
result.tool === "read"
|
|
679
|
+
) {
|
|
680
|
+
if (rereads(call, source, analysis, result.hidden)) return "reread";
|
|
681
|
+
}
|
|
682
|
+
if (reruns(call, source, analysis)) return "rerun";
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* What held-out (and gated) results would have cost had they been cut: each later call (of another request) that
|
|
688
|
+
* would recover such a result by the recovery rules, judged against its would-be hidden lines, adds its tokens to the
|
|
689
|
+
* latest such result's {@link Call.baselineTokens}. The model saw these results whole, so this is what the agent
|
|
690
|
+
* fetches of a would-be cut's output regardless.
|
|
691
|
+
*/
|
|
692
|
+
function detectHoldoutBaselines(analysis: SessionAnalysis): void {
|
|
693
|
+
const sources: Call[] = [];
|
|
694
|
+
for (const call of analysis.calls) {
|
|
695
|
+
for (let i = sources.length - 1; i >= 0; i--) {
|
|
696
|
+
const source = sources[i];
|
|
697
|
+
if (call.entryId !== "" && call.entryId === source.entryId) continue;
|
|
698
|
+
if (!recoveryKind(call, source, analysis, true)) continue;
|
|
699
|
+
source.baselineTokens += call.tokens;
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
if (call.result?.holdout || call.result?.gated) sources.push(call);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The uniform fetch-back rule every evaluated result is measured by, whatever the model saw: within
|
|
708
|
+
* {@link RERUN_WINDOW} calls, a call of another request reading an artifact the result named, re-reading its
|
|
709
|
+
* file over its window, or rerunning its call (edits in between excuse re-reads and reruns, as for recoveries).
|
|
710
|
+
* Sets each evaluated result's {@link Call.fetchBackGap}.
|
|
711
|
+
*/
|
|
712
|
+
function detectFetchBacks(analysis: SessionAnalysis): void {
|
|
713
|
+
const { calls } = analysis;
|
|
714
|
+
for (let i = 0; i < calls.length; i++) {
|
|
715
|
+
const source = calls[i];
|
|
716
|
+
if (!source.result || !Number.isFinite(source.seq)) continue;
|
|
717
|
+
for (let j = i + 1; j < calls.length && calls[j].seq - source.seq <= RERUN_WINDOW; j++) {
|
|
718
|
+
const call = calls[j];
|
|
719
|
+
if (call.entryId === source.entryId) continue;
|
|
720
|
+
if (
|
|
721
|
+
namesArtifactOf(call, source) ||
|
|
722
|
+
(call.tool === "read" && source.tool === "read" && rereads(call, source, analysis)) ||
|
|
723
|
+
reruns(call, source, analysis)
|
|
724
|
+
) {
|
|
725
|
+
source.fetchBackGap = call.seq - source.seq;
|
|
726
|
+
call.fetchesBack.push(source);
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Attribute each call to the latest earlier result it fetches hidden output from: a shortened result, or one whose
|
|
735
|
+
* text names artifacts (a native truncation notice included).
|
|
736
|
+
*/
|
|
737
|
+
function detectRecoveries(analysis: SessionAnalysis): void {
|
|
738
|
+
const sources: Call[] = [];
|
|
739
|
+
for (const call of analysis.calls) {
|
|
740
|
+
for (let i = sources.length - 1; i >= 0; i--) {
|
|
741
|
+
const source = sources[i];
|
|
742
|
+
// A parallel call of the same request was issued before the model saw the cut.
|
|
743
|
+
if (call.entryId !== "" && call.entryId === source.entryId) continue;
|
|
744
|
+
const kind = recoveryKind(call, source, analysis);
|
|
745
|
+
if (!kind) continue;
|
|
746
|
+
call.recovery = {
|
|
747
|
+
kind,
|
|
748
|
+
source,
|
|
749
|
+
after: Number.isFinite(source.seq) ? Math.max(0, call.seq - source.seq - 1) : 0,
|
|
750
|
+
turn: false,
|
|
751
|
+
};
|
|
752
|
+
source.recoveredBy.push(call);
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
if (call.result?.shortened || call.artifacts.size > 0) sources.push(call);
|
|
756
|
+
}
|
|
757
|
+
const recoveriesByEntry = new Map<string, number>();
|
|
758
|
+
for (const call of analysis.calls) {
|
|
759
|
+
if (call.recovery) recoveriesByEntry.set(call.entryId, (recoveriesByEntry.get(call.entryId) ?? 0) + 1);
|
|
760
|
+
}
|
|
761
|
+
for (const call of analysis.calls) {
|
|
762
|
+
if (call.recovery && call.entryId) call.recovery.turn = recoveriesByEntry.get(call.entryId) === call.callsInTurn;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
// Compounding
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Carry each shortened result's saving and each recovery's tokens through the session's later requests until the
|
|
772
|
+
* next compaction, pricing them at those requests' own cache rates.
|
|
773
|
+
*/
|
|
774
|
+
function compound(analysis: SessionAnalysis): void {
|
|
775
|
+
const { requests } = analysis;
|
|
776
|
+
const n = requests.length;
|
|
777
|
+
// readPrefix[i] = Σ readRate of requests[0..i-1]; nextCompaction[i] = first j ≥ i that is compacted.
|
|
778
|
+
const readPrefix = new Float64Array(n + 1);
|
|
779
|
+
for (let i = 0; i < n; i++) readPrefix[i + 1] = readPrefix[i] + requests[i].readRate;
|
|
780
|
+
const nextCompaction = new Int32Array(n + 1);
|
|
781
|
+
nextCompaction[n] = n;
|
|
782
|
+
for (let i = n - 1; i >= 0; i--) nextCompaction[i] = requests[i].compacted ? i : nextCompaction[i + 1];
|
|
783
|
+
const firstAfter = (timestamp: number): number => {
|
|
784
|
+
let lo = 0;
|
|
785
|
+
let hi = n;
|
|
786
|
+
while (lo < hi) {
|
|
787
|
+
const mid = (lo + hi) >> 1;
|
|
788
|
+
if (requests[mid].timestamp > timestamp) hi = mid;
|
|
789
|
+
else lo = mid + 1;
|
|
790
|
+
}
|
|
791
|
+
return lo;
|
|
792
|
+
};
|
|
793
|
+
/** Requests carrying something that entered the context at `timestamp`, and the price of one token over them. */
|
|
794
|
+
const carry = (timestamp: number): [number, number] => {
|
|
795
|
+
const first = firstAfter(timestamp);
|
|
796
|
+
const end = first < n ? nextCompaction[first] : n;
|
|
797
|
+
if (end <= first) return [0, 0];
|
|
798
|
+
return [end - first, requests[first].writeRate + readPrefix[end] - readPrefix[first]];
|
|
799
|
+
};
|
|
800
|
+
for (const call of analysis.calls) {
|
|
801
|
+
const timestamp = call.result?.timestamp ?? call.timestamp;
|
|
802
|
+
if (call.result?.shortened && call.result.savedTokens !== 0) {
|
|
803
|
+
const [requestsCarried, rate] = carry(timestamp);
|
|
804
|
+
call.savedCarry = call.result.savedTokens * requestsCarried;
|
|
805
|
+
call.savedCarryUsd = call.result.savedTokens * rate;
|
|
806
|
+
}
|
|
807
|
+
if (call.recovery && call.tokens > 0) {
|
|
808
|
+
const [requestsCarried, rate] = carry(timestamp);
|
|
809
|
+
call.recoveryCarry = call.tokens * requestsCarried;
|
|
810
|
+
call.recoveryCarryUsd = call.tokens * rate;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
// Range aggregation
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
|
|
819
|
+
function emptyToolTotals(): SpotlightToolTotals {
|
|
820
|
+
return { results: 0, shaped: 0, inTokens: 0, plainTokens: 0, outTokens: 0, savedTokens: 0, fullSavedTokens: 0 };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function emptyRecoveryTotals(): SpotlightRecoveryTotals {
|
|
824
|
+
return { events: 0, tokens: 0, turns: 0, turnsUsd: 0, usd: 0 };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function emptyBucket(timestamp: number): SpotlightBucket {
|
|
828
|
+
return {
|
|
829
|
+
timestamp,
|
|
830
|
+
results: 0,
|
|
831
|
+
shaped: 0,
|
|
832
|
+
savedTokens: 0,
|
|
833
|
+
recoveries: 0,
|
|
834
|
+
recoveredTokens: 0,
|
|
835
|
+
netTokens: 0,
|
|
836
|
+
savedUsd: 0,
|
|
837
|
+
netUsd: 0,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function displayTarget(call: Call): string {
|
|
842
|
+
return call.target.slice(0, DISPLAY_TARGET_CHARS);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function toRecovery(call: Call, recovery: Recovery): SpotlightRecovery {
|
|
846
|
+
return {
|
|
847
|
+
kind: recovery.kind,
|
|
848
|
+
toolCallId: call.id,
|
|
849
|
+
tool: call.tool,
|
|
850
|
+
target: displayTarget(call),
|
|
851
|
+
timestamp: call.timestamp,
|
|
852
|
+
tokens: call.tokens,
|
|
853
|
+
after: recovery.after,
|
|
854
|
+
turn: recovery.turn,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Raw per-bucket facts of one session over the range. */
|
|
859
|
+
interface SessionFacts {
|
|
860
|
+
/** bucket → [result chars, output tokens, user chars] */
|
|
861
|
+
buckets: Map<number, [number, number, number]>;
|
|
862
|
+
totalTokens: number;
|
|
863
|
+
promptTokens: number;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function loadSessionFacts(database: Database, sessionFile: string, cutoff: number, bucketMs: number): SessionFacts {
|
|
867
|
+
const buckets = new Map<number, [number, number, number]>();
|
|
868
|
+
const bucket = (timestamp: number) => {
|
|
869
|
+
let entry = buckets.get(timestamp);
|
|
870
|
+
if (!entry) {
|
|
871
|
+
entry = [0, 0, 0];
|
|
872
|
+
buckets.set(timestamp, entry);
|
|
873
|
+
}
|
|
874
|
+
return entry;
|
|
875
|
+
};
|
|
876
|
+
const tools = database
|
|
877
|
+
.query<{ bucket: number; chars: number }, [string, number]>(
|
|
878
|
+
`SELECT (timestamp / ${bucketMs}) * ${bucketMs} AS bucket, TOTAL(result_chars) AS chars
|
|
879
|
+
FROM tool_calls WHERE session_file = ? AND timestamp >= ? GROUP BY 1`,
|
|
880
|
+
)
|
|
881
|
+
.all(sessionFile, cutoff);
|
|
882
|
+
for (const row of tools) bucket(row.bucket)[0] += row.chars;
|
|
883
|
+
const messages = database
|
|
884
|
+
.query<{ bucket: number; output: number; total: number; prompt: number }, [string, number]>(
|
|
885
|
+
`SELECT (timestamp / ${bucketMs}) * ${bucketMs} AS bucket, TOTAL(output_tokens) AS output,
|
|
886
|
+
TOTAL(total_tokens) AS total, TOTAL(input_tokens + cache_read_tokens + cache_write_tokens) AS prompt
|
|
887
|
+
FROM messages WHERE session_file = ? AND timestamp >= ? GROUP BY 1`,
|
|
888
|
+
)
|
|
889
|
+
.all(sessionFile, cutoff);
|
|
890
|
+
let totalTokens = 0;
|
|
891
|
+
let promptTokens = 0;
|
|
892
|
+
for (const row of messages) {
|
|
893
|
+
bucket(row.bucket)[1] += row.output;
|
|
894
|
+
totalTokens += row.total;
|
|
895
|
+
promptTokens += row.prompt;
|
|
896
|
+
}
|
|
897
|
+
const users = database
|
|
898
|
+
.query<{ bucket: number; chars: number }, [string, number]>(
|
|
899
|
+
`SELECT (timestamp / ${bucketMs}) * ${bucketMs} AS bucket, TOTAL(chars) AS chars
|
|
900
|
+
FROM user_messages WHERE session_file = ? AND timestamp >= ? GROUP BY 1`,
|
|
901
|
+
)
|
|
902
|
+
.all(sessionFile, cutoff);
|
|
903
|
+
for (const row of users) bucket(row.bucket)[2] += row.chars;
|
|
904
|
+
return { buckets, totalTokens, promptTokens };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
type ShareGroup = SpotlightSharePoint["group"];
|
|
908
|
+
const SHARE_GROUPS: readonly ShareGroup[] = ["shaped", "outside"];
|
|
909
|
+
|
|
910
|
+
interface ShareCell {
|
|
911
|
+
sessions: number;
|
|
912
|
+
toolTokens: number;
|
|
913
|
+
contextTokens: number;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** An experiment session as the share trend splits it: its activity from `markedAt` on is `shaped`. */
|
|
917
|
+
export interface ShareExperiment {
|
|
918
|
+
markedAt: number;
|
|
919
|
+
/** Prompt of its first request at or after the marker: the context shaping started from. */
|
|
920
|
+
firstPrompt: number;
|
|
921
|
+
/** Its facts from the marker (or the window start, if later) on. */
|
|
922
|
+
facts: SessionFacts;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Whether a transcript of `agentType` is in `scope`; advisors only count under `all`. */
|
|
926
|
+
export function inAgentScope(scope: SpotlightAgentScope, agentType: AgentType): boolean {
|
|
927
|
+
return scope === "all" || scope === agentType;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Strategy 4 over every transcript of `scope` in the window. Totals come from the rollups; experiment sessions are
|
|
932
|
+
* read raw (they are few) and subtracted to leave `outside`, so the cost does not grow with history. A transcript's
|
|
933
|
+
* activity before its marker stays `outside`.
|
|
934
|
+
*/
|
|
935
|
+
export function computeShareSeries(
|
|
936
|
+
database: Database,
|
|
937
|
+
window: RangeWindow,
|
|
938
|
+
experiments: ReadonlyMap<string, ShareExperiment>,
|
|
939
|
+
scope: SpotlightAgentScope = "all",
|
|
940
|
+
now = Date.now(),
|
|
941
|
+
): SpotlightSharePoint[] {
|
|
942
|
+
const { bucketMs } = window;
|
|
943
|
+
const cutoff = window.cutoff ?? 0;
|
|
944
|
+
const agentTypes = new Map<string, AgentType>();
|
|
945
|
+
const inScope = (sessionFile: string): boolean => {
|
|
946
|
+
if (scope === "all") return true;
|
|
947
|
+
let agentType = agentTypes.get(sessionFile);
|
|
948
|
+
if (!agentType) {
|
|
949
|
+
agentType = classifyAgentType(sessionFile);
|
|
950
|
+
agentTypes.set(sessionFile, agentType);
|
|
951
|
+
}
|
|
952
|
+
return agentType === scope;
|
|
953
|
+
};
|
|
954
|
+
const cells = new Map<number, Record<ShareGroup, ShareCell>>();
|
|
955
|
+
const cell = (bucket: number, group: ShareGroup): ShareCell => {
|
|
956
|
+
let row = cells.get(bucket);
|
|
957
|
+
if (!row) {
|
|
958
|
+
row = {
|
|
959
|
+
shaped: { sessions: 0, toolTokens: 0, contextTokens: 0 },
|
|
960
|
+
outside: { sessions: 0, toolTokens: 0, contextTokens: 0 },
|
|
961
|
+
};
|
|
962
|
+
cells.set(bucket, row);
|
|
963
|
+
}
|
|
964
|
+
return row[group];
|
|
965
|
+
};
|
|
966
|
+
const bucketOf = (timestamp: number) => Math.floor(timestamp / bucketMs) * bucketMs;
|
|
967
|
+
|
|
968
|
+
// Everything lands in `outside` first; experiment sessions are moved out below.
|
|
969
|
+
for (const point of getContextFactSeries(database, window)) {
|
|
970
|
+
if (!inAgentScope(scope, point.agentType)) continue;
|
|
971
|
+
const tool = point.resultChars / CHARS_PER_TOKEN;
|
|
972
|
+
const target = cell(point.bucket, "outside");
|
|
973
|
+
target.toolTokens += tool;
|
|
974
|
+
target.contextTokens += tool + point.outputTokens;
|
|
975
|
+
}
|
|
976
|
+
// User messages carry no agent type: a scoped trend classifies their transcripts.
|
|
977
|
+
const users =
|
|
978
|
+
scope === "all"
|
|
979
|
+
? database
|
|
980
|
+
.query<{ bucket: number; session_file: string; chars: number }, [number]>(
|
|
981
|
+
`SELECT (timestamp / ${bucketMs}) * ${bucketMs} AS bucket, '' AS session_file, TOTAL(chars) AS chars
|
|
982
|
+
FROM user_messages WHERE timestamp >= ? GROUP BY 1`,
|
|
983
|
+
)
|
|
984
|
+
.all(cutoff)
|
|
985
|
+
: database
|
|
986
|
+
.query<{ bucket: number; session_file: string; chars: number }, [number]>(
|
|
987
|
+
`SELECT (timestamp / ${bucketMs}) * ${bucketMs} AS bucket, session_file, TOTAL(chars) AS chars
|
|
988
|
+
FROM user_messages WHERE timestamp >= ? GROUP BY 1, 2`,
|
|
989
|
+
)
|
|
990
|
+
.all(cutoff);
|
|
991
|
+
for (const row of users) {
|
|
992
|
+
if (inScope(row.session_file)) cell(row.bucket, "outside").contextTokens += row.chars / CHARS_PER_TOKEN;
|
|
993
|
+
}
|
|
994
|
+
for (const [sessionFile, experiment] of experiments) {
|
|
995
|
+
if (!inScope(sessionFile)) continue;
|
|
996
|
+
for (const [bucket, [resultChars, output, userChars]] of experiment.facts.buckets) {
|
|
997
|
+
const tool = resultChars / CHARS_PER_TOKEN;
|
|
998
|
+
const context = tool + output + userChars / CHARS_PER_TOKEN;
|
|
999
|
+
const into = cell(bucket, "shaped");
|
|
1000
|
+
into.toolTokens += tool;
|
|
1001
|
+
into.contextTokens += context;
|
|
1002
|
+
const from = cell(bucket, "outside");
|
|
1003
|
+
from.toolTokens = Math.max(0, from.toolTokens - tool);
|
|
1004
|
+
from.contextTokens = Math.max(0, from.contextTokens - context);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const lastBucket = bucketOf(now);
|
|
1008
|
+
/** Count a session active in every bucket `[start, end]` overlaps within the window, starting from `prompt`. */
|
|
1009
|
+
const addSpan = (group: ShareGroup, start: number, end: number, prompt: number) => {
|
|
1010
|
+
if (start >= cutoff) cell(bucketOf(start), group).contextTokens += prompt;
|
|
1011
|
+
const to = Math.min(bucketOf(end), lastBucket);
|
|
1012
|
+
for (let bucket = bucketOf(Math.max(start, cutoff)); bucket <= to; bucket += bucketMs)
|
|
1013
|
+
cell(bucket, group).sessions++;
|
|
1014
|
+
};
|
|
1015
|
+
for (const span of getSessionSpans(database, window.cutoff)) {
|
|
1016
|
+
if (!inScope(span.sessionFile)) continue;
|
|
1017
|
+
const experiment = experiments.get(span.sessionFile);
|
|
1018
|
+
if (!experiment || span.startedAt >= experiment.markedAt) {
|
|
1019
|
+
addSpan(experiment ? "shaped" : "outside", span.startedAt, span.endedAt, span.firstPrompt);
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
// A resumed pre-experiment session: its history before the marker is outside the experiment.
|
|
1023
|
+
if (experiment.markedAt > cutoff) {
|
|
1024
|
+
addSpan("outside", span.startedAt, Math.min(span.endedAt, experiment.markedAt - 1), span.firstPrompt);
|
|
1025
|
+
}
|
|
1026
|
+
if (span.endedAt >= experiment.markedAt) {
|
|
1027
|
+
addSpan("shaped", experiment.markedAt, span.endedAt, experiment.firstPrompt);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const points: SpotlightSharePoint[] = [];
|
|
1032
|
+
for (const bucket of [...cells.keys()].sort((a, b) => a - b)) {
|
|
1033
|
+
const row = cells.get(bucket);
|
|
1034
|
+
if (!row) continue;
|
|
1035
|
+
for (const group of SHARE_GROUPS) {
|
|
1036
|
+
const c = row[group];
|
|
1037
|
+
points.push({
|
|
1038
|
+
timestamp: bucket,
|
|
1039
|
+
group,
|
|
1040
|
+
sessions: c.sessions,
|
|
1041
|
+
toolTokens: Math.round(c.toolTokens),
|
|
1042
|
+
contextTokens: Math.round(c.contextTokens),
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return points;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function loadSessions(database: Database): SessionRow[] {
|
|
1050
|
+
return database
|
|
1051
|
+
.query<SessionRow, []>(
|
|
1052
|
+
"SELECT session_file, folder, marked_at, cwd, title FROM spotlight_sessions ORDER BY marked_at",
|
|
1053
|
+
)
|
|
1054
|
+
.all();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function summarize(analysis: SessionAnalysis): SpotlightSessionSummary {
|
|
1058
|
+
let savedTokens = 0;
|
|
1059
|
+
let savedUsd = 0;
|
|
1060
|
+
let recoveryUsd = 0;
|
|
1061
|
+
let spotlightUsd = 0;
|
|
1062
|
+
const turnEntries = new Set<string>();
|
|
1063
|
+
for (const call of analysis.calls) {
|
|
1064
|
+
if (call.result) spotlightUsd += call.result.spotlightUsd;
|
|
1065
|
+
if (call.result?.shortened) {
|
|
1066
|
+
savedTokens += call.result.savedTokens;
|
|
1067
|
+
savedUsd += call.savedCarryUsd;
|
|
1068
|
+
}
|
|
1069
|
+
if (call.recovery) {
|
|
1070
|
+
recoveryUsd += call.recoveryCarryUsd;
|
|
1071
|
+
if (call.recovery.turn) turnEntries.add(call.entryId);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
for (const entry of turnEntries) recoveryUsd += analysis.costByEntry.get(entry) ?? 0;
|
|
1075
|
+
return {
|
|
1076
|
+
sessionFile: analysis.session.session_file,
|
|
1077
|
+
title: analysis.session.title,
|
|
1078
|
+
folder: analysis.session.folder,
|
|
1079
|
+
agentType: analysis.agentType,
|
|
1080
|
+
startedAt: analysis.session.marked_at,
|
|
1081
|
+
requests: analysis.requests.length,
|
|
1082
|
+
savedTokens,
|
|
1083
|
+
netUsd: savedUsd - recoveryUsd - spotlightUsd,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Latest request or tool call of the session; 0 for none (a fork holding only copied entries). */
|
|
1088
|
+
function lastActivity(analysis: SessionAnalysis): number {
|
|
1089
|
+
let last = analysis.requests.at(-1)?.timestamp ?? 0;
|
|
1090
|
+
for (const call of analysis.calls) last = Math.max(last, call.result?.timestamp ?? call.timestamp);
|
|
1091
|
+
return last;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function emptyDashboard(experimentStart: number | null): SpotlightDashboardStats {
|
|
1095
|
+
return {
|
|
1096
|
+
sessions: 0,
|
|
1097
|
+
experimentStart,
|
|
1098
|
+
gross: {
|
|
1099
|
+
...emptyToolTotals(),
|
|
1100
|
+
ofToolOutput: null,
|
|
1101
|
+
ofSessionTokens: null,
|
|
1102
|
+
byTool: [],
|
|
1103
|
+
estimatedPlain: 0,
|
|
1104
|
+
widerThanPlain: 0,
|
|
1105
|
+
},
|
|
1106
|
+
clawback: emptyClawback(),
|
|
1107
|
+
compounding: {
|
|
1108
|
+
avoidedTokens: 0,
|
|
1109
|
+
recoveredCarriedTokens: 0,
|
|
1110
|
+
promptTokens: 0,
|
|
1111
|
+
avoidedShare: null,
|
|
1112
|
+
savedUsd: 0,
|
|
1113
|
+
recoveryUsd: 0,
|
|
1114
|
+
spotlightUsd: 0,
|
|
1115
|
+
netUsd: 0,
|
|
1116
|
+
shapeMs: 0,
|
|
1117
|
+
},
|
|
1118
|
+
series: [],
|
|
1119
|
+
share: [],
|
|
1120
|
+
recent: [],
|
|
1121
|
+
sessionList: [],
|
|
1122
|
+
holdouts: 0,
|
|
1123
|
+
scoreFree: 0,
|
|
1124
|
+
adjusted: emptyAdjusted(),
|
|
1125
|
+
ranking: emptyRanking(),
|
|
1126
|
+
steps: [],
|
|
1127
|
+
timeCost: computeTimeCost([], 0),
|
|
1128
|
+
coverage: [],
|
|
1129
|
+
fetchBack: [],
|
|
1130
|
+
economics: [],
|
|
1131
|
+
gaps: [],
|
|
1132
|
+
overhead: computeOverhead([]),
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Per-tool totals as rows, the largest saving first (then the most results, then by name). */
|
|
1137
|
+
function toolRows(byTool: ReadonlyMap<SpotlightTool, SpotlightToolTotals>): SpotlightToolRow[] {
|
|
1138
|
+
return [...byTool]
|
|
1139
|
+
.map(([tool, totals]) => ({ tool, ...totals }))
|
|
1140
|
+
.sort((a, b) => b.savedTokens - a.savedTokens || b.results - a.results || a.tool.localeCompare(b.tool));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function emptyClawback(): SpotlightClawback {
|
|
1144
|
+
return {
|
|
1145
|
+
...emptyRecoveryTotals(),
|
|
1146
|
+
withHidden: 0,
|
|
1147
|
+
recovered: 0,
|
|
1148
|
+
byKind: { artifact: emptyRecoveryTotals(), reread: emptyRecoveryTotals(), rerun: emptyRecoveryTotals() },
|
|
1149
|
+
netTokens: 0,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** Count an evaluated result; a held-out or gated one as the model saw it, `plain`'s text, saving nothing. */
|
|
1154
|
+
function addTotals(into: SpotlightToolTotals, result: Result): void {
|
|
1155
|
+
into.results++;
|
|
1156
|
+
into.inTokens += result.inTokens;
|
|
1157
|
+
into.plainTokens += result.plainTokens;
|
|
1158
|
+
if (result.holdout || result.gated) {
|
|
1159
|
+
into.outTokens += result.plainTokens;
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
into.outTokens += result.outTokens;
|
|
1163
|
+
if (!result.shortened) return;
|
|
1164
|
+
into.shaped++;
|
|
1165
|
+
into.savedTokens += result.savedTokens;
|
|
1166
|
+
into.fullSavedTokens += result.fullSavedTokens;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** Build the dashboard payload for a window and agent scope; `now` pins the series end (tests). */
|
|
1170
|
+
export function buildSpotlightDashboard(
|
|
1171
|
+
database: Database,
|
|
1172
|
+
window: RangeWindow,
|
|
1173
|
+
scope: SpotlightAgentScope = "all",
|
|
1174
|
+
now = Date.now(),
|
|
1175
|
+
): SpotlightDashboardStats {
|
|
1176
|
+
const sessions = loadSessions(database);
|
|
1177
|
+
const experimentStart = sessions.length > 0 ? sessions[0].marked_at : null;
|
|
1178
|
+
const cutoff = window.cutoff ?? 0;
|
|
1179
|
+
const { bucketMs } = window;
|
|
1180
|
+
const bucketOf = (timestamp: number) => Math.floor(timestamp / bucketMs) * bucketMs;
|
|
1181
|
+
const stats = emptyDashboard(experimentStart);
|
|
1182
|
+
|
|
1183
|
+
const loader = new SessionLoader(database);
|
|
1184
|
+
const active: SessionAnalysis[] = [];
|
|
1185
|
+
for (const session of sessions) {
|
|
1186
|
+
const analysis = loader.analyze(session);
|
|
1187
|
+
if (!inAgentScope(scope, analysis.agentType)) continue;
|
|
1188
|
+
const last = lastActivity(analysis);
|
|
1189
|
+
if (last > 0 && last >= cutoff) active.push(analysis);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Only activity from the marker on is the experiment's.
|
|
1193
|
+
const experiments = new Map<string, ShareExperiment>();
|
|
1194
|
+
for (const analysis of active) {
|
|
1195
|
+
const file = analysis.session.session_file;
|
|
1196
|
+
experiments.set(file, {
|
|
1197
|
+
markedAt: analysis.session.marked_at,
|
|
1198
|
+
firstPrompt: analysis.requests[0]?.context ?? 0,
|
|
1199
|
+
facts: loadSessionFacts(database, file, Math.max(cutoff, analysis.session.marked_at), bucketMs),
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
stats.share = computeShareSeries(database, window, experiments, scope, now);
|
|
1203
|
+
if (active.length === 0) return stats;
|
|
1204
|
+
|
|
1205
|
+
const series = new Map<number, SpotlightBucket>();
|
|
1206
|
+
const seriesStart = bucketOf(window.cutoff ?? experimentStart ?? now);
|
|
1207
|
+
for (let bucket = seriesStart; bucket <= bucketOf(now); bucket += bucketMs) series.set(bucket, emptyBucket(bucket));
|
|
1208
|
+
const bucket = (timestamp: number): SpotlightBucket => {
|
|
1209
|
+
const key = bucketOf(timestamp);
|
|
1210
|
+
let point = series.get(key);
|
|
1211
|
+
if (!point) {
|
|
1212
|
+
point = emptyBucket(key);
|
|
1213
|
+
series.set(key, point);
|
|
1214
|
+
}
|
|
1215
|
+
return point;
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
const { gross, clawback, compounding } = stats;
|
|
1219
|
+
const recent: { call: Call; analysis: SessionAnalysis }[] = [];
|
|
1220
|
+
const evaluated: Evaluated[] = [];
|
|
1221
|
+
const byTool = new Map<SpotlightTool, SpotlightToolTotals>();
|
|
1222
|
+
const money: SessionMoney[] = [];
|
|
1223
|
+
let toolTokens = 0;
|
|
1224
|
+
let sessionTokens = 0;
|
|
1225
|
+
|
|
1226
|
+
for (const analysis of active) {
|
|
1227
|
+
stats.sessions++;
|
|
1228
|
+
const sessionFacts = experiments.get(analysis.session.session_file)?.facts;
|
|
1229
|
+
sessionTokens += sessionFacts?.totalTokens ?? 0;
|
|
1230
|
+
compounding.promptTokens += sessionFacts?.promptTokens ?? 0;
|
|
1231
|
+
|
|
1232
|
+
const turnsCounted = new Set<string>();
|
|
1233
|
+
const sessionMoney: SessionMoney = {
|
|
1234
|
+
sessionFile: analysis.session.session_file,
|
|
1235
|
+
savedTokens: 0,
|
|
1236
|
+
savedUsd: 0,
|
|
1237
|
+
recoveryUsd: 0,
|
|
1238
|
+
spotlightUsd: 0,
|
|
1239
|
+
};
|
|
1240
|
+
money.push(sessionMoney);
|
|
1241
|
+
for (const call of analysis.calls) {
|
|
1242
|
+
const inRange = call.timestamp >= cutoff;
|
|
1243
|
+
if (inRange && Number.isFinite(call.seq)) toolTokens += call.tokens;
|
|
1244
|
+
const result = call.result;
|
|
1245
|
+
if (result && result.timestamp >= cutoff) {
|
|
1246
|
+
addTotals(gross, result);
|
|
1247
|
+
let toolTotals = byTool.get(result.tool);
|
|
1248
|
+
if (!toolTotals) {
|
|
1249
|
+
toolTotals = emptyToolTotals();
|
|
1250
|
+
byTool.set(result.tool, toolTotals);
|
|
1251
|
+
}
|
|
1252
|
+
addTotals(toolTotals, result);
|
|
1253
|
+
evaluated.push({ call, result, analysis, tool: result.tool });
|
|
1254
|
+
if (result.shortened && result.plainEstimated) gross.estimatedPlain++;
|
|
1255
|
+
if (result.widerThanPlain) gross.widerThanPlain++;
|
|
1256
|
+
if (result.shortened && result.scoreFree) stats.scoreFree++;
|
|
1257
|
+
compounding.spotlightUsd += result.spotlightUsd;
|
|
1258
|
+
sessionMoney.spotlightUsd += result.spotlightUsd;
|
|
1259
|
+
compounding.shapeMs += result.shapeMs ?? 0;
|
|
1260
|
+
const point = bucket(result.timestamp);
|
|
1261
|
+
point.results++;
|
|
1262
|
+
point.netUsd -= result.spotlightUsd;
|
|
1263
|
+
if (result.holdout) {
|
|
1264
|
+
stats.holdouts++;
|
|
1265
|
+
recent.push({ call, analysis });
|
|
1266
|
+
}
|
|
1267
|
+
if (result.shortened) {
|
|
1268
|
+
point.shaped++;
|
|
1269
|
+
point.savedTokens += result.savedTokens;
|
|
1270
|
+
point.netTokens += result.savedTokens;
|
|
1271
|
+
point.savedUsd += call.savedCarryUsd;
|
|
1272
|
+
point.netUsd += call.savedCarryUsd;
|
|
1273
|
+
compounding.avoidedTokens += call.savedCarry;
|
|
1274
|
+
compounding.savedUsd += call.savedCarryUsd;
|
|
1275
|
+
sessionMoney.savedTokens += result.savedTokens;
|
|
1276
|
+
sessionMoney.savedUsd += call.savedCarryUsd;
|
|
1277
|
+
recent.push({ call, analysis });
|
|
1278
|
+
if (result.hiddenLines > 0) {
|
|
1279
|
+
clawback.withHidden++;
|
|
1280
|
+
if (call.recoveredBy.length > 0) clawback.recovered++;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
const recovery = call.recovery;
|
|
1285
|
+
if (!recovery || !inRange) continue;
|
|
1286
|
+
const kind = clawback.byKind[recovery.kind];
|
|
1287
|
+
for (const totals of [clawback, kind]) {
|
|
1288
|
+
totals.events++;
|
|
1289
|
+
totals.tokens += call.tokens;
|
|
1290
|
+
totals.usd += call.recoveryCarryUsd;
|
|
1291
|
+
}
|
|
1292
|
+
compounding.recoveredCarriedTokens += call.recoveryCarry;
|
|
1293
|
+
compounding.recoveryUsd += call.recoveryCarryUsd;
|
|
1294
|
+
sessionMoney.recoveryUsd += call.recoveryCarryUsd;
|
|
1295
|
+
const point = bucket(call.timestamp);
|
|
1296
|
+
point.recoveries++;
|
|
1297
|
+
point.recoveredTokens += call.tokens;
|
|
1298
|
+
point.netTokens -= call.tokens;
|
|
1299
|
+
point.netUsd -= call.recoveryCarryUsd;
|
|
1300
|
+
if (recovery.turn && !turnsCounted.has(call.entryId)) {
|
|
1301
|
+
turnsCounted.add(call.entryId);
|
|
1302
|
+
const turnUsd = analysis.costByEntry.get(call.entryId) ?? 0;
|
|
1303
|
+
for (const totals of [clawback, kind]) {
|
|
1304
|
+
totals.turns++;
|
|
1305
|
+
totals.turnsUsd += turnUsd;
|
|
1306
|
+
}
|
|
1307
|
+
compounding.recoveryUsd += turnUsd;
|
|
1308
|
+
sessionMoney.recoveryUsd += turnUsd;
|
|
1309
|
+
point.netUsd -= turnUsd;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
gross.byTool = toolRows(byTool);
|
|
1315
|
+
const tools = gross.byTool.map(row => row.tool);
|
|
1316
|
+
gross.ofToolOutput =
|
|
1317
|
+
gross.savedTokens + toolTokens > 0 ? gross.savedTokens / (gross.savedTokens + toolTokens) : null;
|
|
1318
|
+
gross.ofSessionTokens = sessionTokens > 0 ? gross.savedTokens / sessionTokens : null;
|
|
1319
|
+
clawback.netTokens = gross.savedTokens - clawback.tokens;
|
|
1320
|
+
const avoidedNet = compounding.avoidedTokens - compounding.recoveredCarriedTokens;
|
|
1321
|
+
const shareDen = compounding.promptTokens + avoidedNet;
|
|
1322
|
+
compounding.avoidedShare = shareDen > 0 ? avoidedNet / shareDen : null;
|
|
1323
|
+
compounding.netUsd = compounding.savedUsd - compounding.recoveryUsd - compounding.spotlightUsd;
|
|
1324
|
+
|
|
1325
|
+
stats.series = [...series.values()].sort((a, b) => a.timestamp - b.timestamp);
|
|
1326
|
+
recent.sort((a, b) => (b.call.result?.timestamp ?? 0) - (a.call.result?.timestamp ?? 0));
|
|
1327
|
+
stats.recent = recent.slice(0, RECENT_LIMIT).map(({ call, analysis }) => toResultRow(call, analysis));
|
|
1328
|
+
stats.sessionList = active
|
|
1329
|
+
.toSorted((a, b) => lastActivity(b) - lastActivity(a))
|
|
1330
|
+
.slice(0, SESSION_LIST_LIMIT)
|
|
1331
|
+
.map(summarize);
|
|
1332
|
+
stats.fetchBack = computeFetchBack(evaluated, tools);
|
|
1333
|
+
stats.economics = computeEconomics(evaluated, active, tools);
|
|
1334
|
+
stats.gaps = computeGaps(evaluated, tools);
|
|
1335
|
+
stats.overhead = computeOverhead(evaluated);
|
|
1336
|
+
stats.adjusted = computeAdjusted(evaluated, money, gross.savedTokens, compounding, tools);
|
|
1337
|
+
stats.ranking = computeRanking(evaluated);
|
|
1338
|
+
stats.steps = computeSteps(evaluated, holdoutBaselines(evaluated), tools);
|
|
1339
|
+
stats.timeCost = computeTimeCost(active, cutoff);
|
|
1340
|
+
stats.coverage = computeCoverage(database, active, cutoff, new Set(tools));
|
|
1341
|
+
return stats;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ---------------------------------------------------------------------------
|
|
1345
|
+
// Fetch-backs, economics, gaps, overhead
|
|
1346
|
+
// ---------------------------------------------------------------------------
|
|
1347
|
+
|
|
1348
|
+
/** An evaluated result in the range. */
|
|
1349
|
+
export interface Evaluated {
|
|
1350
|
+
call: Call;
|
|
1351
|
+
result: Result;
|
|
1352
|
+
analysis: SessionAnalysis;
|
|
1353
|
+
tool: SpotlightTool;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function sizeBin(tokens: number): number {
|
|
1357
|
+
const bin = SIZE_BIN_BOUNDS.findIndex(bound => tokens < bound);
|
|
1358
|
+
return bin === -1 ? SIZE_BIN_BOUNDS.length : bin;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** Per-session fetch-back counts of one group, by original-size bin. */
|
|
1362
|
+
interface FetchBackCluster {
|
|
1363
|
+
fetched: number[];
|
|
1364
|
+
results: number[];
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Σ weight × bin rate / Σ weight over the bins with results; null without any. `weights` is the applied cuts' size
|
|
1369
|
+
* mix, so a reference standardized this way compares like with like.
|
|
1370
|
+
*/
|
|
1371
|
+
function standardizedRate(
|
|
1372
|
+
fetched: readonly number[],
|
|
1373
|
+
results: readonly number[],
|
|
1374
|
+
weights: readonly number[],
|
|
1375
|
+
): number | null {
|
|
1376
|
+
let rate = 0;
|
|
1377
|
+
let weight = 0;
|
|
1378
|
+
for (let bin = 0; bin < weights.length; bin++) {
|
|
1379
|
+
if (results[bin] === 0 || weights[bin] === 0) continue;
|
|
1380
|
+
rate += (weights[bin] * fetched[bin]) / results[bin];
|
|
1381
|
+
weight += weights[bin];
|
|
1382
|
+
}
|
|
1383
|
+
return weight > 0 ? rate / weight : null;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* The reference group's fetch-back rate standardized to `weights`, bootstrapped over sessions (bins a resample
|
|
1388
|
+
* lacks drop from both sides).
|
|
1389
|
+
*/
|
|
1390
|
+
function standardizedEstimate(
|
|
1391
|
+
clusters: readonly FetchBackCluster[],
|
|
1392
|
+
weights: readonly number[],
|
|
1393
|
+
key: string,
|
|
1394
|
+
): SpotlightEstimate {
|
|
1395
|
+
const bins = weights.length;
|
|
1396
|
+
const sum = (sample: readonly FetchBackCluster[]): [number[], number[]] => {
|
|
1397
|
+
const fetched = Array.from({ length: bins }, () => 0);
|
|
1398
|
+
const results = Array.from({ length: bins }, () => 0);
|
|
1399
|
+
for (const cluster of sample) {
|
|
1400
|
+
for (let bin = 0; bin < bins; bin++) {
|
|
1401
|
+
fetched[bin] += cluster.fetched[bin];
|
|
1402
|
+
results[bin] += cluster.results[bin];
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return [fetched, results];
|
|
1406
|
+
};
|
|
1407
|
+
const value = standardizedRate(...sum(clusters), weights);
|
|
1408
|
+
if (value === null || clusters.length < BOOTSTRAP_MIN_SESSIONS) return { value, lo: null, hi: null };
|
|
1409
|
+
return {
|
|
1410
|
+
value,
|
|
1411
|
+
...bootstrapInterval(key, random => {
|
|
1412
|
+
const sample = Array.from(clusters, () => clusters[Math.floor(random() * clusters.length)]);
|
|
1413
|
+
return standardizedRate(...sum(sample), weights);
|
|
1414
|
+
}),
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** A result's fetch-back group; null for a cut the no-score fallback made because Spotlight's calls failed. */
|
|
1419
|
+
function cutGroup(result: Result): SpotlightCutGroup | null {
|
|
1420
|
+
if (result.holdout) return "holdout";
|
|
1421
|
+
if (result.gated) return "gated";
|
|
1422
|
+
if (!result.shortened) return "reference";
|
|
1423
|
+
if (result.scoreFree) return "scoreFree";
|
|
1424
|
+
return result.failed ? null : "applied";
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Fetch-back rates per tool (in `tools` order): applied (Spotlight-scored) cuts, score-free cuts, held-out results,
|
|
1429
|
+
* unchanged results standardized to the applied cuts' original-size mix (bins without unchanged results drop from
|
|
1430
|
+
* both; with no applied cuts, the reference keeps its own mix), and reads pi's former edit gate kept whole (only
|
|
1431
|
+
* where there are any). Results without a recorded call (issued before the session's marker) and cuts whose
|
|
1432
|
+
* Spotlight calls failed are not measured.
|
|
1433
|
+
*/
|
|
1434
|
+
function computeFetchBack(evaluated: readonly Evaluated[], tools: readonly SpotlightTool[]): SpotlightFetchBackCell[] {
|
|
1435
|
+
const cells: SpotlightFetchBackCell[] = [];
|
|
1436
|
+
const bins = SIZE_BIN_BOUNDS.length + 1;
|
|
1437
|
+
for (const tool of tools) {
|
|
1438
|
+
const groups: Record<SpotlightCutGroup, Map<string, FetchBackCluster>> = {
|
|
1439
|
+
applied: new Map(),
|
|
1440
|
+
holdout: new Map(),
|
|
1441
|
+
reference: new Map(),
|
|
1442
|
+
scoreFree: new Map(),
|
|
1443
|
+
gated: new Map(),
|
|
1444
|
+
};
|
|
1445
|
+
let measured = 0;
|
|
1446
|
+
for (const item of evaluated) {
|
|
1447
|
+
if (item.tool !== tool || !Number.isFinite(item.call.seq)) continue;
|
|
1448
|
+
const group = cutGroup(item.result);
|
|
1449
|
+
if (!group) continue;
|
|
1450
|
+
const file = item.analysis.session.session_file;
|
|
1451
|
+
let cluster = groups[group].get(file);
|
|
1452
|
+
if (!cluster) {
|
|
1453
|
+
cluster = {
|
|
1454
|
+
fetched: Array.from({ length: bins }, () => 0),
|
|
1455
|
+
results: Array.from({ length: bins }, () => 0),
|
|
1456
|
+
};
|
|
1457
|
+
groups[group].set(file, cluster);
|
|
1458
|
+
}
|
|
1459
|
+
const bin = sizeBin(item.result.inTokens);
|
|
1460
|
+
cluster.results[bin]++;
|
|
1461
|
+
if (item.call.fetchBackGap !== null) cluster.fetched[bin]++;
|
|
1462
|
+
measured++;
|
|
1463
|
+
}
|
|
1464
|
+
if (measured === 0) continue;
|
|
1465
|
+
const total = (cluster: FetchBackCluster, keep: (bin: number) => boolean): [number, number] => {
|
|
1466
|
+
let fetched = 0;
|
|
1467
|
+
let results = 0;
|
|
1468
|
+
for (let bin = 0; bin < bins; bin++) {
|
|
1469
|
+
if (!keep(bin)) continue;
|
|
1470
|
+
fetched += cluster.fetched[bin];
|
|
1471
|
+
results += cluster.results[bin];
|
|
1472
|
+
}
|
|
1473
|
+
return [fetched, results];
|
|
1474
|
+
};
|
|
1475
|
+
for (const group of ["applied", "holdout", "scoreFree", "gated"] as const) {
|
|
1476
|
+
// Only reads are gated: the group is listed where it has results.
|
|
1477
|
+
if (group === "gated" && groups.gated.size === 0) continue;
|
|
1478
|
+
const clusters = [...groups[group].values()];
|
|
1479
|
+
const pairs = clusters.map(cluster => total(cluster, () => true));
|
|
1480
|
+
cells.push({
|
|
1481
|
+
tool,
|
|
1482
|
+
group,
|
|
1483
|
+
results: pairs.reduce((sum, [, results]) => sum + results, 0),
|
|
1484
|
+
sessions: clusters.length,
|
|
1485
|
+
rate: bootstrapRatio(pairs, `fetchBack:${tool}:${group}`),
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
const appliedMix = Array.from({ length: bins }, () => 0);
|
|
1489
|
+
for (const cluster of groups.applied.values()) {
|
|
1490
|
+
for (let bin = 0; bin < bins; bin++) appliedMix[bin] += cluster.results[bin];
|
|
1491
|
+
}
|
|
1492
|
+
const referenceMix = Array.from({ length: bins }, () => 0);
|
|
1493
|
+
for (const cluster of groups.reference.values()) {
|
|
1494
|
+
for (let bin = 0; bin < bins; bin++) referenceMix[bin] += cluster.results[bin];
|
|
1495
|
+
}
|
|
1496
|
+
const weights = appliedMix.some(count => count > 0) ? appliedMix : referenceMix;
|
|
1497
|
+
const kept = [...groups.reference.values()].filter(cluster => total(cluster, bin => weights[bin] > 0)[1] > 0);
|
|
1498
|
+
cells.push({
|
|
1499
|
+
tool,
|
|
1500
|
+
group: "reference",
|
|
1501
|
+
results: kept.reduce((sum, cluster) => sum + total(cluster, bin => weights[bin] > 0)[1], 0),
|
|
1502
|
+
sessions: kept.length,
|
|
1503
|
+
rate: standardizedEstimate(kept, weights, `fetchBack:${tool}:reference`),
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
return cells;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Requests that did nothing but recover output applied cuts hid, shared among their calls' sources: a request of
|
|
1511
|
+
* two such calls adds half a turn (and half its cost) to each call's cut.
|
|
1512
|
+
*/
|
|
1513
|
+
function extraTurnShares(analysis: SessionAnalysis): Map<Call, { turns: number; usd: number }> {
|
|
1514
|
+
const byEntry = new Map<string, Call[]>();
|
|
1515
|
+
for (const call of analysis.calls) {
|
|
1516
|
+
if (!call.recovery?.source.result?.shortened || !call.entryId) continue;
|
|
1517
|
+
const calls = byEntry.get(call.entryId);
|
|
1518
|
+
if (calls) calls.push(call);
|
|
1519
|
+
else byEntry.set(call.entryId, [call]);
|
|
1520
|
+
}
|
|
1521
|
+
const shares = new Map<Call, { turns: number; usd: number }>();
|
|
1522
|
+
for (const [entryId, calls] of byEntry) {
|
|
1523
|
+
if (calls.length !== calls[0].callsInTurn) continue;
|
|
1524
|
+
const usd = analysis.costByEntry.get(entryId) ?? 0;
|
|
1525
|
+
for (const call of calls) {
|
|
1526
|
+
const source = call.recovery?.source;
|
|
1527
|
+
if (!source) continue;
|
|
1528
|
+
const share = shares.get(source) ?? { turns: 0, usd: 0 };
|
|
1529
|
+
share.turns += 1 / calls.length;
|
|
1530
|
+
share.usd += usd / calls.length;
|
|
1531
|
+
shares.set(source, share);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return shares;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** What the cuts of each tool (in `tools` order) earned; tools without applied cuts are left out. */
|
|
1538
|
+
function computeEconomics(
|
|
1539
|
+
evaluated: readonly Evaluated[],
|
|
1540
|
+
active: readonly SessionAnalysis[],
|
|
1541
|
+
tools: readonly SpotlightTool[],
|
|
1542
|
+
): SpotlightCutEconomics[] {
|
|
1543
|
+
const shares = new Map<Call, { turns: number; usd: number }>();
|
|
1544
|
+
for (const analysis of active) {
|
|
1545
|
+
for (const [source, share] of extraTurnShares(analysis)) shares.set(source, share);
|
|
1546
|
+
}
|
|
1547
|
+
const cells: SpotlightCutEconomics[] = [];
|
|
1548
|
+
for (const tool of tools) {
|
|
1549
|
+
const cell: SpotlightCutEconomics = {
|
|
1550
|
+
tool,
|
|
1551
|
+
cuts: 0,
|
|
1552
|
+
savedTokens: 0,
|
|
1553
|
+
recoveredTokens: 0,
|
|
1554
|
+
extraTurns: 0,
|
|
1555
|
+
savedUsd: 0,
|
|
1556
|
+
recoveryUsd: 0,
|
|
1557
|
+
turnUsd: 0,
|
|
1558
|
+
netUsd: 0,
|
|
1559
|
+
extraTurnsPer100Cuts: 0,
|
|
1560
|
+
netUsdPer100Cuts: 0,
|
|
1561
|
+
};
|
|
1562
|
+
let spotlightUsd = 0;
|
|
1563
|
+
for (const { call, result, ...item } of evaluated) {
|
|
1564
|
+
if (item.tool !== tool) continue;
|
|
1565
|
+
// The bill covers every evaluated result: the cuts cannot be had without evaluating the rest.
|
|
1566
|
+
spotlightUsd += result.spotlightUsd;
|
|
1567
|
+
if (!result.shortened) continue;
|
|
1568
|
+
cell.cuts++;
|
|
1569
|
+
cell.savedTokens += result.savedTokens;
|
|
1570
|
+
cell.savedUsd += call.savedCarryUsd;
|
|
1571
|
+
for (const recovered of call.recoveredBy) {
|
|
1572
|
+
cell.recoveredTokens += recovered.tokens;
|
|
1573
|
+
cell.recoveryUsd += recovered.recoveryCarryUsd;
|
|
1574
|
+
}
|
|
1575
|
+
const share = shares.get(call);
|
|
1576
|
+
if (share) {
|
|
1577
|
+
cell.extraTurns += share.turns;
|
|
1578
|
+
cell.turnUsd += share.usd;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (cell.cuts === 0) continue;
|
|
1582
|
+
cell.netUsd = cell.savedUsd - cell.recoveryUsd - cell.turnUsd - spotlightUsd;
|
|
1583
|
+
cell.extraTurnsPer100Cuts = (cell.extraTurns * 100) / cell.cuts;
|
|
1584
|
+
cell.netUsdPer100Cuts = (cell.netUsd * 100) / cell.cuts;
|
|
1585
|
+
cells.push(cell);
|
|
1586
|
+
}
|
|
1587
|
+
return cells;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function gapBin(gap: number): SpotlightGapBin {
|
|
1591
|
+
if (gap <= 3) return GAP_BINS[Math.max(0, gap - 1)];
|
|
1592
|
+
return gap <= 6 ? "4–6" : "7–12";
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* First fetch-backs of applied cuts and held-out results per tool (in `tools` order) and gap bin: every bin of each
|
|
1597
|
+
* tool with at least one.
|
|
1598
|
+
*/
|
|
1599
|
+
function computeGaps(evaluated: readonly Evaluated[], tools: readonly SpotlightTool[]): SpotlightGapCount[] {
|
|
1600
|
+
const counts = new Map<SpotlightTool, SpotlightGapCount[]>();
|
|
1601
|
+
for (const { call, result, tool } of evaluated) {
|
|
1602
|
+
if (call.fetchBackGap === null || !Number.isFinite(call.seq)) continue;
|
|
1603
|
+
const group = result.shortened ? "applied" : result.holdout ? "holdout" : null;
|
|
1604
|
+
if (!group) continue;
|
|
1605
|
+
let bins = counts.get(tool);
|
|
1606
|
+
if (!bins) {
|
|
1607
|
+
bins = GAP_BINS.map(gap => ({ tool, gap, applied: 0, holdout: 0 }));
|
|
1608
|
+
counts.set(tool, bins);
|
|
1609
|
+
}
|
|
1610
|
+
bins[GAP_BINS.indexOf(gapBin(call.fetchBackGap))][group]++;
|
|
1611
|
+
}
|
|
1612
|
+
return tools.flatMap(tool => counts.get(tool) ?? []);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** Shaping time, Spotlight latency, failures and bill over every evaluated result. */
|
|
1616
|
+
function computeOverhead(evaluated: readonly Evaluated[]): SpotlightOverhead {
|
|
1617
|
+
const shapeMs: number[] = [];
|
|
1618
|
+
const spotlightMs: number[] = [];
|
|
1619
|
+
let failed = 0;
|
|
1620
|
+
let failedWaitMs = 0;
|
|
1621
|
+
let called = 0;
|
|
1622
|
+
let spotlightUsd = 0;
|
|
1623
|
+
for (const { result } of evaluated) {
|
|
1624
|
+
spotlightUsd += result.spotlightUsd;
|
|
1625
|
+
if (result.shapeMs !== null) shapeMs.push(result.shapeMs);
|
|
1626
|
+
if (result.spotlightCalls > 0) {
|
|
1627
|
+
called++;
|
|
1628
|
+
if (result.spotlightFailures > 0) {
|
|
1629
|
+
// A failed call records no Spotlight time; its wait shows in the shaping time instead.
|
|
1630
|
+
failed++;
|
|
1631
|
+
failedWaitMs += result.shapeMs ?? 0;
|
|
1632
|
+
} else {
|
|
1633
|
+
spotlightMs.push(result.spotlightMs);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
shapeMs.sort((a, b) => a - b);
|
|
1638
|
+
spotlightMs.sort((a, b) => a - b);
|
|
1639
|
+
const at = (sorted: readonly number[], p: number) => (sorted.length > 0 ? quantile(sorted, p) : null);
|
|
1640
|
+
return {
|
|
1641
|
+
results: evaluated.length,
|
|
1642
|
+
shapeMsP50: at(shapeMs, 0.5),
|
|
1643
|
+
shapeMsP90: at(shapeMs, 0.9),
|
|
1644
|
+
spotlightMsP50: at(spotlightMs, 0.5),
|
|
1645
|
+
spotlightMsP90: at(spotlightMs, 0.9),
|
|
1646
|
+
failureRate: called > 0 ? failed / called : null,
|
|
1647
|
+
failedWaitMs,
|
|
1648
|
+
spotlightUsd,
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function toResultRow(call: Call, analysis: SessionAnalysis): SpotlightResultRow {
|
|
1653
|
+
const result = call.result;
|
|
1654
|
+
if (!result) throw new Error("spotlight: result row without a result");
|
|
1655
|
+
const recoveries = call.recoveredBy.flatMap(recovered =>
|
|
1656
|
+
recovered.recovery ? [toRecovery(recovered, recovered.recovery)] : [],
|
|
1657
|
+
);
|
|
1658
|
+
return {
|
|
1659
|
+
sessionFile: analysis.session.session_file,
|
|
1660
|
+
toolCallId: call.id,
|
|
1661
|
+
timestamp: result.timestamp,
|
|
1662
|
+
tool: result.tool,
|
|
1663
|
+
holdout: result.holdout,
|
|
1664
|
+
target: displayTarget(call),
|
|
1665
|
+
inTokens: result.inTokens,
|
|
1666
|
+
outTokens: result.outTokens,
|
|
1667
|
+
savedTokens: result.savedTokens,
|
|
1668
|
+
hiddenLines: result.hiddenLines,
|
|
1669
|
+
steps: result.steps,
|
|
1670
|
+
spotlightUsd: result.spotlightUsd,
|
|
1671
|
+
shapeMs: result.shapeMs ?? 0,
|
|
1672
|
+
plainTokens: result.plainTokens,
|
|
1673
|
+
scoreFree: result.scoreFree,
|
|
1674
|
+
recoveries,
|
|
1675
|
+
netTokens: result.savedTokens - recoveries.reduce((total, r) => total + r.tokens, 0),
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Per-request context of one session with and without its net savings. */
|
|
1680
|
+
function contextPoints(analysis: SessionAnalysis): SpotlightContextPoint[] {
|
|
1681
|
+
const landed = analysis.calls
|
|
1682
|
+
.map(call => ({
|
|
1683
|
+
timestamp: call.result?.timestamp ?? call.timestamp,
|
|
1684
|
+
saved: call.result?.shortened ? call.result.savedTokens : 0,
|
|
1685
|
+
recovered: call.recovery ? call.tokens : 0,
|
|
1686
|
+
}))
|
|
1687
|
+
.filter(item => item.saved !== 0 || item.recovered !== 0)
|
|
1688
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
1689
|
+
const points: SpotlightContextPoint[] = [];
|
|
1690
|
+
let next = 0;
|
|
1691
|
+
let net = 0;
|
|
1692
|
+
for (const request of analysis.requests) {
|
|
1693
|
+
let saved = 0;
|
|
1694
|
+
let recovered = 0;
|
|
1695
|
+
while (next < landed.length && landed[next].timestamp < request.timestamp) {
|
|
1696
|
+
saved += landed[next].saved;
|
|
1697
|
+
recovered += landed[next].recovered;
|
|
1698
|
+
next++;
|
|
1699
|
+
}
|
|
1700
|
+
net = request.compacted ? 0 : net + saved - recovered;
|
|
1701
|
+
points.push({
|
|
1702
|
+
timestamp: request.timestamp,
|
|
1703
|
+
contextTokens: request.context,
|
|
1704
|
+
withoutTokens: request.context + net,
|
|
1705
|
+
compacted: request.compacted,
|
|
1706
|
+
saved,
|
|
1707
|
+
recovered,
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
return points;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// ---------------------------------------------------------------------------
|
|
1714
|
+
// Entry points
|
|
1715
|
+
// ---------------------------------------------------------------------------
|
|
1716
|
+
|
|
1717
|
+
/** `GET /api/stats/spotlight?range=&agent=`; an unknown `agent` measures every transcript. */
|
|
1718
|
+
export async function getSpotlightDashboardStats(
|
|
1719
|
+
range?: string | null,
|
|
1720
|
+
agent?: string | null,
|
|
1721
|
+
): Promise<SpotlightDashboardStats> {
|
|
1722
|
+
const database = await initDb();
|
|
1723
|
+
const scope: SpotlightAgentScope = agent === "main" || agent === "subagent" ? agent : "all";
|
|
1724
|
+
return buildSpotlightDashboard(database, getTimeRangeConfig(range), scope);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** `GET /api/stats/spotlight/session?file=`; null when the file is not an experiment session. */
|
|
1728
|
+
export async function getSpotlightSessionContext(sessionFile: string): Promise<SpotlightSessionContext | null> {
|
|
1729
|
+
const database = await initDb();
|
|
1730
|
+
const session = database
|
|
1731
|
+
.query<SessionRow, [string]>(
|
|
1732
|
+
"SELECT session_file, folder, marked_at, cwd, title FROM spotlight_sessions WHERE session_file = ?",
|
|
1733
|
+
)
|
|
1734
|
+
.get(sessionFile);
|
|
1735
|
+
if (!session) return null;
|
|
1736
|
+
const analysis = new SessionLoader(database).analyze(session);
|
|
1737
|
+
return { session: summarize(analysis), points: contextPoints(analysis) };
|
|
1738
|
+
}
|