@zenera/cli 1.1.23 → 1.1.24

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.
Files changed (57) hide show
  1. package/README.md +35 -2
  2. package/dist/ask.d.ts +51 -0
  3. package/dist/ask.js +143 -0
  4. package/dist/batch.d.ts +77 -0
  5. package/dist/batch.js +338 -0
  6. package/dist/command.d.ts +6 -0
  7. package/dist/commands/inspect.d.ts +11 -0
  8. package/dist/commands/inspect.js +388 -89
  9. package/dist/commands/list.js +128 -19
  10. package/dist/commands/memory.js +342 -12
  11. package/dist/commands/models.js +14 -2
  12. package/dist/commands/run.js +139 -34
  13. package/dist/commands/sandbox.js +72 -1
  14. package/dist/engine.d.ts +19 -0
  15. package/dist/engine.js +60 -8
  16. package/dist/liveness.js +6 -3
  17. package/dist/main.js +5 -2
  18. package/dist/podman.d.ts +16 -0
  19. package/dist/podman.js +115 -2
  20. package/dist/progress.d.ts +33 -0
  21. package/dist/progress.js +402 -0
  22. package/dist/request.d.ts +45 -0
  23. package/dist/request.js +300 -0
  24. package/dist/sandbox.js +10 -0
  25. package/dist/session.d.ts +26 -1
  26. package/dist/session.js +42 -3
  27. package/dist/term.d.ts +3 -0
  28. package/dist/term.js +17 -1
  29. package/dist/trace.d.ts +84 -0
  30. package/dist/trace.js +686 -0
  31. package/dist/validate.js +25 -3
  32. package/package.json +2 -2
  33. package/templates/editor/.github/copilot-instructions.md +11 -6
  34. package/templates/editor/.github/prompts/finetune.prompt.md +251 -0
  35. package/templates/editor/.github/skills/zen-cli/SKILL.md +9 -3
  36. package/templates/editor/.github/skills/zen-cli/references/check.md +7 -5
  37. package/templates/editor/.github/skills/zen-cli/references/inspect.md +262 -21
  38. package/templates/editor/.github/skills/zen-cli/references/memory.md +89 -19
  39. package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
  40. package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
  41. package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
  42. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1000 -0
  43. package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +432 -0
  44. package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +199 -0
  45. package/templates/editor/.github/skills/zen-inspect/SKILL.md +396 -0
  46. package/templates/editor/.github/skills/zen-memory/SKILL.md +74 -29
  47. package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +363 -531
  48. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +19 -0
  49. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
  50. package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
  51. package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
  52. package/templates/editor/.vscode/agents.schema.json +1 -1
  53. package/templates/editor/.vscode/settings.json +1 -0
  54. package/templates/project/agents/files-instructions.md +13 -0
  55. package/templates/project/agents/fork-instructions.md +27 -0
  56. package/templates/project/agents/memory-instructions.md +80 -7
  57. package/templates/project/gitignore +3 -0
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Read back a fine-tuning batch: what each item did, and where its trajectory is.
4
+ #
5
+ # collect.sh the index: verdict, agent, stop reason, cost
6
+ # collect.sh graphs [id...] every trajectory graph, concatenated
7
+ # collect.sh paths [id...] id <TAB> run directory, for xargs and zen inspect
8
+ # collect.sh failures only the items that did not finish, with errors
9
+ # collect.sh oom items whose commands were killed; is this round gradeable
10
+ # collect.sh compare the per-sample table findings.md opens with
11
+ #
12
+ # -d <dir> the batch directory; default the newest .finetune/rounds/*/batch
13
+ # -p <dir> the batch to compare against, for `compare`; default none
14
+ # -s <file> the dataset, for rubrics; default .finetune/dataset.json
15
+ #
16
+ # `index`, `graphs` and `compare` each open by saying whether the round was
17
+ # COLD, WARM or memory-OFF, because the same trajectory means opposite things
18
+ # either way and a round written up without that line compares to nothing.
19
+ #
20
+ # Run `oom` first, and do not expect `failures` to have caught it: an item whose
21
+ # command was killed usually recovers, answers anyway and is recorded `ok`. A
22
+ # round can read "16 items, 16 ok, 0 failed" with six of them OOM-killed inside.
23
+ # A round that contains an exit 137 ran out of memory, and nothing it did is
24
+ # evidence about the prompt — grading it sends the next round after a defect
25
+ # that does not exist. See the zen-sandbox-capacity skill.
26
+ #
27
+ # `graphs` is the one that saves the most: it is every item's graph.mmd with its
28
+ # id, verdict and rubric written above it, which is one read instead of N
29
+ # invocations of `zen inspect graph`. Grade from that, then descend into the
30
+ # runs that need it with `zen inspect node --dir "$(collect.sh paths | ...)"`.
31
+ #
32
+ # Run it from anywhere; it finds the project root from its own location, which
33
+ # `zen init` and `zen open` fix. See .github/skills/zen-finetune/SKILL.md.
34
+
35
+ set -eu
36
+
37
+ # Taken before the cd, or a relative $0 stops naming this file.
38
+ SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
39
+ cd "$(dirname "$0")/../../../.."
40
+
41
+ MODE=""
42
+ BATCH=""
43
+ PREV=""
44
+ DATASET=.finetune/dataset.json
45
+ WANTED=""
46
+
47
+ usage() {
48
+ sed -n '3,25p' "$SELF" | sed 's/^# \{0,1\}//'
49
+ }
50
+
51
+ while [ $# -gt 0 ]; do
52
+ case "$1" in
53
+ -d) BATCH="${2:?-d needs a directory}"; shift 2 ;;
54
+ -p) PREV="${2:?-p needs a directory}"; shift 2 ;;
55
+ -s) DATASET="${2:?-s needs a file}"; shift 2 ;;
56
+ -h | --help) usage; exit 0 ;;
57
+ index | graphs | paths | failures | oom | compare)
58
+ if [ -n "$MODE" ]; then
59
+ echo "collect.sh: one mode at a time" >&2
60
+ exit 2
61
+ fi
62
+ MODE="$1"
63
+ shift
64
+ ;;
65
+ -*)
66
+ echo "collect.sh: unknown flag $1" >&2
67
+ usage >&2
68
+ exit 2
69
+ ;;
70
+ *)
71
+ WANTED="$WANTED $1"
72
+ shift
73
+ ;;
74
+ esac
75
+ done
76
+
77
+ MODE="${MODE:-index}"
78
+
79
+ if ! command -v jq > /dev/null 2>&1; then
80
+ echo "collect.sh: jq is required" >&2
81
+ exit 2
82
+ fi
83
+
84
+ # Without -d, the most recently written batch. Rounds are read far more often
85
+ # than they are named, and the one just run is almost always the one meant.
86
+ if [ -z "$BATCH" ]; then
87
+ for dir in .finetune/rounds/*/batch; do
88
+ if [ -f "$dir/batch.json" ]; then BATCH="$dir"; fi
89
+ done
90
+ fi
91
+
92
+ if [ -z "$BATCH" ] || [ ! -f "$BATCH/batch.json" ]; then
93
+ echo "collect.sh: no batch.json${BATCH:+ in $BATCH}" >&2
94
+ echo " name one with -d, or run a batch first" >&2
95
+ exit 2
96
+ fi
97
+
98
+ # `id ok` per line, in the order the batch file listed them, filtered to the
99
+ # ids asked for if any were. Everything below walks this.
100
+ roster() {
101
+ jq -r '.batch_results[] | "\(.id) \(.ok)"' "$BATCH/batch.json" | while read -r id ok; do
102
+ if [ -z "$WANTED" ]; then
103
+ echo "$id $ok"
104
+ else
105
+ for want in $WANTED; do
106
+ if [ "$want" = "$id" ]; then echo "$id $ok"; fi
107
+ done
108
+ fi
109
+ done
110
+ }
111
+
112
+ # One field out of an item's envelope, empty if the item failed or never wrote.
113
+ field() {
114
+ if [ -f "$BATCH/$1/output.json" ]; then
115
+ jq -r "$2 // empty" "$BATCH/$1/output.json" 2> /dev/null || true
116
+ fi
117
+ }
118
+
119
+ # Everything one item cost, as "tokens secs nodes llm tools forks", from any
120
+ # batch directory rather than the current one — `compare` reads two.
121
+ #
122
+ # The counts come from the graph's own header row, which already states them:
123
+ # %% nodes 103 · 33 llm · 30 tool calls · 1 forks
124
+ # Counting nodes out of state.json instead over-counts tool calls about
125
+ # threefold, because nested structures repeat the same call.
126
+ metrics() {
127
+ out="$1/$2/output.json"
128
+ if [ ! -f "$out" ]; then
129
+ echo "0 0 0 0 0 0"
130
+ return
131
+ fi
132
+ tok="$(jq -r '((.usage.inputTokens // 0) + (.usage.outputTokens // 0))' "$out" 2> /dev/null || echo 0)"
133
+ sec="$(jq -r '(((.durationMs // 0) / 1000) | floor)' "$out" 2> /dev/null || echo 0)"
134
+ graph="$(jq -r '.run.graph // empty' "$out" 2> /dev/null || true)"
135
+ hdr=""
136
+ if [ -n "$graph" ] && [ -f "$graph" ]; then
137
+ hdr="$(sed -n 's/^%% nodes *//p' "$graph" | head -1 || true)"
138
+ fi
139
+ if [ -z "$hdr" ]; then
140
+ echo "$tok $sec 0 0 0 0"
141
+ return
142
+ fi
143
+ echo "$tok $sec $(echo "$hdr" | awk -F' · ' '{printf "%d %d %d %d", $1+0, $2+0, $3+0, $4+0}')"
144
+ }
145
+
146
+ # 1.09M, 812k, 94 — these are read by eye and compared by eye.
147
+ hum() {
148
+ awk -v n="$1" 'BEGIN {
149
+ if (n + 0 >= 1000000) printf "%.2fM\n", n / 1000000
150
+ else if (n + 0 >= 1000) printf "%dk\n", n / 1000
151
+ else printf "%d\n", n
152
+ }'
153
+ }
154
+
155
+ # "33 → 30 (−3)" against a previous round, "33" without one. Tokens and seconds
156
+ # read as a percentage because they move continuously; calls and forks read as
157
+ # an absolute difference because two of them is two, not eleven per cent.
158
+ cell() {
159
+ prev="$1"
160
+ now="$2"
161
+ mode="$3"
162
+ case "$mode" in
163
+ tok) shown_prev="$(hum "$prev")"; shown_now="$(hum "$now")" ;;
164
+ sec) shown_prev="${prev}s"; shown_now="${now}s" ;;
165
+ *) shown_prev="$prev"; shown_now="$now" ;;
166
+ esac
167
+ if [ -z "$PREV" ]; then
168
+ echo "$shown_now"
169
+ return
170
+ fi
171
+ awk -v p="$prev" -v n="$now" -v sp="$shown_prev" -v sn="$shown_now" -v mode="$mode" 'BEGIN {
172
+ d = n - p
173
+ a = (d < 0) ? -d : d
174
+ sign = (d >= 0) ? "+" : "−"
175
+ if (a == 0) printf "%s (=)\n", sn
176
+ else if (mode == "num") printf "%s → %s (%s%d)\n", sp, sn, sign, a
177
+ else if (p > 0) printf "%s → %s (%s%.0f%%)\n", sp, sn, sign, a * 100.0 / p
178
+ else printf "%s → %s\n", sp, sn
179
+ }'
180
+ }
181
+
182
+ # cold / warm / off, for a batch directory.
183
+ #
184
+ # batch.json records the mode and the source but not whether that source was a
185
+ # real graph when the batch ran — and `--memory .finetune/empty`, which is how a
186
+ # cold round is made, records as `copied` exactly like a warm one. The graph
187
+ # file is the difference: an empty-graph round never creates one at the source.
188
+ memkind() {
189
+ mode="$(jq -r '.batch.memory.mode // "none"' "$1/batch.json" 2> /dev/null || echo none)"
190
+ src="$(jq -r '.batch.memory.source // empty' "$1/batch.json" 2> /dev/null || true)"
191
+ case "$mode" in
192
+ none) echo off ;;
193
+ read-only) echo warm ;;
194
+ copied)
195
+ if [ -n "$src" ] && [ -f "$src/graph.json" ]; then echo warm; else echo cold; fi
196
+ ;;
197
+ *) echo "$mode" ;;
198
+ esac
199
+ }
200
+
201
+ # The line every report opens with, so that no round is ever graded or compared
202
+ # without it being plain whether memory was in play.
203
+ memline() {
204
+ mode="$(jq -r '.batch.memory.mode // "none"' "$1/batch.json" 2> /dev/null || echo none)"
205
+ src="$(jq -r '.batch.memory.source // empty' "$1/batch.json" 2> /dev/null || true)"
206
+ case "$(memkind "$1")" in
207
+ off) echo "OFF — no memory was given to this batch, and none was written" ;;
208
+ cold) echo "COLD — every item started from an empty graph and wrote its own (${src:-none})" ;;
209
+ warm)
210
+ if [ "$mode" = read-only ]; then
211
+ echo "WARM — one shared graph, read-only, every item read it and none wrote ($src)"
212
+ else
213
+ echo "WARM — each item got its own copy of an existing graph ($src)"
214
+ fi
215
+ ;;
216
+ *) echo "$mode${src:+ ($src)}" ;;
217
+ esac
218
+ }
219
+
220
+ # The rubric from the dataset, joined on the id. Absent dataset, absent rubric,
221
+ # absent sample: all the same thing here, and none of them is an error.
222
+ rubric() {
223
+ if [ -f "$DATASET" ]; then
224
+ jq -r --arg id "$1" '
225
+ .samples[] | select(.id == $id) | (.rubric // [])[]
226
+ | " - " + .' "$DATASET" 2> /dev/null || true
227
+ fi
228
+ }
229
+
230
+ case "$MODE" in
231
+ index)
232
+ jq -r '.batch | "batch \(.items) items, \(.ok) ok, \(.failed) failed"
233
+ + " · \((.durationMs / 1000) | floor)s"' "$BATCH/batch.json"
234
+ echo "dir $BATCH"
235
+ echo "memory $(memline "$BATCH")"
236
+ echo
237
+ printf '%-24s %-5s %-12s %-12s %7s %5s %6s\n' \
238
+ ID OK AGENT STOP TOKENS SEC RUBRIC
239
+ roster | while read -r id ok; do
240
+ agent="$(field "$id" .agent)"
241
+ stop="$(field "$id" .stopReason)"
242
+ tokens="$(field "$id" '(.usage.inputTokens + .usage.outputTokens)')"
243
+ secs="$(field "$id" '((.durationMs / 1000) | floor)')"
244
+ lines="$(rubric "$id" | grep -c . || true)"
245
+ if [ "$lines" -eq 0 ]; then lines="-"; fi
246
+ printf '%-24s %-5s %-12s %-12s %7s %5s %6s\n' \
247
+ "$id" "$ok" "${agent:--}" "${stop:--}" "${tokens:--}" "${secs:--}" "$lines"
248
+ done
249
+ echo
250
+ echo "next: collect.sh -d $BATCH graphs | less"
251
+ ;;
252
+
253
+ graphs)
254
+ # Before the first graph, because a warm trajectory graded as a cold one
255
+ # reads as an agent that knew things it was never told.
256
+ echo "== memory: $(memline "$BATCH")"
257
+ roster | while read -r id ok; do
258
+ graph="$(field "$id" .run.graph)"
259
+ echo "================================================================"
260
+ echo "== $id ok=$ok"
261
+ lines="$(rubric "$id")"
262
+ if [ -n "$lines" ]; then
263
+ echo "== rubric:"
264
+ echo "$lines" | sed 's/^/== /'
265
+ fi
266
+ echo "================================================================"
267
+ if [ -n "$graph" ] && [ -f "$graph" ]; then
268
+ cat "$graph"
269
+ elif [ -f "$BATCH/$id/output.json" ]; then
270
+ jq -r '.error.message // "no graph was written"' "$BATCH/$id/output.json"
271
+ else
272
+ echo "no output.json"
273
+ fi
274
+ echo
275
+ done
276
+ ;;
277
+
278
+ paths)
279
+ roster | while read -r id ok; do
280
+ dir="$(field "$id" .run.dir)"
281
+ printf '%s\t%s\n' "$id" "${dir:--}"
282
+ done
283
+ ;;
284
+
285
+ failures)
286
+ # The count comes from batch.json rather than the loop below: a `while`
287
+ # on the right of a pipe is a subshell, and a variable set in one is
288
+ # gone by the time anything could read it.
289
+ if [ "$(jq -r '[.batch_results[] | select(.ok | not)] | length' "$BATCH/batch.json")" -eq 0 ]; then
290
+ echo "nothing failed"
291
+ exit 0
292
+ fi
293
+ roster | while read -r id ok; do
294
+ if [ "$ok" != "true" ]; then
295
+ echo "-- $id"
296
+ if [ -f "$BATCH/$id/output.json" ]; then
297
+ jq -r '" " + (.error.message // "unknown")
298
+ + (if .error.hint then "\n hint: " + .error.hint else "" end)' \
299
+ "$BATCH/$id/output.json"
300
+ fi
301
+ fi
302
+ done
303
+ ;;
304
+
305
+ compare)
306
+ # roster is redirected to a file rather than piped: a `while` on the
307
+ # right of a pipe is a subshell, and the totals set in one are gone by
308
+ # the time the total row could print them.
309
+ tmp="$(mktemp)"
310
+ trap 'rm -f "$tmp"' EXIT
311
+ roster > "$tmp"
312
+
313
+ if [ -n "$PREV" ] && [ ! -f "$PREV/batch.json" ]; then
314
+ echo "collect.sh: no batch.json in $PREV" >&2
315
+ exit 2
316
+ fi
317
+
318
+ # Part of the table, not a footnote: the same numbers mean opposite
319
+ # things depending on this line, and a pasted table loses anything
320
+ # printed after it.
321
+ echo "memory: $(memline "$BATCH")"
322
+ if [ -n "$PREV" ]; then
323
+ echo "prev memory: $(memline "$PREV")"
324
+ fi
325
+ echo
326
+ echo "| sample | verdict | tokens | llm calls | tool calls | forks | time |"
327
+ echo "| --- | --- | --- | --- | --- | --- | --- |"
328
+
329
+ ptok=0; psec=0; pllm=0; ptool=0; pfork=0
330
+ ntok=0; nsec=0; nllm=0; ntool=0; nfork=0
331
+ while read -r id ok; do
332
+ read -r a_tok a_sec a_nodes a_llm a_tool a_fork <<< "$(metrics "$BATCH" "$id")"
333
+ if [ -n "$PREV" ]; then
334
+ read -r b_tok b_sec b_nodes b_llm b_tool b_fork <<< "$(metrics "$PREV" "$id")"
335
+ else
336
+ b_tok=0; b_sec=0; b_llm=0; b_tool=0; b_fork=0
337
+ fi
338
+ ntok=$((ntok + a_tok)); nsec=$((nsec + a_sec))
339
+ nllm=$((nllm + a_llm)); ntool=$((ntool + a_tool)); nfork=$((nfork + a_fork))
340
+ ptok=$((ptok + b_tok)); psec=$((psec + b_sec))
341
+ pllm=$((pllm + b_llm)); ptool=$((ptool + b_tool)); pfork=$((pfork + b_fork))
342
+ # The verdict column is left for the grader: it is the one thing
343
+ # here no script can read off a trajectory.
344
+ verdict="?"
345
+ if [ "$ok" != "true" ]; then verdict="**did not finish**"; fi
346
+ printf '| %s | %s | %s | %s | %s | %s | %s |\n' \
347
+ "$id" \
348
+ "$verdict" \
349
+ "$(cell "$b_tok" "$a_tok" tok)" \
350
+ "$(cell "$b_llm" "$a_llm" num)" \
351
+ "$(cell "$b_tool" "$a_tool" num)" \
352
+ "$(cell "$b_fork" "$a_fork" num)" \
353
+ "$(cell "$b_sec" "$a_sec" sec)"
354
+ done < "$tmp"
355
+
356
+ printf '| **total** | %s | **%s** | **%s** | **%s** | **%s** | **%s** |\n' \
357
+ "—" \
358
+ "$(cell "$ptok" "$ntok" tok)" \
359
+ "$(cell "$pllm" "$nllm" num)" \
360
+ "$(cell "$ptool" "$ntool" num)" \
361
+ "$(cell "$pfork" "$nfork" num)" \
362
+ "$(cell "$psec" "$nsec" sec)"
363
+
364
+ echo
365
+ echo "this $BATCH"
366
+ if [ -n "$PREV" ]; then echo "prev $PREV"; fi
367
+ echo "note: wall time is concurrent — the totals are machine load, not a sum anybody waited."
368
+ if [ -n "$PREV" ] && [ "$(memkind "$BATCH")" != "$(memkind "$PREV")" ]; then
369
+ echo
370
+ echo "note: these two rounds did not run on the same memory ($(memkind "$PREV") → $(memkind "$BATCH"))."
371
+ echo " Cold against warm is the phase-8 measurement and reads as one; cold against"
372
+ echo " cold is a prose diff. Say which this is in the verdict block, or the numbers"
373
+ echo " will be read as the effect of an instruction that did nothing."
374
+ fi
375
+ ;;
376
+
377
+ oom)
378
+ # The graph is where an exit code survives: every tool call is a node,
379
+ # and a killed `run_command` reads `= exit code 137`. Do not grep the
380
+ # batch directory instead — an item's workspace is full of data files
381
+ # with 137 in them, and every one is a false positive.
382
+ #
383
+ # Collected into a variable rather than counted in the loop: a `while`
384
+ # on the right of a pipe is a subshell, and a count set in one is gone
385
+ # before the verdict could read it.
386
+ scan="$(
387
+ roster | while read -r id ok; do
388
+ graph="$(field "$id" .run.graph)"
389
+ if [ -z "$graph" ] || [ ! -f "$graph" ]; then continue; fi
390
+ killed="$(grep -c 'exit code 137' "$graph" 2> /dev/null || true)"
391
+ timedout="$(grep -c 'exit code 124' "$graph" 2> /dev/null || true)"
392
+ if [ "${killed:-0}" -gt 0 ] || [ "${timedout:-0}" -gt 0 ]; then
393
+ printf '%s %s %s\n' "$id" "${killed:-0}" "${timedout:-0}"
394
+ fi
395
+ done
396
+ )"
397
+
398
+ if [ -z "$scan" ]; then
399
+ echo "no killed commands in this round — it is gradeable"
400
+ exit 0
401
+ fi
402
+
403
+ printf '%-32s %8s %8s\n' ITEM 'OOM 137' 'TIME 124'
404
+ echo "$scan" | while read -r id killed timedout; do
405
+ printf '%-32s %8s %8s\n' "$id" "$killed" "$timedout"
406
+ done
407
+ echo
408
+
409
+ items_killed="$(echo "$scan" | awk '$2 > 0' | grep -c . || true)"
410
+ total_killed="$(echo "$scan" | awk '{ s += $2 } END { print s + 0 }')"
411
+ total_timedout="$(echo "$scan" | awk '{ s += $3 } END { print s + 0 }')"
412
+
413
+ if [ "${items_killed:-0}" -gt 0 ]; then
414
+ echo "$total_killed command(s) across $items_killed item(s) were OOM-killed."
415
+ echo
416
+ echo "THIS ROUND IS VOID, NOT GRADED."
417
+ echo " Nothing here is evidence about the prompt. Do not grade it, do not"
418
+ echo " change instructions on it, and do not compare its tokens to another"
419
+ echo " round. Size the machine, then run it again:"
420
+ echo
421
+ echo " .github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh --concurrency 8"
422
+ exit 1
423
+ fi
424
+
425
+ echo "$total_timedout command(s) hit the timeout; none was OOM-killed."
426
+ echo " Under memory pressure 124 is 137 wearing a different number, and raising"
427
+ echo " the timeout converts one into the other rather than fixing either. Check"
428
+ echo " the machine before concluding the index is slow:"
429
+ echo
430
+ echo " .github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh --concurrency 8"
431
+ ;;
432
+ esac
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Choose the next fine-tuning batch out of .finetune/dataset.json.
4
+ #
5
+ # sample.sh every sample, in stratified order
6
+ # sample.sh -n 16 sixteen, spread evenly over the strata
7
+ # sample.sh -n 16 --class planning sixteen out of one class
8
+ # sample.sh --rubric-only -o cases.json only the graded ones, to a file
9
+ #
10
+ # A stratum is one class x one complexity. The sample is taken one item at a
11
+ # time from each stratum in turn, so a budget that cannot afford the dataset
12
+ # still buys a cross-section of it rather than the front of the file. Inside a
13
+ # stratum, samples carrying a rubric come first: a graded run says why it
14
+ # failed, an ungraded one only says what it did.
15
+ #
16
+ # Deterministic. The same --seed over the same dataset picks the same batch, so
17
+ # a re-run after a prompt change is a comparison and not a new experiment.
18
+ #
19
+ # What it writes is a `zen run batch --input` file: { "batch": [ {id, input} ] }.
20
+ # Relative media paths inside `input` resolve against the file they are written
21
+ # in, so -o somewhere other than beside the dataset needs absolute paths in it.
22
+ #
23
+ # Run it from anywhere; it finds the project root from its own location, which
24
+ # `zen init` and `zen open` fix. See .github/skills/zen-finetune/SKILL.md.
25
+
26
+ set -eu
27
+ cd "$(dirname "$0")/../../../.."
28
+
29
+ DATASET=.finetune/dataset.json
30
+ LIMIT=0
31
+ CLASS=""
32
+ COMPLEXITY=""
33
+ RUBRIC_ONLY=0
34
+ SEED=1
35
+ OUT=""
36
+
37
+ usage() {
38
+ sed -n '3,10p' "$0" | sed 's/^# \{0,1\}//'
39
+ echo
40
+ echo " -d <file> dataset; default $DATASET"
41
+ echo " -n <N> budget; default every sample"
42
+ echo " -o <file> where to write; default stdout"
43
+ echo " --class <name> one class only"
44
+ echo " --complexity <lvl> one complexity only"
45
+ echo " --rubric-only drop samples with no rubric"
46
+ echo " --seed <n> shuffle seed; default $SEED"
47
+ }
48
+
49
+ while [ $# -gt 0 ]; do
50
+ case "$1" in
51
+ -d) DATASET="${2:?-d needs a file}"; shift 2 ;;
52
+ -n) LIMIT="${2:?-n needs a number}"; shift 2 ;;
53
+ -o) OUT="${2:?-o needs a file}"; shift 2 ;;
54
+ --class) CLASS="${2:?--class needs a name}"; shift 2 ;;
55
+ --complexity) COMPLEXITY="${2:?--complexity needs a level}"; shift 2 ;;
56
+ --rubric-only) RUBRIC_ONLY=1; shift ;;
57
+ --seed) SEED="${2:?--seed needs a number}"; shift 2 ;;
58
+ -h | --help) usage; exit 0 ;;
59
+ *)
60
+ echo "sample.sh: unknown argument $1" >&2
61
+ usage >&2
62
+ exit 2
63
+ ;;
64
+ esac
65
+ done
66
+
67
+ if ! command -v jq > /dev/null 2>&1; then
68
+ echo "sample.sh: jq is required" >&2
69
+ exit 2
70
+ fi
71
+
72
+ if [ ! -f "$DATASET" ]; then
73
+ echo "sample.sh: no $DATASET" >&2
74
+ echo " build it first: see .github/skills/zen-finetune/SKILL.md, phase 1" >&2
75
+ exit 2
76
+ fi
77
+
78
+ case "$LIMIT$SEED" in
79
+ *[!0-9]*)
80
+ echo "sample.sh: -n and --seed take whole numbers" >&2
81
+ exit 2
82
+ ;;
83
+ esac
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # The dataset has to be sound before it is worth sampling. An id names a
87
+ # directory under the batch dir, and `zen run batch` refuses a bad one item by
88
+ # item, halfway through a long run. Refuse it here instead, all at once.
89
+ # ---------------------------------------------------------------------------
90
+
91
+ if ! jq -e '.samples | type == "array" and length > 0' "$DATASET" > /dev/null 2>&1; then
92
+ echo "sample.sh: $DATASET has no non-empty \"samples\" array" >&2
93
+ exit 2
94
+ fi
95
+
96
+ BAD="$(jq -r '
97
+ .samples[]
98
+ | select((.id | type) != "string"
99
+ or (.id | test("^[A-Za-z0-9_.-]+$") | not)
100
+ or .id == "." or .id == "..")
101
+ | (.id | tostring)' "$DATASET")"
102
+ if [ -n "$BAD" ]; then
103
+ echo "sample.sh: these ids cannot name a directory:" >&2
104
+ echo "$BAD" | sed 's/^/ /' >&2
105
+ echo " letters, digits, dot, dash and underscore only" >&2
106
+ exit 2
107
+ fi
108
+
109
+ DUPES="$(jq -r '[.samples[].id] | group_by(.) | map(select(length > 1) | .[0]) | .[]' "$DATASET")"
110
+ if [ -n "$DUPES" ]; then
111
+ echo "sample.sh: duplicate ids in $DATASET:" >&2
112
+ echo "$DUPES" | sed 's/^/ /' >&2
113
+ exit 2
114
+ fi
115
+
116
+ MISSING="$(jq -r '.samples[] | select(.input == null) | .id' "$DATASET")"
117
+ if [ -n "$MISSING" ]; then
118
+ echo "sample.sh: these samples have no \"input\":" >&2
119
+ echo "$MISSING" | sed 's/^/ /' >&2
120
+ exit 2
121
+ fi
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # The sample
125
+ #
126
+ # Lehmer's generator gives the shuffle: s = s * 48271 mod 2^31-1, which stays
127
+ # exact in a double and so gives the same order on every machine jq runs on.
128
+ # Strata are interleaved rather than concatenated, which is what makes a short
129
+ # budget a cross-section; rubric-bearing samples sort to the front of each.
130
+ # ---------------------------------------------------------------------------
131
+
132
+ PROGRAM='
133
+ def lehmer($s): ($s * 48271) % 2147483647;
134
+
135
+ (($seed | tonumber) % 2147483646 + 1) as $s0
136
+ | [ .samples[]
137
+ | select($class == "" or (.class // "") == $class)
138
+ | select($complexity == "" or (.complexity // "") == $complexity)
139
+ | select($rubric == "0" or ((.rubric // []) | length) > 0) ] as $in
140
+ | [ foreach range(0; ($in | length)) as $i ($s0; lehmer(.); .) ] as $r
141
+ | [ range(0; ($in | length))
142
+ | . as $i
143
+ | { s: $in[$i],
144
+ k: $r[$i],
145
+ st: ((($in[$i].class // "unclassified") + " / "
146
+ + ($in[$i].complexity // "unrated"))),
147
+ rb: (if (($in[$i].rubric // []) | length) > 0 then 0 else 1 end) } ]
148
+ | group_by(.st)
149
+ | map(sort_by(.rb, .k))
150
+ | . as $g
151
+ | ((map(length) | max) // 0) as $m
152
+ | [ range(0; $m) as $i | $g[] | select(length > $i) | .[$i] ]
153
+ | (if ($n | tonumber) > 0 then .[0:($n | tonumber)] else . end)
154
+ | .[].s
155
+ '
156
+
157
+ SELECTED="$(jq -c \
158
+ --arg class "$CLASS" \
159
+ --arg complexity "$COMPLEXITY" \
160
+ --arg rubric "$RUBRIC_ONLY" \
161
+ --arg seed "$SEED" \
162
+ --arg n "$LIMIT" \
163
+ "$PROGRAM" "$DATASET")"
164
+
165
+ COUNT="$(printf '%s' "$SELECTED" | grep -c . || true)"
166
+ if [ "$COUNT" -eq 0 ]; then
167
+ echo "sample.sh: nothing matched" >&2
168
+ if [ -n "$CLASS" ] || [ -n "$COMPLEXITY" ] || [ "$RUBRIC_ONLY" -eq 1 ]; then
169
+ echo " the filters may be narrower than the dataset; what is in it:" >&2
170
+ jq -r '.samples[] | " " + (.class // "unclassified") + " / "
171
+ + (.complexity // "unrated")' "$DATASET" | sort | uniq -c >&2
172
+ fi
173
+ exit 1
174
+ fi
175
+
176
+ # The batch file itself. Only `id` and `input`: a rubric is not part of a run
177
+ # request, and the dataset stays the one place it is written down. The id is
178
+ # the join back to it, and the name of the item's directory in the batch.
179
+ CASES="$(printf '%s\n' "$SELECTED" | jq -s '{ batch: map({ id, input }) }')"
180
+
181
+ if [ -n "$OUT" ]; then
182
+ mkdir -p "$(dirname "$OUT")"
183
+ printf '%s\n' "$CASES" > "$OUT"
184
+ else
185
+ printf '%s\n' "$CASES"
186
+ fi
187
+
188
+ # What was taken, and from where, on stderr so it survives a redirect of the
189
+ # cases file. A round that looks lopsided here will grade lopsided too.
190
+ {
191
+ echo
192
+ echo "sample.sh: $COUNT of $(jq '.samples | length' "$DATASET") samples${OUT:+ -> $OUT}"
193
+ printf '%s\n' "$SELECTED" \
194
+ | jq -r '" " + (.class // "unclassified") + " / " + (.complexity // "unrated")
195
+ + (if ((.rubric // []) | length) > 0 then " [rubric]" else "" end)' \
196
+ | sort | uniq -c
197
+ GRADED="$(printf '%s\n' "$SELECTED" | jq -r 'select(((.rubric // []) | length) > 0) | .id' | grep -c . || true)"
198
+ echo " $GRADED with a rubric, seed $SEED"
199
+ } >&2