diff --git a/scripts/apply-protected-cast.sh b/scripts/apply-protected-cast.sh index a1553c5..89df9cb 100755 --- a/scripts/apply-protected-cast.sh +++ b/scripts/apply-protected-cast.sh @@ -1,27 +1,45 @@ #!/usr/bin/env bash -# apply-protected-cast.sh — RUNS ON DANKSERVER. Rewrite the recipe's -# PROTECTED-BEGIN/END section from Swift's OWN calibration grams: run +# apply-protected-cast.sh — RUNS ON DANKSERVER. Rewrite a recipe's +# PROTECTED-BEGIN/END section from the campaign's OWN calibration grams: run # derive-protected-experts.py over the calib dir, splice its cast lines into # the marked section of the recipe in place, keep the pre-edit recipe in the -# campaign trash. Refuses to run twice (marker text says derived). +# campaign trash. Refuses to run twice (marker text says derived), and refuses +# when a requested target count was not reached. Run it on a COPY of the +# template so the committed recipe stays the clean, reviewable baseline. set -euo pipefail -CALIB=${1:-/home/dank/corpora/calib} -RECIPE=${2:-/home/dank/serverAppConfigs-swift15/scripts/recipes/qwen4exp-swift15-w4dn8-i8-hc8m.recipe} -PY=/home/dank/venvs/radcorpus/bin/python -DERIVE=/home/dank/serverAppConfigs-swift15/scripts/derive-protected-experts.py +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CALIB=${1:-/home/dank/campaign/qwen38-w5nl64/calib} +RECIPE=${2:-$SELF_DIR/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe} +TARGET_COUNT=${3:-0} +PY=${PY:-/home/dank/venvs/radcorpus/bin/python} +DERIVE=${DERIVE:-$SELF_DIR/derive-protected-experts.py} TS=$(date +%Y%m%d-%H%M%S) +# OUT and TRASH_DIR are overridable so the seam can drive the whole script in a +# scratch directory; on the box they are the campaign's usual places. +OUT=${OUT:-/tmp/protected-cast-$TS.txt} +TRASH_DIR=${TRASH_DIR:-/home/dank/.AGENTTRASH} grep -q "derive-protected-experts.py rewrote" "$RECIPE" && { echo "recipe already carries derived cast lines; refusing to re-derive" >&2; exit 1; } grep -q "PROTECTED-BEGIN" "$RECIPE" || { echo "no PROTECTED markers in $RECIPE" >&2; exit 1; } -"$PY" "$DERIVE" "$CALIB" > "/tmp/protected-cast-$TS.txt" -N=$(grep -c 'cast dtype=bf16' "/tmp/protected-cast-$TS.txt") -[ "$N" -ge 2 ] || { echo "derive produced $N cast lines; refusing" >&2; exit 1; } -echo "derived $N protected experts -> /tmp/protected-cast-$TS.txt" +ARGS=("$DERIVE" "$CALIB") +if [ "$TARGET_COUNT" -gt 0 ]; then + ARGS+=(--target-count "$TARGET_COUNT") +fi +"$PY" "${ARGS[@]}" > "$OUT" +N=$(grep -c 'cast dtype=bf16' "$OUT") +[ "$N" -ge 2 ] || { echo "derive produced $N cast lines; refusing (see $OUT)" >&2; exit 1; } +if [ "$TARGET_COUNT" -gt 0 ] && [ "$N" -lt "$TARGET_COUNT" ]; then + echo "derive reached $N protected experts, target was $TARGET_COUNT; refusing to convert" >&2 + echo " (the bar sweep bottomed out: the recording is too thin or the target too high)" >&2 + exit 1 +fi +echo "derived $N protected experts -> $OUT" -cp -f "$RECIPE" "/home/dank/.AGENTTRASH/$TS-qwen4exp-swift15-w4dn8-i8-hc8m.recipe" -"$PY" - "$RECIPE" "/tmp/protected-cast-$TS.txt" <<'PYEOF' +mkdir -p "$TRASH_DIR" +cp -f "$RECIPE" "$TRASH_DIR/$TS-$(basename "$RECIPE")" +"$PY" - "$RECIPE" "$OUT" <<'PYEOF' import sys recipe, castfile = sys.argv[1], sys.argv[2] lines = open(recipe).read().splitlines() @@ -35,7 +53,7 @@ for l in lines: elif 'PROTECTED-END' in l: insec = False out.append(l) - out.append('# (derive-protected-experts.py rewrote this section from Swift\'s own grams)') + out.append('# (derive-protected-experts.py rewrote this section from the campaign grams)') elif not insec: out.append(l) open(recipe, 'w').write('\n'.join(out) + '\n') diff --git a/scripts/build-qwen38-bf16-rad.sh b/scripts/build-qwen38-bf16-rad.sh new file mode 100755 index 0000000..2defea7 --- /dev/null +++ b/scripts/build-qwen38-bf16-rad.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# build-qwen38-bf16-rad.sh — RUNS ON DANKSERVER. rad-convert the vendor BF16 +# checkpoint /home/dank/models/Qwen3.8-Flash-Next into +# /home/dank/models/rad/qwen38-bf16.rad with NO recipe and NO --reuse: a weight +# no rule matches keeps the checkpoint's encoding, so every tensor stays the +# bf16 it is in the safetensors and the container is the checkpoint's own +# numbers behind a .rad header. +# +# This file is the campaign's measuring stick, twice over: scripts/ +# run-qwen38-bf16.sh kldref records the KLD reference logits from it (every +# arm's --kld-ref is scored against that record) and the same container serves +# with the calibration tap to write the Grams that derive the protected-expert +# list and feed GPTQ. A reference that is not the vendor checkpoint, byte for +# byte in bf16, silently moves every gate in the campaign — hence no recipe, no +# --reuse, and the four refusal gates below. +# +# Why a container at all: a checkpoint dir has no mapped/file tiers, and a +# weight only a host kernel reads (the 47.7 GiB PLE n-gram table) cannot be +# served from one; rad-convert also bakes the tokenizer. The convert runs on +# the HOST backend — no --device /dev/kfd, no /dev/dri — so declare resolves +# kernels on CPU and production serving is untouched for the whole run (the +# precedent that proved it: scripts/convert-swift15-w4dn8.sh). +# +# Gates, every one a refusal with exit 2 and a named reason, BEFORE anything is +# written, cheapest first — the verifier runs last of them because its Hub gate +# hashes 343 GiB and must not be paid for by a run that was going to refuse on +# the output file anyway. +# checkpoint the tree must be on disk; an absent dir names the command that +# makes one (scripts/download-qwen38-bf16.sh --fetch) and stops. +# free space the convert writes ~343,323 MiB next to the ~343,323 MiB +# checkpoint; QWEN38_MIN_FREE_MIB (default 350000 = that figure +# plus 6.5 GiB of slack) is the floor. The volume that cannot +# hold both is discovered at minute 2, not hour 5. +# output the campaign converts the reference ONCE. An existing +# qwen38-bf16.rad is refused; --force retires it to +# ~/.AGENTTRASH/-qwen38-bf16.rad (trash law: nothing is +# deleted, and the move is a rename on the same volume). +# verified scripts/download-qwen38-bf16.sh's verify mode must pass. A +# failed verify exits 1, not 2: the tree being the wrong payload +# is a result, a missing environment is a refusal. +# plan rad-convert --plan-only -v first — the oracle for "does this +# shape boot". A refused plan costs seconds and saves a convert. +# +# usage: scripts/build-qwen38-bf16-rad.sh [--dry-run] [--plan-only] [--force] +# --dry-run print the argv it would exec (podman wrapping rad-convert), +# one shell-quoted argument a line, and exit 0. No gate runs, no +# container starts, nothing is written — the offline test seam. +# It does resolve the checkpoint and output paths through the +# /models mount, because an argv that carries host paths would +# not run either. +# --plan-only run the plan and every gate except the disk and output ones — +# those two protect bytes the plan never writes, and this is the +# probe to run when the disk gate itself is what refuses +# --force retire an existing output container to the trash dir first +# +# knobs: FRANK_QWEN38_MODELS the host dir mounted at /models (default +# /home/dank/models); FRANK_QWEN38_CKPT (default $MODELS/ +# Qwen3.8-Flash-Next, the same knob the verifier reads); +# FRANK_QWEN38_BF16_OUT (default $MODELS/rad/qwen38-bf16.rad); +# QWEN38_MIN_FREE_MIB; QWEN38_TRASH (default $HOME/.AGENTTRASH). The +# verifier's own census knobs (FRANK_QWEN38_SHARDS / _BYTES / _REPO) +# reach it through the environment untouched. +# exit: 0 converted (or planned), 1 the checkpoint failed verification, +# 2 refused before anything was written +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# ONE owner of the engine pin: the digest frank-archon serves on. The reference +# must be built by the SAME engine build that scores it, or a KLD delta is +# partly a version delta. +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/frank-archon.env" +IMAGE="${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}" + +MODELS="${FRANK_QWEN38_MODELS:-/home/dank/models}" +CKPT="${FRANK_QWEN38_CKPT:-$MODELS/Qwen3.8-Flash-Next}" +OUT="${FRANK_QWEN38_BF16_OUT:-$MODELS/rad/qwen38-bf16.rad}" +TRASH="${QWEN38_TRASH:-$HOME/.AGENTTRASH}" +MIN_FREE_MIB="${QWEN38_MIN_FREE_MIB:-350000}" +LOG="/home/dank/logs/qwen38_bf16_convert.log" + +DRY=0 +PLAN_ONLY=0 +FORCE=0 +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --plan-only) PLAN_ONLY=1 ;; + --force) FORCE=1 ;; + *) echo "usage: ${0##*/} [--dry-run] [--plan-only] [--force]" >&2 + exit 2 ;; + esac + shift +done + +# The convert sees the tree only through the /models mount, so both paths must +# be under it; the container-side spelling is what goes into the argv. +case "$CKPT" in + "$MODELS"/*) CKPT_IN="/models/${CKPT#"$MODELS"/}" ;; + *) echo "REFUSED: checkpoint $CKPT is not under the mounted models dir $MODELS — the convert cannot see it" >&2 + exit 2 ;; +esac +case "$OUT" in + "$MODELS"/*) OUT_IN="/models/${OUT#"$MODELS"/}" ;; + *) echo "REFUSED: output $OUT is not under the mounted models dir $MODELS — rad-convert could not write it" >&2 + exit 2 ;; +esac + +# Declare with NO recipe line — that is what keeps every tensor bf16 — and at the +# bound this container will actually be served at. Both flags come off the pinned +# image's own help: --kv-cache-dtype at convert "only picks the kernels it +# checks" and the container is identical either way, so naming fp8 is free +# insurance — if the fp8 KV kernels the fleet serves would not resolve, the +# convert refuses here instead of the boot failing on a drained window. +# --max-ctx is the bound declare is run at, whose default is the checkpoint's +# training context (262,144 here, whose fp8 cache is 258.8 GiB against a 192 GiB +# machine, so the default is not an option). 32,768 is set by the corpus, not by +# taste: kld.jsonl's longest record is 38,263 characters — ~9,600 tokens at the +# pessimistic chars/4, ~7,700 at the chars/4.97 this repo measured on the K3 +# corpus — so the window the rig boots at clears every record 2.6x over and the +# reference boot cannot die on a sequence that will not fit. The rig's window is +# one knob (FRANK_QWEN38_MAX_MODEL_LEN in scripts/run-qwen38-bf16.sh, default +# 32,768) and that script refuses a corpus its window cannot hold, so raising one +# without the other is caught before the drained window, not inside it. The +# payload containers the gate scores declare at the training context because +# production must hold the 200k shape; a declared bound is a declare-time check, +# not a weight change, so the reference and the payloads carry the same numbers +# whatever each declared at — which is why one can be measured against the other. +CONVERT_ARGS=("$CKPT_IN" -o "$OUT_IN" --max-ctx 32768 --kv-cache-dtype fp8) +PODMAN=(podman run --rm --entrypoint rad-convert -v "$MODELS:/models") +PLAN_CMD=("${PODMAN[@]}" --name qwen38-bf16-plan "$IMAGE" "${CONVERT_ARGS[@]}" --plan-only -v) +CONV_CMD=("${PODMAN[@]}" --name qwen38-bf16-convert "$IMAGE" "${CONVERT_ARGS[@]}") + +if [ "$DRY" = "1" ]; then + echo "dry run: no gate checked, no container started, nothing written" + echo "# $CKPT -> $OUT (the convert sees them as $CKPT_IN and $OUT_IN)" + echo "# plan argv:" + printf '%q\n' "${PLAN_CMD[@]}" + echo "# convert argv:" + printf '%q\n' "${CONV_CMD[@]}" + exit 0 +fi + +# ---- gate: the checkpoint is on disk --------------------------------------- +if [ ! -d "$CKPT" ]; then + echo "REFUSED: no checkpoint at $CKPT — fetch and verify it first: scripts/download-qwen38-bf16.sh --fetch" >&2 + exit 2 +fi + +# ---- gate: peak disk -------------------------------------------------------- +# The deepest existing ancestor is the right thing to ask: a fresh +# /home/dank/models/rad has no free space of its own to report. +OUT_DIR="$(dirname "$OUT")" +PROBE="$OUT_DIR" +while [ ! -d "$PROBE" ] && [ "$PROBE" != "/" ]; do PROBE="$(dirname "$PROBE")"; done +FREE_MIB="$(df --output=avail -m "$PROBE" | tail -1 | tr -d ' ')" +if ! [[ "$FREE_MIB" =~ ^[0-9]+$ ]]; then + echo "REFUSED: cannot read the free space under $PROBE (df said: $FREE_MIB)" >&2 + exit 2 +fi +if [ "$PLAN_ONLY" != "1" ] && [ "$FREE_MIB" -lt "$MIN_FREE_MIB" ]; then + echo "REFUSED peak-disk: $FREE_MIB MiB free under $PROBE, $MIN_FREE_MIB MiB wanted (QWEN38_MIN_FREE_MIB) — the bf16 container rewrites the checkpoint's own 343,323 MiB of tensors beside it" >&2 + exit 2 +fi + +# ---- gate: one conversion --------------------------------------------------- +if [ -e "$OUT" ]; then + if [ "$PLAN_ONLY" = "1" ]; then + echo "note: $OUT already exists; --plan-only writes nothing, so it stays" + elif [ "$FORCE" = "1" ]; then + mkdir -p "$TRASH" + DEST="$TRASH/$(date -u +%Y%m%dT%H%M%SZ)-$(basename "$OUT")" + mv "$OUT" "$DEST" + echo "TRASHED: retired the existing reference container $OUT -> $DEST" + else + echo "REFUSED: $OUT already exists — the campaign converts the reference once; re-run with --force to retire it to $TRASH first" >&2 + exit 2 + fi +fi + +# ---- gate: the tree really is the checkpoint (the full verify) ------------- +echo "== verifying $CKPT (scripts/download-qwen38-bf16.sh, full gates)" +rc=0 +bash "$SCRIPT_DIR/download-qwen38-bf16.sh" --dir "$CKPT" || rc=$? +if [ "$rc" -ne 0 ]; then + if [ "$rc" -eq 2 ]; then + echo "REFUSED: the checkpoint verifier refused the environment (rc=2); nothing was converted" >&2 + exit 2 + fi + echo "REFUSED: $CKPT failed verification (rc=$rc) — a half tree converts to a half reference" >&2 + exit 1 +fi + +# ---- plan, then convert ------------------------------------------------------ +mkdir -p "$(dirname "$LOG")" +# rad-convert opens -o during declare as well, so the output directory exists +# before the plan; that empty directory is all this creates ahead of it. +mkdir -p "$OUT_DIR" +echo "== rad-convert --plan-only -v (declare + plan; writes nothing)" +"${PLAN_CMD[@]}" 2>&1 | tee -a "$LOG" +rc=${PIPESTATUS[0]} +if [ "$rc" -ne 0 ]; then + echo "REFUSED plan: rad-convert --plan-only exited rc=$rc — the engine refuses this encoding/shape, no container was written (log: $LOG)" >&2 + exit 2 +fi +if [ "$PLAN_ONLY" = "1" ]; then + echo "PLAN_OK: $CKPT_IN declares for $OUT_IN; no container written (log: $LOG)" + exit 0 +fi + +echo "== convert: $CKPT -> $OUT ($(date -Is))" +"${CONV_CMD[@]}" 2>&1 | tee -a "$LOG" +rc=${PIPESTATUS[0]} +if [ "$rc" -ne 0 ]; then + echo "rad-convert rc=$rc; log: $LOG" >&2 + exit "$rc" +fi +MIB=$(( $(stat -c %s "$OUT") / 1048576 )) +echo "BUILD_DONE: $OUT is ${MIB} MiB (log: $LOG)" +echo "NEXT: scripts/run-qwen38-bf16.sh kldref records the reference logits from it" diff --git a/scripts/convert-qwen38-w5nl64.sh b/scripts/convert-qwen38-w5nl64.sh new file mode 100755 index 0000000..e95813e --- /dev/null +++ b/scripts/convert-qwen38-w5nl64.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# convert-qwen38-w5nl64.sh — RUNS ON DANKSERVER. The CPU half of the Qwen3.8-Flash-Next five-bit +# campaign: convert the vendor BF16 checkpoint into the campaign's arms, each one the whole +# `w5nl64a8h` rung on BOTH expert projections with the protected experts kept bf16. +# +# Nothing here touches a GPU: the container is started without /dev/kfd or /dev/dri, so the +# converter runs on the host backend and the fleet can keep serving through the whole script. +# The engine pin is the digest that serves frank-archon, so a winning arm crosses no version +# boundary between measurement and serving. +# +# Three arms, because the campaign's question is which search knob pays for its hours +# (recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe): +# +# cd3 the recipe as written — coordinate descent three sweeps. The control. +# cd6 six sweeps: the same search, twice the passes. +# ao1 act_order=1 instead of cd: GPTQ sweeps columns in activation-importance order and +# stores them permuted. This is a SEPARATE arm, not a fourth sweep — libquant refuses +# cd= together with act_order=1 ("cd= sweeps the columns in their stored order, and +# act_order=1 stores them permuted; one or the other", libquant/lq_registry.cpp), so +# the two knobs cannot be combined into a best-of-both container. +# +# The arms are generated from ONE template with `--quant` overrides, not three near-duplicate +# recipes: --quant rules are inserted BEFORE the recipe file and the first rule whose glob matches +# a weight decides it (core/format/recipe.cpp: Recipe::match), so an override cleanly replaces the +# expert line and the other 60 lines cannot drift between arms. +# +# Protected experts: apply-protected-cast.sh derives the bf16 set from THIS campaign's grams +# (--target-count 48, two per layer so each rank's 256-expert half stays even) and splices it into +# a per-run COPY of the template; the committed recipe keeps its markers and stays reviewable. +# +# Gates, in the order they bite — each one is a lesson the swift15 campaign paid for: +# inputs checkpoint (dir + index), recipe, grams, imatrix, tokenizer. A missing grams +# directory converts UNCALIBRATED with one stderr note, which is silently worse +# than failing here. +# reference the 335.3 GiB BF16 reference container must be GONE before the converts start. +# It and the checkpoint together eat the volume; its job (the KLD recording and +# the calibration grams) is finished by then, and both survive on disk without the +# container beside it. The FP8-native anchor is FETCHED after the converts (disk +# sequencing), so nothing here waits on it. +# plan --plan-only runs FIRST, per arm, and prints "about X would be written". The +# number is the bill: free space must cover it plus the 128k-prompt overshoot +# margin, and the finished artifact is checked against it. +# outputs no arm overwrites anything; move the old one to trash. +# +# Usage: convert-qwen38-w5nl64.sh [--arm cd3|cd6|ao1]... [--dry-run] [--plan-only] +# [--target-count N] [--keep-recipe] +set -u -o pipefail + +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGE=${IMAGE:-stilldeadcode/radiance@sha256:d590e300234bf6d2f942a0b8d456d183153fd808f28820e6c2fa69c9389178ed} +CKPT=${CKPT:-/home/dank/models/Qwen3.8-Flash-Next} +CAMPAIGN=${CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64} +RECIPE=${RECIPE:-$SELF/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe} +CALIB=${CALIB:-$CAMPAIGN/calib} +IMATRIX=${IMATRIX:-$CAMPAIGN/imatrix.gguf} +OUTDIR=${OUTDIR:-/home/dank/models/rad} +BF16_RAD=${BF16_RAD:-$OUTDIR/qwen38-bf16.rad} # the reference container, not an arm output +STATE=${STATE:-$CAMPAIGN} # the dir the container mounts read-only: the + # spliced recipe and the imatrix live here +TARGET_COUNT=${TARGET_COUNT:-48} +MIN_GRAMS=${MIN_GRAMS:-24576} # per-expert DOWN grams: 48 recorded layers x 512 + # experts. The checkpoint has 49 MoE layers, but the + # MTP layer is never recorded, so its 512 experts are + # absent from the tap's output by design +MARGIN_MIB=${MARGIN_MIB:-40960} # the long-prompt overshoot, off the plan number +TS=$(date +%Y%m%d-%H%M%S) + +ARMS=() +DRY=0 +PLAN_ONLY=0 +KEEP_RECIPE=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --arm) ARMS+=("$2"); shift 2 ;; + --dry-run) DRY=1; shift ;; + --plan-only) PLAN_ONLY=1; shift ;; + --target-count) TARGET_COUNT="$2"; shift 2 ;; + --keep-recipe) KEEP_RECIPE=1; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ "${#ARMS[@]}" -gt 0 ] || ARMS=(cd3 cd6 ao1) +for a in "${ARMS[@]}"; do + case "$a" in cd3|cd6|ao1) ;; *) echo "unknown arm: $a (cd3|cd6|ao1)" >&2; exit 2 ;; esac +done + +# The expert rule's option set, spelled once. --quant replaces the template's line wholesale, so +# every option has to be repeated here; `calib=$CALIB` is expanded by the recipe reader from the +# container environment (core/format/recipe.cpp: expand refuses an unset name rather than taking +# it for empty). +expert_opts='table=w5nl,group=64,scale=fp8_e4m3,scale2=f32,block2=*x*,scale2_value=0.0001220703125,transform=fwht128,rule=search,calib=$CALIB' +arm_quant() { + case "$1" in + # The control is the template itself: no override, so the recipe's own line decides. + cd3) return 0 ;; + cd6) echo "blk.*.ffn_*_exps.*.weight=gptq:${expert_opts},cd=6" ;; + # act_order=1 WITHOUT cd: libquant refuses the pair (lq_registry.cpp:105, "cd= sweeps the + # columns in their stored order, and act_order=1 stores them permuted; one or the other"), so + # this arm is the interleave knob alone, not cd=3 plus it. + ao1) echo "blk.*.ffn_*_exps.*.weight=gptq:${expert_opts},act_order=1" ;; + esac +} + +# --- gates ------------------------------------------------------------------------------------ +[ -d "$CKPT" ] || { echo "no checkpoint at $CKPT; run scripts/download-qwen38-bf16.sh --fetch" >&2; exit 2; } +[ -f "$CKPT/model.safetensors.index.json" ] || { echo "checkpoint at $CKPT has no safetensors index — a torn fetch" >&2; exit 2; } +[ -f "$CKPT/tokenizer.json" ] || { echo "no tokenizer.json in $CKPT; the .rad cannot carry one without it" >&2; exit 2; } +[ -f "$RECIPE" ] || { echo "no recipe at $RECIPE" >&2; exit 2; } +GRAMS=$(find "$CALIB" -maxdepth 1 -name 'gram.r*.bin' 2>/dev/null | wc -l) +[ "$GRAMS" -ge "$MIN_GRAMS" ] || { + echo "calib dir $CALIB holds $GRAMS gram files (<$MIN_GRAMS); run $0 after run-qwen38-bf16.sh calib" >&2; exit 2; } +[ -f "$IMATRIX" ] || { echo "no imatrix at $IMATRIX; run scripts/grams-to-imatrix.py over $CALIB" >&2; exit 2; } +# The container sees the campaign dir at one fixed mount point, so an imatrix kept elsewhere has +# no path inside it: copy it in (or point IMATRIX at the campaign dir) rather than let the +# converter fail halfway through a planned arm. +[ "$(dirname "$IMATRIX")" = "$STATE" ] || { + echo "imatrix $IMATRIX is not in the campaign dir $STATE the container mounts; move it there" >&2; exit 2; } +if [ -e "$BF16_RAD" ]; then + echo "the 335.3 GiB BF16 reference container $BF16_RAD is still on disk; its recording and" \ + "the calibration grams are already taken, so move it to trash — the converts need its" \ + "space (peak-disk law)" >&2 + exit 2 +fi +for a in "${ARMS[@]}"; do + [ -e "$OUTDIR/qwen38-w5nl64-$a.rad" ] && { + echo "$OUTDIR/qwen38-w5nl64-$a.rad already exists; move it to trash first" >&2; exit 2; } +done + +# --- the protected plane, spliced into a per-run copy of the template -------------------------- +mkdir -p "$STATE" +WORK="$STATE/recipe-$TS.recipe" +cp "$RECIPE" "$WORK" +if [ "$DRY" = 1 ]; then + echo "would run: $SELF/apply-protected-cast.sh $CALIB $WORK $TARGET_COUNT" +else + # Positional, because the tool reads its recipe and calib dir by position: passing them as + # environment variables would be silently ignored and it would rewrite the COMMITTED + # template in place instead of this run's copy. + "$SELF/apply-protected-cast.sh" "$CALIB" "$WORK" "$TARGET_COUNT" \ + || { echo "the protected-expert derivation refused; the arms would convert unprotected" >&2; exit 1; } +fi + +run_convert() { # run_convert ARM [extra flags...] + local arm="$1"; shift + local out="$OUTDIR/qwen38-w5nl64-$arm.rad" log="$STATE/convert-$arm-$TS.log" + local argv=(--rm --name "qwen38-convert-$arm" + -e "CALIB=/data/calib" -e "OMP_NUM_THREADS=$(nproc)" + -v "$(dirname "$CKPT"):/models:ro" + -v "$CALIB:/data/calib:ro" + -v "$STATE:/campaign:ro" + -v "$OUTDIR:/out") + local conv=(/models/"${CKPT##*/}" --recipe "/campaign/$(basename "$WORK")" + --tokenizer "/models/${CKPT##*/}/tokenizer.json" + --imatrix /campaign/"${IMATRIX##*/}" + # No --max-ctx: the context declare then runs at the checkpoint's training context + # (262144), which is what the shipped container did — rad-info --meta on it carries + # no override. A container declared at a smaller bound cannot be expected to serve + # the 200k window D2 holds, and the convert is not where to discover that. + # --kv-cache-dtype changes nothing in the artifact; it picks the kernels the declare + # checks, and the fleet serves fp8 KV, so an unresolvable fp8 kernel must be said no + # to by the convert, not by a boot on a drained window. + --kv-cache-dtype fp8 -o "/out/$(basename "$out")") + local q; q=$(arm_quant "$arm") + [ -n "$q" ] && conv+=(--quant "$q") + if [ "$DRY" = 1 ]; then + echo "podman run ${argv[*]} \$IMAGE --entrypoint /opt/radiance/bin/rad-convert ${conv[*]} $*" + return 0 + fi + podman run "${argv[@]}" --entrypoint /opt/radiance/bin/rad-convert "$IMAGE" "${conv[@]}" "$@" \ + 2>&1 | tee "$log" + return "${PIPESTATUS[0]}" +} + +# --- plan, then bill, then convert ------------------------------------------------------------- +overall=0 +for arm in "${ARMS[@]}"; do + echo "== arm $arm ==" + if [ "$DRY" = 1 ]; then + # Both lines, in order: the plan runs before the convert, and the convert is what the + # operator is about to pay an afternoon for. + run_convert "$arm" --plan-only + run_convert "$arm" + continue + fi + rc=0; plan_log=$(run_convert "$arm" --plan-only) || rc=$? + [ "$rc" -eq 0 ] || { echo "arm $arm: --plan-only failed (rc=$rc); nothing was written" >&2; overall=1; continue; } + echo "$plan_log" + # "about 141.2 GiB would be written" is the converter's own bill; parse it rather than + # hardcoding a size, so the gate still works after any recipe edit. + want=$(printf '%s' "$plan_log" | sed -n 's/.*about \([0-9.]*\) \([KMGT]iB\) would be written.*/\1 \2/p' | tail -1) + [ -n "$want" ] || { echo "arm $arm: the plan printed no size; refusing to convert blind" >&2; overall=1; continue; } + need_mib=$(awk -v n="${want% *}" -v u="${want#* }" \ + 'BEGIN{ f = (u=="KiB") ? 2^-10 : (u=="MiB") ? 1 : (u=="GiB") ? 1024 : (u=="TiB") ? 1048576 : 0 + if (f == 0) { exit 1 }; printf "%d", n*f + 0.5 }') || { + echo "arm $arm: the plan printed an unknown unit in '$want'" >&2; overall=1; continue; } + free_mib=$(df --output=avail -m "$OUTDIR" | tail -1 | tr -d ' ') + echo "plan: $want ($need_mib MiB); free ${free_mib} MiB on $OUTDIR (margin $MARGIN_MIB MiB)" + if [ "$free_mib" -lt $((need_mib + MARGIN_MIB)) ]; then + echo "arm $arm: needs ${need_mib} MiB + ${MARGIN_MIB} margin, ${free_mib} free; free space and re-run" >&2 + overall=1; continue + fi + if [ "$PLAN_ONLY" = 1 ]; then echo "planned, not converted (--plan-only)"; continue; fi + rc=0; run_convert "$arm" || rc=$? + [ "$rc" -eq 0 ] || { echo "arm $arm: rad-convert rc=$rc; log: $STATE/convert-$arm-$TS.log" >&2; overall=1; continue; } + out="$OUTDIR/qwen38-w5nl64-$arm.rad" + got_mib=$(( $(stat -c %s "$out") / 1048576 )) + # The receipt: the artifact is what the plan predicted, within the metadata the plan does not + # count. An arm that came out materially smaller lost a plane somewhere. + lo=$(( need_mib * 98 / 100 )); hi=$(( need_mib * 103 / 100 )) + if [ "$got_mib" -lt "$lo" ] || [ "$got_mib" -gt "$hi" ]; then + echo "arm $arm: $out is ${got_mib} MiB, the plan said ${need_mib} MiB (allowed ${lo}-${hi})" >&2 + overall=1; continue + fi + echo "CONVERT_DONE $arm ${got_mib} MiB -> $out" +done + +if [ "$KEEP_RECIPE" = 0 ] && [ "$DRY" = 0 ]; then + mkdir -p "$CAMPAIGN/.AGENTTRASH" + mv "$WORK" "$CAMPAIGN/.AGENTTRASH/$(basename "$WORK")" 2>/dev/null || true +fi +exit "$overall" diff --git a/scripts/derive-protected-experts.py b/scripts/derive-protected-experts.py index 4a3503b..d8a4472 100755 --- a/scripts/derive-protected-experts.py +++ b/scripts/derive-protected-experts.py @@ -20,9 +20,16 @@ of the Gram is its down-input energy. Rank halves: 0-255, 256-511 Output: the cast-rule lines (recipe format) on stdout, a report on stderr. Usage: derive-protected-experts.py [--min-share 0.03] - [--min-route-mult 3.0] + [--min-route-mult 3.0] [--target-count 0] + --target-count N fills the byte bill: the bars pick the base set exactly as + the bar-only pass does, then the energy ranking admits experts one at a time + (each with its parity partner) until the count lands. The energy bar NEVER + relaxes, so asking for 48 bf16 experts never quietly buys 80; the ratio bar + walks down only when the ranking cannot produce that many candidates. + 0 takes the bars as given. """ import argparse +import collections import glob import os import re @@ -32,6 +39,7 @@ import sys import numpy as np NAME_RE = re.compile(r"^gram\.r(\d)\.blk\.(\d+)\.ffn_down_exps\.(\d+)\.bin$") +CAST_RE = re.compile(r"^blk\.(\d+)\.ffn_\*_exps\.(\d+)\.weight") def read_gram(path): @@ -49,27 +57,8 @@ def read_gram(path): return k, rank, int(rows), tr -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("calib_dir") - ap.add_argument("--min-share", type=float, default=0.03) - ap.add_argument("--min-route-mult", type=float, default=3.0) - a = ap.parse_args() - - # layer -> global expert -> (rank, rows, energy) - data = {} - for path in sorted(glob.glob(os.path.join( - a.calib_dir, "gram.r*.ffn_down_exps.*.bin"))): - m = NAME_RE.match(os.path.basename(path)) - if not m: - continue - rank, layer, expert = int(m.group(1)), int(m.group(2)), int(m.group(3)) - k, _, rows, tr = read_gram(path) - data.setdefault(layer, {})[expert] = (rank, rows, tr) - - if not data: - raise SystemExit("no per-expert down grams found") - +def select(data, min_share, min_route_mult): + """(cast lines, report lines, count) for one pair of bars.""" lines, report = [], [] total = 0 for layer in sorted(data): @@ -83,7 +72,7 @@ def main(): for e, (rank, rows, tr) in experts.items(): es, rs = tr / tot_e, rows / max(tot_r, 1) rec = (e, rank, es, rs) - if es >= a.min_share and es >= a.min_route_mult * rs: + if es >= min_share and es >= min_route_mult * rs: qual.append(rec) elif es > 0: pool.append(rec) @@ -107,10 +96,148 @@ def main(): f"energy {es*100:5.2f}%, routing {rs*100:5.3f}%, " f"ratio {es/max(rs,1e-12):6.1f}") total += len(chosen) + return lines, report, total + + +def parity_repair(chosen, by_half, report, only=None): + """Add the next-best candidate to every half — or to `only` — holding an odd count. + + The four-bit GEMM takes each half's remainder as two equal tables, so an odd count + of bf16 experts on a half is not a style problem: the two tables would not match. + `by_half` is energy-descending, so the first unchosen candidate is the next most + energetic, which is the expert worth keeping. Returns how many were added. + """ + added = 0 + for key in ([only] if only is not None else sorted(by_half)): + layer, half = key + n = sum(1 for (l, x) in chosen if l == layer and x // 256 == half) + if n % 2 == 0: + continue + for e, es, rs in by_half.get(key, []): + if (layer, e) not in chosen: + chosen[(layer, e)] = (es, rs) + added += 1 + report.append(f"blk.{layer}: half {half} odd -> added e{e} " + f"(energy {es*100:.2f}%)") + break + else: + report.append(f"blk.{layer}: half {half} holds {n} and no candidate is " + f"left to make it even") + return added + + +def select_to_count(data, min_share, min_route_mult, target): + """(lines, report, total, cutoff) for the set that LANDS the count. + + The bars decide the base set; the count is then filled from the energy ranking, + one expert at a time, each addition taking its half's next-best candidate with it + so every rank's half stays even. Only the RATIO bar walks down to find candidates + — it is the law that says an expert is disproportionate and not merely busy, and + the caller's byte bill has to be filled — while the ENERGY bar holds, because that + bar is what the extra GiB of bf16 is supposed to buy. `cutoff` is the energy share + the set bottoms out at: the bar this campaign actually converted with. + """ + report = [] + shares, ranked = {}, [] + for layer in sorted(data): + experts = data[layer] + tot_e = sum(v[2] for v in experts.values()) + tot_r = sum(v[1] for v in experts.values()) + if tot_e <= 0 or tot_r <= 0: + report.append(f"blk.{layer}: empty recording, skipped") + continue + for e, (_rank, rows, tr) in experts.items(): + es, rs = tr / tot_e, rows / max(tot_r, 1) + if es <= 0: + continue + shares[(layer, e)] = (es, rs) + ranked.append((es, layer, e, rs)) + ranked.sort(key=lambda r: (-r[0], r[1], r[2])) + by_half = {} + for es, layer, e, rs in ranked: + by_half.setdefault((layer, e // 256), []).append((e, es, rs)) + + chosen = {k: v for k, v in shares.items() + if v[0] >= min_share and v[0] >= min_route_mult * v[1]} + total = len(chosen) + total += parity_repair(chosen, by_half, report) + + mult = min_route_mult + while True: + pool = [(es, l, e, rs) for es, l, e, rs in ranked + if es >= mult * rs and (l, e) not in chosen] + if len(pool) + total >= target or mult <= 0.0: + break + mult -= 0.5 + report.append(f"ratio bar {mult + 0.5:g} leaves {len(pool)} fill candidates " + f"for {target - total} slots; relaxing toward 0") + for es, layer, e, rs in pool: + if total >= target: + break + if (layer, e) in chosen: + continue + chosen[(layer, e)] = (es, rs) + total += 1 + report.append(f"blk.{layer}: count fill -> added e{e} (energy {es*100:.2f}%)") + total += parity_repair(chosen, by_half, report, only=(layer, e // 256)) + lines, cutoff = [], None + for (layer, e) in sorted(chosen): + es, rs = chosen[(layer, e)] + cutoff = es if cutoff is None else min(cutoff, es) + lines.append(f"blk.{layer}.ffn_*_exps.{e}.weight cast dtype=bf16") + return lines, report, total, cutoff + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("calib_dir") + ap.add_argument("--min-share", type=float, default=0.03) + ap.add_argument("--min-route-mult", type=float, default=3.0) + ap.add_argument("--target-count", type=int, default=0) + a = ap.parse_args() + + # layer -> global expert -> (rank, rows, energy) + data = {} + for path in sorted(glob.glob(os.path.join( + a.calib_dir, "gram.r*.ffn_down_exps.*.bin"))): + m = NAME_RE.match(os.path.basename(path)) + if not m: + continue + rank, layer, expert = int(m.group(1)), int(m.group(2)), int(m.group(3)) + k, _, rows, tr = read_gram(path) + data.setdefault(layer, {})[expert] = (rank, rows, tr) + + if not data: + raise SystemExit("no per-expert down grams found") + + if a.target_count > 0: + lines, report, total, cutoff = select_to_count( + data, a.min_share, a.min_route_mult, a.target_count) + bar = f"energy cutoff {cutoff:.3g}" if cutoff is not None else "no candidates" + else: + lines, report, total = select(data, a.min_share, a.min_route_mult) + bar = f"min-share {a.min_share:.3g}" + + # The emitted lines ARE the contract with the four-bit GEMM: every rank's half of + # every layer must hold an even number of bf16 experts. Judged on the output and + # not on the internal sets, so a slip in either pass is caught here rather than at + # engine load time. + halves = collections.Counter() + for rule in lines: + m = CAST_RE.match(rule) + if m: + halves[(int(m.group(1)), int(m.group(2)) // 256)] += 1 + odd = {key: n for key, n in halves.items() if n % 2} + if odd: + print("PARITY LAW BROKEN: " + ", ".join( + f"blk.{lay} half {half} holds {n} protected experts" + for (lay, half), n in sorted(odd.items())), file=sys.stderr) + raise SystemExit(2) print("\n".join(lines)) print("\n".join(report), file=sys.stderr) - print(f"total protected: {total}", file=sys.stderr) + print(f"total protected: {total} (target {a.target_count or 'none'}, {bar}, " + f"min-route-mult {a.min_route_mult:g})", file=sys.stderr) if __name__ == "__main__": diff --git a/scripts/download-qwen38-bf16.sh b/scripts/download-qwen38-bf16.sh new file mode 100755 index 0000000..68590c0 --- /dev/null +++ b/scripts/download-qwen38-bf16.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# download-qwen38-bf16.sh — RUNS ON DANKSERVER. Fetch and verify the vendor +# BF16 checkpoint Qwen/Qwen3.8-Flash-Next: the source the five-bit campaign +# converts, and the source the KLD reference is recorded from. +# +# Why the base and not a finetune: the campaign converts to compare against the +# publisher's own iq4r container, and that container was built from this +# checkpoint. Base-in, base-out; anything else confounds the gate. (The +# predecessor campaign ran ukisai/Swift-1.5-Qwen3.8-Flash-Next-BF16, whose key +# set is identical to this repo's but whose weights are not — none of its +# calibration grams, KLD reference or protected list carries over.) +# +# Why re-fetch instead of reusing: every BF16 copy of this family was destroyed +# in the 2026-10-15 storage purge — only the 113.6 GiB served .rad survived. +# 335.3 GiB of safetensors, 131 shards, ungated (the predecessor campaign +# verified the key set against this repo, so no gate letter is needed). +# +# Verification is four gates deep, because the failure this repo actually lived +# through is a fetch that reported success: `curl -o FILE URL` exits 0 on an +# HTTP 401/403 and writes the error body as the artifact — three shards of +# identical tiny size and a cheerful DONE in 20 seconds for 104 GiB. +# +# 1. shard census: the file count must equal the expected shard count, so a +# partially fetched fragment tree (the .partNofM trap) cannot pass. +# 2. per-shard completeness, from the shard's OWN safetensors header: the +# header declares where the tensor data ends, so every shard's true size is +# known offline. A torn tail is named here, by file, instead of surfacing +# as a load error five hours later. +# 3. index coverage: every shard model.safetensors.index.json references +# exists and is named. +# 4. total bytes against the number the Hub declared, then, unless --no-hub, +# every file's sha256 against the Hub's LFS record — including the shards +# already on disk, so a previous partial download is never trusted. +# +# The token never enters a command line (the 2026-09-16 leak law): it is read +# into HF_TOKEN in the environment, where a local process listing is the only +# reader. This repo is ungated, so the fetch simply runs without one when no +# token file exists. scripts/hfget.sh is curl+jq only — no Python, no venv, no +# hf_transfer — and it drops the pickle/ONNX weight duplicates by itself. +# +# usage: download-qwen38-bf16.sh [--fetch] [--no-hub] [--dir DIR] +# [--shards N] [--bytes B] [--repo ID] +# --fetch download first (resumable: re-run to continue), then verify +# --no-hub skip the Hub-side sha256 sweep; the other gates always run +# exit: 0 verified, 1 verification failed, 2 environment / usage error +set -euo pipefail + +REPO="${FRANK_QWEN38_HF_REPO:-Qwen/Qwen3.8-Flash-Next}" +LOCAL_DIR="${FRANK_QWEN38_CKPT:-/home/dank/models/Qwen3.8-Flash-Next}" +# Hub census of 2026-10-27: 144 files, 131 of them .safetensors, together +# 360,023,351,514 bytes; the shards alone are 360,000,192,888. This gate counts +# shards only, so a config file edited on the Hub cannot fail it. +EXPECTED_SHARDS="${FRANK_QWEN38_SHARDS:-131}" +EXPECTED_BYTES="${FRANK_QWEN38_BYTES:-360000192888}" +MIN_FREE_MIB="${FRANK_QWEN38_MIN_FREE_MIB:-350000}" + +FETCH=0 +HUB=1 +while [ $# -gt 0 ]; do + case "$1" in + --fetch) FETCH=1 ;; + --no-hub) HUB=0 ;; + --dir) LOCAL_DIR="$2"; shift ;; + --shards) EXPECTED_SHARDS="$2"; shift ;; + --bytes) EXPECTED_BYTES="$2"; shift ;; + --repo) REPO="$2"; shift ;; + *) echo "usage: ${0##*/} [--fetch] [--no-hub] [--dir DIR] [--shards N] [--bytes B] [--repo ID]" >&2 + exit 2 ;; + esac + shift +done + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FAILS=0 +note() { echo "$@"; FAILS=$((FAILS + 1)); } + +if [ "$FETCH" = "1" ]; then + # Every fetch gate is a hard stop: skipping the download silently and then + # verifying a half tree is how a campaign ships a stale payload. + HFGET="$SELF_DIR/hfget.sh" + if [ ! -f "$HFGET" ]; then + echo "REFUSED: the fetch helper is not here: $HFGET" >&2 + exit 2 + fi + if ! command -v jq >/dev/null 2>&1; then + echo "REFUSED: hfget.sh needs jq on PATH" >&2 + exit 2 + fi + mkdir -p "$LOCAL_DIR" + # The campaign's peak-disk law starts at the fetch: the checkpoint and the + # reference container built from it are ~335 GiB each, and a tree that does + # not fit is discovered hours into a transfer, when it costs the most. + free_mib=$(df -m --output=avail "$LOCAL_DIR" | tail -1 | tr -dc '0-9') + if [ "${free_mib:-0}" -lt "$MIN_FREE_MIB" ]; then + echo "REFUSED: $free_mib MiB free at $LOCAL_DIR; the fetch wants $MIN_FREE_MIB MiB free" >&2 + echo " (retire the peak-disk predecessors first — nothing is downloaded, so" >&2 + echo " nothing is left half-written)" >&2 + exit 2 + fi + echo "fetch: $REPO -> $LOCAL_DIR ($free_mib MiB free)" + if [ -z "${HF_TOKEN:-}" ]; then + CREDSTORE="$HOME/.config/huggingface/credentials/https://huggingface.co" + if [ -f "$CREDSTORE" ]; then + HF_TOKEN="$(awk '/^ OauthToken: / {print $2; exit}' "$CREDSTORE")" + elif [ -f "$HOME/.cache/huggingface/token" ]; then + # The legacy single-line token file the hub library writes. + HF_TOKEN="$(tr -d '[:space:]' < "$HOME/.cache/huggingface/token")" + elif [ -f "$HOME/.hf_token" ]; then + HF_TOKEN="$(tr -d '[:space:]' < "$HOME/.hf_token")" + fi + fi + if [ -n "${HF_TOKEN:-}" ]; then + export HF_TOKEN + fi + bash "$HFGET" "$REPO" "$LOCAL_DIR" main + unset HF_TOKEN +fi + +if [ ! -d "$LOCAL_DIR" ]; then + echo "REFUSED: $LOCAL_DIR does not exist (run with --fetch, or pass --dir)" >&2 + exit 1 +fi +INDEX="$LOCAL_DIR/model.safetensors.index.json" +if [ ! -f "$INDEX" ]; then + echo "REFUSED: $INDEX is missing — the checkpoint tree is not addressable without it" >&2 + exit 1 +fi + +echo "== gate 1: shard census ($LOCAL_DIR)" +SHARDS=$(find "$LOCAL_DIR" -maxdepth 1 -type f -name '*.safetensors' | wc -l) +if [ "$SHARDS" != "$EXPECTED_SHARDS" ]; then + note "FAIL SHARD_COUNT: $SHARDS safetensors files on disk, expected $EXPECTED_SHARDS" +else + echo " ok: $SHARDS shards" +fi + +echo "== gate 2: every shard is whole, judged by its own header" +# A safetensors file is [u64 header length][JSON header][tensor data]. The JSON +# declares where the data ends, so the file's true size is computable offline: +# 8 + header + the largest end offset. Truncation and error bodies both fall +# here, and each is reported with the file that caused it. +while IFS= read -r f; do + state=$(python3 - "$f" <<'PY' +import json, os, struct, sys +path = sys.argv[1] +size = os.path.getsize(path) +try: + with open(path, "rb") as fh: + (hlen,) = struct.unpack(" size: + print("NOT_SAFETENSORS") + raise SystemExit + header = json.loads(fh.read(hlen)) +except Exception: + print("NOT_SAFETENSORS") + raise SystemExit +if not header: + print("NOT_SAFETENSORS") + raise SystemExit +end = max((v["data_offsets"][1] for v in header.values() + if isinstance(v, dict) and "data_offsets" in v), default=0) +want = 8 + hlen + end +print("OK" if want == size else f"TRUNCATED want={want} have={size}") +PY +) + case "$state" in + OK) ;; + NOT_SAFETENSORS*) note "FAIL NOT_SAFETENSORS: $(basename "$f") is not a safetensors file (an error body written as an artifact?)" ;; + TRUNCATED*) note "FAIL TRUNCATED: $(basename "$f") $state" ;; + esac +done < <(find "$LOCAL_DIR" -maxdepth 1 -type f -name '*.safetensors' | sort) +if [ "$FAILS" -eq 0 ]; then + echo " ok: every shard's data extent matches its size on disk" +fi + +echo "== gate 3: the index's shards are all present" +INDEX_SHARDS=$(python3 - "$INDEX" <<'PY' +import json, sys +map_ = json.load(open(sys.argv[1]))["weight_map"] +print("\n".join(sorted(set(map_.values())))) +PY +) +while IFS= read -r name; do + [ -n "$name" ] || continue + if [ ! -f "$LOCAL_DIR/$name" ]; then + note "FAIL MISSING: $name is named by the index and is not on disk" + fi +done <<< "$INDEX_SHARDS" +if [ "$FAILS" -eq 0 ]; then + echo " ok: $(printf '%s\n' "$INDEX_SHARDS" | grep -c .) referenced shards on disk" +fi + +echo "== gate 4: total bytes vs the Hub's declaration" +TOTAL=$(find "$LOCAL_DIR" -maxdepth 1 -type f -name '*.safetensors' -printf '%s\n' | awk '{s+=$1} END{print s+0}') +if [ "$TOTAL" != "$EXPECTED_BYTES" ]; then + note "FAIL TOTAL_BYTES: shards total $TOTAL bytes, expected $EXPECTED_BYTES" +else + echo " ok: $TOTAL bytes" +fi + +if [ "$HUB" = "1" ]; then + echo "== gate 5: every file's sha256 vs the Hub's LFS record" + # size = file size; lfs.sha256 = sha256 of the file itself (LFS or not). + TMPD="$(mktemp -d)" + trap 'rm -rf "$TMPD"' EXIT + MANIFEST="$TMPD/tree.json" + PLAN="$TMPD/plan" + curl -sf "https://huggingface.co/api/models/$REPO/tree/main?recursive=true&expand=true" \ + -o "$MANIFEST" || { echo "FAIL: the Hub API call failed — refusing to report success" >&2; exit 1; } + python3 - "$MANIFEST" > "$PLAN" <<'PY' +import json, sys +for e in json.load(open(sys.argv[1])): + if e.get("type") != "file": + continue + sha = (e.get("lfs") or {}).get("sha256") or e.get("sha") or "" + if sha: + print(e["path"], e["size"], sha) +PY + BAD=0 + while read -r rel size sha; do + [ -n "${rel:-}" ] || continue + if [ ! -f "$LOCAL_DIR/$rel" ]; then + note "FAIL MISSING: $rel is on the Hub and not on disk" + continue + fi + have=$(stat -c %s "$LOCAL_DIR/$rel") + if [ "$have" != "$size" ]; then + note "FAIL SIZE: $rel is $have bytes, Hub says $size" + continue + fi + got=$(sha256sum "$LOCAL_DIR/$rel" | awk '{print $1}') + if [ "$got" != "$sha" ]; then + note "FAIL SHA256: $rel got $got want $sha" + BAD=$((BAD + 1)) + fi + done < "$PLAN" + if [ "$BAD" -eq 0 ] && [ "$FAILS" -eq 0 ]; then + echo " ok: sha256 matches for every file" + fi + rm -rf "$TMPD" + trap - EXIT +fi + +if [ "$FAILS" -gt 0 ]; then + echo "VERIFY_FAILED: $FAILS problem(s) in $LOCAL_DIR" >&2 + exit 1 +fi +echo "VERIFY_OK: $SHARDS shards, $TOTAL bytes of safetensors in $LOCAL_DIR" +echo "NEXT: build the BF16 reference container with scripts/build-qwen38-bf16-rad.sh" diff --git a/scripts/grams-to-imatrix.py b/scripts/grams-to-imatrix.py new file mode 100755 index 0000000..12f6d48 --- /dev/null +++ b/scripts/grams-to-imatrix.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""grams-to-imatrix.py — the engine's own calibration recording, re-voiced as a +llama.cpp importance matrix for `rad-convert --imatrix F`. RUNS ON DANKSERVER +beside the recording. Python 3 stdlib only: numpy must NOT be required, because +this runs in the plain container next to a 335 GiB checkpoint, not in a venv. + +WHY. `rad-convert` takes its per-column activation second moments and its +expert-popularity profile from ONE input, an imatrix (core/format/imatrix.h). +Only the GGUF form carries a per-expert dimension — with the legacy flat form, +or with none, every expert in a layer is weighted by the layer's aggregate, +which is exactly the wrong thing on a 512-expert MoE. The calibration tap has +already measured everything; this tool only re-voices it, so the imatrix and the +protected-expert census (derive-protected-experts.py) are the SAME recording: +one measurement, two consumers, neither able to disagree about which experts are +hot. + +INPUT. RADGRAM2 files in a RADIANCE_CALIB_DIR output directory +(arch/common/rad_arch.h, calib_write). Header law, same parse as +scripts/derive-protected-experts.py — 32 bytes, `<8sIIQI`: magic `RADGRAM2`, +u32 k, u32 rank, u64 rows, u32 domain, a pad word, then k*k f32 row-major. Two +kinds of name, both GGUF-style, both with the checkpoint's GLOBAL expert +numbering (rad_block_moe_fp8.h: ge = expert_base + e): + + gram.r.blk.L.ffn_down_exps.bin the layer's pooled gram + gram.r.blk.L.ffn_down_exps..bin expert e's own gram + +`rows` is that file's routing/token count and the matrix is Σ x xᵀ over those +rows, so **the diagonal is exactly the per-column activation second moment an +imatrix wants**, and `rows` is the count the reader hands back from `count()`. +Every off-diagonal term is dropped on purpose: the GGUF format has nowhere to +put a full matrix, and the quantiser asks for columns. + +OUTPUT, per layer L (core/format/imatrix.h: names WITHOUT `.weight`, a MoE +layer's experts folded into ONE entry, so expert 9 is slice 9 of +`blk.L.ffn_down_exps`): + + blk.L.ffn_down_exps in_sum2 [k, n_expert] expert-major, counts [1, n_expert] + blk.L.ffn_gate_up_exps the SAME matrices at the SAME offsets. A routed expert's + gate_up input is the same hidden-state rows as its down + input, so the second moment over them is identical and + so is the routing count. Two tensor infos over one + payload: the reader maps each half independently + (imatrix.cpp joins `.in_sum2` with `.counts` + by base name), and copying ~200 MB to state one fact + twice buys no correctness. + blk.L.ssm_inz [k] and [1], from the pooled gram: that row's input is + the same hidden state, so its columns take the pooled + diagonal and its count is the layer's pooled rows. + + Then STOP. Nothing whose input width is not the gram's k — no ssm_out, no + attn_output, no mtp.*, no ple_ngram. importance_for() answers false when the + entry's cols do not equal the weight's width and the convert proceeds without + it: an entry the reader silently ignores is a lie in the artifact, worse than + an absent one. The corollary to check before trusting the two hidden-width + entries: the tap writes the gate_up gram at g.n_embd and the down gram at the + expert width, so the gram's k must equal the checkpoint's hidden width (its + config.json). Where they differ, gate_up/ssm_inz are inert, not wrong. + +INHERITANCE. An expert with no own gram carries the layer's POOLED diagonal, and +its count stays its own routing rows — the rule the engine itself applies when it +picks a Hessian (libquant/lq_hess.h, calib_resolve: an expert's own gram only +once its rank files hold kOwnRowsPerK * K rows; below that a fit to so few rows +is worse than round-to-nearest, and those experts carry a fraction of a percent +of the routing). The count never inherits: popularity is `count`'s job. The count +of inherited experts is reported on stderr. + +RANK FOLDING. TP=2 writes gram.r0.* and gram.r1.* with GLOBAL expert numbering, +so the ranks describe DISJOINT experts and the fold is a UNION into the one +[k, n_expert] entry. A pooled gram is the opposite case: every rank summed the +same stem over its own rows, so the fold is a SUM, rows included (calib_sum, +calib_rows). The same global expert in two per-expert files is legal only when +the numbers agree exactly; otherwise the recording is corrupt and the entry would +be whichever file sorted last, so refuse. + +GGUF v3 (the only version imatrix.cpp accepts — version != 3 is a refusal): +`general.alignment` u32, `imatrix.chunk_size` u32, `imatrix.chunk_count` u32, +`imatrix.datasets` array of strings; tensors f32 (ggml type 0) with offsets +relative to the aligned data section. Every tensor offset is padded up to +`general.alignment` and the data section itself starts aligned. That is the +classic hand-rolled-writer bug: an unaligned offset makes the reader's extent +arithmetic point into the middle of the previous tensor's floats, and it reads +them without complaint. + +chunk_count DERIVATION. The corpus row count is the LARGEST single-layer pooled +row total, not a sum over layers: every layer's tap accumulated over the whole +corpus, so adding the layers counts each token once per layer and inflates the +figure by the layer count. chunk_count = that row total // chunk_size, the +"chunks of chunk_size" line imatrix.cpp prints for itself. + +REFUSALS, each exit 2 with a message naming what is missing: + NO_GRAMS the directory holds no RADGRAM2 down-projection gram + NOT_RADGRAM2 a gram whose magic is not RADGRAM2 + SHORT a gram whose length is not 32 + k*k*4 + NO_POOLED a layer with expert grams and no pooled gram to inherit from + K_MISMATCH a layer whose files disagree about k (rank pooled grams, or an + expert gram against its layer's pooled gram) + EXPERT_CONFLICT a global expert recorded twice with different numbers + EXISTS the -o path already holds a file (the trash law: move it) + CHUNK_COUNT the derived chunk count does not fit the u32 metadata field + +USAGE. grams-to-imatrix.py -o + [--datasets NAME,...] [--chunk-size N] [--min-rows N] [-v] +""" +import argparse +import os +import re +import struct +import sys + +MAGIC = b"RADGRAM2" +HDR = 32 # 8s I I Q I + pad, rad_arch.h +KOWN_ROWS_PER_K = 4 # libquant/lq_hess.h kOwnRowsPerK +ALIGNMENT = 32 # general.alignment, and the pad unit + +POOLED_RE = re.compile(r"^gram\.r(\d+)\.blk\.(\d+)\.ffn_down_exps\.bin$") +EXPERT_RE = re.compile(r"^gram\.r(\d+)\.blk\.(\d+)\.ffn_down_exps\.(\d+)\.bin$") + +GT_U32, GT_STR, GT_ARR = 4, 8, 9 +GGML_F32 = 0 + + +def refuse(msg): + sys.stderr.write("REFUSED %s\n" % msg) + sys.exit(2) + + +def read_gram(path): + """(k, rank, rows, domain, diagonal) from one RADGRAM2 file.""" + try: + with open(path, "rb") as f: + raw = f.read() + except OSError as e: + refuse("UNREADABLE %s: %s" % (path, e)) + if len(raw) < HDR: + refuse("SHORT %s: %d bytes, under the %d-byte RADGRAM2 header" + % (path, len(raw), HDR)) + magic, k, rank, rows, domain = struct.unpack_from("<8sIIQI", raw, 0) + if magic != MAGIC: + refuse("NOT_RADGRAM2 %s: magic %r, expected b'RADGRAM2'" % (path, magic)) + if k <= 0: + refuse("SHORT %s: header claims k=%d, which describes no matrix" % (path, k)) + need = HDR + k * k * 4 + if len(raw) != need: + refuse("SHORT %s: %d bytes, expected %d (32 + k*k*4 for k=%d)" + % (path, len(raw), need, k)) + # The diagonal only, read by stride out of the row-major matrix. + diag = [struct.unpack_from(".blk..ffn_down_exps" + "[.].bin files in the directory" % d) + return layers, rotated + + +def build_entries(layers, min_rows, verbose): + """([ (name, dims, payload|None, key) ], census, layers emitted).""" + entries = [] + census = {"inherited": 0, "experts": 0, "routing": 0, "rows": 0, "layers": 0} + for layer in sorted(layers): + P, E = layers[layer]["pooled"], layers[layer]["experts"] + if E and P is None: + refuse("NO_POOLED layer %d: %d expert gram(s) but no pooled gram (an" + " expected gram.r.blk.%d.ffn_down_exps.bin), so an expert" + " under the row threshold would have nothing to inherit" + % (layer, len(E), layer)) + if P is None: + continue + k = P["k"] + census["layers"] += 1 + census["rows"] = max(census["rows"], P["rows"]) + dn = "blk.%d.ffn_down_exps" % layer + # A staging key names ONE payload. A gate_up entry reuses the down + # layer's bytes by carrying the same key with no payload of its own; + # in_sum2 and counts are different bytes and must never share a key. + ksum, kcnt = (layer, "sum2"), (layer, "counts") + kpgm, kpcnt = (layer, "pooled_sum2"), (layer, "pooled_counts") + # One entry holds one width: an expert gram recorded at another k would + # inherit (or be inherited by) a diagonal of the wrong length, and the + # reader would then answer `cols` from whichever file set dims[0]. + for e in sorted(E): + if E[e]["k"] != k: + refuse("K_MISMATCH layer %d expert %d: %s holds k=%d and this" + " layer's pooled gram is k=%d" % (layer, e, E[e]["path"], + E[e]["k"], k)) + + if not E: + # No per-expert recording for this layer: emit the dense form + # ([k], [1]) rather than 512 identical slices, which would claim a + # per-expert measurement that was never taken. + entries.append((dn + ".in_sum2", [k], + struct.pack("<%df" % k, *P["diag"]), ksum)) + entries.append((dn + ".counts", [1], + struct.pack(" 0 else KOWN_ROWS_PER_K * k + n_expert = max(E) + 1 + sum2, counts = bytearray(), bytearray() + for e in range(n_expert): + own = E.get(e) + if own is not None and own["rows"] >= floor: + src = own["diag"] + rows = own["rows"] + else: + src = P["diag"] + rows = own["rows"] if own is not None else 0 + census["inherited"] += 1 + sum2 += struct.pack("<%df" % k, *src) + counts += struct.pack(" 0xFFFFFFFF: + # imatrix.chunk_count is a u32 in the reader's grammar, so there is no + # legal way to record a bigger one. Reaching this means either a chunk + # size picked for a far larger run or a row total read out of the wrong + # header offset — rows is a u64 and a u32 parse is off by 2**32 — and + # the reader would trust whatever number landed in the field. + refuse("CHUNK_COUNT %d = %d rows // %d does not fit the u32" + " imatrix.chunk_count field; raise --chunk-size" + % (chunk_count, census["rows"], a.chunk_size)) + nbytes = write_gguf(a.out, entries, [d for d in a.datasets.split(",") if d], + a.chunk_size, chunk_count) + sys.stderr.write("IMATRIX_OK entries=%d layers=%d experts=%d inherited=%d" + " rows=%d routing=%d chunks=%d/%d bytes=%d out=%s\n" + % (len(entries), census["layers"], census["experts"], + census["inherited"], census["rows"], census["routing"], + chunk_count, a.chunk_size, nbytes, a.out)) + + +if __name__ == "__main__": + main() diff --git a/scripts/kld-gate-qwen38.sh b/scripts/kld-gate-qwen38.sh new file mode 100755 index 0000000..6ad5738 --- /dev/null +++ b/scripts/kld-gate-qwen38.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# kld-gate-qwen38.sh — RUNS ON DANKSERVER (or anywhere the reports are). The campaign's +# acceptance gate: three KLD reports against ONE reference recording, one verdict. +# +# The reference is the BF16 container built from the vendor checkpoint (run-qwen38-bf16.sh +# kldref, in the same drained window that records the calibration grams). Every scored +# container is measured against that one recording, so the KL scale runs from 0 = the +# checkpoint itself downward in faithfulness. Three containers ride it: +# +# fp8.json the published FP8-native release — the achievable end of the interval +# iq4r.json the payload frank-archon serves — the bar to beat +# cand.json the arm under test — the subject +# +# The FP8 number is what makes the interval absolute instead of relative: "half the gap" +# is half of the distance between the 4-bit payload and the near-lossless release, not half +# of an invented distance to the checkpoint. That is also why the FP8 payload is FETCHED and +# scored, never converted: it is a second opinion about what this checkpoint tolerates, not a +# step of the campaign. It lands LAST (disk sequencing) — by then the 335 GiB bf16 container +# is gone, so its 172.8 GiB fits. +# +# The law, in order of severity, on the mean KL of one slice (--slice all | a corpus source): +# 1. KL(fp8) < KL(iq4r) the anchors are ordered. If the near-lossless +# release does NOT sit closer to the checkpoint than +# the 4-bit payload, the corpus, the tokenizer or the +# boot shape is wrong and no arm number means anything. +# 2. KL(fp8) < KL(cand) < KL(iq4r) the candidate lies strictly inside the interval — +# outside it, something is mis-measured, not better +# 3. KL(cand) <= KL(iq4r) - R x gap it closes at least R (--recover, default 0.5) of the +# gap = KL(iq4r) - KL(fp8): at least half the way from +# what is served toward the release (agreed 2026-10-27) +# 4. top-1(cand) >= top-1(iq4r) the agreement the harness keys on does not fall +# +# Booting the containers is NOT this script's job: `run-qwen38-bf16.sh score RAD OUT.json` +# writes the reports and owns the single engine argv (same pin, --tp 2, --kv-cache-dtype fp8, +# same step size, --deterministic, no speculation) that makes the three numbers comparable. +# This script is arithmetic, so it re-runs against old reports and is testable without a GPU. +# +# Usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F] +set -uo pipefail + +[ "$#" -ge 3 ] || { echo "usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]" >&2; exit 2; } +python3 - "$@" <<'PY' +import json, sys + +a = sys.argv[1:] +opts, files = [], [] +i = 0 +while i < len(a): + if a[i].startswith("--"): + opts += [a[i], a[i + 1]] + i += 2 + else: + files.append(a[i]) + i += 1 +if len(files) != 3: + sys.exit("usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]") +o = dict(zip(opts[0::2], opts[1::2])) +slice_ = o.get("--slice", "all") +recover = float(o.get("--recover", 0.5)) +if not 0.0 < recover <= 1.0: + sys.exit(f"--recover must be in (0,1], got {recover}") + + +def die(msg): + print(msg, file=sys.stderr) + raise SystemExit(2) + + +def read(path): + try: + d = json.load(open(path)) + except Exception as e: + die(f"cannot read the KLD report {path}: {e}") + if slice_ == "all": + g = d.get("all") + else: + g = (d.get("by_source") or {}).get(slice_) + if g is None: + die(f"{path} has no slice {slice_!r}; it has: " + f"{', '.join(sorted(d.get('by_source') or {})) or '(none)'}") + if g is None or "kld" not in g or "top1_agreement" not in g: + die(f"{path} has no {slice_!r} summary — wrong report version?") + return d, g + + +rows = [] +for label, path in zip(("fp8", "iq4r", "cand"), files): + d, g = read(path) + rows.append((label, path, d.get("model", "?"), g["kld"]["mean"], g["kld"]["p99_9"], + g["top1_agreement"], g["positions"])) + +print(f"reference: BF16 container; slice: {slice_}") +print(f"{'':6} {'model':38} {'KL mean':>10} {'KL p99.9':>10} {'top-1':>8} positions") +for label, path, model, kl, p999, top1, n in rows: + print(f"{label:6} {str(model)[:38]:38} {kl:10.6f} {p999:10.4f} {100*top1:7.2f}% {n}") +r = {label: (kl, top1) for label, _p, _m, kl, _q, top1, _n in rows} +kl_fp8, kl_iq4r, kl_cand = r["fp8"][0], r["iq4r"][0], r["cand"][0] +t_iq4r, t_cand = r["iq4r"][1], r["cand"][1] +gap = kl_iq4r - kl_fp8 +ok = True + + +def law(n, text, passed): + global ok + ok = ok and passed + print(f" [{'PASS' if passed else 'FAIL'}] {n}. {text}") + + +law(1, f"anchors ordered: fp8 {kl_fp8:.6f} < iq4r {kl_iq4r:.6f} (gap {gap:.6f})", + gap > 0) +law(2, f"candidate inside the interval: {kl_fp8:.6f} < {kl_cand:.6f} < {kl_iq4r:.6f}", + kl_fp8 < kl_cand < kl_iq4r) +need = kl_iq4r - recover * gap +law(3, f"candidate closes {100*max((kl_iq4r - kl_cand)/gap, 0):.1f}% of the " + f"{gap:.6f} gap (needs {100*recover:.0f}%, i.e. KL <= {need:.6f})" + if gap > 0 else "gap is not positive; cannot score recovery", + gap > 0 and kl_cand <= need) +law(4, f"candidate top-1 {100*t_cand:.2f}% >= served iq4r top-1 {100*t_iq4r:.2f}%", + t_cand >= t_iq4r) +print("KLD GATE " + ("PASS" if ok else "FAIL")) +sys.exit(0 if ok else 1) +PY diff --git a/scripts/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe b/scripts/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe new file mode 100644 index 0000000..6dedd31 --- /dev/null +++ b/scripts/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe @@ -0,0 +1,80 @@ +# qwen4exp-qwen38-w5nl64-bf16trunk.recipe — every routed expert, BOTH projections, at five bits: +# u5 codes into libquant's 32-level w5nl table, an E4M3 scale a group of 64 under the fixed 2^-13 +# second level, rotated by the 128-wide Hadamard, chosen by GPTQ against $CALIB and swept three +# times by coordinate descent. This is `w5nl64a8h` in the engine's served-encoding table — the top of +# the four-bit class — applied wider than any published radiance container of this checkpoint: the +# author's own w5 rung (`qwen4exp-w5dn64-i8-hc8m.recipe`) moves only `down`, because that rung was +# priced as a byte-for-error trade against prefill; this campaign buys the whole rung and pays for +# it out of the pinned host pool (+15.3 GiB of expert plane, `--host-pool-mib 61440` per rank), not +# out of the KV pool. +# +# Every other line is the served container's recipe (dump it with +# `rad-info --recipe /home/dank/models/rad/qwen3.8-next-flash-fp8-iq4r-moe.rad`), minus the rows this +# campaign leaves at the checkpoint: +# +# * the trunk's block linears, the shared expert and the hyper-connection mixers are NOT listed — +# dropping their i8/fp8 lines leaves them bf16, which is what the checkpoint holds. Measured bill +# +1.978 GiB of always-resident weight = ~131k tokens of KV off the pool. +# * `ssm_inz`/`ssm_out` STAY int8: the delta-net gates are the largest trunk rows (+1.9 GiB, 126k +# tokens) and the least sensitive per byte. +# * `mtp.draft_head` STAYS 2-bit and the PLE n-gram table STAYS one fixed E4M3 scale: the draft +# head's rung is cheap and its acceptance cost is a decode question this campaign does not touch, +# and bf16 for the n-gram table would pin +47.69 GiB of host RAM for a lookup. +# +# Protected experts: none in this file. `scripts/apply-protected-cast.sh` appends the derived set +# (48, two per layer so each rank's 256-expert half stays even — the four-bit GEMM takes the rest of +# a half as two equal tables by parity) from the fresh 12M-token energy/routing census, and they must +# sit ABOVE the expert row because the first rule that matches a weight decides it. +# +# CALIB=/home/dank/campaign/qwen38-w5nl64/calib \ +# rad-convert /home/dank/models/Qwen3.8-Flash-Next \ +# --recipe this.file --imatrix .../imatrix.gguf -o out.rad +# +# The arm driver (`scripts/convert-qwen38-w5nl64.sh`) generates the per-arm variants of the expert +# row from this template — cd=3 (control), cd=6, and `act_order=1` with NO cd= (libquant refuses the +# pair, lq_registry.cpp:105) — rather than keeping three near-duplicate recipes that can drift apart. +# +# The whole campaign, in the order that fits on one disk (every step runs on dankserver): +# +# 1. scripts/download-qwen38-bf16.sh --fetch the vendor BF16 checkpoint, 335.3 GiB +# 2. scripts/build-qwen38-bf16-rad.sh the BF16 reference container, another 335.3 GiB +# 3. one drained window, two boots: +# scripts/run-qwen38-bf16.sh kldref the KLD reference recording (the zero of scale) +# scripts/run-qwen38-bf16.sh calib the 12M-position calibration tap -> grams +# 4. trash qwen38-bf16.rad the peak-disk law: the converts need its room +# 5. scripts/grams-to-imatrix.py grams -> imatrix.gguf in the campaign dir +# 6. scripts/convert-qwen38-w5nl64.sh the arms, planned and billed before each one runs +# 7. fetch the published FP8-native release, then per container: +# scripts/run-qwen38-bf16.sh score RAD OUT fp8 anchor, served iq4r, each arm +# then scripts/kld-gate-qwen38.sh fp8.json iq4r.json cand.json +# 8. a winner is a trio edit plus `scripts/deploy.sh dankserver --service frank-archon`; the +# rollback is the commit before it. If the win needs the bigger pinned plane, raise +# FRANK_ARCHON_HOST_POOL_MIB and FRANK_ARCHON_TTM_TARGET_MIB in the SAME commit: the preflight +# floor is 2xHOST_POOL + 48,824 + prefix cache + MNBT + the store, and a pool raised without +# the TTM target is a boot refusal that Restart=always repeats forever, invisible to deploy.sh. + +# PROTECTED-BEGIN — scripts/apply-protected-cast.sh splices the derived cast lines in here. +# They MUST sit above the expert rule: the first rule whose glob matches a weight decides it, so a +# cast line below the five-bit rule would never win. The set is derived from the campaign's own +# 12M-token recording (`derive-protected-experts.py --target-count 48` over the fresh grams), and the +# driver splices it into a per-arm COPY so this template stays the clean, reviewable baseline. +# PROTECTED-END + +# Every routed expert, trunk and MTP layer alike, both projections, at five bits. The experts' scales +# sit between 2^-15 and 2^-10.6 under w4nl's top level of 22 and half a binade lower under w5nl's 30 +# — inside E4M3's normal range under 2^-13 either way. The MTP layer has no recording and is rounded +# to nearest, which libquant says on stderr; it is one layer of 49 and only ever changes drafts. +blk.*.ffn_*_exps.*.weight gptq table=w5nl group=64 scale=fp8_e4m3 scale2=f32 block2=*x* scale2_value=0.0001220703125 transform=fwht128 rule=search cd=3 calib=$CALIB + +# The delta-net's in/out gates, int8 with a bf16 scale a group of 128 searched for least error — the +# served recipe's lines, unchanged. +blk.*.ssm_inz.weight rtn codes=i8 group=128 scale=bf16 clamp=sym rule=search +blk.*.ssm_out.weight rtn codes=i8 group=128 scale=bf16 clamp=sym rule=search + +# The MTP head's two mixing rows, E4M3 as served. They only ever change the drafts. +mtp.fc_hidden.weight rtn codes=fp8_e4m3 group=128 scale=f32 +mtp.fc_embedding.weight rtn codes=fp8_e4m3 group=128 scale=f32 + +# The n-gram table at one fixed E4M3 scale, and the 2-bit draft head — both the served lines. +blk.*.ple_ngram.weight rtn codes=fp8_e4m3 block=*x* scale=bf16 rule=fixed scale_value=1.99317932128906e-4 +mtp.draft_head.weight rtn codes=u2 zero=u8 group=128 scale=f16 diff --git a/scripts/run-qwen38-bf16.sh b/scripts/run-qwen38-bf16.sh new file mode 100755 index 0000000..5568ef4 --- /dev/null +++ b/scripts/run-qwen38-bf16.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +# run-qwen38-bf16.sh — RUNS ON DANKSERVER. Boot a container over the campaign's +# ONE measuring shape for the phases that need both R9700s. The engine owns the +# cards, so frank-archon is stopped first and restored on every exit path — the +# autoresearch pattern: stop, never mask (the unit file in /etc/systemd/system +# is a symlink into the checkout and mask fails on a relinked unit; the deploy +# restores it). +# +# kldref --kld-record the reference log-probabilities: the engine +# runs /home/dank/corpora/out/kld.jsonl (197 prompts, 50,432 +# scored positions, score tail 256) through prefill, writes +# /home/dank/campaign/qwen38-w5nl64/kld-ref, and EXITS. The +# model behind it is the BF16 container from +# scripts/build-qwen38-bf16-rad.sh — the vendor checkpoint's +# own numbers, which is what makes it the zero point. +# score RAD OUT --kld-ref score ONE container against that recording and +# EXIT: the FP8-native anchor, the served iq4r, and every +# arm. Deliberately the SAME engine pin, the same TP, the +# same step size, the same fp8 KV, --deterministic and no +# speculation as kldref — KL and top-1 agreement are only +# comparable across boots when everything except the weights +# is identical, so this mode is one argv with --model and +# the two --kld-* flags changed, never a second shape. +# RAD must sit under the models dir (the container reads it +# through the ro /models mount); OUT lands on a writable +# mount because --kld-out F also writes F.rows beside it. +# calib serve on :7770 with the calibration tap open +# (RADIANCE_CALIB_DIR + RADIANCE_CALIB_TOKENS=12000000). A +# separate driver — scripts/calib-push.py --port 7770 +# --container qwen38-bf16 --corpus +# /home/dank/corpora/out/calib.jsonl — pushes the corpus; at +# 12M pooled positions the engine writes the Grams and the +# tap switches itself off (calib-push prints TAP_DONE). Stop +# this script after that; the trap brings frank-archon back. +# +# Shape: the vendor's bf16 calibration lines — expert_tiered placement with the +# weights disk tier, deterministic (the resident set pinned to the loader's plan, +# so a recorded reference and a scored candidate see the same one), no +# speculation, fp8 KV, a 32,768-token window, the PLE n-gram table pinned to RAM, +# and the production chat template rather than the one baked into the container — +# every scored container is asked the same tokenised questions the reference +# answered. The window is 32,768 because this corpus is long: measured on +# Dankserver 2026-10-27, kld.jsonl is 197 records with a longest of 38,263 +# characters (longest prompt FIELD 36,855, median record 27,228), which is ~9,600 +# tokens at the pessimistic chars/4 and ~7,700 at the ratio this repo actually +# measured on the K3 corpus (380,143 chars = 76,568 tokens, chars/4.97). A 16,384 +# window cleared that by 1.7x; 32,768 clears every record of both corpora by at +# least 2.6x, and the kldref gate below re-checks it against the file on disk. +# +# NO --vram-kv-mib: it was REMOVED at 1.3.0 and naming it is a boot refusal, not +# a no-op — verified on the pinned image 2026-10-27: "E --vram-kv-mib was +# removed: the card is split between the weights and the cache from what the +# model needs." The budget resolver derives the per-card split from +# --host-pool-mib and --gpu-headroom-mib, and those two are deliberately NOT +# production's 49,152 / 1,536 (frank-archon.env:77): the pool decides WHICH +# experts stay resident, so it buys speed and never logits — the shape the gate +# has to hold identical is the kv dtype, the slot count, the step size, the spec +# depth and the window. It is also the only figure that fits: a 335 GiB bf16 +# container cannot be booted against a 49 GiB resident set on a 192 GiB machine, +# and a prefill-only pass does not care that the disk tier paces at ~3 GiB/s. +# +# The step size is ONE value for the recording and every scoring pass: +# FRANK_QWEN38_MNBT, default 2,048. The gate's whole claim is that the reference, +# the served iq4r and every arm rode one shape, so the ceiling is set by the +# TIGHTEST payload the gate boots rather than by the bf16 reference: the quantised +# expert FILE tiers (the served iq4r, the arms) stream only at MNBT<=2048 and +# refuse the widened form above it, and 2,048 is what production holds anyway. +# Recording the reference at 32,768 would therefore measure a shape the payloads +# cannot reproduce; at 2,048 the 50,432 positions cost more steps, which is still +# one drained window. Calibration keeps the wide 65,536 — grams are not logits, +# nothing downstream is compared against a step size, and that is where a big +# step pays (a disk-tier step costs the same at 1 token as at 64k). +# +# usage: scripts/run-qwen38-bf16.sh kldref|calib|score RAD OUT [--dry-run] [--image REF] +# (RAD and OUT are score's two positionals: RAD is a container under the models +# dir, OUT the report path whose directory is created if missing) +# --dry-run print the exact `podman run` argv, one shell-quoted argument a +# line, and exit 0: the service is NOT stopped, nothing is +# mounted, nothing is written — the offline test seam. It still +# validates the argv's own shape (paths through the /models mount, +# a step size that is a number), because an argv that could not +# boot is not worth printing. +# --image REF override the digest pin (a probe boot, or the test seam) +# +# knobs: FRANK_QWEN38_MODELS (the dir mounted at /models), FRANK_QWEN38_RAD (the +# bf16 container, kldref only), FRANK_QWEN38_CAMPAIGN (holds kld-ref/ and +# calib/), FRANK_QWEN38_KLD_CORPUS, FRANK_QWEN38_TPL (the template copy +# the container reads through /models), FRANK_QWEN38_LOG_DIR, and +# FRANK_QWEN38_MNBT — the step size shared by kldref and score (2,048 is +# the file tier's ceiling and production's own value, four slots and all), +# and FRANK_QWEN38_MAX_MODEL_LEN — the window every mode boots at (32,768; +# see the corpus census above, and raise it together with the corpus). +# kldref refuses (exit 2) when the longest record of the corpus it is about to +# read is longer than MAX_MODEL_LEN x 3 characters — chars/3 being the +# pessimistic token:char ratio, so the script says no before the engine finds out +# inside a drained window, after the checkpoint work is already done. score +# refuses when the reference dir is absent, when RAD is not on disk or not under +# the models mount, or when OUT already exists: the trash law means a second +# score over the same name is a deliberate act, and two numbers in one file is +# how a campaign loses its own history. +# Engine stdout goes to $LOG_DIR/qwen38_bf16_.log — the boot ledger is the +# record of what the resolver actually gave the cards; the journal never sees it. +# exit: the engine's code; 2 = refused before the service was touched +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/frank-archon.env" +IMAGE="${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}" + +MODE="" +POS=() +DRY=0 +while [ $# -gt 0 ]; do + case "$1" in + kldref|calib|score) MODE="$1" ;; + --dry-run) DRY=1 ;; + --image) IMAGE="$2"; shift ;; + *) + # score's two positional arguments, and nothing else may be bare. + if [ "$MODE" = score ] && [ "${#POS[@]}" -lt 2 ]; then + POS+=("$1") + else + echo "usage: ${0##*/} kldref|calib|score RAD OUT [--dry-run] [--image REF] (unknown argument: $1)" >&2 + exit 2 + fi ;; + esac + shift +done +if [ -z "$MODE" ]; then + echo "usage: ${0##*/} kldref|calib|score RAD OUT [--dry-run] [--image REF] (no mode given)" >&2 + exit 2 +fi +if [ "$MODE" = score ] && [ "${#POS[@]}" -ne 2 ]; then + echo "usage: ${0##*/} score RAD OUT — RAD is the container to score, OUT the report to write (got ${#POS[@]} argument(s))" >&2 + exit 2 +fi + +MODELS="${FRANK_QWEN38_MODELS:-/home/dank/models}" +CAMPAIGN="${FRANK_QWEN38_CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}" +KLD_CORPUS="${FRANK_QWEN38_KLD_CORPUS:-/home/dank/corpora/out/kld.jsonl}" +RAD="${FRANK_QWEN38_RAD:-$MODELS/rad/qwen38-bf16.rad}" +TPL="${FRANK_QWEN38_TPL:-$MODELS/tpl.jinja}" +TEMPLATE="$SCRIPT_DIR/qwen38-froggeric-v22.5.jinja" +LOG_DIR="${FRANK_QWEN38_LOG_DIR:-/home/dank/logs}" +LOG="$LOG_DIR/qwen38_bf16_${MODE}.log" +KLD_REF_DIR="$CAMPAIGN/kld-ref" +CALIB_DIR="$CAMPAIGN/calib" +C=qwen38-bf16 +SERVICE=frank-archon + +# ONE step size for both logits modes. The file tier streams only at <=2,048 and +# the gate scores file-tier payloads (the served iq4r, the arms) against the +# reference, so the reference is recorded at the ceiling the payloads can meet — +# never two numbers that a KL between them would have to forgive. +MNBT="${FRANK_QWEN38_MNBT:-2048}" +if ! [[ "$MNBT" =~ ^[0-9]+$ ]]; then + echo "REFUSED: FRANK_QWEN38_MNBT must be a token count (got: $MNBT)" >&2 + exit 2 +fi + +# The window every mode boots at. Over-length is a REFUSAL, not a truncation (the +# env file says so for production), so this is the number the corpus must fit in. +MAX_MODEL_LEN="${FRANK_QWEN38_MAX_MODEL_LEN:-32768}" +if ! [[ "$MAX_MODEL_LEN" =~ ^[0-9]+$ ]]; then + echo "REFUSED: FRANK_QWEN38_MAX_MODEL_LEN must be a token count (got: $MAX_MODEL_LEN)" >&2 + exit 2 +fi + +# Every container this campaign boots is read through the ro /models mount, so +# the host path must live under it; the container-side spelling is what goes +# into the argv. Same rule for the container a score pass opens. +under_models() { + case "$1" in + "$MODELS"/*) printf '/models/%s' "${1#"$MODELS"/}" ;; + *) return 1 ;; + esac +} + +# same_dir A B — the two name one directory, judged only when BOTH exist: a dry +# run has no fixtures on disk, and two absent paths are not the same place. +same_dir() { + [ -d "$1" ] && [ -d "$2" ] && [ "$(cd "$1" && pwd)" = "$(cd "$2" && pwd)" ] +} + +if [ "$MODE" = score ]; then + SCORE_RAD="${POS[0]}" + SCORE_OUT="${POS[1]}" + OUT_DIR="$(dirname "$SCORE_OUT")" + MODEL_IN="$(under_models "$SCORE_RAD")" || { + echo "REFUSED: container to score $SCORE_RAD is not under the mounted models dir $MODELS — the engine cannot read it" >&2 + exit 2 + } +else + MODEL_IN="$(under_models "$RAD")" || { + echo "REFUSED: reference container $RAD is not under the mounted models dir $MODELS — the engine cannot read it" >&2 + exit 2 + } +fi + +# The measuring shape. Identical for kldref and score by construction — this +# array is the only place the campaign's KL numbers are defined. +ARGS=(--tp 2 --placement expert_tiered --weights-disk-tier + --host-pool-mib 12288 --gpu-headroom-mib 768 + --deterministic --num-speculative-tokens 0 + --kv-cache-dtype fp8 --max-model-len "$MAX_MODEL_LEN" + --ngram-placement ram + --override-chat-template /models/tpl.jinja) +MOUNTS=(-v "$MODELS:/models:ro") +TAPENV=() +case "$MODE" in + kldref) + # Recording is prefill-only over four slots and the shared step, which is + # production's own operating point (frank-archon.env: FRANK_ARCHON_MAX_SEQS=4 + # with FRANK_ARCHON_MAX_BATCHED_TOKENS=2048). Corpus read-only, record dir rw. + ARGS+=(--model "$MODEL_IN" + --kld-record /data/kld-ref --kld-corpus /data/kld.jsonl + --max-num-seqs 4 --max-num-batched-tokens "$MNBT") + MOUNTS+=(-v "$KLD_CORPUS:/data/kld.jsonl:ro" + -v "$KLD_REF_DIR:/data/kld-ref") + ;; + score) + # The same argv with three words changed: which container, and that this + # time the engine reads a recording and writes a report instead of the + # reverse. The reference dir mounts READ-ONLY — a scoring pass must not be + # able to touch the zero point — and the report gets its own writable bind. + if [ "$OUT_DIR" = "$KLD_REF_DIR" ] || same_dir "$OUT_DIR" "$KLD_REF_DIR"; then + echo "REFUSED: score output $SCORE_OUT is inside the reference dir $KLD_REF_DIR, which this mode mounts read-only" >&2 + exit 2 + fi + ARGS+=(--model "$MODEL_IN" + --kld-ref /data/kld-ref --kld-out "/data/out/$(basename "$SCORE_OUT")" + --max-num-seqs 4 --max-num-batched-tokens "$MNBT") + MOUNTS+=(-v "$KLD_REF_DIR:/data/kld-ref:ro" + -v "$OUT_DIR:/data/out") + ;; + calib) + # Calibration is exempt from the shared step: the tap counts pooled + # positions and nothing downstream is compared against a step size, so the + # wide step is free throughput (a disk-tier step costs the same at 1 token + # as at 64k). + ARGS+=(--model "$MODEL_IN" --port 7770 + --max-num-seqs 8 --max-num-batched-tokens 65536) + MOUNTS+=(-v "$CALIB_DIR:/data/calib") + TAPENV=(-e RADIANCE_CALIB_DIR=/data/calib + -e RADIANCE_CALIB_TOKENS=12000000) + ;; +esac + +CMD=(podman run --rm --name "$C" + --network host --init + --security-opt seccomp=unconfined + --ulimit memlock=-1:-1 + --device /dev/kfd --device /dev/dri + "${MOUNTS[@]}" "${TAPENV[@]}" + "$IMAGE" "${ARGS[@]}") + +# ---- gate: the window must hold the corpus (kldref alone can meet it) -------- +# score reads a recording instead of a corpus, and every arm declares at the +# checkpoint's training context, so neither can meet a sequence too long to boot. +# Measured on the raw JSON line — the pessimistic direction, and the same figure +# the census in the header quotes. It runs on a dry run too, because a refusal +# this cheap should never have to wait for the drained window to be discovered. +if [ "$MODE" = kldref ] && [ -f "$KLD_CORPUS" ]; then + read -r REC_NO REC_CHARS < <(awk '{ if (length($0) > best) { best = length($0); where = NR } } END { print where + 0, best + 0 }' "$KLD_CORPUS") + LIMIT_CHARS=$(( MAX_MODEL_LEN * 3 )) + if [ "$REC_CHARS" -gt "$LIMIT_CHARS" ]; then + echo "REFUSED: record $REC_NO of $KLD_CORPUS is $REC_CHARS chars, past the $LIMIT_CHARS a $MAX_MODEL_LEN-token window promises at the pessimistic chars/3 — raise FRANK_QWEN38_MAX_MODEL_LEN, and the container's declared --max-ctx with it, before booting the reference" >&2 + exit 2 + fi + echo "corpus fits: longest record $REC_CHARS chars (number $REC_NO) inside the $MAX_MODEL_LEN-token window (limit $LIMIT_CHARS chars)" +fi + +if [ "$DRY" = 1 ]; then + echo "dry run: $MODE — the production unit is left running, nothing is mounted, nothing is written" + printf '%q\n' "${CMD[@]}" + exit 0 +fi + +# ---- gates that only a real run needs --------------------------------------- +if [ "$MODE" = score ]; then + if [ ! -d "$KLD_REF_DIR" ]; then + echo "REFUSED: no KLD reference at $KLD_REF_DIR — record it first in a drained window: scripts/run-qwen38-bf16.sh kldref" >&2 + exit 2 + fi + if [ ! -f "$SCORE_RAD" ]; then + echo "REFUSED: no container to score at $SCORE_RAD" >&2 + exit 2 + fi + if [ -e "$SCORE_OUT" ]; then + echo "REFUSED: $SCORE_OUT already exists — one report per name; move the old one to trash before re-scoring" >&2 + exit 2 + fi + mkdir -p "$OUT_DIR" +else + if [ ! -f "$RAD" ]; then + echo "REFUSED: no reference container at $RAD — build it first: scripts/build-qwen38-bf16-rad.sh" >&2 + exit 2 + fi + [ "$MODE" = kldref ] && [ ! -f "$KLD_CORPUS" ] && { + echo "REFUSED: no KLD corpus at $KLD_CORPUS — the record would be empty" >&2 + exit 2 + } +fi +if [ ! -f "$TEMPLATE" ]; then + echo "REFUSED: no chat template at $TEMPLATE" >&2 + exit 2 +fi +mkdir -p "$LOG_DIR" +# The prod template, staged where the ro /models mount reaches it: a reference +# recorded with the container's baked template scores a different tokenisation +# than the arms served with froggeric's, and the KL would be measuring that. +cp -f "$TEMPLATE" "$TPL" || { + echo "REFUSED: cannot stage the chat template at $TPL (the container reads it through /models)" >&2 + exit 2 +} +[ "$MODE" = kldref ] && mkdir -p "$KLD_REF_DIR" +[ "$MODE" = calib ] && mkdir -p "$CALIB_DIR" + +restore() { sudo systemctl start "$SERVICE" >/dev/null 2>&1 || true; } +trap restore EXIT +echo "run-qwen38-bf16 $MODE: stopping $SERVICE ($(date -Is)); log: $LOG" +sudo systemctl stop "$SERVICE" >/dev/null 2>&1 || true +podman rm -f "$C" >/dev/null 2>&1 || true + +"${CMD[@]}" 2>&1 | tee "$LOG" +rc=${PIPESTATUS[0]} +echo "run-qwen38-bf16 $MODE exited rc=$rc; $SERVICE restored; log: $LOG" +exit "$rc" diff --git a/tests/qwen38_campaign.bats b/tests/qwen38_campaign.bats new file mode 100755 index 0000000..f061a8d --- /dev/null +++ b/tests/qwen38_campaign.bats @@ -0,0 +1,527 @@ +#!/usr/bin/env bats +# Seam: the Qwen3.8-Flash-Next five-bit campaign (feat/qwen38-w5nl64). +# +# The campaign converts the vendor BF16 checkpoint into a w5nl64a8h .rad on +# dankserver while frank-archon keeps serving. Every gate that protects the box +# or the result lives in a script under scripts/, and each gate is tested here +# through that script's own exit status and printed output — no engine, no GPU, +# no network. Fixtures are synthetic; the expected numbers are worked examples, +# not values recomputed the way the script computes them. +# +# Seams under test (the campaign's public boundaries): +# scripts/download-qwen38-bf16.sh fetch/verify of the BF16 checkpoint +# scripts/grams-to-imatrix.py calibration recording -> imatrix GGUF +# scripts/derive-protected-experts.py --target-count sweep + the parity law +# scripts/build-qwen38-bf16-rad.sh the reference container's convert argv +# scripts/run-qwen38-bf16.sh the drained-serve argv, mode selection +# scripts/convert-qwen38-w5nl64.sh the arm driver's refusal gates +# scripts/kld-gate-qwen38.sh the three-anchor interval law +# scripts/deploy-plan.py campaign files must deploy nothing + +load helpers + +DOWNLOAD="$REPO_ROOT/scripts/download-qwen38-bf16.sh" +PLANNER="$REPO_ROOT/scripts/deploy-plan.py" + +# make_shard — a REAL safetensors file whose size on +# disk is exactly : 8-byte header length, JSON header, tensor data. +# Real, because the verifier reads each shard's own header to learn how many +# bytes it claims to be, which is the only per-file truth available offline. +make_shard() { + mkdir -p "$1" + python3 - "$1/$2" "$3" <<'PY' +import json, struct, sys +path, want = sys.argv[1], int(sys.argv[2]) +payload = max(0, want - 96) +header = b"" +for _ in range(16): + # The header is part of the file, so its own length moves the target: solve + # payload = want - 8 - len(header(payload)) by fixed-point iteration. + header = json.dumps({"w": {"dtype": "F32", "shape": [payload // 4], + "data_offsets": [0, payload]}}, + separators=(",", ":")).encode() + want_payload = want - 8 - len(header) + if want_payload == payload: + break + if want_payload < 0: + raise SystemExit("fixture too small to carry a header") + payload = want_payload +else: + raise SystemExit("fixture header never converged") +with open(path, "wb") as f: + f.write(struct.pack(" ... — a safetensors index that references them +make_index() { + local dir="$1"; shift + python3 - "$dir/model.safetensors.index.json" "$@" <<'PY' +import json, sys +out, names = sys.argv[1], sys.argv[2:] +json.dump({"metadata": {"total_size": 0}, + "weight_map": {f"model.layers.{i}.attn.q.weight": n + for i, n in enumerate(names)}}, + open(out, "w")) +PY +} + +# three_fixture — three valid 1000-byte shards plus the index naming them +three_fixture() { + make_shard "$1" model-00001-of-00003.safetensors 1000 + make_shard "$1" model-00002-of-00003.safetensors 1000 + make_shard "$1" model-00003-of-00003.safetensors 1000 + make_index "$1" model-00001-of-00003.safetensors \ + model-00002-of-00003.safetensors \ + model-00003-of-00003.safetensors +} + +# --- download / verify ------------------------------------------------------- + +@test "the checkpoint verifier accepts a complete checkpoint and reports its footprint" { + C="$BATS_TEST_TMPDIR/complete" + three_fixture "$C" + # 3 x 1000 bytes of shards. The index file itself is deliberately not + # counted, so a Hub-side edit to a config file cannot fail this gate. + run bash "$DOWNLOAD" --no-hub --dir "$C" --shards 3 --bytes 3000 + [ "$status" -eq 0 ] + [[ "$output" == *"VERIFY_OK"*"3 shards"*"3000 bytes"* ]] +} + +@test "the verifier names the shard whose own header disagrees with its size on disk" { + C="$BATS_TEST_TMPDIR/truncated" + three_fixture "$C" + # Cut the tail, keep the header: that is a torn transfer, which is what the + # box actually gets from an interrupted fetch. Zero-filling instead would + # destroy the header and be caught as "not a safetensors file" — a different + # gate, and it would prove nothing about the size check. + python3 -c 'import sys +p = sys.argv[1] +keep = open(p, "rb").read(400) +open(p, "wb").write(keep)' "$C/model-00002-of-00003.safetensors" + run bash "$DOWNLOAD" --no-hub --dir "$C" --shards 3 --bytes 3000 + [ "$status" -eq 1 ] + [[ "$output" == *"TRUNCATED"*"model-00002-of-00003.safetensors"* ]] +} + +@test "the verifier refuses a missing shard and names it" { + C="$BATS_TEST_TMPDIR/missing" + three_fixture "$C" + mv "$C/model-00003-of-00003.safetensors" "$C/hidden.bin" + run bash "$DOWNLOAD" --no-hub --dir "$C" --shards 3 --bytes 3000 + [ "$status" -eq 1 ] + [[ "$output" == *"MISSING"*"model-00003-of-00003.safetensors"* ]] +} + +@test "the verifier refuses when the shard census disagrees with the expected count" { + # The fragment law this repo learned the hard way: large quants ship as + # .partNofM fragments and a naive glob reports success on a partial tree. + C="$BATS_TEST_TMPDIR/count" + three_fixture "$C" + make_shard "$C" model-00004-of-00004.safetensors 1000 + run bash "$DOWNLOAD" --no-hub --dir "$C" --shards 3 --bytes 3000 + [ "$status" -eq 1 ] + [[ "$output" == *"SHARD_COUNT"*"4"*"3"* ]] +} + +@test "the verifier refuses a shard the index names that never landed" { + C="$BATS_TEST_TMPDIR/orphan-index" + three_fixture "$C" + rm "$C/model-00003-of-00003.safetensors" + make_index "$C" model-00001-of-00003.safetensors \ + model-00002-of-00003.safetensors \ + model-00003-of-00003.safetensors + run bash "$DOWNLOAD" --no-hub --dir "$C" --shards 2 --bytes 2000 + [ "$status" -eq 1 ] + [[ "$output" == *"MISSING"*"model-00003-of-00003.safetensors"* ]] +} + +@test "the verifier treats an HTTP error body written as a shard as a failure, not a download" { + # curl -o FILE exits 0 on 401/403 and writes the error body; three shards + # of identical tiny size must never look like a checkpoint. + C="$BATS_TEST_TMPDIR/errorbody" + mkdir -p "$C" + for n in 1 2 3; do + printf '{"error":"You are not logged in"}' \ + > "$C/model-0000${n}-of-00003.safetensors" + done + make_index "$C" model-00001-of-00003.safetensors \ + model-00002-of-00003.safetensors \ + model-00003-of-00003.safetensors + run bash "$DOWNLOAD" --no-hub --dir "$C" --shards 3 --bytes 3000 + [ "$status" -eq 1 ] + [[ "$output" == *"NOT_SAFETENSORS"* ]] +} + +@test "the verifier refuses a shard census whose bytes do not total what the Hub declared" { + # Every shard header can be self-consistent and the tree still be the wrong + # payload: the total is the cross-check against the Hub's declared size. + C="$BATS_TEST_TMPDIR/wrongtotal" + three_fixture "$C" + run bash "$DOWNLOAD" --no-hub --dir "$C" --shards 3 --bytes 2500 + [ "$status" -eq 1 ] + [[ "$output" == *"TOTAL_BYTES"*"3000"*"2500"* ]] +} + +@test "the fetch path refuses when the destination cannot hold the checkpoint" { + # The campaign's peak-disk law starts at the fetch: the BF16 checkpoint and + # the reference container built from it are both ~335 GiB, and the tree that + # does not fit is discovered hours into a transfer. + C="$BATS_TEST_TMPDIR/tiny" + three_fixture "$C" + run env FRANK_QWEN38_MIN_FREE_MIB=999999999 \ + bash "$DOWNLOAD" --fetch --no-hub --dir "$C" --shards 3 --bytes 3000 + [ "$status" -eq 2 ] + [[ "$output" == *"REFUSED"*"free"* ]] +} + +# --- deployment scoping ------------------------------------------------------ + +@test "the campaign tooling deploys nothing: merging it cannot drain frank-archon" { + # Existence is asserted first: a planner that plans nothing because the + # files vanished would otherwise pass. + [ -f "$REPO_ROOT/scripts/download-qwen38-bf16.sh" ] + run python3 "$PLANNER" --target dankserver --paths \ + scripts/download-qwen38-bf16.sh \ + scripts/build-qwen38-bf16-rad.sh \ + scripts/run-qwen38-bf16.sh \ + scripts/convert-qwen38-w5nl64.sh \ + scripts/kld-gate-qwen38.sh \ + scripts/grams-to-imatrix.py \ + scripts/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe \ + tests/qwen38_campaign.bats + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# --- protected experts ------------------------------------------------------- + +DERIVE="$REPO_ROOT/scripts/derive-protected-experts.py" +APPLY="$REPO_ROOT/scripts/apply-protected-cast.sh" +CONVERT="$REPO_ROOT/scripts/convert-qwen38-w5nl64.sh" +GATE="$REPO_ROOT/scripts/kld-gate-qwen38.sh" +TEMPLATE="$REPO_ROOT/scripts/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe" + +# make_gram — a RADGRAM2 down-Gram whose +# trace (its down-input energy) is and whose routing count is . +make_gram() { + python3 - "$@" <<'PY' +import os, struct, sys +d, layer, e, energy, rows = sys.argv[1:6] +os.makedirs(d, exist_ok=True) +hdr = struct.pack("<8sIIQI4x", b"RADGRAM2", 2, 0, int(rows), 0) +mat = struct.pack("<4f", float(energy), 0.0, 0.0, 0.0) +with open(os.path.join(d, f"gram.r0.blk.{layer}.ffn_down_exps.{e}.bin"), "wb") as f: + f.write(hdr + mat) +PY +} + +# eight_grams — one layer, eight experts inside rank half 0, energies +# 60/15/10/8/4/2/1/0.5 against equal routing. Under the default bars (3% of the +# layer's energy AND 3x its routing share) exactly ONE expert is disproportionate, +# so the parity law has to hand it a partner. +eight_grams() { + local d="$1" + make_gram "$d" 0 0 60 100 + make_gram "$d" 0 1 15 100 + make_gram "$d" 0 2 10 100 + make_gram "$d" 0 3 8 100 + make_gram "$d" 0 4 4 100 + make_gram "$d" 0 5 2 100 + make_gram "$d" 0 6 1 100 + make_gram "$d" 0 7 0.5 100 +} + +cast_count() { grep -c 'cast dtype=bf16' <<< "$output"; } + +@test "the bar-only pass keeps the disproportionate expert and repairs the odd rank half" { + C="$BATS_TEST_TMPDIR/calib" + eight_grams "$C" + run python3 "$DERIVE" "$C" + [ "$status" -eq 0 ] + # e0 holds 59.7% of the layer's energy on 12.5% of the routing: the only expert + # that clears three times its routing share. e1 joins it because half 0 was odd. + [ "$(cast_count)" -eq 2 ] + [[ "$output" == *"blk.0.ffn_*_exps.0.weight"* ]] + [[ "$output" == *"blk.0.ffn_*_exps.1.weight"* ]] + [[ "$output" != *"ffn_*_exps.2.weight"* ]] + [[ "$output" == *"half 0 odd -> added e1"* ]] +} + +@test "--target-count lands the byte bill that was asked for, and never buys more" { + # The count is a disk and VRAM decision (each protected expert costs bf16 twice, + # once per rank half's copy), so landing it matters as much as not missing it. + C="$BATS_TEST_TMPDIR/calib" + eight_grams "$C" + run python3 "$DERIVE" "$C" --target-count 6 + [ "$status" -eq 0 ] + [ "$(cast_count)" -eq 6 ] + [[ "$output" == *"count fill -> added e2"* ]] + [[ "$output" != *"ffn_*_exps.6.weight"* ]] + # the energy share the set bottoms out at is the bar the campaign paid for + [[ "$output" == *"total protected: 6 (target 6, energy cutoff"* ]] +} + +@test "--target-count cannot invent experts: the pool's ceiling is reported, not padded" { + C="$BATS_TEST_TMPDIR/calib" + eight_grams "$C" + run python3 "$DERIVE" "$C" --target-count 20 + [ "$status" -eq 0 ] + [ "$(cast_count)" -eq 8 ] + [[ "$output" == *"total protected: 8 (target 20"* ]] +} + +@test "a protected set that cannot be made even is refused instead of emitted" { + # One disproportionate expert in half 0, its only companion in half 1: no + # candidate could make half 0 even, and an odd half hands the four-bit GEMM two + # tables that are not equal. Refusing is the only honest answer. + C="$BATS_TEST_TMPDIR/calib" + make_gram "$C" 0 0 100 100 + make_gram "$C" 0 300 1 1000 + run python3 "$DERIVE" "$C" + [ "$status" -eq 2 ] + [[ "$output" == *"PARITY LAW BROKEN"* ]] + [[ "$output" == *"blk.0 half 0 holds 1"* ]] + [ "$(cast_count)" -eq 0 ] +} + +@test "the splice rewrites the run's copy above the expert rule and refuses a second run" { + C="$BATS_TEST_TMPDIR/calib" + eight_grams "$C" + W="$BATS_TEST_TMPDIR/recipe-work.recipe" + cp "$TEMPLATE" "$W" + run env PY=python3 TRASH_DIR="$BATS_TEST_TMPDIR/trash" bash "$APPLY" "$C" "$W" 0 + [ "$status" -eq 0 ] + grep -q "derive-protected-experts.py rewrote" "$W" + # The derived lines must precede the expert rule: the first rule whose glob + # matches a weight decides it, so a cast line below the expert line never fires. + cast_line=$(grep -n 'cast dtype=bf16' "$W" | head -1 | cut -d: -f1) + expert_line=$(grep -n 'ffn_\*_exps\.\*\.weight' "$W" | head -1 | cut -d: -f1) + [ "$cast_line" -lt "$expert_line" ] + # The committed template stays the clean, reviewable baseline. + ! grep -q "derive-protected-experts.py rewrote" "$TEMPLATE" + # A second run on the same copy would derive from a recording it already spent. + run env PY=python3 TRASH_DIR="$BATS_TEST_TMPDIR/trash" bash "$APPLY" "$C" "$W" 0 + [ "$status" -ne 0 ] + [[ "$output" == *"refusing to re-derive"* ]] +} + +@test "the splice refuses before touching the recipe when the count is not reached" { + C="$BATS_TEST_TMPDIR/calib" + eight_grams "$C" + W="$BATS_TEST_TMPDIR/recipe-work.recipe" + cp "$TEMPLATE" "$W" + run env PY=python3 TRASH_DIR="$BATS_TEST_TMPDIR/trash" bash "$APPLY" "$C" "$W" 20 + [ "$status" -eq 1 ] + [[ "$output" == *"target was 20"* ]] + ! grep -q "derive-protected-experts.py rewrote" "$W" +} + +# --- the arm driver ---------------------------------------------------------- + +# drv_fixture — the smallest tree the driver's gates accept: a checkpoint +# with its index and tokenizer, three grams, an imatrix inside the campaign dir +# the container mounts, and an empty output dir. +drv_fixture() { + local d="$1" + mkdir -p "$d/ck/Qwen3.8-Flash-Next" "$d/campaign" "$d/calib" "$d/out" + : > "$d/ck/Qwen3.8-Flash-Next/model.safetensors.index.json" + : > "$d/ck/Qwen3.8-Flash-Next/tokenizer.json" + make_gram "$d/calib" 0 0 60 100 + make_gram "$d/calib" 0 1 15 100 + make_gram "$d/calib" 0 2 10 100 + # four experts, not three: a thin pool that cannot be made even trips the parity + # audit, and this fixture is meant to reach the later gates + make_gram "$d/calib" 0 3 8 100 + : > "$d/campaign/imatrix.gguf" +} + +# run_drv — the driver with every path +# pointed at the fixture. Override words are separated out because `env` only +# accepts assignments ahead of the command; later assignments win, so a test can +# break exactly one input. +run_drv() { + local d="$BATS_TEST_TMPDIR/drv" assigns=() flags=() w + for w in "$@"; do + if [[ "$w" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then assigns+=("$w"); else flags+=("$w"); fi + done + run env CKPT="$d/ck/Qwen3.8-Flash-Next" CAMPAIGN="$d/campaign" STATE="$d/campaign" \ + CALIB="$d/calib" OUTDIR="$d/out" IMATRIX="$d/campaign/imatrix.gguf" \ + MIN_GRAMS=2 TARGET_COUNT=0 ${assigns[@]+"${assigns[@]}"} \ + bash "$CONVERT" ${flags[@]+"${flags[@]}"} +} + +@test "the dry run prints the plan and then the convert for every arm" { + drv_fixture "$BATS_TEST_TMPDIR/drv" + run_drv --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"== arm cd3 =="* ]] + [[ "$output" == *"== arm cd6 =="* ]] + [[ "$output" == *"== arm ao1 =="* ]] + # one --plan-only bill per arm, and one convert line per arm beside it + [ "$(grep -c -- '--plan-only' <<< "$output")" -eq 3 ] + [ "$(grep -c 'rad-convert' <<< "$output")" -eq 6 ] + awk '/arm cd3/ {next} + /qwen38-w5nl64-cd3\.rad/ { if ($0 ~ /--plan-only/) p = NR; else c = NR } + END { exit !(p && c && p < c) }' <<< "$output" +} + +@test "the cd6 arm re-sends the whole expert option set with cd=6; the control sends none" { + # --quant replaces the recipe line wholesale, so an override that forgot an + # option would quietly convert with the default for it. + drv_fixture "$BATS_TEST_TMPDIR/drv" + run_drv --arm cd3 --arm cd6 --dry-run + [ "$status" -eq 0 ] + cd6line=$(grep -- '-cd6\.rad --quant' <<< "$output" | head -1) + [[ "$cd6line" == *"table=w5nl"* ]] + [[ "$cd6line" == *"group=64"* ]] + [[ "$cd6line" == *"scale=fp8_e4m3"* ]] + [[ "$cd6line" == *"transform=fwht128"* ]] + [[ "$cd6line" == *"calib=\$CALIB"* ]] + [[ "$cd6line" == *",cd=6"* ]] + cd3line=$(grep -- '-cd3\.rad' <<< "$output" | grep -v -- '--plan-only' | head -1) + [[ "$cd3line" != *"--quant"* ]] +} + +@test "the ao1 arm carries act_order=1 and no cd=, because libquant refuses the pair" { + drv_fixture "$BATS_TEST_TMPDIR/drv" + run_drv --arm ao1 --dry-run + [ "$status" -eq 0 ] + line=$(grep -- '-ao1\.rad --quant' <<< "$output" | head -1) + [[ "$line" == *"act_order=1"* ]] + [[ "$line" != *",cd="* ]] +} + +@test "the driver splices the protected plane into its own copy, never the template" { + drv_fixture "$BATS_TEST_TMPDIR/drv" + run_drv --dry-run + [[ "$output" == *"apply-protected-cast.sh $BATS_TEST_TMPDIR/drv/calib "* ]] + [[ "$output" == *"/campaign/recipe-"* ]] + [[ "$output" != *"$TEMPLATE"* ]] +} + +@test "the driver refuses while the BF16 reference container still holds the volume" { + drv_fixture "$BATS_TEST_TMPDIR/drv" + : > "$BATS_TEST_TMPDIR/drv/out/qwen38-bf16.rad" + run_drv --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"peak-disk"* ]] +} + +@test "a thin recording is refused: the driver never converts uncalibrated" { + # rad-convert converts WITHOUT a calibration recording with one stderr note, + # which is silently worse than a GPTQ run that failed. + drv_fixture "$BATS_TEST_TMPDIR/drv" + run_drv MIN_GRAMS=9 --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"run-qwen38-bf16.sh calib"* ]] +} + +@test "an imatrix outside the mounted campaign dir is refused before any convert" { + drv_fixture "$BATS_TEST_TMPDIR/drv" + mv "$BATS_TEST_TMPDIR/drv/campaign/imatrix.gguf" "$BATS_TEST_TMPDIR/imatrix.gguf" + run_drv IMATRIX="$BATS_TEST_TMPDIR/imatrix.gguf" --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"not in the campaign dir"* ]] +} + +@test "the driver never overwrites an arm that already exists" { + drv_fixture "$BATS_TEST_TMPDIR/drv" + : > "$BATS_TEST_TMPDIR/drv/out/qwen38-w5nl64-cd6.rad" + run_drv --arm cd6 --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"move it to trash first"* ]] +} + +@test "an unknown arm name is refused before the gates run" { + # No fixture built: the name check must not need a checkpoint to exist. + run_drv --arm cd9 --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"unknown arm: cd9"* ]] +} + +@test "the driver stops when the protected derivation cannot land the target" { + drv_fixture "$BATS_TEST_TMPDIR/drv" + run_drv PY=python3 TARGET_COUNT=20 --arm cd3 + [ "$status" -eq 1 ] + [[ "$output" == *"target was 20"* ]] + [[ "$output" == *"would convert unprotected"* ]] + [[ "$output" != *"--entrypoint"* ]] +} + +# --- the acceptance gate ----------------------------------------------------- + +# kld_report — one score report, in the +# shape `run-qwen38-bf16.sh score` writes: an `all` summary plus a per-source one. +kld_report() { + python3 - "$@" <<'PY' +import json, sys +path, model, kl, top1 = sys.argv[1:5] +kl, top1 = float(kl), float(top1) +group = {"kld": {"mean": kl, "p99_9": kl * 4}, "top1_agreement": top1, "positions": 50432} +json.dump({"model": model, "all": group, + "by_source": {"ops": dict(group, positions=20000)}}, open(path, "w")) +PY +} + +gate_case() { # gate_case [extra flags...] + D="$BATS_TEST_TMPDIR/kl" + mkdir -p "$D" + kld_report "$D/fp8.json" fp8-native "$1" 0.981 + kld_report "$D/iq4r.json" iq4r-moe "$2" 0.962 + kld_report "$D/cand.json" w5nl64 "$3" "$4" + run bash "$GATE" "$D/fp8.json" "$D/iq4r.json" "$D/cand.json" "${@:5}" +} + +@test "an arm strictly inside the interval that closes half the gap passes" { + gate_case 0.0021 0.0190 0.0090 0.971 + [ "$status" -eq 0 ] + [[ "$output" == *"closes 59.2%"* ]] + [[ "$output" == *"KLD GATE PASS"* ]] +} + +@test "an arm worse than the served payload fails the interval and the recovery" { + gate_case 0.0021 0.0190 0.0210 0.971 + [ "$status" -eq 1 ] + [[ "$output" == *"[FAIL] 2."* ]] + [[ "$output" == *"[FAIL] 3."* ]] + [[ "$output" == *"KLD GATE FAIL"* ]] +} + +@test "anchors that are not ordered stop the gate: the instrument is wrong, not the arm" { + # If the near-lossless FP8 release does not sit CLOSER to the bf16 reference + # than the 4-bit payload does, the corpus, tokenizer or boot shape is broken and + # no arm number can mean anything. + gate_case 0.0300 0.0190 0.0120 0.971 + [ "$status" -eq 1 ] + [[ "$output" == *"[FAIL] 1."* ]] + [[ "$output" == *"gap is not positive"* ]] +} + +@test "an arm measured outside the interval fails even when it looks better" { + # Closer to the reference than the achievable anchor is a measurement fault. + gate_case 0.0021 0.0190 0.0010 0.971 + [ "$status" -eq 1 ] + [[ "$output" == *"[FAIL] 2."* ]] +} + +@test "an arm that buys KL with top-1 agreement fails" { + gate_case 0.0021 0.0190 0.0090 0.940 + [ "$status" -eq 1 ] + [[ "$output" == *"[PASS] 3."* ]] + [[ "$output" == *"[FAIL] 4."* ]] +} + +@test "--recover tightens the same numbers until they fail" { + gate_case 0.0021 0.0190 0.0090 0.971 --recover 0.8 + [ "$status" -eq 1 ] + [[ "$output" == *"needs 80%"* ]] + [[ "$output" == *"[FAIL] 3."* ]] +} + +@test "a slice the report does not carry is named, not guessed at" { + gate_case 0.0021 0.0190 0.0090 0.971 --slice code + [ "$status" -eq 2 ] + [[ "$output" == *"has no slice 'code'"* ]] + [[ "$output" == *"it has: ops"* ]] +} diff --git a/tests/qwen38_grams.bats b/tests/qwen38_grams.bats new file mode 100755 index 0000000..cd2bd80 --- /dev/null +++ b/tests/qwen38_grams.bats @@ -0,0 +1,445 @@ +#!/usr/bin/env bats +# Seam: scripts/grams-to-imatrix.py — the calibration recording (RADGRAM2 grams) +# folded into the GGUF v3 imatrix `rad-convert --imatrix F` reads. +# +# The only contract that matters is the reader's: core/format/imatrix.cpp joins +# `.in_sum2` with `.counts`, takes cols from in_sum2 dims[0] and +# n_expert from dims[1], demands ggml f32 tensors and GGUF version 3, and +# silently IGNORES an entry whose width does not match the weight it is asked +# about. So every test here parses the artifact back with its own tiny GGUF +# reader and asserts bytes — a file that merely exists and exits 0 proves +# nothing about a quantiser that will read it hours later on another machine. +# +# Fixtures are synthetic: k=4 grams with real RADGRAM2 headers (rows is a u64 +# at offset 16 — pack it as u32 and 100 rows read back as 4294967296000) and +# real f32 payloads whose OFF-DIAGONAL is filled with 99.0, so an entry built +# from anything but the diagonal is instantly visible. Expected numbers are +# worked examples from the fixture tables below, never recomputed the way the +# tool computes them. + +load helpers + +TOOL="$REPO_ROOT/scripts/grams-to-imatrix.py" + +# make_gram +# A RADGRAM2 file: 32-byte header <8sIIQI (magic, k, rank, rows, domain, pad) +# then k*k f32 row-major. Off-diagonals are 99.0 — a sentinel, not data. +make_gram() { + python3 - "$@" <<'PY' +import struct, sys +path, k, rank, rows, domain = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), \ + int(sys.argv[4]), int(sys.argv[5]) +diag = [float(x) for x in sys.argv[6].split(",")] +assert len(diag) == k, "diagonal must hold k values" +vals = [99.0] * (k * k) +for i, d in enumerate(diag): + vals[i * k + i] = d +hdr = struct.pack("<8sIIQI", b"RADGRAM2", k, rank, rows, domain) + struct.pack(" — the fixture every happy-path test reads. +# +# layer 0 (k=4, own-gram floor 4*k = 16 rows): +# pooled r0 rows 40 diag 1,2,3,4 + r1 rows 8 diag .5,.5,.5,.5 +# -> summed gram (the engine's calib_sum law): diag 1.5,2.5,3.5,4.5, +# pooled rows 48 +# expert 0 r0 rows 100 diag 10,20,30,40 (>= 16: own gram kept) +# expert 1 r0 rows 3 diag 7,7,7,7 (< 16: inherits pooled) +# expert 2 r1 rows 64 diag 1,1,1,2 (>= 16: own gram kept) +# expert 3 no file at all (inherits pooled, count 0) +# expert 4 r1 rows 100 diag 5,6,7,8 (>= 16: own gram kept) +# -> n_expert 5, counts 100 3 64 0 100, inherited 2, routing 267 +# layer 1: pooled r0 rows 48 diag 2,2,2,2 ; expert 0 on r1 rows 48 diag 1,1,1,1 +# -> n_expert 1, counts 48, inherited 0, routing 48 +# Corpus rows = the largest single-layer pooled rows = 48. +full_recording() { + local d="$1" + mkdir -p "$d" + make_gram "$d/gram.r0.blk.0.ffn_down_exps.bin" 4 0 40 0 "1,2,3,4" + make_gram "$d/gram.r1.blk.0.ffn_down_exps.bin" 4 1 8 0 "0.5,0.5,0.5,0.5" + make_gram "$d/gram.r0.blk.0.ffn_down_exps.0.bin" 4 0 100 0 "10,20,30,40" + make_gram "$d/gram.r0.blk.0.ffn_down_exps.1.bin" 4 0 3 0 "7,7,7,7" + make_gram "$d/gram.r1.blk.0.ffn_down_exps.2.bin" 4 1 64 0 "1,1,1,2" + make_gram "$d/gram.r1.blk.0.ffn_down_exps.4.bin" 4 1 100 0 "5,6,7,8" + make_gram "$d/gram.r0.blk.1.ffn_down_exps.bin" 4 0 48 0 "2,2,2,2" + make_gram "$d/gram.r1.blk.1.ffn_down_exps.0.bin" 4 1 48 0 "1,1,1,1" +} + +# gguf_read [vals ] — an independent GGUF +# v3 reader: KV block, tensor infos, and (on request) the f32 values behind a +# tensor's own declared offset. Prints KV/HDR/TENSOR lines. Nothing here shares +# code with the writer, which is the only reason the byte assertions mean +# anything: imatrix.cpp walks the same three structures. +gguf_read() { + python3 - "$@" <<'PY' +import struct, sys +d = open(sys.argv[1], "rb").read() +def u32(o): return struct.unpack_from(" 2 and sys.argv[2] == "vals": + name, i0, n = sys.argv[3], int(sys.argv[4]), int(sys.argv[5]) + for nm, dims, ty, off in tens: + if nm == name: + base = ds + off + vals = [("%d" % v) if float(v).is_integer() else repr(v) + for v in (struct.unpack_from(" — exit 0 iff the data section starts aligned AND every +# tensor's absolute data offset (data_start + t.off) is a multiple of +# general.alignment. This is the writer bug that no value assertion can see: an +# unaligned offset makes the reader's extent arithmetic land mid-float in the +# previous tensor, and it reads the result without complaining. +aligned_offsets() { + python3 - "$1" <<'PY' +import struct, sys +d = open(sys.argv[1], "rb").read() +def u32(o): return struct.unpack_from(""$BATS_TEST_TMPDIR/err" + [ -s "$BATS_TEST_TMPDIR/err" ] + grep -q "inherited=2" "$BATS_TEST_TMPDIR/err" +} + +@test "the hidden-width entries are the same bytes, and nothing else is emitted" { + C="$BATS_TEST_TMPDIR/full" + full_recording "$C" + run python3 "$TOOL" "$C" -o "$BATS_TEST_TMPDIR/im.gguf" + [ "$status" -eq 0 ] + run gguf_read "$BATS_TEST_TMPDIR/im.gguf" + # gate_up shares the down matrices (same hidden-state rows), so its slice 4 + # must read 5 6 7 8 at its own tensor info's offset. + [[ "$output" == *"TENSOR blk.0.ffn_gate_up_exps.in_sum2 4x5 0 "* ]] + [[ "$output" == *"TENSOR blk.0.ssm_inz.in_sum2 4 0 "* ]] + [[ "$output" == *"TENSOR blk.0.ssm_inz.counts 1 0 "* ]] + run gguf_read "$BATS_TEST_TMPDIR/im.gguf" vals blk.0.ffn_gate_up_exps.in_sum2 16 4 + [[ "$output" == *"VALS blk.0.ffn_gate_up_exps.in_sum2 5 6 7 8"* ]] + run gguf_read "$BATS_TEST_TMPDIR/im.gguf" vals blk.0.ssm_inz.in_sum2 0 4 + [[ "$output" == *"VALS blk.0.ssm_inz.in_sum2 1.5 2.5 3.5 4.5"* ]] + run gguf_read "$BATS_TEST_TMPDIR/im.gguf" vals blk.0.ssm_inz.counts 0 1 + [[ "$output" == *"VALS blk.0.ssm_inz.counts 48"* ]] + # The stop rule: importance_for() false-on-a-width-mismatch means an entry + # for a projection whose input is NOT the gram's width is a silent lie. + [[ "$output" != *"ssm_out"* ]] + [[ "$output" != *"attn"* ]] + [[ "$output" != *"mtp."* ]] + [[ "$output" != *"ple_ngram"* ]] + [[ "$output" != *"output.weight"* ]] +} + +@test "rows is a u64: a corpus too big for u32 survives the header" { + # The trap this fixture law exists for: pack rows as u32 at offset 16 and a + # 100-row file reads back as 4294967296000. 2**32 is exactly representable + # in f32, so the count round-trips only if the tool read 8 bytes. + C="$BATS_TEST_TMPDIR/wide" + mkdir -p "$C" + make_gram "$C/gram.r0.blk.0.ffn_down_exps.bin" 4 0 4294967296 0 "1,1,1,1" + make_gram "$C/gram.r0.blk.0.ffn_down_exps.0.bin" 4 0 4294967296 0 "3,3,3,3" + run python3 "$TOOL" "$C" -o "$BATS_TEST_TMPDIR/wide.gguf" --chunk-size 4096 + [ "$status" -eq 0 ] + run gguf_read "$BATS_TEST_TMPDIR/wide.gguf" vals blk.0.ffn_down_exps.counts 0 1 + [[ "$output" == *"VALS blk.0.ffn_down_exps.counts 4294967296"* ]] + [[ "$output" != *"4294967296000"* ]] + run gguf_read "$BATS_TEST_TMPDIR/wide.gguf" + [[ "$output" == *"KV imatrix.chunk_count 1048576"* ]] + # imatrix.chunk_count is a u32 field, so 2**32 rows in 1-row chunks has no + # legal encoding: wrapping it would put a number downstream believes. + run python3 "$TOOL" "$C" -o "$BATS_TEST_TMPDIR/wide2.gguf" --chunk-size 1 + [ "$status" -eq 2 ] + [[ "$output" == *"REFUSED"*"CHUNK_COUNT"* ]] + [ ! -e "$BATS_TEST_TMPDIR/wide2.gguf" ] +} + +@test "every tensor's data offset is a multiple of general.alignment" { + C="$BATS_TEST_TMPDIR/full" + full_recording "$C" + run python3 "$TOOL" "$C" -o "$BATS_TEST_TMPDIR/im.gguf" + [ "$status" -eq 0 ] + run aligned_offsets "$BATS_TEST_TMPDIR/im.gguf" + [ "$status" -eq 0 ] + [[ "$output" == "ALIGNED 12 tensor(s),"*"alignment=32" ]] + + # Red proof, in the same test so the assertion cannot rot: the same file + # with one tensor's declared offset nudged by 4. A counts tensor is 4 bytes, + # so a writer that forgets to pad between tensors produces exactly this + # shape of file — and every value assertion above still passes on it. + python3 - "$BATS_TEST_TMPDIR/im.gguf" "$BATS_TEST_TMPDIR/unaligned.gguf" <<'PY' +import struct, sys +src, dst = sys.argv[1], sys.argv[2] +d = bytearray(open(src, "rb").read()) +def u32(o): return struct.unpack_from(".blk.3.ffn_down_exps.bin"* ]] +} + +@test "the same global expert recorded twice with different numbers is refused" { + # Rank files carry GLOBAL expert numbering and so describe disjoint experts; + # a collision means the recording is corrupt, and whichever file sorted last + # would win. + C="$BATS_TEST_TMPDIR/conflict" + full_recording "$C" + make_gram "$C/gram.r1.blk.0.ffn_down_exps.0.bin" 4 1 77 0 "10,20,30,40" + run python3 "$TOOL" "$C" -o "$BATS_TEST_TMPDIR/no.gguf" + [ "$status" -eq 2 ] + [[ "$output" == *"REFUSED"*"EXPERT_CONFLICT"*"expert 0"* ]] + [[ "$output" == *"gram.r0.blk.0.ffn_down_exps.0.bin"* ]] + [[ "$output" == *"gram.r1.blk.0.ffn_down_exps.0.bin"* ]] +} + +@test "an existing output is refused by name rather than overwritten" { + # The trash law: a re-run must not destroy the imatrix a convert is reading. + C="$BATS_TEST_TMPDIR/full" + full_recording "$C" + : > "$BATS_TEST_TMPDIR/keep.gguf" + printf 'do not clobber me\n' > "$BATS_TEST_TMPDIR/keep.gguf" + run python3 "$TOOL" "$C" -o "$BATS_TEST_TMPDIR/keep.gguf" + [ "$status" -eq 2 ] + [[ "$output" == *"REFUSED"*"EXISTS"*"keep.gguf"* ]] + run cat "$BATS_TEST_TMPDIR/keep.gguf" + [[ "$output" == *"do not clobber me"* ]] +} + +@test "--min-rows moves the threshold and the bytes follow it" { + # The default threshold is the engine's own law (4*k rows, lq_hess.h), but + # the operator has to be able to raise it when a corpus is thin. Asserted on + # the slices, not on a report line: a flag that only changes prose is a + # flag that changes nothing. + C="$BATS_TEST_TMPDIR/floor" + full_recording "$C" + run python3 "$TOOL" "$C" -o "$BATS_TEST_TMPDIR/far.gguf" --min-rows 200 + [ "$status" -eq 0 ] + # 200 is above every expert's routing (100, 3, 64, 0, 100 and layer 1's 48), + # so all six slices now inherit. + [[ "$output" == *"inherited=6"* ]] + run gguf_read "$BATS_TEST_TMPDIR/far.gguf" vals blk.0.ffn_down_exps.in_sum2 0 4 + [[ "$output" == *"VALS blk.0.ffn_down_exps.in_sum2 1.5 2.5 3.5 4.5"* ]] + # The counts do not move with the threshold: routing is a measurement, not a + # judgement about how much data is enough. + run gguf_read "$BATS_TEST_TMPDIR/far.gguf" vals blk.0.ffn_down_exps.counts 0 5 + [[ "$output" == *"VALS blk.0.ffn_down_exps.counts 100 3 64 0 100"* ]] +} diff --git a/tests/qwen38_reference.bats b/tests/qwen38_reference.bats new file mode 100755 index 0000000..b999fb0 --- /dev/null +++ b/tests/qwen38_reference.bats @@ -0,0 +1,337 @@ +#!/usr/bin/env bats +# Seam: the BF16 REFERENCE container of the Qwen3.8-Flash-Next five-bit +# campaign (feat/qwen38-w5nl64) — scripts/build-qwen38-bf16-rad.sh and +# scripts/run-qwen38-bf16.sh. +# +# The reference is what every later stage is judged or derived from (the KLD +# record scores the arms, the Grams derive the protected experts), so the +# deliverable here is the set of refusals: a half checkpoint, a second convert, +# a volume that cannot hold the convert, an engine-refused plan, and any run — +# including a dry one — that would take the GPUs from frank-archon. Each test +# drives the script's own exit status and printed argv. Offline: no engine, no +# GPU, no container, no systemctl, no network. Path knobs aim at +# $BATS_TEST_TMPDIR. +# +# The third mode, score, carries the campaign's comparability law: its engine +# argv must equal kldref's except for --model and the --kld-* pair, or the KL +# numbers of the FP8-native anchor, the served payload and the arms are not one +# measurement series. The seam proves that by diffing the two dry runs. + +# The 1.8.1 law asserted in two places below: --vram-kv-mib was REMOVED at +# 1.3.0, so naming it is a boot refusal — the reference serve must carry the +# budget-resolver knobs (--host-pool-mib / --gpu-headroom-mib) instead. + +load helpers + +BUILD="$REPO_ROOT/scripts/build-qwen38-bf16-rad.sh" +RUNNER="$REPO_ROOT/scripts/run-qwen38-bf16.sh" + +# The reference shape, spelled exactly as run-qwen38-bf16.sh must pass it: this +# is the contract the recorded reference is measured at, so a silent edit here +# is a silent edit to every KLD number in the campaign. +SHAPE="--tp 2 --placement expert_tiered --weights-disk-tier --host-pool-mib 12288 --gpu-headroom-mib 768 --deterministic --num-speculative-tokens 0 --kv-cache-dtype fp8 --max-model-len 32768 --ngram-placement ram --override-chat-template /models/tpl.jinja" + +# oneline — both dry runs print one shell-quoted argument a line; joining them +# lets an option and its value be asserted as the pair they are. +oneline() { local s="${output//$'\n'/ }"; printf '%s' "$s"; } + +# engine_argv — the arguments AFTER the image line of a dry run, with --model +# and every --kld-* flag and its value removed: what is left is the measuring +# shape, and it must be identical between kldref and score. +engine_argv() { + local seen=0 skip=0 a + while IFS= read -r a; do + if [ "$seen" = 0 ]; then + [[ "$a" == *"radiance@sha256:"* ]] && seen=1 + continue + fi + case "$a" in + --model|--kld-record|--kld-corpus|--kld-ref|--kld-out) skip=1; continue ;; + esac + if [ "$skip" = 1 ]; then skip=0; continue; fi + printf '%s\n' "$a" + done <<< "$1" +} + +# flag_value FLAG — the value a dry run printed for FLAG (the argv is one +# argument a line, so the value is the line after it). Lets a test ask what +# number the script chose instead of re-declaring the literal it guards. +flag_value() { + local want="$1" prev="" a + while IFS= read -r a; do + if [ "$prev" = "$want" ]; then printf '%s' "$a"; return 0; fi + prev="$a" + done + return 1 +} + +@test "the build dry run prints the rad-convert argv for the checkpoint and the bf16 output" { + M="$BATS_TEST_TMPDIR/models" + run env FRANK_QWEN38_MODELS="$M" bash "$BUILD" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"rad-convert"* ]] + # the argv is the container's view: the models dir rides the /models mount + [[ "$output" == *"$M:/models"* ]] + flat="$(oneline)" + [[ "$flat" == *"/models/Qwen3.8-Flash-Next -o /models/rad/qwen38-bf16.rad"* ]] + # a reference is the checkpoint re-headed, not re-encoded, and it declares a + # window the long KLD corpus can be read inside of + [[ "$output" != *"--recipe"* ]] + [[ "$output" != *"--reuse"* ]] + [[ "$flat" == *"--max-ctx 32768"* ]] + # and a dry run looked at nothing: the fixture dirs were never created + [ ! -e "$M" ] +} + +@test "the build refuses a checkpoint that is not on disk and names the fetch command" { + M="$BATS_TEST_TMPDIR/models" + run env FRANK_QWEN38_MODELS="$M" bash "$BUILD" + [ "$status" -eq 2 ] + [[ "$output" == *"$M/Qwen3.8-Flash-Next"* ]] + [[ "$output" == *"scripts/download-qwen38-bf16.sh --fetch"* ]] + [ ! -e "$M/rad/qwen38-bf16.rad" ] +} + +@test "the build refuses an existing reference container and names --force as the way past it" { + M="$BATS_TEST_TMPDIR/models" + mkdir -p "$M/Qwen3.8-Flash-Next" "$M/rad" + echo "not a container" > "$M/rad/qwen38-bf16.rad" + run env FRANK_QWEN38_MODELS="$M" QWEN38_MIN_FREE_MIB=1 \ + QWEN38_TRASH="$BATS_TEST_TMPDIR/trash" bash "$BUILD" + [ "$status" -eq 2 ] + [[ "$output" == *"$M/rad/qwen38-bf16.rad"* ]] + [[ "$output" == *"--force"* ]] + # refusing retires nothing: the trash law means the file survives a refusal + [ -f "$M/rad/qwen38-bf16.rad" ] + [ ! -e "$BATS_TEST_TMPDIR/trash" ] +} + +@test "the build refuses when the output filesystem cannot hold a second 335 GiB" { + # The campaign's peak-disk law: 343,323 MiB of container written next to + # 343,323 MiB of checkpoint. The wanted figure is set above whatever this + # box really has, which is what the gate reads. + M="$BATS_TEST_TMPDIR/models" + mkdir -p "$M/Qwen3.8-Flash-Next" "$M/rad" + run env FRANK_QWEN38_MODELS="$M" QWEN38_MIN_FREE_MIB=999999999 bash "$BUILD" + [ "$status" -eq 2 ] + [[ "$output" == *"REFUSED"* ]] + [[ "$output" =~ [0-9]+" MiB free" ]] + [[ "$output" == *"999999999 MiB wanted"* ]] + # the disk gate is the cheap one: it refuses long before the Hub sweep runs + [[ "$output" != *"VERIFY_OK"* ]] + [ ! -e "$M/rad/qwen38-bf16.rad" ] +} + +@test "the reference serve rejects an unknown mode and names every valid mode" { + # `probe` is the swift15 era's third mode; it is retired and must not boot + # a container here either. + run bash "$RUNNER" probe + [ "$status" -eq 2 ] + [[ "$output" == *"probe"* ]] + [[ "$output" == *"kldref"* ]] + [[ "$output" == *"calib"* ]] + [[ "$output" == *"score"* ]] + [[ "$output" != *"podman"* ]] + run bash "$RUNNER" + [ "$status" -eq 2 ] + [[ "$output" == *"kldref"* ]] + [[ "$output" == *"calib"* ]] + [[ "$output" == *"score"* ]] + # score without its two positionals is the same usage refusal + run bash "$RUNNER" score + [ "$status" -eq 2 ] + [[ "$output" == *"score RAD OUT"* ]] +} + +@test "the kldref dry run carries the record flags at the reference shape and no --vram-kv-mib" { + run bash "$RUNNER" kldref --dry-run + [ "$status" -eq 0 ] + flat="$(oneline)" + [[ "$flat" == *"--kld-record /data/kld-ref"* ]] + [[ "$flat" == *"--kld-corpus /data/kld.jsonl"* ]] + # the corpus arrives read-only, the record dir writable + [[ "$flat" == *":/data/kld.jsonl:ro"* ]] + [[ "$flat" == *":/data/kld-ref "* ]] + [[ "$flat" == *"$SHAPE"* ]] + # 1.8.1: the flag does not exist — naming it is a boot refusal + [[ "$output" != *"--vram-kv-mib"* ]] +} + +@test "the calib dry run opens the tap at 12M tokens and serves on 7770" { + run bash "$RUNNER" calib --dry-run + [ "$status" -eq 0 ] + flat="$(oneline)" + [[ "$flat" == *"-e RADIANCE_CALIB_TOKENS=12000000"* ]] + [[ "$flat" == *"-e RADIANCE_CALIB_DIR=/data/calib"* ]] + [[ "$flat" == *"--port 7770"* ]] + [[ "$flat" == *":/data/calib "* ]] + # calib serves; it does not record + [[ "$output" != *"--kld-record"* ]] +} + +@test "a dry run prints argv only: no drain, no container, nothing written" { + L="$BATS_TEST_TMPDIR/logs" + run env FRANK_QWEN38_LOG_DIR="$L" \ + bash "$RUNNER" kldref --dry-run --image example.invalid/radiance@sha256:0000000000000000000000000000000000000000000000000000000000000000 + [ "$status" -eq 0 ] + [[ "$output" != *"systemctl"* ]] + [[ "$output" != *"frank-archon stopped"* ]] + [[ "$output" != *"podman rm"* ]] + # not vacuous: the podman argv WAS printed, and --image replaced the pin + [[ "$output" == *"podman"* ]] + [[ "$output" == *"example.invalid/radiance@sha256:000"* ]] + [[ "$output" != *"stilldeadcode/radiance"* ]] + [ ! -d "$L" ] +} + +@test "the score dry run is the kldref argv with only --model and the --kld-* pair changed" { + M="$BATS_TEST_TMPDIR/models" + C="$BATS_TEST_TMPDIR/campaign" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" \ + bash "$RUNNER" kldref --dry-run + [ "$status" -eq 0 ] + ref_argv="$(engine_argv "$output")" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" \ + bash "$RUNNER" score "$M/rad/arm-w5nl64.rad" "$C/scores/arm1.json" --dry-run + [ "$status" -eq 0 ] + score_argv="$(engine_argv "$output")" + # The whole value of the campaign's three KL numbers is that they rode this + # one shape: same pin, --tp 2, fp8 KV, step size, --deterministic, no spec. + if [[ "$score_argv" != "$ref_argv" ]]; then + echo "--- kldref engine argv ---"; echo "$ref_argv" + echo "--- score engine argv ---"; echo "$score_argv" + return 1 + fi + flat="$(oneline)" + [[ "$flat" == *"--model /models/rad/arm-w5nl64.rad"* ]] + [[ "$flat" == *"--kld-ref /data/kld-ref --kld-out /data/out/arm1.json"* ]] + # the zero point mounts read-only; the report needs a writable bind because + # --kld-out F also writes F.rows beside it + [[ "$flat" == *":/data/kld-ref:ro "* ]] + [[ "$flat" == *":/data/out "* ]] + [[ "$output" != *"--kld-record"* ]] + [[ "$output" != *"--kld-corpus"* ]] + [[ "$output" != *"--vram-kv-mib"* ]] +} + +@test "score refuses a missing reference, a missing container and an existing report" { + M="$BATS_TEST_TMPDIR/models" + C="$BATS_TEST_TMPDIR/campaign" + L="$BATS_TEST_TMPDIR/logs" + mkdir -p "$M/rad" + : > "$M/rad/arm-w5nl64.rad" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" FRANK_QWEN38_LOG_DIR="$L" \ + bash "$RUNNER" score "$M/rad/arm-w5nl64.rad" "$C/scores/arm1.json" + [ "$status" -eq 2 ] + [[ "$output" == *"$C/kld-ref"* ]] + [[ "$output" == *"run-qwen38-bf16.sh kldref"* ]] + # refused before the log dir, so before anything that could reach systemctl + [ ! -d "$L" ] + + mkdir -p "$C/kld-ref" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" FRANK_QWEN38_LOG_DIR="$L" \ + bash "$RUNNER" score "$M/rad/gone.rad" "$C/scores/arm1.json" + [ "$status" -eq 2 ] + [[ "$output" == *"$M/rad/gone.rad"* ]] + [ ! -d "$L" ] + + mkdir -p "$C/scores" + : > "$C/scores/arm1.json" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" FRANK_QWEN38_LOG_DIR="$L" \ + bash "$RUNNER" score "$M/rad/arm-w5nl64.rad" "$C/scores/arm1.json" + [ "$status" -eq 2 ] + [[ "$output" == *"$C/scores/arm1.json"* ]] + [[ "$output" == *"trash"* ]] + # the trash law: a refusal keeps the old report, it does not overwrite it + [ -f "$C/scores/arm1.json" ] + [ ! -d "$L" ] +} + +@test "the reference and a score pass ride ONE step size, legal on the file tier" { + # The gate's claim is one shape; that shape's ceiling is set by the tightest + # payload it boots, and the served iq4r and every arm are file-tier + # containers, which stream only at <=2048. A reference recorded above that is + # a KL between two shapes, not two weights. + M="$BATS_TEST_TMPDIR/models" + C="$BATS_TEST_TMPDIR/campaign" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" bash "$RUNNER" kldref --dry-run + [ "$status" -eq 0 ] + ref_step="$(flag_value --max-num-batched-tokens <<< "$output")" + [ -n "$ref_step" ] + [ "$ref_step" -le 2048 ] + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" \ + bash "$RUNNER" score "$M/rad/arm-w5nl64.rad" "$C/scores/arm1.json" --dry-run + [ "$status" -eq 0 ] + score_step="$(flag_value --max-num-batched-tokens <<< "$output")" + [ "$ref_step" = "$score_step" ] + # one knob moves both, so the operator can lower it at 2 a.m. without an edit + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" FRANK_QWEN38_MNBT=1024 \ + bash "$RUNNER" kldref --dry-run + [ "$(flag_value --max-num-batched-tokens <<< "$output")" = "1024" ] + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" FRANK_QWEN38_MNBT=1024 \ + bash "$RUNNER" score "$M/rad/arm-w5nl64.rad" "$C/scores/arm1.json" --dry-run + [ "$(flag_value --max-num-batched-tokens <<< "$output")" = "1024" ] + # calibration is exempt: grams are not logits, so the tap keeps the wide step + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_CAMPAIGN="$C" FRANK_QWEN38_MNBT=1024 \ + bash "$RUNNER" calib --dry-run + [ "$(flag_value --max-num-batched-tokens <<< "$output")" = "65536" ] + # a step size that is not a token count is refused, never handed to the engine + run env FRANK_QWEN38_MNBT=broad bash "$RUNNER" kldref --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"FRANK_QWEN38_MNBT"* ]] + [[ "$output" == *"must be a token count"* ]] +} + +@test "kldref refuses a corpus its window cannot hold, before the service is touched" { + M="$BATS_TEST_TMPDIR/models" R="$M/rad" + mkdir -p "$M/tpl" "$R" "$BATS_TEST_TMPDIR/out" + : > "$M/tpl/qwen38-froggeric-v22.5.jinja" + : > "$R/qwen38-bf16.rad" + CORPUS="$BATS_TEST_TMPDIR/long.jsonl" + { + printf '{"prompt":"short one"}\n' + printf '{"prompt":"%s"}\n' "$(head -c 120000 /dev/zero | tr '\0' 'x')" + printf '{"prompt":"short three"}\n' + } > "$CORPUS" + # 32,768 tokens x 3 chars is the pessimistic limit; the 120,002-char line + # that opens record 2 is well past it, so the campaign says no up front. + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_KLD_CORPUS="$CORPUS" \ + bash "$RUNNER" kldref --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"REFUSED"* ]] + [[ "$output" == *"record 2"* ]] + [[ "$output" == *"120013 chars"* ]] + [[ "$output" == *"past the 98304 a 32768-token window"* ]] + [[ "$output" == *"FRANK_QWEN38_MAX_MODEL_LEN"* ]] + # the same corpus is fine once the window is raised past it, and the raised + # window is what the boot is asked for + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_KLD_CORPUS="$CORPUS" \ + FRANK_QWEN38_MAX_MODEL_LEN=65536 FRANK_QWEN38_LOG_DIR="$BATS_TEST_TMPDIR/logs" \ + bash "$RUNNER" kldref --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"corpus fits"* ]] + flat="$(oneline)" + [[ "$flat" == *"--max-model-len 65536"* ]] + # the shipped corpus of three short records fits the shipped window + SHORT="$BATS_TEST_TMPDIR/short.jsonl" + printf '{"prompt":"tiny"}\n{"prompt":"also tiny"}\n' > "$SHORT" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_KLD_CORPUS="$SHORT" \ + FRANK_QWEN38_LOG_DIR="$BATS_TEST_TMPDIR/logs" bash "$RUNNER" kldref --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"corpus fits: longest record 22 chars (number 2)"* ]] + # a real kldref run refuses at the same place: no log dir, no drain, and the + # service would still be up when the script gave up + L="$BATS_TEST_TMPDIR/logs-real" + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_KLD_CORPUS="$CORPUS" \ + FRANK_QWEN38_LOG_DIR="$L" bash "$RUNNER" kldref + [ "$status" -eq 2 ] + [ ! -d "$L" ] + # and it is kldref that reads the corpus: score and calib never consult it + run env FRANK_QWEN38_MODELS="$M" FRANK_QWEN38_KLD_CORPUS="$CORPUS" \ + FRANK_QWEN38_KLD_REF_DIR="$BATS_TEST_TMPDIR/ref" \ + FRANK_QWEN38_LOG_DIR="$BATS_TEST_TMPDIR/logs" \ + bash "$RUNNER" score "$R/qwen38-bf16.rad" "$BATS_TEST_TMPDIR/out/arm.json" --dry-run + [ "$status" -eq 0 ] + [[ "$output" != *"corpus fits"* ]] + [[ "$output" != *"REFUSED"* ]] +}