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
package/eval/models.mjs
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// models.mjs - measure each model on the live tier.
|
|
3
|
+
//
|
|
4
|
+
// The routing table in docs/ROUTING.md says how fast each lane's model is,
|
|
5
|
+
// and a number in a table that nobody can reproduce is a number nobody can
|
|
6
|
+
// check. This is the script that produced the current one.
|
|
7
|
+
//
|
|
8
|
+
// node eval/models.mjs the table, for the models the profiles use
|
|
9
|
+
// node eval/models.mjs --reps 3 more samples per model
|
|
10
|
+
// node eval/models.mjs --effort also measure the `low` effort variant
|
|
11
|
+
// node eval/models.mjs --json machine-readable
|
|
12
|
+
// node eval/models.mjs --model <id> one model
|
|
13
|
+
//
|
|
14
|
+
// What it measures:
|
|
15
|
+
//
|
|
16
|
+
// wall ms the whole `opencode run`, so it includes the process start
|
|
17
|
+
// and the round trip. A lane is a round trip, so this is the
|
|
18
|
+
// number a person waits for.
|
|
19
|
+
// reasoning the tokens the model spent thinking. A model that reasons
|
|
20
|
+
// for 400 tokens on a question that needs none is a model
|
|
21
|
+
// being asked the wrong way.
|
|
22
|
+
//
|
|
23
|
+
// The prompt is one line and the same for every model, so the comparison is
|
|
24
|
+
// between models and not between prompts. It is not a benchmark of the
|
|
25
|
+
// models: it is a measurement of what a lane would wait for.
|
|
26
|
+
|
|
27
|
+
import { execFileSync, spawnSync } from "node:child_process"
|
|
28
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
|
|
29
|
+
import { tmpdir } from "node:os"
|
|
30
|
+
import { join } from "node:path"
|
|
31
|
+
|
|
32
|
+
import { median } from "./metrics.js"
|
|
33
|
+
import { countFlag, stringFlag } from "./flags.mjs"
|
|
34
|
+
|
|
35
|
+
const args = process.argv.slice(2)
|
|
36
|
+
const wantJson = args.includes("--json")
|
|
37
|
+
// The shared flag parser, so `--reps=5` means what `--reps 5` means and a
|
|
38
|
+
// mistyped value is an error rather than a measurement of nothing.
|
|
39
|
+
let reps
|
|
40
|
+
let only
|
|
41
|
+
try {
|
|
42
|
+
reps = countFlag("--reps", args) ?? 3
|
|
43
|
+
only = stringFlag("--model", args)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
const withEffort = args.includes("--effort")
|
|
49
|
+
|
|
50
|
+
const PROMPT = "Reply with exactly the word OK and nothing else."
|
|
51
|
+
|
|
52
|
+
// The models the shipped profiles use. The catalogue turns over in days, so
|
|
53
|
+
// this is read from the profiles rather than written down here: a model that
|
|
54
|
+
// has been withdrawn is reported as gone instead of measured.
|
|
55
|
+
async function modelsInUse() {
|
|
56
|
+
const { listProfileIds, loadProfile, modelsInPlay } = await import("../src/profile.js")
|
|
57
|
+
const seen = new Map()
|
|
58
|
+
for (const id of listProfileIds()) {
|
|
59
|
+
let p
|
|
60
|
+
try {
|
|
61
|
+
p = loadProfile(id)
|
|
62
|
+
} catch {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
for (const m of modelsInPlay(p) ?? []) {
|
|
66
|
+
// The provider is part of the model id as opencode knows it.
|
|
67
|
+
const full = m.includes("/") ? m : `${p.provider}/${m}`
|
|
68
|
+
if (!seen.has(full)) seen.set(full, [])
|
|
69
|
+
seen.get(full).push(id)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...seen.entries()].map(([model, profiles]) => ({ model, profiles }))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One request to one model.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} model
|
|
79
|
+
* @param {string | null} variant
|
|
80
|
+
* @returns {{ ok: boolean, ms: number, reasoning: number, output: number, input: number, error: string | null }}
|
|
81
|
+
*/
|
|
82
|
+
function measure(model, variant) {
|
|
83
|
+
const dir = mkdtempSync(join(tmpdir(), "ock-model-"))
|
|
84
|
+
const argv = ["run", "--format", "json", "--model", model, "--dir", dir]
|
|
85
|
+
if (variant) argv.push("--variant", variant)
|
|
86
|
+
argv.push(PROMPT)
|
|
87
|
+
const t0 = Date.now()
|
|
88
|
+
let res
|
|
89
|
+
try {
|
|
90
|
+
res = spawnSync("opencode", argv, {
|
|
91
|
+
cwd: dir,
|
|
92
|
+
encoding: "utf8",
|
|
93
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
94
|
+
timeout: 180_000,
|
|
95
|
+
killSignal: "SIGKILL",
|
|
96
|
+
})
|
|
97
|
+
} finally {
|
|
98
|
+
// The directory holds a whole opencode project. A run that is killed
|
|
99
|
+
// leaves it behind, and a scratch directory that is never cleaned up is
|
|
100
|
+
// a scratch directory nobody can find again.
|
|
101
|
+
try {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true })
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
const ms = Date.now() - t0
|
|
106
|
+
const out = res.stdout ?? ""
|
|
107
|
+
let reasoning = 0
|
|
108
|
+
let output = 0
|
|
109
|
+
let input = 0
|
|
110
|
+
let finished = false
|
|
111
|
+
for (const line of out.split("\n")) {
|
|
112
|
+
if (!line.trim().startsWith("{")) continue
|
|
113
|
+
let ev
|
|
114
|
+
try {
|
|
115
|
+
ev = JSON.parse(line)
|
|
116
|
+
} catch {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (ev.type !== "step_finish") continue
|
|
120
|
+
finished = true
|
|
121
|
+
const t = ev.part?.tokens ?? {}
|
|
122
|
+
reasoning += Number(t.reasoning ?? 0)
|
|
123
|
+
output += Number(t.output ?? 0)
|
|
124
|
+
input += Number(t.input ?? 0) + Number(t.cache?.read ?? 0)
|
|
125
|
+
}
|
|
126
|
+
if (!finished) {
|
|
127
|
+
// The reason, in the order it is useful: the spawn failed (opencode is
|
|
128
|
+
// not installed), the process was killed (the ceiling), and only then
|
|
129
|
+
// whatever the provider said. "no reply" on its own says nothing about
|
|
130
|
+
// which of the three it was.
|
|
131
|
+
const reason = res.error
|
|
132
|
+
? `could not run opencode: ${res.error.message}`
|
|
133
|
+
: res.signal
|
|
134
|
+
? `killed after the ${Math.round(ms / 1000)}s ceiling (${res.signal})`
|
|
135
|
+
: ((res.stderr ?? "").split("\n").find((l) => l.trim()) ?? "no reply")
|
|
136
|
+
return { ok: false, ms, reasoning, output, input, error: reason }
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, ms, reasoning, output, input, error: null }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The effort levels each model publishes, from the live catalogue.
|
|
143
|
+
*
|
|
144
|
+
* A model that publishes no `low` level cannot be asked for one: the
|
|
145
|
+
* request is ignored and the model runs at its natural rate. Measuring that
|
|
146
|
+
* and putting it in a column called "wall @low" would say a model is
|
|
147
|
+
* faster at a tier it does not have.
|
|
148
|
+
*
|
|
149
|
+
* @returns {Promise<Map<string, string[]>>}
|
|
150
|
+
*/
|
|
151
|
+
async function publishedEffort() {
|
|
152
|
+
const out = new Map()
|
|
153
|
+
const url = process.env.OPENCODE_HARNESS_CATALOG_URL ?? "https://models.dev/api.json"
|
|
154
|
+
try {
|
|
155
|
+
const body = url.startsWith("file://")
|
|
156
|
+
? readFileSync(url.slice("file://".length), "utf8")
|
|
157
|
+
: execFileSync("curl", ["-sL", "--fail", "--max-time", "40", url], {
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
// The catalogue is over a megabyte, and the default buffer is one
|
|
160
|
+
// megabyte: without this the fetch fails with ENOBUFS, the catch
|
|
161
|
+
// swallows it, and every model looks like it publishes no effort
|
|
162
|
+
// level at all.
|
|
163
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
164
|
+
})
|
|
165
|
+
const catalog = JSON.parse(body)
|
|
166
|
+
for (const [provider, p] of Object.entries(catalog ?? {})) {
|
|
167
|
+
for (const [id, m] of Object.entries(p?.models ?? {})) {
|
|
168
|
+
const levels = []
|
|
169
|
+
for (const ro of m?.reasoning_options ?? []) {
|
|
170
|
+
if (ro?.type === "effort") levels.push(...(ro?.values ?? []))
|
|
171
|
+
}
|
|
172
|
+
out.set(`${provider}/${id}`, levels)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// A catalogue that cannot be read is not a reason to refuse to measure.
|
|
177
|
+
// The levels are used to mark a column, not to decide anything - but the
|
|
178
|
+
// reason is printed, because a silent failure here marks every model as
|
|
179
|
+
// publishing no effort level, which is a false statement about them.
|
|
180
|
+
console.error(
|
|
181
|
+
` could not read the catalogue: ${err instanceof Error ? err.message : String(err)}`,
|
|
182
|
+
)
|
|
183
|
+
console.error(" the effort levels are unknown, so the @low column will read n/a.")
|
|
184
|
+
}
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const effort = await publishedEffort()
|
|
189
|
+
|
|
190
|
+
const rows = []
|
|
191
|
+
for (const { model, profiles } of await modelsInUse()) {
|
|
192
|
+
// The docs name a model by its bare id, so both spellings work.
|
|
193
|
+
if (only && model !== only && model !== `opencode/${only}` && only !== model.split("/").pop()) continue
|
|
194
|
+
const samples = []
|
|
195
|
+
for (let i = 0; i < reps; i++) {
|
|
196
|
+
let r = measure(model, null)
|
|
197
|
+
// One retry. The free tier drops requests, and a model that failed once
|
|
198
|
+
// is not a model that is gone - reporting it as one would hide the
|
|
199
|
+
// difference between "withdrawn" and "the provider was busy".
|
|
200
|
+
if (!r.ok) r = measure(model, null)
|
|
201
|
+
if (!r.ok) {
|
|
202
|
+
samples.length = 0
|
|
203
|
+
rows.push({ model, profiles, ok: false, error: r.error })
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
samples.push(r)
|
|
207
|
+
}
|
|
208
|
+
if (!samples.length) continue
|
|
209
|
+
const ms = samples.map((s) => s.ms)
|
|
210
|
+
// The `low` variant is only measured for a model that PUBLISHES `low`.
|
|
211
|
+
// A model with no effort levels runs at its natural rate whatever is
|
|
212
|
+
// asked for, and a column that calls that "@low" is a lie.
|
|
213
|
+
const levels = effort.get(model) ?? []
|
|
214
|
+
const publishesLow = levels.includes("low")
|
|
215
|
+
const low = []
|
|
216
|
+
if (withEffort && publishesLow) {
|
|
217
|
+
for (let i = 0; i < reps; i++) {
|
|
218
|
+
const r = measure(model, "low")
|
|
219
|
+
if (r.ok) low.push(r)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
rows.push({
|
|
223
|
+
model,
|
|
224
|
+
profiles,
|
|
225
|
+
ok: true,
|
|
226
|
+
requests: samples.length,
|
|
227
|
+
avg_ms: Math.round(ms.reduce((a, b) => a + b, 0) / ms.length),
|
|
228
|
+
median_ms: Math.round(median(ms)),
|
|
229
|
+
max_ms: Math.max(...ms),
|
|
230
|
+
reasoning: Math.round(median(samples.map((s) => s.reasoning))),
|
|
231
|
+
output: Math.round(median(samples.map((s) => s.output))),
|
|
232
|
+
input: Math.round(median(samples.map((s) => s.input))),
|
|
233
|
+
effort_levels: levels,
|
|
234
|
+
publishes_low: publishesLow,
|
|
235
|
+
low_requests: low.length,
|
|
236
|
+
low_reasoning: low.length ? Math.round(median(low.map((r) => r.reasoning))) : null,
|
|
237
|
+
low_avg_ms: low.length ? Math.round(low.reduce((a, r) => a + r.ms, 0) / low.length) : null,
|
|
238
|
+
})
|
|
239
|
+
process.stderr.write(
|
|
240
|
+
` measured ${model} (${samples.length} request(s), median ${rows.at(-1).median_ms} ms)\n`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const failed = rows.filter((r) => !r.ok).length
|
|
245
|
+
if (wantJson) {
|
|
246
|
+
console.log(
|
|
247
|
+
JSON.stringify(
|
|
248
|
+
{ when: new Date().toISOString(), reps, prompt: PROMPT, ok: failed === 0, failed, rows },
|
|
249
|
+
null,
|
|
250
|
+
2,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log("")
|
|
257
|
+
console.log(`MEASURED ON THE LIVE TIER ${new Date().toISOString().slice(0, 10)}`)
|
|
258
|
+
console.log(` ${reps} request(s) per model, one-line prompt, whole-process wall time`)
|
|
259
|
+
console.log("")
|
|
260
|
+
const head = ["model", "reqs", "avg ms", "median", "max", "reasoning", "output"]
|
|
261
|
+
console.log(" " + head.map((h, i) => (i === 0 ? h.padEnd(30) : h.padStart(10))).join(""))
|
|
262
|
+
console.log(" " + "-".repeat(30 + 10 * (head.length - 1)))
|
|
263
|
+
for (const r of rows) {
|
|
264
|
+
if (!r.ok) {
|
|
265
|
+
console.log(" " + r.model.padEnd(30) + ` FAILED: ${String(r.error).slice(0, 70)}`)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
console.log(
|
|
269
|
+
" " +
|
|
270
|
+
r.model.padEnd(30) +
|
|
271
|
+
String(r.requests).padStart(10) +
|
|
272
|
+
String(r.avg_ms).padStart(10) +
|
|
273
|
+
String(r.median_ms).padStart(10) +
|
|
274
|
+
String(r.max_ms).padStart(10) +
|
|
275
|
+
String(r.reasoning).padStart(10) +
|
|
276
|
+
String(r.output).padStart(10),
|
|
277
|
+
)
|
|
278
|
+
if (r.publishes_low) {
|
|
279
|
+
console.log(
|
|
280
|
+
" " +
|
|
281
|
+
`${r.model} @low`.padEnd(30) +
|
|
282
|
+
String(r.low_requests).padStart(10) +
|
|
283
|
+
String(r.low_avg_ms).padStart(10) +
|
|
284
|
+
"".padStart(10) +
|
|
285
|
+
"".padStart(10) +
|
|
286
|
+
String(r.low_reasoning).padStart(10),
|
|
287
|
+
)
|
|
288
|
+
} else if (withEffort) {
|
|
289
|
+
console.log(
|
|
290
|
+
" " +
|
|
291
|
+
`${r.model} @low`.padEnd(30) +
|
|
292
|
+
"n/a".padStart(10) +
|
|
293
|
+
" (publishes no low level - it runs at its natural rate)",
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
console.log("")
|
|
298
|
+
|
|
299
|
+
if (failed) {
|
|
300
|
+
console.error(` ${failed} model(s) could not be measured - the numbers above are not the whole tier.`)
|
|
301
|
+
process.exit(1)
|
|
302
|
+
}
|
|
@@ -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
|
+
}
|