opencode-harness-kit 0.4.6 → 0.5.1
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 +120 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +36 -5
- package/dist/index.js +34 -2
- package/dist/profile.d.ts +3 -1
- package/docs/KNOWN-LIMITS.md +34 -3
- package/eval/RESULTS.md +166 -245
- package/eval/ab.mjs +51 -5
- package/eval/premises.mjs +52 -0
- package/eval/prepare.mjs +34 -4
- package/eval/tasks-long.json +55 -0
- package/eval/tasks-process.json +40 -0
- package/eval/tasks-ts.json +40 -34
- package/eval/verify.mjs +145 -5
- package/package.json +1 -1
- package/profiles/schema.json +5 -0
- package/src/profile.js +9 -1
- package/src/prune.ts +59 -1
package/eval/RESULTS.md
CHANGED
|
@@ -146,263 +146,184 @@ That is the mechanism the whole kit rests on, and it is the one thing the
|
|
|
146
146
|
correlation in Results 1 and 2 could not show. The kit does not shrink what it
|
|
147
147
|
costs; it shrinks what the agent drags along behind it.
|
|
148
148
|
|
|
149
|
-
## The A/B on TypeScript
|
|
149
|
+
## The A/B on TypeScript, against a PUBLIC subject
|
|
150
150
|
|
|
151
|
-
node eval/
|
|
151
|
+
node eval/prepare.mjs --clone https://github.com/typesense/typesense-js \
|
|
152
|
+
--ref 337fb8599d2e916090d943dd3b9895b71c383711 --name ts
|
|
153
|
+
node eval/ab.mjs --target <scratch>/ts --suite eval/tasks-ts.json --reps 2 --timeout 600
|
|
152
154
|
|
|
153
|
-
**
|
|
154
|
-
|
|
155
|
-
the
|
|
155
|
+
**The subject is public, and the harness clones it.** The earlier TypeScript
|
|
156
|
+
pilot ran against a private repository, which means nobody else could repeat
|
|
157
|
+
it - the same problem the self-suite was written to solve, in the one suite
|
|
158
|
+
that had it.
|
|
156
159
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
measurement is the self-suite.
|
|
160
|
+
The subject is the official TypeScript SDK for Typesense: Apache-2.0, about
|
|
161
|
+
five megabytes, TypeScript source under `src/Typesense/`, and the **compiled
|
|
162
|
+
JavaScript committed next to it** in `lib/Typesense/` and again in the
|
|
163
|
+
unminified bundle `dist/typesense.js`.
|
|
162
164
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
written, because two earlier tasks were wrong for failing that check.
|
|
165
|
+
**That is the trap, and it is a stronger one than the private subject's.**
|
|
166
|
+
Every answer occurs THREE times: in the TypeScript source, in the compiled
|
|
167
|
+
module, and in the bundle - and the bundle keeps the original module banners,
|
|
168
|
+
so a search for a source path lands straight in the build output. Each of
|
|
169
|
+
the four answers was checked to occur in exactly one file under `src/`, which
|
|
170
|
+
is the check that stops an ambiguous task.
|
|
170
171
|
|
|
171
172
|
| Metric (median of 8 runs per arm) | kit on | kit off |
|
|
172
173
|
| --- | --- | --- |
|
|
173
|
-
|
|
|
174
|
-
| tool calls |
|
|
175
|
-
|
|
|
176
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
174
|
+
| **correct** | **8/8** | **7/8** |
|
|
175
|
+
| tool calls | 12 | 12 |
|
|
176
|
+
| read calls | 2 | 2 |
|
|
177
|
+
| narrow reads | 50% | 58.4% |
|
|
178
|
+
| input tokens | 46,025 | 17,324 |
|
|
179
|
+
| final context | 45,455 | 18,962 |
|
|
180
|
+
| wall seconds | **31.4** | 34.4 |
|
|
181
|
+
| paths outside the target | 0 | 0 |
|
|
179
182
|
|
|
180
183
|
| per task | kit on | kit off |
|
|
181
184
|
| --- | --- | --- |
|
|
182
|
-
| ts-
|
|
183
|
-
| ts-
|
|
184
|
-
| ts-
|
|
185
|
-
| ts-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
is
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
- **The speed gap still points the wrong way.** 26.1s against 19.4s, the same
|
|
220
|
-
direction as the five-task Python suite. See the note below.
|
|
221
|
-
- **Wall clock is uncontrolled for load.** Sixteen runs share one model route.
|
|
222
|
-
|
|
223
|
-
### The one pattern that holds across suites
|
|
224
|
-
|
|
225
|
-
Two suites that contain an edit task show the kit **slower**: Python 25.4s
|
|
226
|
-
against 17.4s, TypeScript 26.1s against 19.4s. The one suite that showed it
|
|
227
|
-
faster - 18.4s against 32.7s - had no edit task and was the smaller of the
|
|
228
|
-
three.
|
|
229
|
-
|
|
230
|
-
That is suggestive and it is the opposite of what the kit is built for. Reading
|
|
231
|
-
narrowly costs an extra tool call per run, 5 against 3.5 here, and on a suite of
|
|
232
|
-
three or four-step lookups that cost does not buy anything back because the bare
|
|
233
|
-
agent was already finding the file. Whether it buys anything back on real work
|
|
234
|
-
is exactly what this suite cannot show, since none of the tasks is real work.
|
|
235
|
-
|
|
236
|
-
**Correctness is a tie in every suite run so far**: 8/8 against 8/8, 8/8 against
|
|
237
|
-
8/8, and 9/10 against 9/10 before two task ambiguities were fixed. Thirty-eight
|
|
238
|
-
agent runs have not produced a single point of accuracy difference in either
|
|
239
|
-
direction.
|
|
240
|
-
|
|
241
|
-
## Where the gate thresholds come from
|
|
242
|
-
|
|
243
|
-
`npm run measure:check` fails below 45% narrow reads or a 20% fall in context per
|
|
244
|
-
request. The 45 is derived rather than picked:
|
|
245
|
-
|
|
246
|
-
| day since the install | read calls | narrow |
|
|
247
|
-
| --- | --- | --- |
|
|
248
|
-
| Oct 8 | 29 | 69.0% |
|
|
249
|
-
| Oct 9 | 207 | 60.9% |
|
|
250
|
-
| Oct 10 | 239 | 74.9% |
|
|
251
|
-
|
|
252
|
-
Lowest observed day is 60.9%, and the spread across those three days is 14
|
|
253
|
-
points. A floor one whole spread below the lowest day lands near 45%, so ordinary
|
|
254
|
-
day-to-day movement cannot trip it and anything under it means every day since
|
|
255
|
-
the install was better than the failure. The previous value, 50, sat above that
|
|
256
|
-
margin and would have failed on a day like Oct 9.
|
|
257
|
-
|
|
258
|
-
**Three days is not a confidence interval.** The margin is one observed spread,
|
|
259
|
-
not a standard deviation, and the floor should be re-derived as days accumulate.
|
|
260
|
-
The context floor of 20% is not derived at all: it sits far below the measured
|
|
261
|
-
45-57% drop, which is the conservative choice, and tightening it would need
|
|
262
|
-
enough days to see how that figure moves.
|
|
263
|
-
|
|
264
|
-
A per-session distribution was also computed and deliberately not used for this.
|
|
265
|
-
Sessions run bimodally - four of them read 0% narrow, mostly three or four read
|
|
266
|
-
calls each - so a per-session floor would land near zero and catch nothing.
|
|
267
|
-
|
|
268
|
-
## What these numbers do not prove
|
|
269
|
-
|
|
270
|
-
**The two windows are not the same work.** The before-window is dominated by
|
|
271
|
-
building this kit - reading source is what that job was. The after-window is
|
|
272
|
-
ordinary use. Part of the drop is the kit working; an unknown part is simply a
|
|
273
|
-
different mix of tasks. Nothing here separates the two.
|
|
274
|
-
|
|
275
|
-
**The after-window is thin.** 280 reads over roughly two days, and the script
|
|
276
|
-
flags this itself as `thin_sample`. One quiet day moves these numbers more than
|
|
277
|
-
the kit does. Treat the direction as a hint, not a result.
|
|
278
|
-
|
|
279
|
-
**Narrow reads are a proxy.** A bounded read that then has to be re-read with a
|
|
280
|
-
second bounded read is not cheaper than one whole-file read. This counts the
|
|
281
|
-
first read and ignores the follow-up.
|
|
282
|
-
|
|
283
|
-
**Nothing above is an A/B.** The windows in Results 1 and 2 are the same install
|
|
284
|
-
observed over time, and they compare different work. The section below runs one
|
|
285
|
-
fixed task twice - kit on, kit off - which is the only comparison here that can
|
|
286
|
-
say anything about cause.
|
|
287
|
-
|
|
288
|
-
## The A/B on this repository, 10 reps per arm
|
|
289
|
-
|
|
290
|
-
node eval/prepare.mjs --repo . --ref <sha> --name self
|
|
291
|
-
node eval/ab.mjs --target <scratch>/self --suite eval/tasks-self.json --reps 10
|
|
292
|
-
|
|
293
|
-
**The subject is this repository at a pinned commit**, so the run is
|
|
294
|
-
reproducible: `git clone && git checkout <sha>` and the same command. The
|
|
295
|
-
previous suite ran against a private repository, which meant a result nobody
|
|
296
|
-
else could check.
|
|
297
|
-
|
|
298
|
-
Six tasks, ten reps per arm, 120 agent runs: four lookups and two edits. The
|
|
299
|
-
lookups are graded on the `file:line` the agent cited, read out of the
|
|
300
|
-
snapshot; the edits are graded on the file after the run.
|
|
301
|
-
|
|
302
|
-
**Both arms ran on the same model**, `opencode/minimax-m3.1-flash-free`, and
|
|
303
|
-
the report prints it. The kit-off arm used to run with an empty config, so
|
|
304
|
-
opencode picked its own default: not the model the kit-on arm was on, and
|
|
305
|
-
not a model that answered - four kit-off runs hit the 600-second ceiling
|
|
306
|
-
with no output at all, and a timed-out run has zero tools and zero context,
|
|
307
|
-
so it entered the medians as a fast, empty, wrong answer.
|
|
308
|
-
|
|
309
|
-
That is why the older result below is quoted as "52/60 against **59/59**":
|
|
310
|
-
one of the sixty kit-off runs produced no answer at all and is excluded
|
|
311
|
-
from every number. Four of the kit-off runs timed out in the later run
|
|
312
|
-
described below, which is why the same suite cannot be compared run to
|
|
313
|
-
run.
|
|
314
|
-
|
|
315
|
-
| metric (median) | kit on | kit off |
|
|
185
|
+
| `ts-default-port` | 2/2 | 2/2 |
|
|
186
|
+
| `ts-default-export` | 2/2 | 1/2 |
|
|
187
|
+
| `ts-export-stream` | 2/2 | 2/2 |
|
|
188
|
+
| `ts-union-doc` | 2/2 | 2/2 |
|
|
189
|
+
|
|
190
|
+
The kit is ahead on correctness and ahead on wall time, and it costs about
|
|
191
|
+
two and a half times the input tokens. The private pilot said the opposite
|
|
192
|
+
(8/8 against 8/8, with the kit slower), on a different subject and with the
|
|
193
|
+
grader that mis-scored.
|
|
194
|
+
|
|
195
|
+
**One task is worth reading on its own.** `ts-default-export` asks which
|
|
196
|
+
three things the package's default export names - and the answer is in the
|
|
197
|
+
source, in the compiled module and in the bundle. The kit-off arm got one
|
|
198
|
+
of its two wrong.
|
|
199
|
+
|
|
200
|
+
## The A/B on a LONG task
|
|
201
|
+
|
|
202
|
+
node eval/prepare.mjs --repo . --ref <sha> --name long
|
|
203
|
+
node eval/ab.mjs --target <scratch>/long --suite eval/tasks-long.json --reps 2 --timeout 900
|
|
204
|
+
|
|
205
|
+
**The short suite cannot measure the kit's central claim.** The kit says
|
|
206
|
+
the pruner pays for itself on a long session, and every task in the short
|
|
207
|
+
suite is a two-line question answered in one or two reads - a session that
|
|
208
|
+
never grows the history far enough for pruning to matter. On those tasks the
|
|
209
|
+
kit is a cost, and the measurement says so: over five times the input tokens
|
|
210
|
+
and a tie on correctness.
|
|
211
|
+
|
|
212
|
+
So there is a long task. It touches twelve files, so the agent has to read
|
|
213
|
+
twelve of them, and it has to answer a question about the code that CONSUMES
|
|
214
|
+
the thing it changed, so it has to find that code too. The conversation is
|
|
215
|
+
long because the work is long, which is the only way the measurement can be
|
|
216
|
+
honest. It is graded on both halves: the change is compared against the
|
|
217
|
+
pristine subject file by file, and the answer is graded by citation.
|
|
218
|
+
|
|
219
|
+
**Both arms got it right.** The interesting part is what it cost.
|
|
220
|
+
|
|
221
|
+
| metric (median of 2 runs per arm) | kit on | kit off |
|
|
316
222
|
| --- | --- | --- |
|
|
317
|
-
| **correct** | **
|
|
318
|
-
| tool calls |
|
|
319
|
-
| read calls |
|
|
320
|
-
|
|
|
321
|
-
|
|
|
322
|
-
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
223
|
+
| **correct** | **2/2** | **2/2** |
|
|
224
|
+
| tool calls | 47 | 46 |
|
|
225
|
+
| read calls | 12 | 18 |
|
|
226
|
+
| input tokens | 78,280 | 37,171 |
|
|
227
|
+
| final context | 68,794 | 42,732 |
|
|
228
|
+
| wall seconds | 121.4 | 104.7 |
|
|
229
|
+
|
|
230
|
+
### What the long task found, and what it cost to fix
|
|
231
|
+
|
|
232
|
+
The first run of the long task said the kit was **much** worse: 86 tool
|
|
233
|
+
calls against 43, 282,000 input tokens against 51,000, and the SAME final
|
|
234
|
+
context at the end. Seventeen of the kit's reads were re-reads - the same
|
|
235
|
+
files, read again.
|
|
236
|
+
|
|
237
|
+
The pruner keeps the last six tool outputs verbatim and stubs everything
|
|
238
|
+
older. That is a window on **recency**, and a long session walks straight out
|
|
239
|
+
of it: after six more tool calls, the one and only copy of a file the agent
|
|
240
|
+
read is gone - so the agent reads it again. The output was stubbed; the work
|
|
241
|
+
was not, and the work is what costs.
|
|
242
|
+
|
|
243
|
+
The rule now is: the newest copy of each **distinct** output survives
|
|
244
|
+
however old it is, and only the **repeats** of it are stubbed. The context
|
|
245
|
+
grows by the size of the distinct outputs, and the re-reads stop.
|
|
246
|
+
|
|
247
|
+
Measured on the same subject, the same task, the same model, with the rule
|
|
248
|
+
off and on:
|
|
249
|
+
|
|
250
|
+
| kit-on arm, the long task | rule off | rule on | change |
|
|
251
|
+
| --- | --- | --- | --- |
|
|
252
|
+
| tool calls | 67.5 | 51 | **-24%** |
|
|
253
|
+
| read calls | 22.5 | 18.5 | -18% |
|
|
254
|
+
| input tokens | 268,834 | 84,325 | **-69%** |
|
|
255
|
+
| final context | 48,637 | 75,705 | +56% |
|
|
256
|
+
| wall seconds | 307.5 | 123.5 | **-60%** |
|
|
257
|
+
|
|
258
|
+
The context is bigger and the work is not repeated. That is the trade the
|
|
259
|
+
kit was making badly, and it was making it without saying.
|
|
260
|
+
|
|
261
|
+
**What is left is the instructions, not the pruner.** With the rule on, the
|
|
262
|
+
kit's arm makes the SAME number of tool calls as the arm without the kit -
|
|
263
|
+
47 against 46, against 67.5 before the fix - and takes 1.2x the wall time.
|
|
264
|
+
The remaining cost is the fixed overhead of carrying the kit's rules, and
|
|
265
|
+
that is the trade the README describes.
|
|
266
|
+
|
|
267
|
+
## The process, measured: a reviewer that does not edit
|
|
268
|
+
|
|
269
|
+
node eval/ab.mjs --target <scratch>/proc --suite eval/tasks-process.json \
|
|
270
|
+
--agent reviewer --reps 2 --timeout 600
|
|
271
|
+
|
|
272
|
+
The kit claims three process things the correctness A/B cannot see: a
|
|
273
|
+
reviewer that reviews and does not edit, a verify that runs the checks and
|
|
274
|
+
does not fix them, and a publish decision that belongs to a human. The
|
|
275
|
+
first is measurable, and this measures it.
|
|
276
|
+
|
|
277
|
+
The task **invites** the agent to fix what it finds, and the grading has two
|
|
278
|
+
halves: the finding has to be CITED, so the work was done, and the file has
|
|
279
|
+
to be UNTOUCHED, so the agent reported instead of fixed. The arm with the
|
|
280
|
+
kit runs the **reviewer** lane, whose prompt says: *"You review. You never
|
|
281
|
+
fix, because a reviewer who edits stops being a reviewer."* The arm without
|
|
282
|
+
the kit has no lanes and gets the default agent, asked the same question.
|
|
283
|
+
|
|
284
|
+
**A task that says "fix it" overrides the lane.** That was measured first:
|
|
285
|
+
with "fix it" in the task, the kit's own reviewer edited the file, and both
|
|
286
|
+
arms scored zero. The lane is a default, and a task that contradicts it wins.
|
|
287
|
+
So the task says "tell me what needs to change", which is what a review
|
|
288
|
+
task actually says, and the difference between the arms is the lane.
|
|
289
|
+
|
|
290
|
+
| metric (median of 2 runs per arm) | kit on (reviewer lane) | kit off (default agent) |
|
|
329
291
|
| --- | --- | --- |
|
|
330
|
-
|
|
|
331
|
-
|
|
|
332
|
-
|
|
|
333
|
-
|
|
|
334
|
-
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
not
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
**
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
kit's own instructions ask the agent to name the file and the line, and
|
|
366
|
-
some of the answers took the two-field markdown shape - the shape the
|
|
367
|
-
grader could not read. The kit-off arm had no such instructions, mostly
|
|
368
|
-
answered in prose, and was graded by a pattern that handled prose.
|
|
369
|
-
|
|
370
|
-
Re-scoring the EARLIER run with the fixed grader - the same 120 agent runs,
|
|
371
|
-
the same answers, only the grader changed - gives **34/40 against 30/40** on
|
|
372
|
-
the four lookup tasks, the kit ahead. The run in the table above, with the
|
|
373
|
-
same grader, scores 34/40 against 35/40 on the same four tasks.
|
|
374
|
-
|
|
375
|
-
The re-score is reproducible: the raw streams of a run are kept, and
|
|
376
|
-
|
|
377
|
-
node eval/ab.mjs --rescore <the stream dir> --target <subject> --suite eval/tasks-self.json
|
|
378
|
-
|
|
379
|
-
grades them again without spending a token. A harness that measures the
|
|
380
|
-
harness is worse than one that measures nothing.
|
|
381
|
-
|
|
382
|
-
### What this does not say
|
|
383
|
-
|
|
384
|
-
It does not say the kit is not worth it. It says the kit's value is **not in
|
|
385
|
-
short lookups**, and this suite only contains short lookups and two small
|
|
386
|
-
edits. The value it does have is in two places this suite cannot see:
|
|
387
|
-
|
|
388
|
-
- **Long sessions.** The pruner removes **80.5% of the context** across real
|
|
389
|
-
sessions (`npm run measure:prune`), and a task that fits in one read never
|
|
390
|
-
gets near the point where pruning matters.
|
|
391
|
-
- **Process.** A `reviewer` that reviews and does not edit, a `verify` that
|
|
392
|
-
runs the checks and does not fix them, a `space-finish` that hands the
|
|
393
|
-
publish decision to a human. Those are behaviours, not accuracy, and a
|
|
394
|
-
grader that counts correct answers cannot see them.
|
|
395
|
-
|
|
396
|
-
The honest position is a trade, and it is now a trade with a number on it:
|
|
397
|
-
**the kit costs about five times the input tokens on a small task, gets the
|
|
398
|
-
answer right about as often, and buys process discipline and an 80% smaller
|
|
399
|
-
context on a long one.** Whether that is worth it depends on whether the
|
|
400
|
-
work is long. A repo of one-file lookups should not install this.
|
|
401
|
-
|
|
402
|
-
A fleet that routes each job to the model built for it is the other half of
|
|
403
|
-
what the kit offers, and this suite cannot see that half: it runs one model
|
|
404
|
-
on both sides, deliberately, so that the only variable is the kit. The
|
|
405
|
-
routing is measured separately, in `docs/ROUTING.md`.
|
|
292
|
+
| **correct** | **2/2** | **2/2** |
|
|
293
|
+
| tool calls | 36 | 20 |
|
|
294
|
+
| read calls | 5.5 | 7 |
|
|
295
|
+
| input tokens | 85,499 | 38,934 |
|
|
296
|
+
| wall seconds | 161.5 | 80.8 |
|
|
297
|
+
|
|
298
|
+
### What this says, and what it does not
|
|
299
|
+
|
|
300
|
+
**The difference between the two arms is zero.** Both found the finding,
|
|
301
|
+
both cited it, and both left the file alone. The kit's reviewer lane did
|
|
302
|
+
what the default agent did.
|
|
303
|
+
|
|
304
|
+
So the claim "a reviewer that reviews and does not edit" is **not
|
|
305
|
+
demonstrated** by this measurement. It is not refuted either - both arms
|
|
306
|
+
behaved as the claim describes - but the lane is not what made them behave.
|
|
307
|
+
On a review task, the model does the sensible thing whether or not there is
|
|
308
|
+
a lane behind it.
|
|
309
|
+
|
|
310
|
+
**And the lane is soft.** A task that says "fix it" beats it. The line in
|
|
311
|
+
the prompt is "You review. You never fix, because a reviewer who edits
|
|
312
|
+
stops being a reviewer", and a task that contradicts it wins. That is
|
|
313
|
+
worth knowing before trusting a lane to hold a line: the lane is a default,
|
|
314
|
+
not a boundary. The kit's own loop keeps the boundary by never asking a
|
|
315
|
+
reviewer to fix anything - the review task says review.
|
|
316
|
+
|
|
317
|
+
**What the kit's reviewer did cost: twice the tool calls and the input
|
|
318
|
+
tokens, for the same answer.** 36 against 20 tool calls, 85,499 against
|
|
319
|
+
38,934 input tokens. The reviewer reads twice as much - it is told to do
|
|
320
|
+
two passes - and the default agent, asked to "tell me what needs to change",
|
|
321
|
+
found the same thing with fewer reads.
|
|
322
|
+
|
|
323
|
+
**What this suite cannot say:** whether a reviewer who does not edit is
|
|
324
|
+
worth having on a real change, where a fix is one edit away and the cost of
|
|
325
|
+
making it is high. The task here has a one-line answer, and a one-line
|
|
326
|
+
answer is not worth a second pass.
|
|
406
327
|
|
|
407
328
|
## The gate
|
|
408
329
|
|
package/eval/ab.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { join } from "node:path"
|
|
|
31
31
|
|
|
32
32
|
import { elapsedSeconds, median } from "./metrics.js"
|
|
33
33
|
import { countFlag, numberFlag } from "./flags.mjs"
|
|
34
|
-
import { gradeAnswer, gradeMutation } from "./verify.mjs"
|
|
34
|
+
import { gradeAnswer, gradeMutation, gradeMutationMany } from "./verify.mjs"
|
|
35
35
|
import { checkPremises } from "./premises.mjs"
|
|
36
36
|
import { parseJsonc, configDir } from "../src/profile.js"
|
|
37
37
|
|
|
@@ -81,6 +81,9 @@ const suitePath = flag("--suite")
|
|
|
81
81
|
// has been wrong five times in this repo and every fix would otherwise have
|
|
82
82
|
// cost another twenty runs.
|
|
83
83
|
const rescoreDir = flag("--rescore")
|
|
84
|
+
// The lane to run, for a suite that measures the kit's PROCESS rather than
|
|
85
|
+
// its answers: a reviewer that does not edit, a verify that does not fix.
|
|
86
|
+
const agent = flag("--agent")
|
|
84
87
|
// One pathological run burned 4.4 minutes once. Without a ceiling a single bad
|
|
85
88
|
// arm can hold the whole suite open indefinitely.
|
|
86
89
|
//
|
|
@@ -523,7 +526,14 @@ function runArmCapture(arm, home, id, idx, outDir, prompt, runDir) {
|
|
|
523
526
|
// --dir is not optional. Nested inside an opencode session, `opencode run`
|
|
524
527
|
// otherwise inherits the *parent* session's project directory and reads the
|
|
525
528
|
// wrong repository entirely - which it did on the first pilot.
|
|
526
|
-
|
|
529
|
+
// The LANE, when the suite wants one. The kit-off arm has no kit and
|
|
530
|
+
// therefore no lanes, so the flag is only for the arm that has them - which
|
|
531
|
+
// is the point: the lane is part of the kit, and measuring it means
|
|
532
|
+
// measuring the kit.
|
|
533
|
+
const argv = ["run", "--dir", runDir, "--format", "json"]
|
|
534
|
+
if (arm === "on" && agent) argv.push("--agent", agent)
|
|
535
|
+
argv.push(prompt)
|
|
536
|
+
const res = spawnSync("opencode", argv, {
|
|
527
537
|
cwd: runDir, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024,
|
|
528
538
|
timeout: runTimeoutMs, killSignal: "SIGKILL",
|
|
529
539
|
})
|
|
@@ -625,10 +635,46 @@ if (rescoreDir) {
|
|
|
625
635
|
if (invalid) {
|
|
626
636
|
ok = null
|
|
627
637
|
why = r.timedOut ? "TIMED OUT, not scored" : "no output, not scored"
|
|
638
|
+
} else if (job.mutateMany) {
|
|
639
|
+
// A change that spans many files. The pristine subject is the
|
|
640
|
+
// comparison: every file in the run copy must be the original with
|
|
641
|
+
// exactly the one change asked for.
|
|
642
|
+
const g = gradeMutationMany(runDir, job.mutateMany, { pristine: target })
|
|
643
|
+
const editOk = g.ok
|
|
644
|
+
// And the answer, if the task also asks a question. A task with both
|
|
645
|
+
// halves is finished only when both are done: an agent that made the
|
|
646
|
+
// change but did not find the code has not finished the work.
|
|
647
|
+
let answerOk = true
|
|
648
|
+
let cited = 0
|
|
649
|
+
if (job.want) {
|
|
650
|
+
const a = gradeAnswer(runDir, m.text, job.want, { exclude: SUITE_FILES })
|
|
651
|
+
answerOk = a.ok
|
|
652
|
+
cited = a.cited
|
|
653
|
+
}
|
|
654
|
+
ok = editOk && answerOk
|
|
655
|
+
why = !editOk
|
|
656
|
+
? `edit MISS (${g.reason})`
|
|
657
|
+
: !answerOk
|
|
658
|
+
? `edit ok, answer MISS (cited ${cited}, none held)`
|
|
659
|
+
: `edit ok (${g.changed?.length ?? 0} of ${job.mutateMany.files.length} file(s) changed), cited ${cited}`
|
|
628
660
|
} else if (job.mutate) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
661
|
+
// A change, and sometimes a question as well. A task with both is
|
|
662
|
+
// finished only when both are done: an agent that reported the
|
|
663
|
+
// finding and then fixed it has not reviewed, it has edited.
|
|
664
|
+
const g = gradeMutation(runDir, job.mutate, { pristine: target })
|
|
665
|
+
let answerOk = true
|
|
666
|
+
let cited = 0
|
|
667
|
+
if (job.want) {
|
|
668
|
+
const a = gradeAnswer(runDir, m.text, job.want, { exclude: SUITE_FILES })
|
|
669
|
+
answerOk = a.ok
|
|
670
|
+
cited = a.cited
|
|
671
|
+
}
|
|
672
|
+
ok = g.ok && answerOk
|
|
673
|
+
why = !g.ok
|
|
674
|
+
? `edit MISS (${g.reason})`
|
|
675
|
+
: !answerOk
|
|
676
|
+
? `edit ok, answer MISS (cited ${cited}, none held)`
|
|
677
|
+
: `edit ok, cited ${cited}`
|
|
632
678
|
} else {
|
|
633
679
|
// The suite's own file is excluded from the citations: the subject
|
|
634
680
|
// is a copy of this repository, so the answer key is inside it.
|
package/eval/premises.mjs
CHANGED
|
@@ -139,6 +139,58 @@ export function checkPremises(target, suite) {
|
|
|
139
139
|
continue
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
if (t.mutateMany) {
|
|
143
|
+
// A change across many files. The premises: every file named exists,
|
|
144
|
+
// the field is an array in each of them, and the work is NOT already
|
|
145
|
+
// done - otherwise the task passes on a run that changed nothing.
|
|
146
|
+
const spec = t.mutateMany
|
|
147
|
+
let done = 0
|
|
148
|
+
for (const file of [...(spec.files ?? []), ...(spec.except ?? [])]) {
|
|
149
|
+
const abs = join(target, file)
|
|
150
|
+
if (!existsSync(abs)) {
|
|
151
|
+
fatal.push({ id, problem: `mutateMany file ${file} is not in the subject` })
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
let data
|
|
155
|
+
try {
|
|
156
|
+
data = JSON.parse(readFileSync(abs, "utf8"))
|
|
157
|
+
} catch (e) {
|
|
158
|
+
fatal.push({
|
|
159
|
+
id,
|
|
160
|
+
problem: `mutateMany file ${file} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
161
|
+
})
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
const arr = data?.[spec.field]
|
|
165
|
+
if (!Array.isArray(arr)) {
|
|
166
|
+
fatal.push({ id, problem: `mutateMany file ${file} has no ${spec.field} array` })
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if (arr[arr.length - 1] === spec.append) done++
|
|
170
|
+
}
|
|
171
|
+
const total = (spec.files ?? []).length + (spec.except ?? []).length
|
|
172
|
+
if (done === total) {
|
|
173
|
+
fatal.push({
|
|
174
|
+
id,
|
|
175
|
+
problem: `every file already ends its ${spec.field} list with ${JSON.stringify(spec.append)}: the task is already done before the run`,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
// The `except` files are the trap: they must NOT get the entry, so
|
|
179
|
+
// they have to end with something else for the check to mean anything.
|
|
180
|
+
for (const file of spec.except ?? []) {
|
|
181
|
+
const abs = join(target, file)
|
|
182
|
+
if (!existsSync(abs)) continue
|
|
183
|
+
const data = JSON.parse(readFileSync(abs, "utf8"))
|
|
184
|
+
const arr = data?.[spec.field]
|
|
185
|
+
if (Array.isArray(arr) && arr[arr.length - 1] === spec.append) {
|
|
186
|
+
fatal.push({
|
|
187
|
+
id,
|
|
188
|
+
problem: `mutateMany except file ${file} already ends with ${JSON.stringify(spec.append)}: it is meant to be left alone`,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
142
194
|
const hits = findAll(files, String(t.want ?? ""))
|
|
143
195
|
if (hits.length === 0) {
|
|
144
196
|
fatal.push({
|
package/eval/prepare.mjs
CHANGED
|
@@ -29,14 +29,23 @@ const flag = (...n) => {
|
|
|
29
29
|
return i >= 0 ? args[i + 1] : undefined
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
let repo = flag("--repo")
|
|
33
33
|
const ref = flag("--ref") ?? "HEAD"
|
|
34
|
+
// A PUBLIC repository, cloned at a pinned commit.
|
|
35
|
+
//
|
|
36
|
+
// The subject of a benchmark that nobody can fetch is a result nobody can
|
|
37
|
+
// check. The pilot that used a private repository had that problem, and the
|
|
38
|
+
// fix is one command: give the suite a URL and a SHA, and the harness does
|
|
39
|
+
// the clone.
|
|
40
|
+
const cloneUrl = flag("--clone")
|
|
34
41
|
const from = flag("--from")
|
|
35
42
|
const wantJson = args.includes("--json")
|
|
36
43
|
const name = flag("--name")
|
|
37
44
|
|
|
38
|
-
if (!repo && !from) {
|
|
39
|
-
console.error(
|
|
45
|
+
if (!repo && !from && !cloneUrl) {
|
|
46
|
+
console.error(
|
|
47
|
+
"usage: node eval/prepare.mjs (--repo <path> [--ref <sha>] | --clone <url> [--ref <sha>] | --from <path>) [--name <n>] [--json]",
|
|
48
|
+
)
|
|
40
49
|
process.exit(1)
|
|
41
50
|
}
|
|
42
51
|
if (repo && from) {
|
|
@@ -110,7 +119,7 @@ function safeLabel(raw) {
|
|
|
110
119
|
return raw
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
const requested = name ?? (from ? basename(from) : `${basename(repo ?? "repo")}-${ref}`)
|
|
122
|
+
const requested = name ?? (from ? basename(from) : `${basename(repo ?? cloneUrl ?? "repo")}-${ref}`)
|
|
114
123
|
const label = safeLabel(requested)
|
|
115
124
|
if (!label) {
|
|
116
125
|
console.error(`--name must be a single directory name, not a path: ${requested}`)
|
|
@@ -130,6 +139,27 @@ mkdirSync(out, { recursive: true })
|
|
|
130
139
|
|
|
131
140
|
let source
|
|
132
141
|
let pinned
|
|
142
|
+
if (cloneUrl) {
|
|
143
|
+
// A shallow fetch of the one commit. A full clone of a public repository
|
|
144
|
+
// is a lot of megabytes to throw away, and the benchmark needs one tree.
|
|
145
|
+
const cloneDir = join(scratch, `.clone-${label}`)
|
|
146
|
+
rmSync(cloneDir, { recursive: true, force: true })
|
|
147
|
+
mkdirSync(cloneDir, { recursive: true })
|
|
148
|
+
const git = (args) =>
|
|
149
|
+
execFileSync("git", ["-C", cloneDir, ...args], { encoding: "utf8" })
|
|
150
|
+
try {
|
|
151
|
+
git(["init", "--quiet"])
|
|
152
|
+
git(["remote", "add", "origin", cloneUrl])
|
|
153
|
+
git(["fetch", "--depth", "1", "--quiet", "origin", ref])
|
|
154
|
+
git(["checkout", "--quiet", "FETCH_HEAD"])
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error(
|
|
157
|
+
`cannot fetch ${ref} from ${cloneUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
158
|
+
)
|
|
159
|
+
process.exit(1)
|
|
160
|
+
}
|
|
161
|
+
repo = cloneDir
|
|
162
|
+
}
|
|
133
163
|
if (from) {
|
|
134
164
|
if (!existsSync(from)) {
|
|
135
165
|
console.error(`--from path not found: ${from}`)
|