opencode-harness-kit 0.4.3 → 0.4.4
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 +527 -18
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +307 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -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 +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// premises.mjs - check that a suite's premises are true of the subject before
|
|
3
|
+
// anyone spends twenty agent runs finding out.
|
|
4
|
+
//
|
|
5
|
+
// Seven numbers in eval/RESULTS.md were wrong, and every one of them was a
|
|
6
|
+
// grader agreeing with the agent about something that was not there:
|
|
7
|
+
//
|
|
8
|
+
// - a task asked for a total of 100, the profiles summed to 85/80/80/80/80/75,
|
|
9
|
+
// so four agents correctly preserved sums that were never 100 and the
|
|
10
|
+
// grader passed them
|
|
11
|
+
// - a `mustNotContain` check on a string the subject never contained is
|
|
12
|
+
// vacuous: an agent that changes nothing scores a perfect pass
|
|
13
|
+
// - a `mustContain` string already present in the subject means the work was
|
|
14
|
+
// done before the run started
|
|
15
|
+
//
|
|
16
|
+
// A premise that can be checked by machine should never be checked by reading
|
|
17
|
+
// it. This checks every premise in the suite that is mechanically checkable:
|
|
18
|
+
//
|
|
19
|
+
// read task `want` occurs in the subject at least once
|
|
20
|
+
// mutate task mutate.file exists
|
|
21
|
+
// mustNotContain is present in the ORIGINAL file (not vacuous)
|
|
22
|
+
// mustContain / mustContainMin is absent from the original
|
|
23
|
+
//
|
|
24
|
+
// What it cannot check is a premise written in prose -- "each profile has three
|
|
25
|
+
// dimensions". For that the honest answer is the same one the task file already
|
|
26
|
+
// gives: read the code. What this can do is make sure the parts that are
|
|
27
|
+
// checkable are true, so a false premise that is still prose is the only kind
|
|
28
|
+
// that gets through.
|
|
29
|
+
//
|
|
30
|
+
// node eval/premises.mjs --target <dir> --suite eval/tasks.json
|
|
31
|
+
// node eval/premises.mjs --target <dir> --suite eval/tasks.json --json
|
|
32
|
+
|
|
33
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
|
34
|
+
import { join, relative } from "node:path"
|
|
35
|
+
|
|
36
|
+
const TEXT_FILE = /\.(md|py|ts|tsx|js|json|txt|ya?ml|toml|sh|ini|cfg)$/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every text file in the subject, as {rel, lines}.
|
|
40
|
+
* @param {string} root
|
|
41
|
+
* @param {number} [depth]
|
|
42
|
+
* @returns {{rel: string, lines: string[]}[]}
|
|
43
|
+
*/
|
|
44
|
+
export function walk(root, depth = 0) {
|
|
45
|
+
if (depth > 8) return []
|
|
46
|
+
/** @type {{rel: string, lines: string[]}[]} */
|
|
47
|
+
const out = []
|
|
48
|
+
let entries
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
51
|
+
} catch {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e.name === ".git" || e.name === "node_modules" || e.name === ".space") continue
|
|
56
|
+
const abs = join(root, e.name)
|
|
57
|
+
if (e.isDirectory()) {
|
|
58
|
+
out.push(...walk(abs, depth + 1))
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (!TEXT_FILE.test(e.name)) continue
|
|
62
|
+
try {
|
|
63
|
+
out.push({ rel: relative(root, abs), lines: readFileSync(abs, "utf8").split("\n") })
|
|
64
|
+
} catch {
|
|
65
|
+
/* unreadable file is not a premise failure */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every place `needle` occurs, as file:line.
|
|
73
|
+
* @param {{rel: string, lines: string[]}[]} files
|
|
74
|
+
* @param {string} needle
|
|
75
|
+
*/
|
|
76
|
+
export function findAll(files, needle) {
|
|
77
|
+
const hits = []
|
|
78
|
+
for (const f of files) {
|
|
79
|
+
for (let i = 0; i < f.lines.length; i++) {
|
|
80
|
+
if (f.lines[i].includes(needle)) hits.push(`${f.rel}:${i + 1}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return hits
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} target subject root
|
|
88
|
+
* @param {{tasks?: any[], snapshot?: string}} suite
|
|
89
|
+
* @returns {{ok: boolean, fatal: {id: string, problem: string}[], warnings: {id: string, problem: string}[]}}
|
|
90
|
+
*/
|
|
91
|
+
export function checkPremises(target, suite) {
|
|
92
|
+
/** @type {{id: string, problem: string}[]} */
|
|
93
|
+
const fatal = []
|
|
94
|
+
/** @type {{id: string, problem: string}[]} */
|
|
95
|
+
const warnings = []
|
|
96
|
+
const files = walk(target)
|
|
97
|
+
if (!files.length) {
|
|
98
|
+
return { ok: false, fatal: [{ id: "(subject)", problem: `no text files found under ${target}` }], warnings }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const t of suite.tasks ?? []) {
|
|
102
|
+
const id = t.id ?? "(no id)"
|
|
103
|
+
|
|
104
|
+
if (t.mutate) {
|
|
105
|
+
const abs = join(target, t.mutate.file)
|
|
106
|
+
if (!existsSync(abs)) {
|
|
107
|
+
fatal.push({ id, problem: `mutate.file ${t.mutate.file} is not in the subject` })
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const body = readFileSync(abs, "utf8")
|
|
111
|
+
const count = (s) => body.split(s).length - 1
|
|
112
|
+
for (const s of t.mutate.mustNotContain ?? []) {
|
|
113
|
+
if (count(s) === 0) {
|
|
114
|
+
fatal.push({
|
|
115
|
+
id,
|
|
116
|
+
problem: `mustNotContain ${JSON.stringify(s)} is not in ${t.mutate.file} to begin with, so the check passes no matter what the agent does`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const s of t.mutate.mustContain ?? []) {
|
|
121
|
+
if (count(s) > 0) {
|
|
122
|
+
fatal.push({
|
|
123
|
+
id,
|
|
124
|
+
problem: `mustContain ${JSON.stringify(s)} is already in ${t.mutate.file}: the task is already done before the run`,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const [s, n] of Object.entries(t.mutate.mustContainMin ?? {})) {
|
|
129
|
+
if (count(s) > 0) {
|
|
130
|
+
fatal.push({
|
|
131
|
+
id,
|
|
132
|
+
problem: `mustContainMin ${JSON.stringify(s)} already appears ${count(s)} time(s) in ${t.mutate.file}`,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
if (typeof n !== "number" || n < 1 || !Number.isInteger(n)) {
|
|
136
|
+
fatal.push({ id, problem: `mustContainMin ${JSON.stringify(s)} needs a whole number of at least 1` })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const hits = findAll(files, String(t.want ?? ""))
|
|
143
|
+
if (hits.length === 0) {
|
|
144
|
+
fatal.push({
|
|
145
|
+
id,
|
|
146
|
+
problem: `want ${JSON.stringify(t.want)} occurs nowhere in the subject, so no answer can be graded correct`,
|
|
147
|
+
})
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
const where = hits.map((h) => h.split(":")[0])
|
|
151
|
+
const spread = new Set(where)
|
|
152
|
+
if (spread.size > 1) {
|
|
153
|
+
// Not fatal: read-multi-out is exactly this shape, and being able to name
|
|
154
|
+
// which of several files answers the question is the work being measured.
|
|
155
|
+
warnings.push({ id, problem: `want occurs in ${spread.size} files: ${[...spread].join(", ")}` })
|
|
156
|
+
}
|
|
157
|
+
if (hits.length > 1 && spread.size === 1) {
|
|
158
|
+
warnings.push({ id, problem: `want occurs ${hits.length} times in ${[...spread][0]}` })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { ok: fatal.length === 0, fatal, warnings }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
167
|
+
const args = process.argv.slice(2)
|
|
168
|
+
const flag = (...n) => {
|
|
169
|
+
const i = args.findIndex((a) => n.includes(a))
|
|
170
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
171
|
+
}
|
|
172
|
+
const target = flag("--target")
|
|
173
|
+
const suitePath = flag("--suite")
|
|
174
|
+
if (!target || !suitePath) {
|
|
175
|
+
console.error("usage: node eval/premises.mjs --target <dir> --suite <file> [--json]")
|
|
176
|
+
process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
const suite = JSON.parse(readFileSync(suitePath, "utf8"))
|
|
179
|
+
const report = checkPremises(target, suite)
|
|
180
|
+
if (args.includes("--json")) {
|
|
181
|
+
console.log(JSON.stringify(report, null, 2))
|
|
182
|
+
process.exit(report.ok ? 0 : 1)
|
|
183
|
+
}
|
|
184
|
+
const label = suite.snapshot ?? target
|
|
185
|
+
console.log(`premises for ${suitePath} against ${label}`)
|
|
186
|
+
console.log("")
|
|
187
|
+
if (report.warnings.length) {
|
|
188
|
+
console.log(` ${report.warnings.length} warning(s):`)
|
|
189
|
+
for (const w of report.warnings) console.log(` ${w.id}: ${w.problem}`)
|
|
190
|
+
console.log("")
|
|
191
|
+
}
|
|
192
|
+
if (report.fatal.length) {
|
|
193
|
+
console.log(` ${report.fatal.length} FATAL. The suite would score runs against a subject that does not say what it claims:`)
|
|
194
|
+
for (const f of report.fatal) console.log(` ${f.id}: ${f.problem}`)
|
|
195
|
+
console.log("")
|
|
196
|
+
console.log(" Fix the task, or fix the subject. Do not run the suite against this.")
|
|
197
|
+
process.exit(1)
|
|
198
|
+
}
|
|
199
|
+
console.log(` all premises hold against ${label}`)
|
|
200
|
+
}
|