opencode-harness-kit 0.4.3 → 0.4.5
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 +569 -18
- package/CONTRIBUTING.md +249 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +354 -66
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1268 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +83 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +20 -5
- package/docs/ROUTING.md +191 -31
- package/eval/RESULTS.md +457 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +331 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/models.mjs +302 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// measure-prune.mjs - how much context the pruner actually removes, on real
|
|
3
|
+
// work.
|
|
4
|
+
//
|
|
5
|
+
// The install-wide comparison in measure-install.mjs is an OBSERVATION: it
|
|
6
|
+
// compares the work before the kit landed with the work after, and the work
|
|
7
|
+
// is never the same. It reported the context per request going UP by 17% on
|
|
8
|
+
// one install, with twice as many reads and twice as many messages in the
|
|
9
|
+
// second window - a number about the jobs, not about the kit.
|
|
10
|
+
//
|
|
11
|
+
// This one is attributable. opencode stores the message parts UNPRUNED: the
|
|
12
|
+
// pruner rewrites the outgoing request, not the history. So the stored parts
|
|
13
|
+
// of a real session are exactly what the pruner was given, and replaying the
|
|
14
|
+
// real pruner over them measures exactly what it removed - on the same
|
|
15
|
+
// messages, with and without, on the work that actually happened.
|
|
16
|
+
//
|
|
17
|
+
// node eval/measure-prune.mjs the last 20 sessions
|
|
18
|
+
// node eval/measure-prune.mjs --sessions 50
|
|
19
|
+
// node eval/measure-prune.mjs --min-saved 20 gate: at least 20% removed
|
|
20
|
+
//
|
|
21
|
+
// The gate is on the CONTROLLED number. The install-wide one stays an
|
|
22
|
+
// observation.
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs"
|
|
25
|
+
import { homedir, tmpdir } from "node:os"
|
|
26
|
+
import { dirname, join } from "node:path"
|
|
27
|
+
import { fileURLToPath } from "node:url"
|
|
28
|
+
|
|
29
|
+
import { canQuerySqlite, sqliteMissingMessage, query as queryDb } from "../src/sqlite.js"
|
|
30
|
+
import { countFlag, numberFlag } from "./flags.mjs"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The message of a caught value, whatever it turned out to be.
|
|
34
|
+
*
|
|
35
|
+
* `catch` gives `unknown` under strict mode, and the file is not strict -
|
|
36
|
+
* but a message is what these scripts exist to print.
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} err
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function errorMessage(err) {
|
|
42
|
+
return err instanceof Error ? err.message : String(err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
46
|
+
const ROOT = join(HERE, "..")
|
|
47
|
+
const DIST = join(ROOT, "dist", "index.js")
|
|
48
|
+
|
|
49
|
+
const args = process.argv.slice(2)
|
|
50
|
+
const flag = (name) => {
|
|
51
|
+
const i = args.indexOf(name)
|
|
52
|
+
if (i >= 0) return args[i + 1]
|
|
53
|
+
const inline = args.find((a) => a.startsWith(`${name}=`))
|
|
54
|
+
return inline === undefined ? undefined : inline.slice(name.length + 1)
|
|
55
|
+
}
|
|
56
|
+
const wantJson = args.includes("--json")
|
|
57
|
+
|
|
58
|
+
const DB = flag("--db") ?? join(homedir(), ".local/share/opencode/opencode.db")
|
|
59
|
+
// A mistyped number is an error, not a disabled gate. `--sessions abc`
|
|
60
|
+
// became NaN and was interpolated straight into the SQL, where it became a
|
|
61
|
+
// column name.
|
|
62
|
+
let SESSIONS
|
|
63
|
+
let DAYS
|
|
64
|
+
let MIN_MESSAGES
|
|
65
|
+
let minSaved
|
|
66
|
+
try {
|
|
67
|
+
SESSIONS = countFlag("--sessions") ?? 20
|
|
68
|
+
DAYS = countFlag("--days") ?? 30
|
|
69
|
+
MIN_MESSAGES = countFlag("--min-messages") ?? 6
|
|
70
|
+
// A gate of 0% is a valid gate, so this one is a plain number.
|
|
71
|
+
minSaved = numberFlag("--min-saved")
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(String(errorMessage(err)))
|
|
74
|
+
process.exit(1)
|
|
75
|
+
}
|
|
76
|
+
// The population: sessions in a time window, with enough assistant messages
|
|
77
|
+
// for the pruner to have anything to prune.
|
|
78
|
+
//
|
|
79
|
+
// The pruner leaves the last `keepTurns + 1` messages alone, so a session
|
|
80
|
+
// with five messages has nothing older than that and the pruner correctly
|
|
81
|
+
// does nothing. Measuring the last N sessions therefore measures whatever ran
|
|
82
|
+
// last - a test suite, a batch of probes - rather than the work.
|
|
83
|
+
//
|
|
84
|
+
// A session needs more than `keepTurns + 1` assistant messages to be
|
|
85
|
+
// prunable at all, so the default is six.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if (!existsSync(DB)) {
|
|
90
|
+
console.error(`opencode database not found: ${DB}`)
|
|
91
|
+
console.error("pass --db <path> or run opencode at least once first")
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
if (!canQuerySqlite()) {
|
|
95
|
+
console.error(sqliteMissingMessage())
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
if (!existsSync(DIST)) {
|
|
99
|
+
console.error("dist/index.js is missing. Run: npm run build")
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The real pruner, out of the real bundle. A measurement of a re-implementation
|
|
104
|
+
// is a measurement of the re-implementation.
|
|
105
|
+
const { HarnessPlugin } = await import(DIST)
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The messages of a session, in the shape the pruner sees them.
|
|
109
|
+
* @param {string} sessionId
|
|
110
|
+
*/
|
|
111
|
+
function sessionMessages(sessionId) {
|
|
112
|
+
const msgs = queryDb(
|
|
113
|
+
DB,
|
|
114
|
+
`select id, time_created, data from message where session_id = '${sessionId.replace(/'/g, "''")}' order by time_created asc`,
|
|
115
|
+
)
|
|
116
|
+
const out = []
|
|
117
|
+
for (const m of msgs) {
|
|
118
|
+
let info
|
|
119
|
+
try {
|
|
120
|
+
info = JSON.parse(m.data)
|
|
121
|
+
} catch {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
// EVERY message, not only the assistant ones. The pruner computes its
|
|
125
|
+
// floor over the whole list - `messages.length - 1 - keepTurns` - so a
|
|
126
|
+
// replay that drops the user messages measures a different thing, and a
|
|
127
|
+
// lower bound rather than the real effect.
|
|
128
|
+
const parts = queryDb(
|
|
129
|
+
DB,
|
|
130
|
+
`select data from part where message_id = '${String(m.id).replace(/'/g, "''")}' order by time_created asc`,
|
|
131
|
+
)
|
|
132
|
+
const parsed = []
|
|
133
|
+
for (const p of parts) {
|
|
134
|
+
try {
|
|
135
|
+
parsed.push(JSON.parse(p.data))
|
|
136
|
+
} catch {
|
|
137
|
+
// A part that does not parse is a part the pruner cannot touch.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!parsed.length) continue
|
|
141
|
+
out.push({ info, parts: parsed })
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The sessions to measure: in the window, with enough messages, newest first.
|
|
147
|
+
const sessions = queryDb(
|
|
148
|
+
DB,
|
|
149
|
+
`select s.id as id, count(m.id) as msgs
|
|
150
|
+
from session s
|
|
151
|
+
join message m on m.session_id = s.id
|
|
152
|
+
where s.time_created >= ${Date.now() - DAYS * 864e5}
|
|
153
|
+
and json_extract(m.data,'$.role') = 'assistant'
|
|
154
|
+
group by s.id
|
|
155
|
+
having msgs >= ${MIN_MESSAGES}
|
|
156
|
+
order by s.time_created desc
|
|
157
|
+
limit ${Math.floor(SESSIONS)}`,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// The pruner has to be ACTIVE for the measurement, whatever the machine's
|
|
161
|
+
// local state says. It finds its profile from `.space/active` in the project,
|
|
162
|
+
// and that file is gitignored - so on a fresh clone there is none, the pruner
|
|
163
|
+
// does nothing, and the gate reports 0% removed.
|
|
164
|
+
//
|
|
165
|
+
// The environment variable is the documented override, and it is set for the
|
|
166
|
+
// measurement only.
|
|
167
|
+
const PREV = process.env.OPENCODE_HARNESS_PROFILE
|
|
168
|
+
process.env.OPENCODE_HARNESS_PROFILE = PREV ?? "fleet-safe"
|
|
169
|
+
|
|
170
|
+
// The measurement must not write into the repository it is measuring: the
|
|
171
|
+
// pruner logs every pass to `.space/prune.jsonl` in the project, and that is
|
|
172
|
+
// the same file the kit's own runtime telemetry uses. A scratch directory
|
|
173
|
+
// keeps the measurement a measurement.
|
|
174
|
+
const scratch = mkdtempSync(join(tmpdir(), "ock-prune-measure-"))
|
|
175
|
+
const hook = await HarnessPlugin(
|
|
176
|
+
{
|
|
177
|
+
client: { app: { get: async () => ({}), log: { on: async () => {} } } },
|
|
178
|
+
project: { id: "measure", worktree: scratch, vcs: "git" },
|
|
179
|
+
directory: scratch,
|
|
180
|
+
worktree: scratch,
|
|
181
|
+
serverUrl: new URL("http://127.0.0.1:0"),
|
|
182
|
+
$: Object.assign(() => ({ pipe: () => ({}) }), { env: () => process.env }),
|
|
183
|
+
},
|
|
184
|
+
{ only: ["prune"] },
|
|
185
|
+
)
|
|
186
|
+
const transform = hook["experimental.chat.messages.transform"]
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The size the PRUNER measures.
|
|
190
|
+
*
|
|
191
|
+
* The pruner counts CHARACTERS, not bytes: `part.text.length`. The report
|
|
192
|
+
* labelled that "bytes", which is wrong for anything that is not ASCII - and
|
|
193
|
+
* an agent's output is not ASCII. Both numbers are reported.
|
|
194
|
+
*/
|
|
195
|
+
function sizeOf(part) {
|
|
196
|
+
if (typeof part?.text === "string") return part.text.length
|
|
197
|
+
if (typeof part?.state?.output === "string") return part.state.output.length
|
|
198
|
+
return 0
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The same text, in bytes. */
|
|
202
|
+
function byteSizeOf(part) {
|
|
203
|
+
if (typeof part?.text === "string") return Buffer.byteLength(part.text, "utf8")
|
|
204
|
+
if (typeof part?.state?.output === "string") {
|
|
205
|
+
return Buffer.byteLength(part.state.output, "utf8")
|
|
206
|
+
}
|
|
207
|
+
return 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let totalBefore = 0
|
|
211
|
+
let totalAfter = 0
|
|
212
|
+
let bytesBefore = 0
|
|
213
|
+
let bytesAfter = 0
|
|
214
|
+
let measured = 0
|
|
215
|
+
let stubbed = 0
|
|
216
|
+
const perSession = []
|
|
217
|
+
|
|
218
|
+
for (const s of sessions) {
|
|
219
|
+
const messages = sessionMessages(s.id)
|
|
220
|
+
if (!messages.length) continue
|
|
221
|
+
const before = messages.reduce(
|
|
222
|
+
(n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
|
|
223
|
+
0,
|
|
224
|
+
)
|
|
225
|
+
const beforeBytes = messages.reduce(
|
|
226
|
+
(n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
|
|
227
|
+
0,
|
|
228
|
+
)
|
|
229
|
+
// The pruner mutates in place, so the copy has to be the real thing.
|
|
230
|
+
const reasoningBefore = messages.flatMap((m) =>
|
|
231
|
+
m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
|
|
232
|
+
)
|
|
233
|
+
const copy = messages.map((m) => ({
|
|
234
|
+
info: m.info,
|
|
235
|
+
parts: m.parts.map((p) => (p.state ? { ...p, state: { ...p.state } } : { ...p })),
|
|
236
|
+
}))
|
|
237
|
+
await transform({}, { messages: copy })
|
|
238
|
+
const after = copy.reduce(
|
|
239
|
+
(n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
|
|
240
|
+
0,
|
|
241
|
+
)
|
|
242
|
+
if (!before) continue
|
|
243
|
+
totalBefore += before
|
|
244
|
+
totalAfter += after
|
|
245
|
+
bytesBefore += beforeBytes
|
|
246
|
+
bytesAfter += copy.reduce(
|
|
247
|
+
(n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
|
|
248
|
+
0,
|
|
249
|
+
)
|
|
250
|
+
measured++
|
|
251
|
+
// The pruner marks TOOL parts with `metadata.pruned` and REPLACES reasoning
|
|
252
|
+
// parts, so counting only the marked ones undercounts what was stubbed.
|
|
253
|
+
const toolCut = copy
|
|
254
|
+
.flatMap((m) => m.parts)
|
|
255
|
+
.filter((p) => p.state?.metadata?.pruned).length
|
|
256
|
+
const reasoningAfter = copy.flatMap((m) =>
|
|
257
|
+
m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
|
|
258
|
+
)
|
|
259
|
+
const reasoningCut = reasoningBefore.filter(
|
|
260
|
+
(t, i) => reasoningAfter[i] !== t,
|
|
261
|
+
).length
|
|
262
|
+
const cut = toolCut + reasoningCut
|
|
263
|
+
stubbed += cut
|
|
264
|
+
perSession.push({
|
|
265
|
+
session: s.id,
|
|
266
|
+
bytes_before: before,
|
|
267
|
+
bytes_after: after,
|
|
268
|
+
saved_pct: Math.round((1 - after / before) * 1000) / 10,
|
|
269
|
+
parts_stubbed: cut,
|
|
270
|
+
tool_parts_stubbed: toolCut,
|
|
271
|
+
reasoning_parts_stubbed: reasoningCut,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const savedPct =
|
|
276
|
+
totalBefore > 0 ? Math.round((1 - totalAfter / totalBefore) * 1000) / 10 : null
|
|
277
|
+
|
|
278
|
+
const failures = []
|
|
279
|
+
if (minSaved !== null) {
|
|
280
|
+
if (measured === 0) {
|
|
281
|
+
failures.push("no session had assistant messages to measure")
|
|
282
|
+
} else if (savedPct === null || savedPct < minSaved) {
|
|
283
|
+
failures.push(`the pruner removed ${savedPct ?? "n/a"}% of the context, expected at least ${minSaved}%`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The measurement's own scratch directory, and the environment it borrowed.
|
|
288
|
+
process.on("exit", () => {
|
|
289
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
290
|
+
if (PREV === undefined) delete process.env.OPENCODE_HARNESS_PROFILE
|
|
291
|
+
else process.env.OPENCODE_HARNESS_PROFILE = PREV
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const report = {
|
|
295
|
+
db: DB,
|
|
296
|
+
sessions_measured: measured,
|
|
297
|
+
characters_before: totalBefore,
|
|
298
|
+
characters_after: totalAfter,
|
|
299
|
+
bytes_before: bytesBefore,
|
|
300
|
+
bytes_after: bytesAfter,
|
|
301
|
+
saved_pct: savedPct,
|
|
302
|
+
parts_stubbed: stubbed,
|
|
303
|
+
per_session: perSession,
|
|
304
|
+
verdict: failures.length ? "FAIL" : "PASS",
|
|
305
|
+
failures,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (wantJson) {
|
|
309
|
+
console.log(JSON.stringify(report, null, 2))
|
|
310
|
+
process.exit(failures.length ? 1 : 0)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log("PRUNER EFFECT ON REAL SESSIONS")
|
|
314
|
+
console.log(` db ${DB}`)
|
|
315
|
+
console.log(
|
|
316
|
+
` sessions ${measured} (last ${DAYS}d, at least ${MIN_MESSAGES} assistant messages each)`,
|
|
317
|
+
)
|
|
318
|
+
console.log("")
|
|
319
|
+
console.log(` context before ${totalBefore.toLocaleString("en-US")} characters`)
|
|
320
|
+
console.log(` context after ${totalAfter.toLocaleString("en-US")} characters`)
|
|
321
|
+
console.log(` in bytes ${bytesBefore.toLocaleString("en-US")} -> ${bytesAfter.toLocaleString("en-US")}`)
|
|
322
|
+
console.log(` removed ${savedPct === null ? "n/a" : savedPct + "%"}`)
|
|
323
|
+
console.log(` parts stubbed ${stubbed}`)
|
|
324
|
+
if (perSession.length) {
|
|
325
|
+
console.log("")
|
|
326
|
+
const worst = [...perSession].sort((a, b) => a.saved_pct - b.saved_pct).slice(0, 5)
|
|
327
|
+
console.log(" the five sessions the pruner helped least:")
|
|
328
|
+
for (const p of worst) {
|
|
329
|
+
console.log(` ${p.saved_pct}% ${p.bytes_before.toLocaleString("en-US")} bytes ${p.session.slice(0, 12)}`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (failures.length) {
|
|
333
|
+
console.log("")
|
|
334
|
+
console.log(` FAIL ${failures.join("; ")}`)
|
|
335
|
+
} else if (minSaved !== null) {
|
|
336
|
+
console.log("")
|
|
337
|
+
console.log(` PASS the pruner removed at least ${minSaved}% of the context.`)
|
|
338
|
+
}
|
|
339
|
+
process.exit(failures.length ? 1 : 0)
|
package/eval/metrics.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// metrics.js - the arithmetic behind measure-install.mjs, with no I/O so
|
|
2
|
+
// test/metrics.test.js can pin it without a database.
|
|
3
|
+
//
|
|
4
|
+
// Kept separate from measure-install.mjs on purpose: importing that file runs
|
|
5
|
+
// the whole measurement and calls process.exit, which would make the numbers
|
|
6
|
+
// untestable and the module unusable anywhere else.
|
|
7
|
+
|
|
8
|
+
/** Share of reads that were bounded by offset or limit, as a percentage. */
|
|
9
|
+
export function pct(narrow, total) {
|
|
10
|
+
if (!total) return 0
|
|
11
|
+
return Math.round((narrow / total) * 1000) / 10
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Read calls per search call. Null when there were no searches to divide by. */
|
|
15
|
+
export function readsPerSearch(reads, searches) {
|
|
16
|
+
if (!searches) return null
|
|
17
|
+
return Math.round((reads / searches) * 10) / 10
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Percentage drop in average context per request. Null when there is no
|
|
22
|
+
* baseline, or no measurement on the other side.
|
|
23
|
+
*
|
|
24
|
+
* The other side being NULL means there was no data. Treating that as zero
|
|
25
|
+
* would report a 100% improvement - "context per request: 100% lower" from a
|
|
26
|
+
* window that contained nothing at all. The caller passes null for "no
|
|
27
|
+
* rows", and this returns null rather than a number.
|
|
28
|
+
*/
|
|
29
|
+
export function ctxDropPct(before, after) {
|
|
30
|
+
if (!before || after === null || after === undefined) return null
|
|
31
|
+
return Math.round((1 - after / before) * 1000) / 10
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Tokens of accumulated history per assistant turn. Null with no turns to divide by. */
|
|
35
|
+
export function historyPerTurn(history, turns) {
|
|
36
|
+
if (!turns) return null
|
|
37
|
+
return Math.round((history / turns) * 10) / 10
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wall time between two opencode event timestamps.
|
|
42
|
+
*
|
|
43
|
+
* Those timestamps are milliseconds, and dividing by 100 instead of 1000 made
|
|
44
|
+
* every duration in eval/RESULTS.md ten times too large for one commit - a
|
|
45
|
+
* 4.4 minute run was reported as 44 minutes. The ratios survived because the
|
|
46
|
+
* error is a constant factor, which is exactly why it went unnoticed until the
|
|
47
|
+
* absolute numbers were checked against a wall clock.
|
|
48
|
+
*/
|
|
49
|
+
export function elapsedSeconds(fromMs, toMs) {
|
|
50
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null
|
|
51
|
+
return Math.round((toMs - fromMs) / 100) / 10
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Median of a list, rounded to one decimal.
|
|
56
|
+
*
|
|
57
|
+
* Not an average. One kit-off run took 262.7s against 42.4s and 7.3s, and a mean
|
|
58
|
+
* over three turns that into an 8x slowdown that never happened. Outliers are
|
|
59
|
+
* the normal case for agent wall time, so every reported figure is a median.
|
|
60
|
+
* Null rather than NaN for an empty list.
|
|
61
|
+
*/
|
|
62
|
+
export function median(values) {
|
|
63
|
+
const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b)
|
|
64
|
+
if (!v.length) return null
|
|
65
|
+
const mid = Math.floor(v.length / 2)
|
|
66
|
+
const m = v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2
|
|
67
|
+
return Math.round(m * 10) / 10
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether the kit pays for itself: the fixed overhead it adds to every request
|
|
72
|
+
* versus the history it removes per session. Positive means it costs more.
|
|
73
|
+
*
|
|
74
|
+
* Pinned because the whole conclusion in eval/RESULTS.md rests on the sign, and
|
|
75
|
+
* turning `< 0` into `<= 0` would flip the verdict without any visible change.
|
|
76
|
+
*/
|
|
77
|
+
export function payback(fixedDelta, historyDelta) {
|
|
78
|
+
return fixedDelta + historyDelta < 0
|
|
79
|
+
}
|