unparallelled commited on
Commit
06100fe
·
verified ·
1 Parent(s): 0b89d77

Upload scripts/run_longeval.sh with huggingface_hub

Browse files
Files changed (1) hide show
  1. scripts/run_longeval.sh +349 -0
scripts/run_longeval.sh ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # run_longeval.sh -- batch RULER / NIAH / VAL generative evaluation for bluscriptCP.
4
+ #
5
+ # Scores what the model GENERATES (substring match), unlike probe_1b.sh which
6
+ # measures teacher-forced NLL over an answer span already present in the input.
7
+ #
8
+ # Requires a binary built with generation support:
9
+ # make CP_FUSED_ROPE=1 CP_ATTN_SPLIT=1 SM_ARCH=90 bluscript-cp -j$(nproc)
10
+ #
11
+ # Usage:
12
+ # ./run_longeval.sh # all tasks, all checkpoints
13
+ # TASKS="niah_single vt" ./run_longeval.sh # subset
14
+ # CKPTS="arm_c" N=20 ./run_longeval.sh # quick smoke
15
+ #
16
+ # Resumable: each (ckpt,task) writes a .done marker; re-running skips finished
17
+ # pairs. Kill it any time -- completed pairs survive.
18
+ # =============================================================================
19
+ set -uo pipefail
20
+ cd "$(dirname "$0")"
21
+
22
+ ROOT="$(cd ../.. && pwd)"
23
+ export LD_LIBRARY_PATH="$ROOT/Tensor-Implementations/lib:$ROOT/Profiler/lib:${LD_LIBRARY_PATH:-}"
24
+ export OMPI_ALLOW_RUN_AS_ROOT=1 OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1
25
+
26
+ EXE=./build/bluscriptCP_exec
27
+ PY=${PY:-python3}
28
+ OUT=${OUT:-longeval_runs}
29
+ CK=checkpoints_bluscriptcp/rank_0
30
+
31
+ # CP_T is the RoPE cache length. The C++ side pads each decode step up to a
32
+ # multiple of 256 (the Hopper split kernel's T_q guard), so
33
+ # pad_up(PROMPT_LEN + MAX_NEW) must be <= CP_T or it aborts up front.
34
+ T=${T:-16384}
35
+ # COST: a forward at T=16384 fp32 measures 17.8 s on H200 / ~25 s on H100
36
+ # (probe_arm_c: 21 jobs x 8 windows in 12.5 min at slots=4). Naive re-forward
37
+ # decoding costs ONE such forward per generated token, so the defaults matter:
38
+ # fp32 MAX_NEW=32 N=50 -> 190 GPU-h <- do not
39
+ # bf16, early stop, 4 tasks at ~6 forwards + niah_multivalue at ~24 (its
40
+ # MIN_TOK=14 blocks an early break) -> weighted (4*6+24)/5 = 9.6 /prompt
41
+ # -> ~18.7 GPU-h for 1000 prompts -> ~4.7 h on 4 GPUs. Step 2 measures this
42
+ # on BOTH niah_single and niah_multivalue rather than assuming it.
43
+ # HARD CONSTRAINT: PROMPT_LEN + MAX_NEW must be <= 16384 AND pad up to exactly it.
44
+ # bluscriptCP.cpp:2446 dies when pad_up(P_LEN+MAX_NEW) > CP_T, and PAD=256, so
45
+ # pad_up(16368+24)=16640 > 16384 would abort EVERY job. 16360+24 = 16384 exactly.
46
+ MAX_NEW=${MAX_NEW:-24}
47
+ PROMPT_LEN=${PROMPT_LEN:-16360}
48
+ N=${N:-50}
49
+ BF16=${BF16:-1}
50
+ GPUS=${GPUS:-0} # comma list, e.g. "0,1,2,3" -> (ckpt,task) jobs fan out
51
+ NP=${NP:-1} # CP_SIZE per job; GPUs are used for THROUGHPUT, not size
52
+ YARN=${YARN:-4}
53
+ # YARN_ORIG_MAXPOS (YARNOps.cpp:57) defaults to 1024, but the base model was
54
+ # trained at context_length 4096 -- hence the warning EVERY run emits:
55
+ # seq_len(16384) != scale(4.000)*orig_maxpos(1024.0)=4096.0
56
+ # i.e. the YaRN ramp targets 4096 while the cache is SIZED 16384, so positions
57
+ # past ~4k sit outside the range it was parameterised for. YARN_ORIG=4096 makes
58
+ # scale*orig == seq_len exactly. CAUTION: this is a train/eval MISMATCH -- the
59
+ # checkpoints were trained under the 1024 default, so a correctly-parameterised
60
+ # cache is not one they ever saw. Diagnostic, not a guaranteed fix.
61
+ YARN_ORIG=${YARN_ORIG:-}
62
+ GREEDY=${GREEDY:-1} # deterministic: benchmarks must be reproducible
63
+
64
+ # Needle depth grid. MUST match needle_gen.py's p_frac points or the generative
65
+ # curve cannot be overlaid on the NLL curve. Both place the needle at a fraction
66
+ # of ABSOLUTE POSITION (needle_gen p0.25 -> slot 4087 -> fraction 0.25), so the
67
+ # grids correspond directly.
68
+ DEPTHS=${DEPTHS:-"0.0,0.25,0.5,0.75,1.0"}
69
+
70
+ # Minimum substantive tokens before a newline may end generation. 3 suits a
71
+ # 7-digit number or a single word, but niah_multivalue needs THREE values
72
+ # (~12-15 tokens): at 3 a model that emits value 1 then a newline would stop with
73
+ # 1 of 3 and score MISS -- re-creating the bug the guard exists to prevent,
74
+ # precisely for the long-answer task.
75
+ declare -A MINTOK=( [niah_multivalue]=14 )
76
+
77
+ TASKS=${TASKS:-"niah_single niah_multikey niah_multivalue vt val_forward val_backward"}
78
+ # name:checkpoint-file
79
+ CKPTS=${CKPTS:-"base arm_a arm_b arm_c"}
80
+ declare -A CKPT_FILE=(
81
+ [base]=blumodelcp_run7_step_750.ckpt
82
+ [arm_a]=blumodelcp_run12_step_400.ckpt
83
+ [arm_b]=blumodelcp_run15_step_400.ckpt
84
+ [arm_c]=blumodelcp_run16_step_400.ckpt
85
+ )
86
+ # CKPT_EXTRA lets a caller register checkpoints the map does not know about --
87
+ # e.g. a fresh retrain whose run number is only decided at training time.
88
+ # Format: "key=filename key=filename" (filenames relative to $CK).
89
+ if [ -n "${CKPT_EXTRA:-}" ]; then
90
+ for kv in $CKPT_EXTRA; do CKPT_FILE[${kv%%=*}]="${kv#*=}"; done
91
+ fi
92
+
93
+ A1B="CP_N_EMBD=1536 CP_N_LAYER=36 CP_N_HEAD=12 CP_N_KVHEAD=4 CP_FFN=4096 CP_WEIGHT_TYING=0"
94
+ ARCH="$A1B CP_ATTN_MODE=ring CP_ATTN_FUSION=split"
95
+
96
+ mkdir -p "$OUT"/{prompts,gen,state,scores}
97
+ M="$OUT/master.log"
98
+ say() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$M"; }
99
+
100
+ [ -x "$EXE" ] || { echo "ERROR: $EXE missing -- build first:"; \
101
+ echo " make CP_FUSED_ROPE=1 CP_ATTN_SPLIT=1 SM_ARCH=90 bluscript-cp -j\$(nproc)"; exit 1; }
102
+
103
+ # Fail early on a binary built without generation support rather than after an
104
+ # hour of prompt generation.
105
+ if ! grep -q CP_GENERATE "$EXE" 2>/dev/null; then
106
+ echo "ERROR: $EXE has no CP_GENERATE support (stale build). Rebuild:"
107
+ echo " make CP_FUSED_ROPE=1 CP_ATTN_SPLIT=1 SM_ARCH=90 bluscript-cp -j\$(nproc)"
108
+ exit 1
109
+ fi
110
+
111
+ say "longeval: T=$T prompt_len=$PROMPT_LEN max_new=$MAX_NEW n=$N greedy=$GREEDY gpus=$GPUS"
112
+
113
+ # ---- 1. prompts (shared across checkpoints; identical bytes => fair compare) --
114
+ HAY=$(ls Data_Loader/Data/flux_val_*.bin 2>/dev/null | head -1)
115
+ [ -z "$HAY" ] && HAY=$(ls Data_Loader/Data/flux_train_*.bin 2>/dev/null | head -1)
116
+ say "haystack: ${HAY:-<synthetic fallback>}"
117
+ [ -z "$HAY" ] && say " WARN: synthetic filler is a weaker distractor than real text -- it INFLATES accuracy. Record this."
118
+
119
+ # A bare "-f the .bin exists" test silently reuses prompts from a different run:
120
+ # a smoke run at N=2 then poisons a main run at N=50 (the C++ dies on "short read
121
+ # on prompt 2" AFTER flushing 2 rows, which the old [ -s ] scoring gate happily
122
+ # scored and marked .done, permanently). The signature covers everything that
123
+ # changes prompt content -- N, length, depths, seed, AND the haystack, so a
124
+ # synthetic-filler run is not reused once the flux shard arrives.
125
+ SIG="N=$N|LEN=$PROMPT_LEN|DEPTHS=$DEPTHS|SEED=0|HAY=$(basename "${HAY:-none}")|HAYSZ=$(stat -c %s "${HAY:-/nonexistent}" 2>/dev/null || echo 0)"
126
+ if [ "$(cat "$OUT/prompts/.signature" 2>/dev/null)" != "$SIG" ]; then
127
+ say "prompt signature changed -- regenerating prompts and clearing state"
128
+ rm -f "$OUT"/prompts/*_prompts.bin "$OUT"/prompts/*_meta.json
129
+ # MUST clear state too: the generation skip below tests .gen, so leaving those
130
+ # markers would skip regeneration and score OLD tokens against the NEW
131
+ # meta.json -- attributing accuracy to depths the tokens never came from.
132
+ rm -f "$OUT"/state/*
133
+ fi
134
+
135
+ GEN_OK=1
136
+ for TASK in $TASKS; do
137
+ if [ ! -f "$OUT/prompts/${TASK}_prompts.bin" ]; then
138
+ say "generating prompts: $TASK"
139
+ $PY Tests/bluscriptcp/longeval_gen.py --task "$TASK" --prompt-len "$PROMPT_LEN" \
140
+ --n "$N" --seed 0 --depths "$DEPTHS" ${HAY:+--haystack "$HAY"} \
141
+ --out-dir "$OUT/prompts" \
142
+ >>"$M" 2>&1 || { say " FAILED to generate $TASK"; GEN_OK=0; continue; }
143
+ fi
144
+ # exact stride: the C++ reader slices the stream at N x PROMPT_LEN x 4 bytes
145
+ WANT=$(( N * PROMPT_LEN * 4 ))
146
+ GOT=$(stat -c %s "$OUT/prompts/${TASK}_prompts.bin" 2>/dev/null || echo 0)
147
+ [ "$GOT" = "$WANT" ] || { say " BAD SIZE $TASK: ${GOT}B want ${WANT}B"; GEN_OK=0; }
148
+ done
149
+ # Only claim the signature if every task really produced prompts, else the next
150
+ # run would match it and never retry the missing ones.
151
+ [ "$GEN_OK" = 1 ] && echo "$SIG" > "$OUT/prompts/.signature"
152
+
153
+ # ---- 2. generate (fanned out over GPUs) -------------------------------------
154
+ # Each (checkpoint, task) pair is one independent job needing NP GPUs, so the
155
+ # GPU list is used for THROUGHPUT: slots = nGPU / NP jobs run concurrently.
156
+ # 24 pairs (4 ckpts x 6 tasks) on 8 GPUs at NP=1 is 8-way parallel.
157
+ IFS=',' read -r -a GPUARR <<< "$GPUS"
158
+ NGPU=${#GPUARR[@]}
159
+ # One job needs ~24.3 GB (measured) of an 80 GB card and drives it to only ~42%
160
+ # util -- B=1 decoding of a 1.06B model cannot saturate an H100, and memory is
161
+ # nowhere near the limit. So oversubscribe: JOBS_PER_GPU>1 runs several jobs per
162
+ # card and buys ~1.8x at 2/GPU. 3/GPU is ~73 GB of 81 -- it fits but leaves
163
+ # little headroom, so it is opt-in rather than the default.
164
+ JOBS_PER_GPU=${JOBS_PER_GPU:-1}
165
+ SLOTS=$(( (NGPU / NP) * JOBS_PER_GPU )); [ "$SLOTS" -lt 1 ] && SLOTS=1
166
+ say "fan-out: ${NGPU} GPU(s), NP=$NP -> $SLOTS concurrent job(s)"
167
+
168
+ jobs=()
169
+ for NAME in $CKPTS; do
170
+ F="${CKPT_FILE[$NAME]:-}"
171
+ [ -n "$F" ] || { say "unknown checkpoint key: $NAME"; continue; }
172
+ [ -f "$CK/$F" ] || { say "SKIP $NAME: $CK/$F not present"; continue; }
173
+ for TASK in $TASKS; do
174
+ # Skip on .gen, not .done: .done is written only by the scoring loop, which
175
+ # runs after `wait` for ALL generation, so a teardown mid-run would otherwise
176
+ # lose every completed pair. Safe because a signature change clears state/.
177
+ [ -f "$OUT/state/${NAME}.${TASK}.gen" ] && { say " $NAME/$TASK already generated"; continue; }
178
+ [ -f "$OUT/prompts/${TASK}_prompts.bin" ] || { say " SKIP $NAME/$TASK: no prompts"; continue; }
179
+ jobs+=("$NAME:$TASK")
180
+ done
181
+ done
182
+ say "${#jobs[@]} job(s) to run"
183
+
184
+ running=0; g=0
185
+ for J in "${jobs[@]:-}"; do
186
+ [ -z "$J" ] && continue
187
+ NAME=${J%%:*}; TASK=${J##*:}
188
+ F="${CKPT_FILE[$NAME]}"
189
+ PB="$OUT/prompts/${TASK}_prompts.bin"
190
+ G="$OUT/gen/${NAME}_${TASK}.csv"
191
+ # g cycles over SLOTS, which now EXCEEDS the GPU count when JOBS_PER_GPU>1,
192
+ # so the device offset must wrap modulo NGPU -- otherwise slots beyond the
193
+ # last GPU would compute an empty CUDA_VISIBLE_DEVICES and every job there
194
+ # would silently land on device 0.
195
+ dev_off=$(( (g * NP) % NGPU ))
196
+ devs=$(IFS=,; echo "${GPUARR[*]:$dev_off:$NP}")
197
+ say " [gpu $devs] $NAME / $TASK"
198
+ (
199
+ env $ARCH CP_SIZE=$NP CP_T=$T CP_B=1 YARN_SCALE=$YARN \
200
+ ${BF16:+CP_BF16=$BF16} \
201
+ ${YARN_ORIG:+YARN_ORIG_MAXPOS=$YARN_ORIG} \
202
+ CP_INIT_FROM="$PWD/$CK/$F" \
203
+ CP_GENERATE=1 CP_GEN_TOKENS_BIN="$PWD/$PB" \
204
+ CP_GEN_PROMPT_LEN=$PROMPT_LEN CP_GEN_N=$N CP_GEN_MAX_NEW=$MAX_NEW \
205
+ CP_GEN_GREEDY=$GREEDY CP_GEN_OUT="$PWD/$G" \
206
+ CP_GEN_MIN_TOKENS=${MINTOK[$TASK]:-3} \
207
+ CUDA_VISIBLE_DEVICES=$devs \
208
+ mpirun --allow-run-as-root -np $NP "$EXE" \
209
+ > "$OUT/gen/${NAME}_${TASK}.log" 2>&1 \
210
+ && [ -s "$G" ] && touch "$OUT/state/${NAME}.${TASK}.gen"
211
+ ) &
212
+ g=$(( (g + 1) % SLOTS )); running=$(( running + 1 ))
213
+ if [ "$running" -ge "$SLOTS" ]; then wait -n; running=$(( running - 1 )); fi
214
+ done
215
+ wait
216
+
217
+ # ---- 2b. score (cheap, CPU-only, after all generation) ----------------------
218
+ for NAME in $CKPTS; do
219
+ for TASK in $TASKS; do
220
+ G="$OUT/gen/${NAME}_${TASK}.csv"
221
+ MK="$OUT/state/${NAME}.${TASK}.done"
222
+ [ -f "$MK" ] && continue
223
+ if [ ! -s "$G" ]; then
224
+ [ -f "$OUT/gen/${NAME}_${TASK}.log" ] && \
225
+ say " NO OUTPUT $NAME/$TASK -- see $OUT/gen/${NAME}_${TASK}.log"
226
+ continue
227
+ fi
228
+ # A non-empty file is NOT a complete one. If the C++ died partway (e.g. a
229
+ # short read on a stale prompt file) it still flushed the rows it managed,
230
+ # and scoring those would report an accuracy over a handful of prompts and
231
+ # then mark the pair .done. Note: `$(grep -c . f || echo 0)` is WRONG --
232
+ # grep -c prints 0 AND exits 1, so the substitution yields $'0\n0' and
233
+ # [ -eq ] throws. stat above is fine; it prints nothing on failure.
234
+ ROWS=$(grep -c . "$G" 2>/dev/null); ROWS=${ROWS:-0}
235
+ if [ "$ROWS" -ne "$N" ]; then
236
+ say " INCOMPLETE $NAME/$TASK ($ROWS/$N rows) -- not scoring, not marking done"
237
+ continue
238
+ fi
239
+ say " scoring $NAME / $TASK"
240
+ $PY Tests/bluscriptcp/longeval_score.py --gen "$G" \
241
+ --meta "$OUT/prompts/${TASK}_meta.json" \
242
+ --out "$OUT/scores/${NAME}_${TASK}.csv" --show 2 | tee -a "$M"
243
+ touch "$MK"
244
+ done
245
+ done
246
+
247
+ # ---- 3. summary -------------------------------------------------------------
248
+ say "building summary"
249
+ # TASKS must come through argv: the heredoc is QUOTED (<<'PY'), so $TASKS would
250
+ # never expand inside it.
251
+ $PY - "$OUT" "$TASKS" <<'PY' | tee -a "$M"
252
+ import csv, math, os, sys, collections
253
+ out, tasks = sys.argv[1], sys.argv[2].split()
254
+ d = os.path.join(out, "scores")
255
+
256
+ # Filenames are <model>_<task>.csv and BOTH halves contain underscores, so
257
+ # fn.split("_", 1) mis-parses every arm ("arm_b_niah_single" -> "arm").
258
+ # Match the known task list from the tail instead; for/else skips non-matches so
259
+ # `name` cannot silently carry over from the previous iteration.
260
+ per_task = collections.defaultdict(dict) # task -> model -> (hits, n)
261
+ per_depth = collections.defaultdict(dict) # depth -> model -> (hits, n)
262
+ per_task_depth = collections.defaultdict(dict) # (task,depth) -> model -> (hits,n)
263
+ for fn in sorted(os.listdir(d)) if os.path.isdir(d) else []:
264
+ if not fn.endswith(".csv"):
265
+ continue
266
+ for task in tasks:
267
+ if fn.endswith("_" + task + ".csv"):
268
+ name = fn[:-(len(task) + 5)]
269
+ break
270
+ else:
271
+ continue
272
+ rows = list(csv.DictReader(open(os.path.join(d, fn))))
273
+ if not rows:
274
+ continue
275
+ per_task[task][name] = (sum(int(r["ok"]) for r in rows), len(rows))
276
+ for r in rows:
277
+ dep = float(r["depth"])
278
+ h, n = per_task_depth[(task, dep)].get(name, (0, 0))
279
+ per_task_depth[(task, dep)][name] = (h + int(r["ok"]), n + 1)
280
+
281
+ if not per_task:
282
+ print(" no scores yet"); raise SystemExit
283
+
284
+ models = sorted({m for t in per_task.values() for m in t})
285
+ pct = lambda hn: 100.0 * hn[0] / hn[1] if hn[1] else float("nan")
286
+
287
+ print("\n=== per-task accuracy % (secondary/descriptive: ~10 per depth cell) ===")
288
+ print(" " + "task".ljust(18) + "".join(m.rjust(9) for m in models))
289
+ for task in sorted(per_task):
290
+ print(" " + task.ljust(18) + "".join(
291
+ (f"{pct(per_task[task][m]):8.1f}" if m in per_task[task] else " -").rjust(9)
292
+ for m in models))
293
+
294
+ # Degenerate-task exclusion, pre-registered: a task at <=1% overall for EVERY
295
+ # model adds n without information and would shrink the pooled CI spuriously.
296
+ degenerate = {t for t in per_task
297
+ if all(pct(per_task[t][m]) <= 1.0 for m in per_task[t])}
298
+ if degenerate:
299
+ print(f"\n degenerate (<=1% for every model): {sorted(degenerate)}")
300
+ # If EVERY task is degenerate the exclusion would empty the primary table and
301
+ # report nothing at all -- a floor effect is a result, not an absence of one.
302
+ # Fall back to pooling everything and say so, rather than printing a blank table.
303
+ if len(degenerate) == len(per_task):
304
+ print(" ALL tasks degenerate -> FLOOR EFFECT. Pooling everything anyway;")
305
+ print(" differences between models are untestable at this setting (no power).")
306
+ degenerate = set()
307
+
308
+ for (task, dep), byname in per_task_depth.items():
309
+ if task in degenerate:
310
+ continue
311
+ for name, (h, n) in byname.items():
312
+ ph, pn = per_depth[dep].get(name, (0, 0))
313
+ per_depth[dep][name] = (ph + h, pn + n)
314
+
315
+ def wilson(h, n):
316
+ """95% Wilson half-width in points -- honest near 0, unlike the normal approx."""
317
+ if not n:
318
+ return float("nan")
319
+ z, p = 1.96, h / n
320
+ return 100.0 * z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
321
+
322
+ print("\n=== PRIMARY: accuracy % pooled by depth across tasks (+-95% Wilson) ===")
323
+ print(" " + "depth".ljust(8) + "".join(m.rjust(16) for m in models))
324
+ for dep in sorted(per_depth):
325
+ cells = []
326
+ for m in models:
327
+ if m in per_depth[dep]:
328
+ h, n = per_depth[dep][m]
329
+ cells.append(f"{pct((h,n)):6.1f}+-{wilson(h,n):4.1f}".rjust(16))
330
+ else:
331
+ cells.append("-".rjust(16))
332
+ print(" " + f"{dep:<8.2f}" + "".join(cells))
333
+
334
+ with open(os.path.join(out, "summary.csv"), "w", newline="") as f:
335
+ w = csv.writer(f); w.writerow(["task"] + models)
336
+ for task in sorted(per_task):
337
+ w.writerow([task] + [f"{pct(per_task[task][m]):.1f}" if m in per_task[task] else ""
338
+ for m in models])
339
+ with open(os.path.join(out, "depth_summary.csv"), "w", newline="") as f:
340
+ w = csv.writer(f); w.writerow(["depth", "model", "hits", "n", "pct", "wilson95"])
341
+ for dep in sorted(per_depth):
342
+ for m in models:
343
+ if m in per_depth[dep]:
344
+ h, n = per_depth[dep][m]
345
+ w.writerow([dep, m, h, n, f"{pct((h,n)):.1f}", f"{wilson(h,n):.1f}"])
346
+ print(f"\n wrote {out}/summary.csv and {out}/depth_summary.csv")
347
+ PY
348
+
349
+ say "DONE -> $OUT/summary.csv"