media-verify reads the WAL; the metadata view is live; duplicates get a page #115

Merged
dank merged 4 commits from fix/media-verify-wal-and-prune-record into main 2026-11-01 01:42:20 -07:00
70 changed files with 1898 additions and 4941 deletions
Showing only changes of commit 14f340370c - Show all commits

No files matched your search

@@ -1,71 +0,0 @@
# Tunables for frank-default-small (Tiel-Coder-35B-A3B UD-Q6_K_XL,
# qwen35moe hybrid MoE, Dankserver, RTX 3090).
#
# Runtime: upstream llama.cpp (latest master, CUDA build with
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86) on RTX 3090
# 24 GB; Ryzen 7 7700X; 62 GB RAM. Replaced the ik_llama.cpp Ornith
# CRACK Q8_0 deployment on 2026-09-01.
#
# Model: peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF, UD-Q6_K_XL quant
# (30 GB). Architecture: qwen35moe — 40 layers, full_attention_interval=4
# (10 attention + 30 SSM/Mamba), 256 experts / 8 used, 2 KV heads × 256
# dim, embedding 2048, rope dimension_count 64, rope freq_base 10 000 000.
# Text-only (no mmproj). Built-in chat template in the GGUF.
#
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
# Non-expert weights (~1.6 GB) fit entirely on the 24 GB card.
#
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per slot
# × 3 parallel) and 10 attention layers, the KV cache is ~2 GB — the
# requested 2 GB VRAM buffer.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
# incident). This box is NVIDIA/CUDA, but the rule keeps the env portable.
# Model file (UD-Q6_K_XL, 30 GB, text-only MoE hybrid).
MODEL_DIR=/home/dank/models/tiel-coder-35b
MODEL_FILE=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf
# Upstream llama.cpp llama-server (CUDA build, latest master).
# Built with -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86
# using the staged CUDA 13.2 toolkit at /home/dank/cuda13-stage.
LLAMA_SERVER=/home/dank/llama.cpp/build/bin/llama-server
ALIAS=frank-default-small
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
# Total context budget across all parallel slots. 200700 / 3 = 66 900
# per slot. The model's context_length is 262 144, so 66 900 is well
# within range.
CTX_SIZE=200700
PARALLEL=3
# Expert offload: all MoE expert weights pinned to CPU RAM.
CPU_MOE=1
# All non-expert layers on GPU (auto-fit will determine the exact count;
# with --cpu-moe the non-expert footprint is ~1.6 GB, leaving ample room).
N_GPU_LAYERS=999
# KV cache: Q8_0 for both K and V. ~2 GB total for 200700 ctx × 3 slots
# × 10 attention layers × (2 KV heads × 256 dim × 1 byte) × 2 (K+V).
CACHE_TYPE_K=q8_0
CACHE_TYPE_V=q8_0
VERBOSITY=2
# Prefill batch sizing.
BATCH_SIZE=4096
UBATCH_SIZE=1024
# Sampling.
TEMPERATURE=1.0
TOP_K=20
TOP_P=0.95
MIN_P=0.0
# Compute threads lowered so other processes keep a core.
THREADS=7
THREADS_HTTP=8
@@ -1,16 +0,0 @@
[Unit]
Description=llama.cpp Tiel-Coder-35B-A3B Q6_K_XL qwen35moe hybrid MoE (dankserver, frank-default-small)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
Environment="FRANK_SMALL_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-small.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-small.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
-126
View File
@@ -1,126 +0,0 @@
# Tunables for frank-dragoon (kingjones777/Qwen3.8-Flash-Next-ROCmFP4-
# STRIX-GGUF on the kingjones30/ROCmFPX llama.cpp runtime, Dankclaw,
# Strix Halo gfx1151, unified memory).
#
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
# qwen4exp-on-rocmfpx-d3ca537 patch applied — the model card's pinned
# runtime: ROCmFP4 tensor types AND the qwen4exp architecture in one
# tree; stock llama.cpp and charlie's fork alone cannot load this GGUF).
# Built per the card recipe:
# cmake -B build -DGGML_HIP=ON -DGPU_TARGETS=gfx1151 -DGGML_NATIVE=ON -DCMAKE_BUILD_TYPE=Release
# at /home/dank/GitPackages/kingjones-ROCmFPX/build.
# Replaced the agentionai ROCmFP4-FAST imatrix deployment (charlie's
# build-qwen38 runtime) on 2026-08-30.
#
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
# at layer 1), Q4_0_ROCMFP4_STRIX (file_type 105), 113.47 GiB single
# file. STRIX is the top quality tier: PLE table at Q8_0 (50.66 GiB),
# token embeddings + lm head at Q6_K. The PLE table NEVER enters GPU
# memory — it streams from the file through the OS page cache. Two hard
# card constraints:
# 1. mmap MUST stay enabled (default) — --no-mmap forces the 50.66 GiB
# table into anonymous memory and the cgroup killer takes the
# process with nothing in the server log (only dmesg).
# 2. Do NOT pin the PLE tensor to CPU with -ot — measured gen fell
# ~23 -> 13.4 tok/s; the kernel streams it better.
# NOTE: the card's own build_attn_qsa still calls ggml_fill on the
# F16-cast kq mask (the path that aborted our charlie build with
# --flash-attn on before scripts/patches/qwen4exp-qsa-f32-mask.patch).
# If a clean kingjones-tree build aborts graph build with
# GGML_ASSERT(a-type == GGML_TYPE_F32), port that fix (F32 base mask +
# cast at the attention call) into this tree and rebuild.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as
# env vars.
# kingjones30/ROCmFPX runtime binary (card recipe build).
LLAMA_BIN=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server
# Model package from
# https://huggingface.co/kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF
# Layout: single unsharded GGUF, text-only (the repo ships no mmproj;
# the vision tower below is borrowed from the agentionai FAST package —
# same base model, f16 — kept only if it loads clean, drop
# MMPROJ_PATH if it does not).
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX
MODEL_FILE=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf
MMPROJ_PATH=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The
# Sharp template drives thinking on by default (medium effort); the server
# emits reasoning in DeepSeek format so clients receive reasoning_content
# separately instead of literal thinking tags in content.
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true,"preserve_reasoning":true}'
# Alias + network.
ALIAS=frank-dragoon
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
# Serving: 401408-token context, TWO slots (parallel 2), batch 4096 /
# ubatch 512, 16+16 threads, f16 KV, 16 context checkpoints, 8 GiB
# prompt cache. --fit off: auto-fit segfaults on this model's
# hyper-connection/indexer config; -ngl 999 puts every layer on the
# SVM-backed GPU pool. ubatch 512 matches the card's re-measurement
# (-ub 1024/2048/4096 showed no effect; -t 32 vs 16 is noise).
#
# CTX_SIZE=401408 / PARALLEL=2: carried over from the FAST deployment
# where it loaded clean (~74 s, health 200). The card measures the full
# 262144 window at ~71.7 GiB GTT at load on the same 128 GB board; QSA
# caps KV growth so the extra window is cheap. KV footprint scales
# with the 12 attention layers only. Verify load on first STRIX boot.
CTX_SIZE=401408
PARALLEL=2
BATCH_SIZE=4096
UBATCH_SIZE=512
NUM_THREADS=16
THREAD_BATCH=16
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
CTX_CHECKPOINTS=16
CACHE_RAM=8192
SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state
SPLIT_MODE=none
FIT=off
NGL=999
FLASH_ATTN=on
# Sampling (card instruct table, non-thinking).
TEMPERATURE=0.7
TOP_K=20
TOP_P=0.8
MIN_P=0.0
PRESENCE_PENALTY=1.5
# Reasoning on (Sharp template drives it; preserve_thinking + preserve_reasoning
# keep the reasoning trail in the conversation). DeepSeek format projects the
# reasoning into the API's reasoning_content field instead of literal thinking
# tags.
REASONING=on
REASONING_FORMAT=deepseek
# Strix Halo unified memory + native gfx1151.
HSA_OVERRIDE_GFX_VERSION=11.5.1
VERBOSITY=4
# Speculative decoding: disabled. The card's Usage block ships no spec
# flags. The Flash-Next MTP heads do not load on this loader (requires
# per-layer blk.N.hc_attn_norm; every head carries only blk.48 —
# verified 3x on build-qwen38). DRAFT_MODEL_* point at the retired
# agentionai FAST head and are unused while SPEC_TYPE=none.
DRAFT_MODEL_DIR=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST
DRAFT_MODEL_FILE=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf
SPEC_TYPE=none
SPEC_DRAFT_N_MAX=3
SPEC_DRAFT_N_MIN=0
SPEC_DRAFT_P_MIN=0.75
SPEC_DRAFT_NGL=999
SPEC_DRAFT_DEVICE=ROCm0
SPEC_DRAFT_TYPE_K=f16
SPEC_DRAFT_TYPE_V=f16
@@ -1,16 +0,0 @@
[Unit]
Description=ROCmFPX (kingjones30 fork) llama.cpp Qwen3.8-Flash-Next ROCmFP4 STRIX 177B MoE (dankclaw, frank-dragoon)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
-23
View File
@@ -1,23 +0,0 @@
# Tunables for frank-npu (FastFlowLM NPU endpoint, Dankclaw)
# Runtime: FLM 1.0.1 (/usr/bin/flm) on the XDNA2 NPU (50 TOPS). Boot tag is
# nuextract3:4b (NuExtract3-4B-NPU2, Qwen3.5-4B skeleton, structured-extraction
# model); serve routes every request by the exact FLM registry tag in the
# request's model field, so any registered tag (gemma4-it:e4b, qwythos:9b, ...)
# is reachable on this port regardless of the boot tag. The NPU is a
# single-lane device: generations serialize FIFO, and the first request after a
# model swap pays the ~6-11 s load cost. The NPU draws from the unified RAM
# pool shared with frank-dragoon (iGPU).
FLM_MODEL=nuextract3:4b
FLM_HOST=0.0.0.0
FLM_PORT=7771
# turbo = max NPU power mode (xrt-smi configure --pmode turbo at serve start).
# No other tenant uses the NPU on this host, so the top mode costs nothing to
# neighbours; drop to `performance` if iGPU power-budget contention ever shows.
FLM_PMODE=turbo
# FLM_ASR=1 additionally loads Whisper-V3-Turbo-NPU2 (+0.62 GB) and exposes
# /v1/audio/transcriptions. NuExtract3 answers text-only; audio stays off.
FLM_ASR=0
# -1 = no --ctx-len flag: every pack loads with its own trained context
# (nuextract3:4b -> 32768, gemma4-it:e4b -> 131072). A global override would
# over-allocate KV for the small packs.
FLM_CTX_LEN=-1
-17
View File
@@ -1,17 +0,0 @@
[Unit]
Description=FastFlowLM NPU sight+sound inference (frank-npu) on Dankclaw
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
Environment="FRANK_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-npu.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-npu.sh
LimitMEMLOCK=infinity
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,180 +0,0 @@
#!/usr/bin/env bash
# Serve kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF as
# frank-dragoon via the kingjones30/ROCmFPX llama.cpp runtime on
# Dankclaw (Strix Halo gfx1151, unified memory).
#
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
# model card's qwen4exp-on-rocmfpx patch) built at
# /home/dank/GitPackages/kingjones-ROCmFPX/build (HIP, gfx1151,
# ROCm 7.2.4, card recipe). The ROCmFP4 quant types plus the qwen4exp
# architecture must be in one tree; stock llama.cpp cannot load this
# GGUF.
#
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
# at layer 1) quantized Q4_0_ROCMFP4_STRIX, 113.47 GiB single file. The
# 50.66 GiB PLE n-gram table stays OUT of GPU memory and streams via
# mmap from the OS page cache — never add --no-mmap or -ot for it.
#
# Profile: card sampling table (temp 0.7, top_p 0.80, top_k 20, min_p 0.0,
# presence 1.5 — non-thinking instruct) mixed with the validated serving
# profile: ctx 401408 x 2 slots, b 4096 / ub 512, t 16 / tb 16, f16 KV,
# fa on (if the graph build aborts on the QSA F16 mask, see the note in
# the env file: port scripts/patches/qwen4exp-qsa-f32-mask.patch into
# this tree and rebuild), cont-batching, prompt cache + 8 GiB cache-ram
# + idle-slot cache, 16 ctx checkpoints, --fit off (auto-fit segfaults
# on qwen4exp hyper-connection config). Speculative decoding off: the
# Flash-Next MTP heads do not load on this loader; the card ships no
# spec flags.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -------------------
: "${LLAMA_BIN:=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}"
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}"
: "${MMPROJ_PATH:=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf}"
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
: "${CHAT_TEMPLATE_KWARGS:={}}"
: "${ALIAS:=frank-dragoon}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
: "${CTX_SIZE:=401408}"
: "${PARALLEL:=2}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=512}"
: "${NUM_THREADS:=16}"
: "${THREAD_BATCH:=16}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${CTX_CHECKPOINTS:=16}"
: "${CACHE_RAM:=8192}"
: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}"
: "${SPLIT_MODE:=none}"
: "${FIT:=off}"
: "${NGL:=999}"
: "${FLASH_ATTN:=on}"
: "${TEMPERATURE:=0.7}"
: "${TOP_K:=20}"
: "${TOP_P:=0.8}"
: "${MIN_P:=0.0}"
: "${PRESENCE_PENALTY:=1.5}"
: "${REASONING:=on}"
: "${REASONING_FORMAT:=deepseek}"
: "${HSA_OVERRIDE_GFX_VERSION:=11.5.1}"
: "${SPEC_TYPE:=none}"
: "${DRAFT_MODEL_DIR:=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST}"
: "${DRAFT_MODEL_FILE:=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf}"
: "${SPEC_DRAFT_N_MAX:=3}"
: "${SPEC_DRAFT_N_MIN:=0}"
: "${SPEC_DRAFT_P_MIN:=0.75}"
: "${SPEC_DRAFT_NGL:=999}"
: "${SPEC_DRAFT_DEVICE:=ROCm0}"
: "${SPEC_DRAFT_TYPE_K:=f16}"
: "${SPEC_DRAFT_TYPE_V:=f16}"
MODEL="$MODEL_DIR/$MODEL_FILE"
for f in "$LLAMA_BIN" "$MODEL" "$CHAT_TEMPLATE_FILE"; do
if [[ ! -r "$f" ]]; then
echo "missing or unreadable required artifact: $f" >&2
exit 1
fi
done
if [[ ! -x "$LLAMA_BIN" ]]; then
echo "missing executable runtime: $LLAMA_BIN" >&2
exit 1
fi
# --- speculative decoding (MTP draft head) ---------------------------------
# The MTP draft head is a separate GGUF sidecar loaded via --spec-draft-model
# (alias --model-draft). ngram-only specs do not need a draft file.
# Set SPEC_TYPE=none in the env to disable speculative decoding entirely.
SPEC_ARGS=()
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
SPEC_ARGS=(
--spec-type "$SPEC_TYPE"
--spec-draft-n-max "$SPEC_DRAFT_N_MAX"
--spec-draft-n-min "$SPEC_DRAFT_N_MIN"
--spec-draft-p-min "$SPEC_DRAFT_P_MIN"
)
if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then
DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE"
if [[ ! -r "$DRAFT_MODEL" ]]; then
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
exit 1
fi
SPEC_ARGS+=(
--spec-draft-model "$DRAFT_MODEL"
--spec-draft-ngl "$SPEC_DRAFT_NGL"
--spec-draft-device "$SPEC_DRAFT_DEVICE"
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
)
fi
if [[ -n "${SPEC_DRAFT_P_SPLIT:-}" ]]; then
SPEC_ARGS+=(--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT")
fi
fi
# The STRIX repo ships no mmproj; this is the agentionai FAST package's
# f16 vision tower for the same base model. Passed only when readable.
MMPROJ_ARGS=()
if [[ -n "${MMPROJ_PATH:-}" && -r "${MMPROJ_PATH:-}" ]]; then
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
fi
SERVER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$SLOT_SAVE_PATH"
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
exec /usr/bin/env \
ROCM_PATH="$ROCM_PATH" \
HIP_VISIBLE_DEVICES=0 \
LD_LIBRARY_PATH="$LIBRARY_PATH" \
HSA_OVERRIDE_GFX_VERSION="$HSA_OVERRIDE_GFX_VERSION" \
"$LLAMA_BIN" \
--model "$MODEL" \
--alias "$ALIAS" \
--chat-template-file "$CHAT_TEMPLATE_FILE" \
--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
--jinja \
--device ROCm0 \
--gpu-layers "$NGL" \
--split-mode "$SPLIT_MODE" \
--parallel "$PARALLEL" \
--ctx-size "$CTX_SIZE" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--fit "$FIT" \
--flash-attn "$FLASH_ATTN" \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--threads "$NUM_THREADS" \
--threads-batch "$THREAD_BATCH" \
--ctx-checkpoints "$CTX_CHECKPOINTS" \
--cache-ram "$CACHE_RAM" \
--slot-save-path "$SLOT_SAVE_PATH" \
--cache-prompt \
--cache-idle-slots \
--metrics \
--temp "$TEMPERATURE" \
--top-p "$TOP_P" \
--top-k "$TOP_K" \
--min-p "$MIN_P" \
--presence-penalty "$PRESENCE_PENALTY" \
--reasoning "$REASONING" \
--reasoning-format "$REASONING_FORMAT" \
--reasoning-budget -1 \
"${MMPROJ_ARGS[@]}" \
"${SPEC_ARGS[@]}"
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Serve the FastFlowLM (FLM) sight+sound endpoint — frank-npu.
#
# Runs `flm serve <tag>` on the XDNA2 NPU (50 TOPS, Dankclaw). The NPU is a
# single-lane device: generations serialize in FIFO order, so this is a
# background/triage endpoint, not an interactive multi-user chat. The NPU
# draws from the unified RAM pool shared with frank-dragoon (the iGPU
# llama.cpp service); see serverAppConfigs-docs/LLM/frank-npu.md
# for the memory budget and the model-selection tradeoff.
#
# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity.
# NOTE: an unknown model tag does NOT 404 — /v1/chat/completions answers HTTP 200 with a
# flat {"error":"Failed to load <tag> model!"} body and no choices; /v1/completions returns a
# nested OpenAI-shaped server_error (code 500) also under HTTP 200. Client model IDs must be the exact FLM registry
# tag (e.g. gemma4-it:e4b), never a service alias. Custom tags only resolve
# when FLM_CONFIG_PATH points at the user registry that carries them and their
# kernels are symlinked into /home/dank/.config/flm/xclbins/<PackName>.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_NPU_ENV_FILE:-$SCRIPT_DIR/frank-npu.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -------------------
: "${FLM_BIN:=/usr/bin/flm}"
: "${FLM_MODEL:=gemma4-it:e4b}"
: "${FLM_HOST:=0.0.0.0}"
: "${FLM_PORT:=7771}"
: "${FLM_PMODE:=turbo}"
: "${FLM_ASR:=0}"
: "${FLM_CTX_LEN:=131072}"
# The user registry is a superset of the system registry (it carries the
# custom tags too), and its model_path resolves relative to the registry file,
# so exports keep a tag swap in the env file from needing script edits.
export FLM_CONFIG_PATH="${FLM_CONFIG_PATH:-/home/dank/.config/flm/model_list.json}"
export FLM_XCLBIN_PATH="${FLM_XCLBIN_PATH:-/home/dank/.config/flm/xclbins}"
if [[ ! -x "$FLM_BIN" ]]; then
echo "missing flm binary: $FLM_BIN" >&2
exit 1
fi
# --- optional flags ----------------------------------------------------------
OPT_ARGS=()
if [[ "${FLM_ASR}" != "0" ]]; then
OPT_ARGS+=(--asr "${FLM_ASR}")
fi
if [[ "${FLM_CTX_LEN}" != "-1" ]]; then
OPT_ARGS+=(--ctx-len "${FLM_CTX_LEN}")
fi
exec "$FLM_BIN" serve "$FLM_MODEL" \
--host "$FLM_HOST" \
-p "$FLM_PORT" \
--pmode "$FLM_PMODE" \
"${OPT_ARGS[@]}"
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
# Serve peculiar-ragdoll/Tiel-Coder-35B-A3B (UD-Q6_K_XL) as
# frank-default-small via stock llama.cpp on Dankserver (RTX 3090).
#
# Runtime: upstream llama.cpp (latest master, CUDA build with
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86).
# The model is a 35B-total / 8-active MoE hybrid (qwen35moe arch) with
# SSM/Mamba layers (full_attention_interval=4: 10 attention + 30 SSM
# layers out of 40). 256 experts, 8 used per token.
#
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
# Non-expert weights (attention, SSM, shared expert, embeddings, output)
# are ~1.6 GB and fit entirely on the 24 GB RTX 3090.
#
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per
# slot × 3 parallel) and 10 attention layers (2 KV heads × 256 dim),
# the KV cache is ~2 GB — the requested 2 GB VRAM buffer.
#
# Model is loaded with mmap (default). The 30 GB Q6_K_XL file is
# memory-mapped; only accessed pages enter RAM.
#
# Tunables come from frank-default-small.env unless FRANK_SMALL_ENV_FILE
# overrides that path.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_SMALL_ENV_FILE:-$SCRIPT_DIR/frank-default-small.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -----------------
: "${MODEL_DIR:=/home/dank/models/tiel-coder-35b}"
: "${MODEL_FILE:=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf}"
: "${LLAMA_SERVER:=/home/dank/llama.cpp/build/bin/llama-server}"
: "${ALIAS:=frank-default-small}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
: "${CTX_SIZE:=200700}"
: "${PARALLEL:=3}"
: "${CACHE_TYPE_K:=q8_0}"
: "${CACHE_TYPE_V:=q8_0}"
: "${N_GPU_LAYERS:=999}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=1024}"
: "${VERBOSITY:=2}"
: "${CPU_MOE:=1}"
: "${TEMPERATURE:=1.0}"
: "${TOP_K:=20}"
: "${TOP_P:=0.95}"
: "${MIN_P:=0.0}"
: "${THREADS:=7}"
: "${THREADS_HTTP:=8}"
MODEL="$MODEL_DIR/$MODEL_FILE"
if [[ ! -f "$MODEL" ]]; then
echo "missing required file: $MODEL" >&2
exit 1
fi
if [[ ! -x "$LLAMA_SERVER" ]]; then
echo "missing llama-server binary: $LLAMA_SERVER" >&2
exit 1
fi
exec "$LLAMA_SERVER" \
--model "$MODEL" \
$([ "$CPU_MOE" = "1" ] && printf -- "--cpu-moe") \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--ctx-size "$CTX_SIZE" \
--parallel "$PARALLEL" \
--gpu-layers "$N_GPU_LAYERS" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--temp "$TEMPERATURE" \
--top-k "$TOP_K" \
--top-p "$TOP_P" \
--min-p "$MIN_P" \
--threads "$THREADS" \
--threads-http "$THREADS_HTTP" \
--alias "$ALIAS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT"
+10 -2
View File
@@ -156,6 +156,15 @@ Unit requirements:
(`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by
the quadlet test seam. The host-side unit is generated state; the repo file
is the source of truth.
- **Bind-source gate** — a unit whose `Volume=` source is a mount this host owns
(the entropy array, the metadata view) carries
`ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh <source>`.
Measured 2026-10-31: in a **rootless** unit `RequiresMountsFor=` is inert (the
user manager cannot order against the system's mount unit), and
`ConditionPathIsMountPoint=` is fail-*dead* — an unmet condition logs `skipped,
unmet condition check` with `NRestarts=0` and no start attempt ever follows.
Bind the array before it is mounted and the container sees the empty pre-mount
directory as truth; a media scan then deletes the library it cannot see.
Before declaring a service deployed: `systemctl --user is-enabled <name>` →
`enabled`, `is-active` → `active`, and the container answers its health check.
@@ -852,7 +861,6 @@ history.
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. |
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
| Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. |
| Duplicates in the library | **Prune into the hold** — byte-identical copies and smaller copies of the *same work* go to `.AGENTTRASH/`; a different cut is never touched. Screen by size, confirm by hash before moving, purge only on Frank's word. See [[Media Duplicate Prune]]. |
| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Deployed and live on Dankserver since 2026-11-01: both quadlets bind the view, not the raw array — see [[Media Metadata View]].** |
| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Deployed on Dankserver 2026-10-31, re-verified 2026-11-01: both quadlets bind the view, not the raw array. A restart for any reason discards that server's in-flight library scan, so re-trigger the refresh — see [[Media Metadata View]].** |
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
@@ -1,156 +0,0 @@
#!/usr/bin/env python3
"""HTTP front door for the frank-medium single-GPU replicas.
Why two replicas instead of TP=2: one vLLM process spanning both R9700s needs
RCCL collectives, and RCCL's kernels require PCIe atomics on every
participating GPU. The second card sits behind chipset bridges advertising
`AtomicOpsCap: Routing-`, which no software can enable (see the runbook), so
no collective can launch on it. Two single-GPU replicas use both cards; this
service keeps the fleet contract of one endpoint on :7770, streaming included.
Routing decisions live in routing.py — spread single-turn requests, pin
continuations to the replica whose prefix cache holds their context. This file
only moves bytes: parse enough of the request to route it, forward it verbatim,
stream the answer back, and report replica health on /health.
"""
from __future__ import annotations
import asyncio
import os
from contextlib import suppress
from aiohttp import ClientSession, ClientTimeout, web
import routing
PORT = int(os.environ.get("PORT", "7770"))
HEALTH_TTL = float(os.environ.get("HEALTH_TTL", "10"))
# Hop-by-hop headers must not be forwarded (RFC 9110 §7.6.1) — forwarding
# transfer-encoding or content-length of a rewritten body breaks clients.
HOP_BY_HOP = {
"host", "content-length", "transfer-encoding", "connection",
"keep-alive", "proxy-authenticate", "proxy-authorization", "te",
"trailers", "upgrade",
}
UPSTREAMS = routing.UPSTREAMS
# Replica health, refreshed by the watcher. Module state rather than aiohttp app
# state: the application is frozen once the site is up.
_UP: list[bool] = [True] * len(UPSTREAMS)
async def health(session: ClientSession) -> web.Response:
results = await asyncio.gather(
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
return_exceptions=True,
)
lines, up = [], 0
for url, res in zip(UPSTREAMS, results):
if isinstance(res, Exception) or res.status != 200:
lines.append(f"{url} DOWN")
else:
up += 1
lines.append(f"{url} UP")
return web.Response(status=200 if up else 503, text="\n".join(lines) + "\n")
async def watch_health() -> None:
"""Refresh the replica up/down table so routing never targets a dead one.
One HTTP request per replica per interval, in exchange for never handing a
client a 502 after a restart.
"""
global _UP
session = ClientSession()
try:
while True:
res = await asyncio.gather(
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
return_exceptions=True,
)
_UP = [not isinstance(r, Exception) and r.status == 200 for r in res]
await asyncio.sleep(HEALTH_TTL)
finally:
await session.close()
async def proxy(request: web.Request) -> web.StreamResponse:
session: ClientSession = request.app["session"]
if request.path == "/health":
return await health(session)
body = await request.read()
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP}
# Fail over before anything is written to the client. A replica that
# restarts is invisible to the health watcher for up to HEALTH_TTL, and a
# deploy restarts both replicas — without this retry a 10 s window of
# requests would 502 on a service that has a live card the whole time.
# vLLM's OpenAI endpoints are stateless per request, so re-sending a
# request that never reached an engine is safe; a request that failed
# mid-stream is not retried (that path is below).
tried: set[int] = set()
upstream = None
last_error = "no healthy frank-medium replica"
while len(tried) < len(UPSTREAMS):
idx = routing.route(body, request.remote, _UP)
if idx < 0 or idx in tried:
break
tried.add(idx)
target = UPSTREAMS[idx] + request.path_qs
try:
upstream = await session.request(
request.method, target, headers=headers, data=body or None,
timeout=ClientTimeout(total=None, sock_connect=10, sock_read=3600),
)
break
except Exception as exc:
# Demote at once: the watcher will confirm, but every request until
# then should skip this replica rather than pay the same timeout.
# Logged because a silent failover hides a card that is down.
_UP[idx] = False
last_error = f"{target} unreachable: {exc}"
print(f"proxy: failover — {target} unreachable ({exc}); retrying "
f"another replica", flush=True)
if upstream is None:
# 503 when no replica was even reachable through the routing table,
# 502 when replicas were tried and all of them failed.
status = 502 if tried else 503
return web.Response(status=status, text=f"proxy: {last_error}\n")
out = web.StreamResponse(status=upstream.status, reason=upstream.reason)
for k, v in upstream.headers.items():
if k.lower() not in HOP_BY_HOP:
out.headers[k] = v
await out.prepare(request)
try:
async for chunk in upstream.content.iter_any():
await out.write(chunk)
except Exception:
# The client went away mid-stream (an aborted generation). Nothing to
# report: the engine sees its own cancellation.
pass
finally:
upstream.release()
with suppress(Exception):
await out.write_eof()
return out
async def main() -> None:
app = web.Application(client_max_size=1024 ** 3)
app["session"] = ClientSession()
app.router.add_route("*", "/{tail:.*}", proxy)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "0.0.0.0", PORT).start()
print(f"proxy: :{PORT} -> {', '.join(UPSTREAMS)} routing={routing.ROUTING} "
f"key={routing.AFFINITY_BYTES}B known_max={routing.KNOWN_MAX}", flush=True)
watcher = asyncio.create_task(watch_health())
with suppress(asyncio.CancelledError):
await asyncio.gather(watcher)
if __name__ == "__main__":
with suppress(KeyboardInterrupt):
asyncio.run(main())
@@ -1,98 +0,0 @@
# Quadlet unit: frank-medium replica A (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-a.container
# Unit name: frank-medium-a.service (container name: frank-medium-a)
#
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
# tensor-parallel process across both R9700s (see frank-medium.container, kept
# as the target). That shape cannot run on this host: RCCL's collective kernels
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
# atomics. Result: every collective that touches GPU 2 dies at launch.
# Two independent single-GPU replicas use both cards instead; the proxy unit
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
#
# SHAPE. vLLM binds 0.0.0.0:7771 on the host network directly; no PublishPort.
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
# this shape instead of using the 2x7551-32624 pin, which is only valid at
# TP=2/262144.
#
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=0 (the first R9700). The
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
# first and HIP indexes into the filtered list, so a single-valued pair
# (ROCR=1,HIP=1) leaves an empty device set.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
[Unit]
Description=frank-medium replica A — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #1
After=network-online.target
[Container]
ContainerName=frank-medium-a
Image=localhost/radiance-vllm:0.9.3
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
# The upstream launcher's exact podman posture (rootless).
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
Environment=HIP_VISIBLE_DEVICES=0
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
Environment=SERVED_NAMES=frank-medium
Environment=PORT=7771
Environment=TP=1
Environment=MAXSEQS=4
Environment=CHUNK=8192
Environment=MAXLEN=114688
Environment=GPU_UTIL=0.98
Environment=KV_MEM=0
Environment=SPEC_METHOD=dflash
Environment=SPEC=7
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
Volume=/home/dank/models:/models
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
# Compile cache. Shared with the other replica and with the TP=2 target: the
# entries are content-addressed, so shapes simply add subdirectories.
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
# The pinned radiance clone: patches applied at every container start.
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
# Entrypoint + healthcheck from this checkout, mounted read-only.
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
# The entrypoint must be the container command; the image's own entrypoint
# (which the patched script execs at the end) must not run unpatched.
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
# 200 only once the engine serves; during first-start compilation the
# container is legitimately unhealthy. HealthCmd is split on spaces into an
# argv array by the podman 5.x v1 generator, hence the file-based probe.
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=1800s
Pull=never
[Service]
# First start compiles Triton/inductor kernels (several minutes) before the
# engine loads ~20 GiB of weights.
TimeoutStartSec=1800
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -1,98 +0,0 @@
# Quadlet unit: frank-medium replica B (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-b.container
# Unit name: frank-medium-b.service (container name: frank-medium-a)
#
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
# tensor-parallel process across both R9700s (see frank-medium.container, kept
# as the target). That shape cannot run on this host: RCCL's collective kernels
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
# atomics. Result: every collective that touches GPU 2 dies at launch.
# Two independent single-GPU replicas use both cards instead; the proxy unit
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
#
# SHAPE. vLLM binds 0.0.0.0:7772 on the host network directly; no PublishPort.
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
# this shape instead of using the 2x7551-32624 pin, which is only valid at
# TP=2/262144.
#
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=1 (the second R9700). The
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
# first and HIP indexes into the filtered list, so a single-valued pair
# (ROCR=1,HIP=1) leaves an empty device set.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
[Unit]
Description=frank-medium replica B — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #2
After=network-online.target
[Container]
ContainerName=frank-medium-b
Image=localhost/radiance-vllm:0.9.3
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
# The upstream launcher's exact podman posture (rootless).
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
Environment=HIP_VISIBLE_DEVICES=1
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
Environment=SERVED_NAMES=frank-medium
Environment=PORT=7772
Environment=TP=1
Environment=MAXSEQS=4
Environment=CHUNK=8192
Environment=MAXLEN=114688
Environment=GPU_UTIL=0.98
Environment=KV_MEM=0
Environment=SPEC_METHOD=dflash
Environment=SPEC=7
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
Volume=/home/dank/models:/models
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
# Compile cache. Shared with the other replica and with the TP=2 target: the
# entries are content-addressed, so shapes simply add subdirectories.
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
# The pinned radiance clone: patches applied at every container start.
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
# Entrypoint + healthcheck from this checkout, mounted read-only.
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
# The entrypoint must be the container command; the image's own entrypoint
# (which the patched script execs at the end) must not run unpatched.
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
# 200 only once the engine serves; during first-start compilation the
# container is legitimately unhealthy. HealthCmd is split on spaces into an
# argv array by the podman 5.x v1 generator, hence the file-based probe.
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=1800s
Pull=never
[Service]
# First start compiles Triton/inductor kernels (several minutes) before the
# engine loads ~20 GiB of weights.
TimeoutStartSec=1800
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -1,61 +0,0 @@
# Quadlet unit: frank-medium proxy (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-proxy.container
# Unit name: frank-medium-proxy.service (container name: frank-medium-proxy)
#
# WHAT IT IS. A prefix-affinity reverse proxy in front of the two single-GPU
# frank-medium replicas (:7771 on R9700 #1, :7772 on R9700 #2). It preserves
# the fleet contract: one endpoint on host port 7770 answering as the served
# model name frank-medium, OpenAI /v1, streaming included.
#
# WHY IT EXISTS. TP=2 — one vLLM process spanning both cards — is impossible
# on this host (see frank-medium-a.container and the runbook: RCCL's kernels
# need PCIe atomics, and the second card's chipset path advertises
# AtomicOpsCap: Routing-). Two replicas are how both cards get used.
#
# ROUTING. Requests are keyed on a hash of their leading 4 KiB of prompt text,
# so a conversation family — an agent loop re-sending the same system prompt,
# tool list and file context every turn — keeps landing on the same replica,
# where --enable-prefix-caching has that prefix warm. Plain round-robin threw
# half of those cache hits away. A replica that is down is skipped, and
# ROUTING=roundrobin restores the old behaviour for A/B measurement. /health
# answers 200 while at least one replica is up and names the ones that are
# down. The proxy runs on host networking so it can bind 7770 and reach the
# replicas on loopback.
#
# The script is a few dozen lines of aiohttp, mounted read-only out of
# /home/dank/serverAppConfigs (deploy.sh pulls that checkout before dispatch).
# It reuses the same image as the replicas: aiohttp is already there and no
# extra image has to be pulled on this host.
[Unit]
Description=frank-medium proxy — prefix-affinity in front of the two single-GPU replicas
After=network-online.target
Wants=frank-medium-a.service frank-medium-b.service
After=frank-medium-a.service frank-medium-b.service
[Container]
ContainerName=frank-medium-proxy
Image=localhost/radiance-vllm:0.9.3
PodmanArgs=--network=host
Environment=PORT=7770
Environment=UPSTREAMS=http://127.0.0.1:7771,http://127.0.0.1:7772
# The proxy is two files: routing.py holds the replica-selection policy, the
# server holds the HTTP plumbing. Both are mounted because the server imports
# the policy by name from its own directory (/usr/local/bin is sys.path[0]).
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/routing.py:/usr/local/bin/routing.py:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/affinity-proxy.py:/usr/local/bin/affinity-proxy.py:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
PodmanArgs=--entrypoint /usr/local/bin/affinity-proxy.py
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=60s
Pull=never
[Service]
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -1,129 +0,0 @@
#!/usr/bin/env python3
"""Routing policy for the frank-medium proxy: which replica serves a request.
Kept apart from the HTTP plumbing in affinity-proxy.py so the decisions can be
tested without aiohttp, a network or a GPU — the first version of these rules
shipped with a bug that only appeared on a continuation request, and a test
that loads this module catches that class of mistake directly.
Two cards, two behaviours, and the workload decides which is right:
* A **single-turn** request is one member of a batch (a benchmark run, a burst
of parallel subagents). Spreading those over both replicas lets both cards
prefill at once; measured on this host, a 16-request burst of 15k-token
prompts finishes in 18.6 s spread versus 30.1 s pinned.
* A **continuation** re-sends a prefix the replica has already prefilled. Pinned
to that replica the turn costs a cache lookup; spread, half the turns pay a
cold prefill again (measured: 1096 ms versus 2676 ms mean warm-turn TTFT on
90k-token contexts).
ROUTING picks the strategy; the default `adaptive` applies both rules above.
`affinity` and `roundrobin` exist so the choice can be re-measured, and are not
production settings.
"""
from __future__ import annotations
import collections
import hashlib
import itertools
import json
import os
UPSTREAMS = [u.strip() for u in os.environ.get(
"UPSTREAMS", "http://127.0.0.1:7771,http://127.0.0.1:7772").split(",") if u.strip()]
ROUTING = os.environ.get("ROUTING", "adaptive").strip().lower()
# Leading bytes of prompt text that identify a conversation's shared prefix.
# 4 KiB covers a system prompt plus the start of the first user turn, which is
# where agent loops keep the text they resend every turn.
AFFINITY_BYTES = int(os.environ.get("AFFINITY_BYTES", "4096"))
# Prefixes remembered for pinning. Bounded so a long-lived proxy cannot grow
# without limit; 4096 conversations is far beyond what this fleet runs at once.
KNOWN_MAX = int(os.environ.get("KNOWN_MAX", "4096"))
_counter = itertools.count()
# digest -> replica index, insertion-ordered for O(1) LRU eviction. A prefix is
# remembered as soon as any request carries it; only a continuation is pinned
# there, because only a continuation can profit from the cache entry.
_known: "collections.OrderedDict[bytes, int]" = collections.OrderedDict()
def affinity_key(body: bytes, remote: str | None) -> tuple[bytes, bool]:
"""Key a request by its shared prefix, and report whether it continues one.
Chat and completions bodies both carry their prompt text in JSON, so the
leading bytes of that text are the prefix; a body we cannot parse (or an
endpoint with no prompt, e.g. /v1/models) falls back to the client address,
which still beats nothing for a single-client loop.
"""
fallback = (remote or "anonymous").encode()
try:
payload = json.loads(body)
except Exception:
return fallback, False
text = ""
continuation = False
if isinstance(payload, dict):
if isinstance(payload.get("prompt"), str):
text = payload["prompt"]
elif isinstance(payload.get("messages"), list):
continuation = len(payload["messages"]) > 1
parts = []
for msg in payload["messages"]:
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list): # multimodal content blocks
parts.extend(
blk.get("text", "") for blk in content
if isinstance(blk, dict) and isinstance(blk.get("text"), str)
)
text = "\n".join(parts)
if not text:
return fallback, continuation
return text[:AFFINITY_BYTES].encode("utf-8", "replace"), continuation
def pick(key: bytes, continuation: bool, up: list[bool]) -> int:
"""Replica for a request, or -1 when none is available.
Down replicas are never selected — a rollout must not send traffic to an
engine that is still compiling its graphs — and a pin whose replica is down
is re-established on a live one rather than kept pointing at a corpse.
"""
digest = hashlib.sha1(key).digest()
if ROUTING == "roundrobin":
start = next(_counter) % len(UPSTREAMS)
elif ROUTING == "adaptive":
entry = _known.get(digest)
if continuation and entry is not None and up[entry]:
_known.move_to_end(digest)
return entry
# Anything that is not a continuation spreads by counter — including a
# batch of requests that happen to share one prefix. Hashing the prefix
# would send every member of such a batch to whichever card the hash
# names (measured: 29.8 s for a 16-request burst, against 18.6 s when
# spread). The counter is only advanced by these requests, so batch
# members alternate across the cards.
start = next(_counter) % len(UPSTREAMS)
else: # affinity
start = int.from_bytes(digest[:8], "big") % len(UPSTREAMS)
for offset in range(len(UPSTREAMS)):
idx = (start + offset) % len(UPSTREAMS)
if up[idx]:
if ROUTING == "adaptive":
_known[digest] = idx
_known.move_to_end(digest)
if len(_known) > KNOWN_MAX:
_known.popitem(last=False)
return idx
return -1
def route(body: bytes, remote: str | None, up: list[bool]) -> int:
"""Convenience wrapper: decode the request body and choose a replica."""
return pick(*affinity_key(body, remote), up)
@@ -36,6 +36,9 @@ Volume=/home/dank/Containers/copyparty/cfg:/cfg
PublishPort=3923:3923
[Service]
# /w is the array: serving the pre-mount directory instead of the volume shows an
# empty share and nothing complains, so the mount has to answer first.
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /mnt/entropy
EnvironmentFile=/home/dank/Containers/copyparty/env
Restart=always
RestartSec=5
+1
View File
@@ -0,0 +1 @@
entropy UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec /etc/entropy nofail,luks
+18
View File
@@ -0,0 +1,18 @@
# /etc/fstab: static file system information.
#
# Use 'blkid' to print the universally unique identifier for a
# device; this may be used with UUID= as a more robust way to name devices
# that works even if disks are added and removed. See fstab(5).
#
# <file system> <mount point> <type> <options> <dump> <pass>
# / was on /dev/nvme0n1p2 during curtin installation
/dev/disk/by-uuid/4f3731d2-fb7b-48e7-a79f-0e33cb8a4162 / ext4 defaults 0 1
# /boot/efi was on /dev/nvme0n1p1 during curtin installation
/dev/disk/by-uuid/183C-442D /boot/efi vfat defaults 0 1
#/dev/disk/by-label/archive-one /mnt/archive-one auto nosuid,nodev,nofail,x-gvfs-show 0 0
#/dev/disk/by-label/archive-two /mnt/archive-two auto nosuid,nodev,nofail,x-gvfs-show 0 0
# /dev/md0 is a LUKS container, not a filesystem; its ext4 is mounted via
# /dev/mapper/entropy on the next line. Mounting /dev/md0 directly fails.
#/dev/md0 /mnt/md0 ext4 auto,nofail,nosuid,nodev 0 0
/dev/mapper/entropy /mnt/entropy ext4 auto,nosuid,nofail,nodev,x-gvfs-show 0 0
/swapfile none swap sw 0 0
@@ -0,0 +1,14 @@
# The io controller is what makes IOWeight= mean anything, and systemd does not
# enable it below `user.slice` by default: with io accounting off, `io` never
# appears in `user.slice/cgroup.subtree_control`, so no rootless container can
# have an `io.weight` file and `IOWeight=` in a quadlet is silently inert.
# Measured on Dankserver 2026-10-31: setting this property flipped
# `user.slice/cgroup.subtree_control` from `cpu memory pids` to `cpu io memory
# pids` immediately, with nothing restarted.
#
# This is half the fix; the other half is delegating io to user@1000.service
# (user@1000.service.d/io-delegate.conf). Both must be in place before a reboot,
# because the delegation is read when the user manager starts. Law and the
# verification command: [[Quadlet Lifecycle]].
[Slice]
IOAccounting=yes
@@ -0,0 +1,12 @@
# The system manager gives the user manager cpu/memory/pids by default (systemd
# 259: `systemctl show user@1000.service -p DelegateControllers` → `cpu memory
# pids`). Without `io` in that set no `io.weight` file can exist anywhere below
# `user.slice`, so `IOWeight=` in a rootless quadlet is silently inert — the same
# class of trap as `RequiresMountsFor=`. Granting the controller here is what
# makes the media servers' I/O share real.
#
# Inert until `user@1000.service` starts again, i.e. the next reboot (restarting
# it would kill every rootless container on the host). Verify after the reboot
# with the check in [[Quadlet Lifecycle]].
[Service]
DelegateControllers=cpu memory pids io
+14 -4
View File
@@ -43,11 +43,10 @@ PublishPort=1900:1900/udp
Volume=/home/dank/Containers/jellyfin/config:/config
Volume=/home/dank/Containers/jellyfin/cache:/cache
# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo /
# episode.nfo + artwork) and those bytes belong on the SSD. The condition is the
# fail-closed guard — starting against a plain, unmounted view directory would let
# a scan conclude the library is empty.
# episode.nfo + artwork) and those bytes belong on the SSD. The gate in [Service]
# is the guard: started against a plain, unmounted view directory a scan decides
# the library is empty and deletes what it cannot see.
Volume=/var/lib/media-meta/view:/data
UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
AddDevice=/dev/dri
GroupAdd=keep-groups
HealthCmd=curl -f http://localhost:8096/health
@@ -56,8 +55,19 @@ HealthTimeout=10s
HealthRetries=3
[Service]
# Refuse to start until the bind source is a real mount — see the header of
# scripts/wait-for-mount.sh for the blind-boot incident this prevents.
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy
Restart=always
RestartSec=5
# A library scan reads the array for minutes on end. Weighted LOW (default is
# 100) so that when the array is contended a scan loses to playback and to every
# other container this user runs. Proportional share, not a cap: it changes
# nothing while the disk is idle. 10 is a convention — the requirement is only
# that it sit far below 100 — and it is inert until user@1000.service is
# delegated the io controller ([[Quadlet Lifecycle]]).
IOWeight=10
[Install]
WantedBy=default.target
+10 -1
View File
@@ -16,12 +16,21 @@ Volume=/var/lib/plexmediaserver:/config
# to the overlay view re-points Plex at the sidecars without touching its DB.
# Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata.
Volume=/var/lib/media-meta/view:/mnt/entropy:ro
UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
PublishPort=32400:32400
[Service]
# Refuse to start until the bind source is a real mount: Plex blind is how paths
# get marked missing in its own database. Both the view and the array under it
# must answer — scripts/wait-for-mount.sh names the 2026-10-31 incident.
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy
Restart=always
RestartSec=5
# See the jellyfin unit for the reasoning: a section scan gets a LOW weight
# (default 100) so a walk of 50k files loses the array to playback and to every
# other container under this user. Inert until user@1000.service holds the io
# controller ([[Quadlet Lifecycle]]).
IOWeight=10
[Install]
WantedBy=default.target
@@ -1,309 +0,0 @@
#!/usr/bin/env python3
"""bench-spec-ab.py — settle "which proposer should this endpoint use?" with data.
Runs one payload through several speculative-decoding configurations of the same
binary, one at a time, with the unit stopped between runs: on a single-GPU host
there is no room for two copies of a 29 GiB model, and the benchmarking policy
applies. Every configuration gets identical prompts at temperature 0, and the
greedy text is compared across them — speculation may buy speed, never change
the answer.
bench-spec-ab.py <env-file> <serve-script> [options]
bench-spec-ab.py /home/dank/serverAppConfigs/scripts/frank-medium.env \
/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh \
--configs "dflash=draft-dflash@7;ngram=ngram-mod@64;chain=draft-dflash,ngram-mod@7"
Config syntax: `name=SPEC_TYPE[@n_max][|ENV=VAL|ENV=VAL]`, entries joined by `;`.
The systemd unit is restored in a finally block, so it comes back on whatever the
env file says even if a configuration crashes the harness.
THREE SHAPES, because a single one answers the wrong question. A proposer can
only draft text it has seen, so the shape decides the verdict:
prose a 1k prompt asking for one sentence. Nothing to copy and nothing
repetitive, so only a model-backed proposer can help: this is the
shape that compares drafters.
echo a ~1.5k passage of unique lines, "repeat it back verbatim". The
output IS the prompt, which is ngram-mod's home turf.
structured "25 JSON records, one per line" — repetitive OUTPUT with no prompt
overlap, which is what an ngram matcher is really for in an
agentic fleet (config edits, tables, record lists).
echo and structured disable thinking (`chat_template_kwargs`), otherwise the
model thinks the answer into `reasoning_content`, `content` comes back empty and
the cross-configuration identity check passes vacuously — which is exactly how
this harness first "verified" an echo it never looked at.
Results on Dankserver (frank-medium payload, 2026-09-16, temperature 0). Decode:
shape dflash=7 ngram-mod(48/64) chain (tok/s)
prose 1k/512 43.8 17.7 (0 drafts) 43.7
echo 1.5k/400 76.8 102.5 200.6
structured/400 91.1 17.8 (0 drafts) 91.2
identity 70.4 17.8 (0 drafts) 70.4
`draft-mtp,draft-dflash` fails at load (a chain has ONE draft context and MTP
builds against whatever model that context holds); ngram-mod has no such
constraint — it is draftless and joins the chain unconditionally.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
FILLER = ("The quick brown fox jumps over the lazy dog near the river bank. "
"A cache is a component that stores data so future requests are faster. ")
TOKENS_PER_REP = 28
UNIT = "frank-medium.service"
MODEL = "frank-medium"
SPEC_GREP = ("adding speculative implementation|- n_max=|- block_size=|creating MTP|"
"draft acceptance")
DEFAULT_CONFIGS = [
("none", {"SPEC_TYPE": "none"}),
("mtp-n3", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "3"}),
("mtp-n7", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "7"}),
("dflash-n7", {"SPEC_TYPE": "draft-dflash", "SPEC_DRAFT_N_MAX": "7"}),
]
WORDS = ["latency", "throughput", "cache", "adapter", "scheduler", "allocator",
"queue", "kernel", "buffer", "shard", "replica", "window"]
def echo_passage(lines=90):
"""Unique lines: no long span repeats inside the prompt, so an n-gram can
only fire when the OUTPUT copies the INPUT."""
return "\n".join(
"Record %03d: the %s of subsystem %d reports state %d with checksum %05d."
% (i, WORDS[i % len(WORDS)], i % 7, i % 3, (i * 7919) % 100000)
for i in range(lines))
ECHO = (echo_passage() + "\n\nRepeat the passage above verbatim, every line, exactly as "
"written, with no commentary before or after it.")
STRUCTURED = ("Output exactly 25 JSON records, one per line, no prose and no code fence. "
'Each line must look like {"id": N, "state": "active", "region": "us-west-2"} '
"with N running from 1 to 25.")
PROSE = FILLER * (1000 // TOKENS_PER_REP) + "\n\nReply with a single short sentence."
IDENTITY = "List the first five prime numbers, comma separated, then stop."
NO_THINK = {"chat_template_kwargs": {"enable_thinking": False}}
def log(msg, logfile):
line = "[" + time.strftime("%H:%M:%S") + "] " + str(msg)
print(line, flush=True)
with open(logfile, "a") as fh:
fh.write(line + "\n")
def sh(cmd):
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
def parse_configs(spec):
"""`name=SPEC_TYPE[@n_max][|KEY=VALUE]...`, entries joined by ';'.
KEY=VALUE parts override any env file key, which is how an impl's own window
(SPEC_NGRAM_N_MIN/MAX, for instance) gets swept without hand-editing env
files."""
out = []
for chunk in spec.split(";"):
chunk = chunk.strip()
if not chunk:
continue
name, _, rest = chunk.partition("=")
over = {}
if "|" in rest:
rest, _, extras = rest.partition("|")
for item in extras.split("|"):
if "=" in item:
k, _, v = item.partition("=")
over[k.strip()] = v.strip()
n_max = None
if "@" in rest:
rest, _, n_max = rest.partition("@")
over["SPEC_TYPE"] = rest.strip()
if n_max:
over["SPEC_DRAFT_N_MAX"] = n_max.strip()
out.append((name.strip(), over))
return out
def write_env(src, name, overrides):
"""The env file with this configuration's overrides applied."""
path = "/tmp/spec-ab-" + name.replace("/", "_") + ".env"
with open(src) as fh:
lines = fh.read().splitlines()
out = []
for line in lines:
for k, v in overrides.items():
if line.startswith(k + "="):
line = k + "=" + v
break
out.append(line)
with open(path, "w") as fh:
fh.write("\n".join(out) + "\n")
return path
def post(base, body, timeout=1800):
req = urllib.request.Request(
base + "/v1/chat/completions", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def vram():
out = sh("rocm-smi --showmeminfo vram 2>/dev/null | grep 'Used Memory'")
return [round(int(x) / 1e9, 2) for x in re.findall(r":\s+(\d+)\s*$", out, re.M)][:2]
def wait_health(base, timeout=420):
t0 = time.time()
while time.time() - t0 < timeout:
if not sh("pgrep -x llama-server >/dev/null && echo up"):
return False
try:
with urllib.request.urlopen(base + "/health", timeout=5) as r:
if r.status == 200 and json.load(r).get("status") == "ok":
return True
except Exception:
time.sleep(3)
return False
def stop_unit():
sh("sudo systemctl stop " + UNIT)
sh("pkill -x llama-server")
for _ in range(40):
if not sh("pgrep -x llama-server || true"):
return
time.sleep(1)
def restore_unit(base, logfile):
"""Bring the endpoint back on the deployed configuration. Runs in a
finally, because a harness that can leave production down is a hazard."""
log("restoring " + UNIT, logfile)
sh("sudo systemctl start " + UNIT)
for _ in range(120):
try:
with urllib.request.urlopen(base + "/health", timeout=5) as r:
if json.load(r).get("status") == "ok":
log(UNIT + " healthy again", logfile)
return True
except Exception:
time.sleep(5)
log("WARNING: " + UNIT + " did not come back healthy -- check it by hand", logfile)
return False
def run_shape(base, prompt, max_tokens, label, extra=None):
body = {"model": MODEL, "messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "temperature": 0.0}
if extra:
body.update(extra)
d = post(base, body)
tm = d["timings"]
msg = d["choices"][0]["message"]
content = (msg.get("content") or "").strip()
row = {"shape": label,
"prompt_n": tm["prompt_n"],
"prefill_tps": round(1000 * tm["prompt_n"] / tm["prompt_ms"], 1),
"predicted_n": tm["predicted_n"],
"decode_tps": round(tm["predicted_per_second"], 2),
"draft_n": tm.get("draft_n", 0),
"accepted": tm.get("draft_n_accepted", 0),
"answer": content,
"reasoning_len": len(msg.get("reasoning_content") or "")}
if label.startswith("echo"):
# How much of the answer really is the passage: a configuration that
# "wins" by answering something else is not a win.
want = {l.strip() for l in echo_passage().splitlines()}
got = [l.strip() for l in content.splitlines() if l.strip()]
row["copy_ratio"] = round(sum(1 for l in got if l in want) / len(got), 3) if got else 0.0
return row
def measure(base):
run_shape(base, FILLER * (4000 // TOKENS_PER_REP) +
"\n\nReply with a single short sentence.", 32, "warmup")
return [
run_shape(base, PROSE, 512, "prose 1k/512"),
run_shape(base, ECHO, 400, "echo 1.5k/400", NO_THINK),
run_shape(base, STRUCTURED, 400, "structured/400", NO_THINK),
run_shape(base, IDENTITY, 192, "identity"),
]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("env_file")
ap.add_argument("serve_script")
ap.add_argument("--base-url", default="http://127.0.0.1:7770")
ap.add_argument("--log", default="/tmp/bench-spec-ab.log")
ap.add_argument("--configs", default=None,
help="name=SPEC_TYPE[@n_max][|ENV=VAL] joined by ';' "
"(default: the four configs measured on 2026-09-16)")
ap.add_argument("--json", default="/tmp/bench-spec-ab.json")
args = ap.parse_args()
configs = parse_configs(args.configs) if args.configs else DEFAULT_CONFIGS
# Absolute paths: the launcher must not depend on the caller's cwd, and a
# relative path plus a `cd` to its own directory double-counts the prefix.
env_file = os.path.abspath(args.env_file)
serve_script = os.path.abspath(args.serve_script)
base, logfile = args.base_url, args.log
results = {}
log("spec A/B start: " + ", ".join(n for n, _ in configs) +
" (unit " + UNIT + " stopped for the duration)", logfile)
try:
stop_unit()
for name, over in configs:
env_path = write_env(env_file, name, over)
logf = "/tmp/spec-ab-" + name.replace("/", "_") + ".log"
sh("FRANK_MEDIUM_ENV_FILE=" + env_path + " setsid bash " + serve_script +
" > " + logf + " 2>&1 < /dev/null &")
if not wait_health(base):
log(name + ": FAILED to become healthy -- " + sh("tail -3 " + logf), logfile)
results[name] = {"error": "not healthy", "tail": sh("tail -6 " + logf)}
stop_unit()
continue
rows = measure(base)
results[name] = {"vram_gb": vram(), "rows": rows,
"spec_log": sh("grep -E '" + SPEC_GREP + "' " + logf + " | tail -4")}
for r in rows:
log(name + " | " + r["shape"] + " prefill " + str(r["prefill_tps"]) +
" tok/s | decode " + str(r["decode_tps"]) + " tok/s | draft " +
str(r["draft_n"]) + "/" + str(r["accepted"]) +
(" (" + str(round(100.0 * r["accepted"] / r["draft_n"])) + " %)"
if r["draft_n"] else "") +
(" | copy " + str(r["copy_ratio"]) if "copy_ratio" in r else "") +
(" | EMPTY ANSWER" if not r["answer"] else ""), logfile)
log(name + " | vram " + str(results[name]["vram_gb"]), logfile)
stop_unit()
finally:
restore_unit(base, logfile)
with open(args.json, "w") as fh:
json.dump(results, fh, indent=2)
for shape in ("prose 1k/512", "echo 1.5k/400", "structured/400", "identity"):
texts = [r["answer"] for v in results.values() if "rows" in v
for r in v["rows"] if r["shape"] == shape]
if any(not t for t in texts):
log("WARNING: '" + shape + "' produced an empty answer in some configuration -- "
"its identity check proves nothing", logfile)
elif len(set(texts)) > 1:
log("WARNING: configurations disagree on greedy text for '" + shape +
"' -- speculation is changing answers", logfile)
log("done; results in " + args.json, logfile)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,318 +0,0 @@
# Tunables for frank-medium (orcarouter/Qwen3.8-27B-Uncensored-GGUF Q8_0 +
# z-lab/Qwen3.8-27B-DFlash2-GGUF Q4_K_M drafter), mainline llama.cpp on
# Dankserver, 2x AMD Radeon AI PRO R9700 (gfx1201). Since 2026-09-16 this is
# the SOLE LLM runtime on the box: the vllm-radiance replica pair and its
# routing proxy are retired.
#
# Hardware: two R9700 (Navi 48, RDNA4, 32,624 MiB each), no XGMI, different
# IOMMU groups -- every cross-card byte goes over PCIe. The Raphael iGPU
# (gfx1036/Radeon 610M) enumerates as HIP device 2 and is hidden below so
# nothing can drift onto shared system RAM.
#
# WHY LAYER SPLIT AND NOT TENSOR (operator call, 2026-09-16): tensor parallel
# needs a cross-card allreduce per layer, and this host cannot dispatch one.
# RCCL's kernels carry a hidden hostcall-buffer parameter, and ROCr refuses it
# on a device without PCIe atomics -- the second card sits behind the B650
# chipset bridges (AtomicOpsCap: Routing-, read-only), so every collective that
# touches it dies with hipErrorIllegalState. Measured on this box 2026-09-15
# (llama-bench, pp4096 / tg128, f16 KV, fa on):
#
# single R9700 1160.9 / 19.42
# layer 2x 1991.2 / 18.76 (859.1 / 17.64 at depth 32768)
# tensor 2x 271.8 / 26.26 (no RCCL: butterfly fallback, -7x prefill)
#
# Layer split distributes layers in order; only the activation at the boundary
# crosses the wire, once per forward pass. That is the "pipeline parallelism"
# this service wanted, and it needs no collective at all.
#
# Runtime: mainline llama.cpp master fb27a525 (2026-09-16), gfx1201, built by
# scripts/build-llama-rdna4.sh against a private ROCm 7.14.0a20260624 toolchain
# that is bundled next to the binary (RPATH $ORIGIN/../lib, no LD_LIBRARY_PATH).
# The ref is pinned by TWO features: DFlash2 (ggml-org/llama.cpp#27342 -- the
# drafter is detected from its GGUF) and qwen35 MTP. Do not float it.
#
# Model: orcarouter/Qwen3.8-27B-Uncensored-GGUF @ Q8_0, 27.0 GiB, arch qwen35
# (verified from the GGUF header 2026-09-19: 262144 trained window, 24 heads /
# 4 KV heads, nextn_predict_layers=1). The file ALSO carries the model's MTP
# head (blk.64.nextn.*), so --spec-type draft-mtp is available without a second
# copy in VRAM -- one SPEC_TYPE change, the drafter below stays on disk.
#
# Drafter: z-lab/Qwen3.8-27B-DFlash2-GGUF @ Q4_K_M, 1.06 GiB (operator call
# 2026-09-19: the Q4 rung -- the drafter's job is throughput, not output
# quality, and the target verifies every drafted token). 5 layers, hidden
# 5120, a block-diffusion proposal (block_size 8) under a 2048-token sliding
# window (dflash.attention.sliding_window=2048, verified from the header).
# The window is what makes it cheap HERE: its KV is bounded at 2048 positions
# (~40 MiB) instead of spanning the context (see SIZING).
#
# Architecture (GGUF arch qwen35): 64 layers as 16 x (3 x (Gated DeltaNet ->
# FFN) + 1 x (Gated Attention -> FFN)) -- 48 linear-attention layers whose
# per-sequence state is CONSTANT-SIZE, 16 full-attention layers at 4 KV heads x
# 256 head_dim. A token therefore costs 2 x 4 x 256 x 2 B = 64 KiB of KV at f16
# in the attention layers only, plus ~0.16 GiB per sequence of f32 DeltaNet
# state at any depth.
#
# SIZING, the deployed shape (32:33 / ctx 327680 / 6144x512 / two slots /
# DFlash2 Q4_K_M), measured 2026-09-19 after the model swap (rocm-smi):
#
# card 0 (x16) 26.9 GiB used, 5.7 GiB free
# card 1 (x1) 27.8 GiB used, 4.8 GiB free
#
# The predecessor's measured table (rocm-smi, 2026-09-16, UD-Q8_K_XL target)
# stays as the calibration history:
#
# CTX split batch slots drafter GPU 0 free GPU 1 free note (ubatch 1536)
# 262144 1,1 2048 1 DFlash2 8.64 GiB 4.43 GiB day's start
# 262144 39,26 6144 2 DFlash2 2.42 GiB 7.20 GiB
# 262144 38,27 6144 2 DFlash2 3.85 GiB 5.77 GiB
# 262144 37,28 6144 2 DFlash2 4.28 GiB 5.34 GiB
# 262144 36,29 6144 2 DFlash2 4.71 GiB 4.91 GiB
# 262144 35,30 6144 2 DFlash2 5.14 GiB 4.48 GiB
# 262144 40,25 6144 2 DSpark 1.87 GiB 1.86 GiB (retired)
#
# ...and the same deployed shape re-measured at ubatch 512, after llama-bench
# showed 512 is the throughput optimum and costs 3.71 GiB less in total (see the
# batch block below):
#
# 262144 36,29 6144 512 2 DFlash2 6.01 GiB 7.32 GiB ubatch only
# 262144 35,30 6144 512 2 DFlash2 6.44 GiB 6.90 GiB
#
# ONE -ts STEP IS ONE LAYER, AND LAYERS ARE NOT THE SAME SIZE. This is a hybrid
# stack -- 16 x (3 Gated DeltaNet + 1 Gated Attention) blocks -- so moving a
# DeltaNet layer shifts ~0.43 GiB while an attention layer shifts ~1.43 GiB
# (0.45 of weights plus ~1.0 GiB of its own f16 KV at 262144). Both step sizes
# were measured, which is why the balance is found by measurement rather than by
# interpolating between splits.
#
# WHY THE SPLIT IS 32:33 (operator call, 2026-09-19). The operator asked for
# the tensors BALANCED across the two cards, which for 65 layers is 32:33 --
# the most even split that exists. The predecessor's 35:30 skew existed to lean
# work onto the x16 card (ROCm0), because ROCm1's Gen4 x1 chipset path made it
# the 98 % utilization bottleneck; the measured trade of the balanced point is
# that the x1 card runs hotter while the x16 card idles a little more, and the
# operator accepted that for symmetry. Do not re-skew without asking.
#
# THE TWO CARDS' PATHS ARE NOT SYMMETRIC (measured, 2026-09-17): ROCm0
# (03:00.0) hangs off CPU root port 00:01.1 at Gen4 x16 end to end (~31.5 GB/s);
# ROCm1 (0b:00.0) rides the B650 chipset through bridges that each negotiate
# Gen4 x1 (~1.97 GB/s). The layer-split boundary itself is not the cost
# (~5.2 MB of activation per 512-token ubatch rides the wire); the x1 card is
# COMPUTE-bound, not PCIe-bound.
#
# A FAULT TO WATCH. A 39:26 split, at 1x batch / 1 slot, died on its eighth
# request: the amdgpu driver logged SDMA0 UTCL2 page faults and the engine
# surfaced `ROCm error: an illegal memory access was encountered` -> ggml_abort,
# i.e. a fault in the cross-card boundary copy, this layer split's hot path. It
# has not reproduced since, but the signature is worth recognising -- it is NOT
# an OOM, and no amount of headroom prevents it.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (HIP reads
# those bare vars; 2026-08-13 incident). llama.cpp takes --host/--port/--temp
# as flags only.
# llama-server: mainline fb27a525 built for gfx1201 by scripts/build-llama-rdna4.sh.
LLAMA_BIN=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server
# Payload, fetched with scripts/download-frank-medium-gguf.sh.
MODEL_DIR=/home/dank/models/Qwen3.8-27B-Uncensored-GGUF
MODEL_FILE=Qwen3.8-27B-Uncensored-Q8_0.gguf
# The f16 vision projector, same repo as the target. llama-server loads it via
# the explicit --mmproj flag (no autoload in this build); the serve script
# refuses to start without it when vision is enabled, so a missing file is a
# deploy error, not a text-only server that pretends to be multimodal.
MM_PROJ_FILE=mmproj-Qwen3.8-27B-Uncensored-f16.gguf
DRAFT_MODEL_DIR=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF
DRAFT_MODEL_FILE=Qwen3.8-27B-DFlash2-Q4_K_M.gguf
# Fleet identity. 7770 is the fleet's dankserver endpoint; the alias is the id
# at /v1/models and what every client asks for.
ALIAS=frank-medium
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
# Device placement. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2)
# so nothing can drift onto shared system RAM; DEVICES is what llama.cpp splits
# across, and neither of the two cards has an XGMI link to the other.
HIP_VISIBLE_DEVICES=0,1
DEVICES=ROCm0,ROCm1
SPLIT_MODE=layer
# 32:33 -- balanced (operator call 2026-09-19): 65 layers split as evenly as
# they can be, ROCm1 (the last-layers card, which also carries the drafter and
# the output layers) holding the extra one. See the WHY THE SPLIT IS 32:33
# block above for the trade against the predecessor's 35:30 x16-lean.
TENSOR_SPLIT=32,33
NGL=999
# Auto-fit has no business resizing a context we set explicitly.
FIT=off
FLASH_ATTN=on
# 327680 positions over TWO slots, so each conversation gets 163840 --
# --parallel DIVIDES --ctx-size, so more slots would shrink every
# conversation rather than grow the cache. 163840 is inside the checkpoint's
# trained 262144 window, so the whole pool is trained territory (no rope
# extrapolation, unlike the predecessor's 393216).
CTX_SIZE=327680
PARALLEL=2
# MEASURED 2026-09-16 with scripts/bench-batch-ubatch.sh on this box: llama-bench,
# a 64k prefill plus a 128-token decode with 64k of KV already resident, 3 reps,
# one invocation per cell, the deployed split and f16 KV. --ubatch-size is the
# physical batch the graph is built at -- and the only one of the two knobs that
# costs VRAM:
#
# batch ubatch prefill tok/s GPU 0 / GPU 1 free
# 6144 1536 1164.96 +- 2.30 10.28 / 11.71 GiB
# 6144 768 1135.03 +- 1.09 11.29 / 13.28 GiB
# 6144 512 1192.93 +- 0.91 11.63 / 13.81 GiB <- deployed
# 6144 384 1068.04 +- 0.79 11.80 / 14.07 GiB
# 6144 256 1045.98 +- 1.69 11.97 / 14.33 GiB
# 4096 1536 1161.90 +- 0.96 10.28 / 11.71 GiB
# 2048 1536 1170.23 +- 0.86 10.28 / 11.71 GiB
# 2048 512 1193.88 +- 1.01 11.63 / 13.81 GiB
#
# THE PLATEAU STARTS AT 512. 512 -> 1536 buys ~2 % of prefill -- inside the
# run-to-run spread, and the 512 cells were the faster pair in BOTH batch
# columns -- while costing 1.35 GiB on ROCm0 and 2.10 GiB on ROCm1, so the
# deployment takes the cheap end. BELOW 512 the SMs are no longer saturated:
# 384 and 256 fall ~11 % and ~12 %. The logical batch is free either way --
# 2048, 4096 and 6144 land within 2 % of each other at a fixed ubatch and use
# the SAME VRAM, because the compute buffers are sized by ubatch alone.
#
# The prefill figure is a property of the SHAPE, not of this knob: the same
# sweep with no depth in front of it (depth 0) runs 1543 tok/s at ubatch 256 and
# 1723 at 1536 -- the plateau is in the same place, and the ~32 % between the two
# tables is what attending over 64k of resident KV costs. Decode is 15.88 tok/s
# at depth 65536 on EVERY ubatch (17.80 at depth 0), so this knob cannot touch
# generation at all.
#
# 8192/2048 stays untested under this drafter (the "2020 MiB pp buffer fails on
# ROCm1" note was measured under DSpark, when it had 0.08 GiB free), and there is
# no longer a reason to want it: batch buys nothing measurable here.
BATCH_SIZE=6144
UBATCH_SIZE=512
# Threads cover tokenization, sampling and HTTP only: every layer is on a GPU.
NUM_THREADS=8
THREAD_BATCH=8
# f16 K/V: an agentic checkpoint is the wrong place to spend KV precision.
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
# Context checkpoints: 0 -- no per-slot KV snapshots at all. The build's
# default is 32 (this binary's --help), so the 0 below is a deliberate
# deviation, not an inherited default: a checkpoint is a VRAM->host-RAM copy
# of part of a slot's KV, taken so a partially-matching prompt can be restored
# instead of re-prefilled. This endpoint trades that away -- its reuse comes
# from the slot prompt and from the RAM-side prompt cache below. Stated
# explicitly so the tunable stays visible rather than merely absent.
CTX_CHECKPOINTS=0
# PROMPT CACHE, HOST RAM -- this is the one cache tunable that is NOT VRAM.
# --cache-ram caps the total bytes of saved prompt states
# (server_prompt_cache, tools/server/server-task.cpp): update() evicts
# oldest-first past the cap, and alloc() REFUSES outright any single state
# bigger than the whole cap. A state is the serialized KV of a whole sequence
# plus its DeltaNet state plus the draft context's own KV, so at this shape it
# costs ~68 KiB per token --
# 16 full-attention layers x 4 kv heads x 256 dim x 2 B x 2 (K,V) = 64 KiB
# plus the draft context's window-bounded KV (a ~40 MiB constant for DFlash2)
# -- which makes the cap a TOKEN budget in disguise: 16384 MiB holds ~240k
# tokens, i.e. a FULL 163840-token slot (~10.6 GiB) with ~5 GiB of the budget
# spare for a second entry.
#
# The cost is host RAM, allocated on demand and never pinned (plain heap
# vectors), so no VRAM number above moves. dankserver has 62 GiB beside the
# media-stack co-tenants, so the cache is bounded rather than unlimited (-1):
# the seam test refuses a budget over half the host's RAM.
CACHE_RAM=16384
# Sampling defaults for requests that send none -- the values the vLLM
# predecessor served on this endpoint (`--override-generation-config`), so a
# client sees no change across the runtime swap. The model card's own
# recommendation is temp 1.0 / top_p 0.95; the harness sets its own per model in
# models.yml, so this is the floor, not the contract.
TEMPERATURE=0.7
TOP_P=0.95
TOP_K=20
MIN_P=0.0
PRESENCE_PENALTY=0.0
# Reasoning on, projected into reasoning_content (deepseek format) with no
# budget: the model decides how long to think.
REASONING=on
REASONING_FORMAT=deepseek
REASONING_BUDGET=-1
# Chat template: empty = the one embedded in the GGUF (Qwen3.8's own).
CHAT_TEMPLATE_FILE=
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}'
# Speculative decoding: the DFlash2 sidecar (operator call 2026-09-19). The
# target file also carries its own MTP head (nextn_predict_layers=1), so
# draft-mtp is one SPEC_TYPE change away -- the drafter file stays on disk.
# n_max 7 is the measured DFlash2 winner (see the table below); the drafter
# rides ROCm1, the card that holds the last layers.
SPEC_TYPE=draft-dflash
SPEC_DRAFT_N_MAX=7
SPEC_DRAFT_NGL=999
SPEC_DRAFT_DEVICE=ROCm1
SPEC_DRAFT_TYPE_K=f16
SPEC_DRAFT_TYPE_V=f16
# DFlash2 vs the model's own MTP head -- MEASURED 2026-09-16 on the predecessor
# payload (temperature 0, 1k prompt / 512 out; the autoregressive baseline and
# both MTP depths were run in one session, same binary, same prompts):
#
# none 17.7 tok/s card1 26.07 GB
# draft-mtp n=3 32.4 tok/s 58 % accepted card1 29.35 GB
# draft-mtp n=7 26.7 tok/s 33 % accepted card1 29.65 GB
# draft-dflash 7 43.5 tok/s 41 % accepted card1 29.42 GB
#
# All four produced IDENTICAL greedy text, so verification is lossless
# whichever proposer runs. DFlash2 is the faster proposer (2.46x over
# autoregressive, 34 % over the best MTP). The MTP head adds ~3.3 GB to the
# card that holds it, against 1.06 GB for the Q4_K_M drafter's file -- at the
# 327680 pool the drafter's window-bounded KV (~40 MiB) leaves headroom on
# both cards, so the speed wins.
#
# THE TWO CANNOT BE CHAINED. `--spec-type draft-mtp,draft-dflash` dies at load:
# context type MTP requested but model doesn't contain MTP layers
# failed to create MTP context
# A chain has ONE draft context, and MTP builds against whatever model that
# context holds -- here the DFlash2 file, which has no MTP layers. MTP's head is
# only loaded when MTP IS the draft model ("creating MTP draft context against
# the target model ..."), so a chain of the two is a structural dead end, not a
# tuning question. See serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md.
# ngram-mod: the draftless n-gram matcher (CPU hash table in host RAM, no VRAM,
# no model file, no draft context). It joins the chain unconditionally --
# unlike MTP, which needs a draft context and therefore cannot share one with
# DFlash2. NOT CURRENTLY ACTIVE: SPEC_TYPE is draft-dflash alone, and the serve
# script only passes --spec-ngram-mod-* when the type contains "ngram". The
# window below is retained because the DFlash2 + ngram chain was measured free
# on prose and 2.6x on prompt-echo -- one SPEC_TYPE change brings it back.
#
# WINDOW MEASURED 2026-09-16 (chain at each window, decode tok/s):
#
# n_min/n_max prose 1k/512 prompt-echo 1.5k/400
# 48/64 43.9 200.6
# 16/32 36.0 161.0
# 8/16 36.2 113.8
#
# The long window is the right one, and not only because it wins on echo: a
# SHORT window makes the matcher propose on short fragments of ordinary prose,
# where the proposals are rejected and the verification still costs -- which is
# the "ngram hurts" result the fleet measured on other payloads twice before.
# With 48/64 the chain is byte-identical to DFlash2 alone on prose (same 119
# drafts / 49 accepted) and on the identity prompt (70/53).
#
# Do not shorten these without re-running scripts/bench-spec-ab.py: the
# no-penalty property is what makes the chain safe as a default.
SPEC_NGRAM_N_MATCH=24
SPEC_NGRAM_N_MIN=48
SPEC_NGRAM_N_MAX=64
# Vision enabled: llama-server loads mmproj-Qwen3.8-27B-Uncensored-f16.gguf
# (931 MiB, lands on ROCm0 -- the fit charges it there) via the explicit
# --mmproj flag. This keeps frank-medium multimodal across the model swap.
NO_MMPROJ=0
@@ -1,20 +0,0 @@
[Unit]
Description=llama.cpp Qwen3.8-27B Uncensored UD-Q8_K_XL + DFlash2 drafter (dankserver, frank-medium, 2x Radeon AI PRO R9700)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
# The engine pins staging buffers on the first card; keep the memlock budget
# unbounded so an RLIMIT that is smaller than the split buffers cannot fail the
# load. Parity with the other LLM units on this fleet.
LimitMEMLOCK=infinity
Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,203 +0,0 @@
#!/usr/bin/env bash
# Serve ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF @ UD-Q8_K_XL as
# frank-medium on Dankserver's two Radeon AI PRO R9700 (gfx1201), with the
# z-lab DFlash2 block-diffusion drafter, using mainline llama.cpp.
#
# Runtime: llama.cpp master fb27a525 (2026-09-16), a gfx1201 HIP build that
# bundles its own ROCm 7.14 runtime (RPATH $ORIGIN/../lib), produced by
# scripts/build-llama-rdna4.sh. Two features pin that ref: DFlash2
# (ggml-org/llama.cpp#27342) and qwen35 MTP. The charlie12345/ROCmFPX fork
# this deployment replaced has neither (its tip is c49ebdb, 2026-08-22).
#
# Parallelism: --split-mode layer across both cards. Tensor split would need a
# cross-card allreduce and this host cannot dispatch one -- RCCL's kernels
# require a hostcall buffer that ROCr refuses on a card without PCIe atomics,
# and the second R9700 sits behind the chipset (AtomicOpsCap: Routing-). Layer
# split crosses the wire once per forward pass instead of once per layer.
#
# The vLLM replica pair and its routing proxy were retired with this cutover:
# one process, one model, both cards.
#
# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE overrides it.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) ------------------
: "${LLAMA_BIN:=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF}"
: "${MODEL_FILE:=Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf}"
: "${DRAFT_MODEL_DIR:=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF}"
: "${DRAFT_MODEL_FILE:=Qwen3.8-27B-DFlash2-Q8_0.gguf}"
: "${ALIAS:=frank-medium}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
# Devices. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2) so nothing
# can drift onto shared system RAM; DEVICES is what llama.cpp splits across.
: "${HIP_VISIBLE_DEVICES:=0,1}"
: "${DEVICES:=ROCm0,ROCm1}"
: "${SPLIT_MODE:=layer}"
: "${TENSOR_SPLIT:=1,1}"
: "${NGL:=999}"
: "${FIT:=off}"
: "${FLASH_ATTN:=on}"
: "${CTX_SIZE:=131072}"
: "${PARALLEL:=1}"
: "${BATCH_SIZE:=2048}"
: "${UBATCH_SIZE:=512}"
: "${NUM_THREADS:=8}"
: "${THREAD_BATCH:=8}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${CTX_CHECKPOINTS:=8}"
: "${CACHE_RAM:=8192}"
# Sampling defaults for silent clients: the values the vLLM predecessor served.
: "${TEMPERATURE:=0.7}"
: "${TOP_P:=0.95}"
: "${TOP_K:=20}"
: "${MIN_P:=0.0}"
: "${PRESENCE_PENALTY:=0.0}"
: "${REASONING:=on}"
: "${REASONING_FORMAT:=deepseek}"
: "${REASONING_BUDGET:=-1}"
: "${CHAT_TEMPLATE_FILE:=}"
: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}"
# Speculative decoding: draft-dflash or draft-dspark (both are MODEL drafters --
# a file, not a flag), or draft-mtp (the target GGUF's own MTP head, no draft
# file). The drafter's own tuning knobs (n-min, p-min, p-split, poll) are left
# at the build's defaults: they were only ever passed for dspark, and dspark is
# not the deployed drafter.
: "${SPEC_TYPE:=draft-dflash}"
: "${SPEC_DRAFT_N_MAX:=7}"
: "${SPEC_DRAFT_NGL:=999}"
: "${SPEC_DRAFT_DEVICE:=}"
: "${SPEC_DRAFT_TYPE_K:=f16}"
: "${SPEC_DRAFT_TYPE_V:=f16}"
: "${SPEC_NGRAM_N_MATCH:=24}"
: "${SPEC_NGRAM_N_MIN:=48}"
: "${SPEC_NGRAM_N_MAX:=64}"
NO_MMPROJ="${NO_MMPROJ:-0}"
: "${MM_PROJ_FILE:=mmproj-BF16.gguf}"
export HIP_VISIBLE_DEVICES
MODEL="$MODEL_DIR/$MODEL_FILE"
DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE"
if [[ ! -x "$LLAMA_BIN" ]]; then
echo "missing executable runtime: $LLAMA_BIN" >&2
echo "build it with: bash $SCRIPT_DIR/build-llama-rdna4.sh" >&2
exit 1
fi
if [[ ! -r "$MODEL" ]]; then
echo "missing or unreadable target model: $MODEL" >&2
echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2
exit 1
fi
if [[ "$NO_MMPROJ" != "1" ]]; then
MM_PROJ="$MODEL_DIR/$MM_PROJ_FILE"
if [[ ! -r "$MM_PROJ" ]]; then
echo "vision is enabled but the projector is missing: $MM_PROJ" >&2
echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2
exit 1
fi
fi
if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then
echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2
exit 1
fi
# --- speculative decoding -------------------------------------------------
SPEC_ARGS=()
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
SPEC_ARGS=(--spec-type "$SPEC_TYPE" --spec-draft-n-max "$SPEC_DRAFT_N_MAX")
if [[ "$SPEC_TYPE" == *dflash* || "$SPEC_TYPE" == *dspark* ]]; then
if [[ ! -r "$DRAFT_MODEL" ]]; then
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
exit 1
fi
SPEC_ARGS+=(
--spec-draft-model "$DRAFT_MODEL"
--spec-draft-ngl "$SPEC_DRAFT_NGL"
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
)
fi
# ngram-mod is draftless: it needs no draft context and its window is its
# own (--spec-ngram-mod-*), so it can ride in the chain beside a model
# drafter. It also costs no VRAM -- a CPU hash table.
if [[ "$SPEC_TYPE" == *ngram* ]]; then
SPEC_ARGS+=(
--spec-ngram-mod-n-match "$SPEC_NGRAM_N_MATCH"
--spec-ngram-mod-n-min "$SPEC_NGRAM_N_MIN"
--spec-ngram-mod-n-max "$SPEC_NGRAM_N_MAX"
)
fi
if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then
SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE")
fi
fi
# --- template + placement -------------------------------------------------
TEMPLATE_ARGS=()
if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then
TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE")
fi
TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS")
PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE" --tensor-split "$TENSOR_SPLIT")
MMPROJ_ARGS=()
if [[ "$NO_MMPROJ" == "1" ]]; then
MMPROJ_ARGS=(--no-mmproj)
else
# No autoload in this build: a server without the flag starts text-only
# and answers image requests with a 500, so name the projector.
MMPROJ_ARGS=(--mmproj "$MM_PROJ")
fi
exec "$LLAMA_BIN" \
--model "$MODEL" \
--alias "$ALIAS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
--jinja \
--gpu-layers "$NGL" \
--fit "$FIT" \
--parallel "$PARALLEL" \
--ctx-size "$CTX_SIZE" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--flash-attn "$FLASH_ATTN" \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--threads "$NUM_THREADS" \
--threads-batch "$THREAD_BATCH" \
--ctx-checkpoints "$CTX_CHECKPOINTS" \
--cache-ram "$CACHE_RAM" \
--cache-prompt \
--metrics \
--temp "$TEMPERATURE" \
--top-p "$TOP_P" \
--top-k "$TOP_K" \
--min-p "$MIN_P" \
--presence-penalty "$PRESENCE_PENALTY" \
--reasoning "$REASONING" \
--reasoning-format "$REASONING_FORMAT" \
--reasoning-budget "$REASONING_BUDGET" \
"${TEMPLATE_ARGS[@]}" \
"${PLACEMENT_ARGS[@]}" \
"${MMPROJ_ARGS[@]}" \
"${SPEC_ARGS[@]}"
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""
Decode throughput benchmark for frank-dragoon.
Starts the server with a given --threads-batch value, sends an 8K+ token prompt,
generates 256 tokens, measures generation tok/s.
Each --threads-batch value requires a cold server load (~3 min).
"""
import json, time, urllib.request, urllib.error, subprocess, os, sys
MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf"
SERVE = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server"
PORT = 7770
N_GEN = 256
PROMPT_FILE = "/tmp/bench-prompts/prompt_code.txt"
ENV_FILE = os.path.join(os.path.dirname(__file__), "frank-dragoon.env")
SERVER_DIR = os.path.dirname(SERVE)
ROCM_PATH = "/opt/rocm"
def make_env():
"""Build the same env as serve-frank-dragoon.sh."""
env = {**os.environ, "HSA_OVERRIDE_GFX_VERSION": "11.5.1", "HIP_VISIBLE_DEVICES": "0"}
# Source env file for tunables
if os.path.exists(ENV_FILE):
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k] = v
# Build LD_LIBRARY_PATH
lib_path = f"{SERVER_DIR}:{ROCM_PATH}/lib:{ROCM_PATH}/lib/rocm_sysdeps/lib:{ROCM_PATH}/lib/llvm/lib"
if os.environ.get("LD_LIBRARY_PATH"):
lib_path += f":{os.environ['LD_LIBRARY_PATH']}"
env["LD_LIBRARY_PATH"] = lib_path
env["ROCM_PATH"] = ROCM_PATH
return env
ENV = make_env()
def kill_all():
subprocess.run(["pkill", "-9", "llama-server"], capture_output=True)
time.sleep(3)
def wait_ready():
for i in range(600):
try:
r = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2)
if json.loads(r.read()).get("status") == "ok":
return True
except:
pass
time.sleep(1)
return False
def send_request(prompt, max_tokens=N_GEN):
"""Send chat completion, measure generation throughput."""
body = json.dumps({
"model": "frank-dragoon",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"stream": False,
"temperature": 0.7,
"top_p": 0.8
}).encode()
t0 = time.time()
req = urllib.request.Request(
f"http://127.0.0.1:{PORT}/v1/chat/completions",
data=body,
headers={"Content-Type": "application/json"},
method="POST"
)
r = urllib.request.urlopen(req, timeout=1200)
resp = json.loads(r.read())
wall = time.time() - t0
usage = resp.get("usage", {})
pt = usage.get("prompt_tokens", 0)
gt = usage.get("completion_tokens", 0)
gtps = gt / wall if wall > 0 else 0
return pt, gt, wall, gtps
def load_and_extend_prompt():
"""Load prompt and extend to ~8K tokens if needed."""
with open(PROMPT_FILE) as f:
text = f.read()
# Count approximate tokens (rough: ~4 chars per token for code)
approx_tokens = len(text) / 4.0
needed = 8200
if approx_tokens < needed:
repeats = needed // max(int(approx_tokens), 1) + 1
text = (text + "\n") * repeats
words = text.split()[:needed]
text = " ".join(words)
return text
def main():
tb_values = [int(x) for x in sys.argv[1:]] if len(sys.argv) > 1 else [16, 32, 64, 128]
results = []
prompt = load_and_extend_prompt()
n_words = len(prompt.split())
print(f"Prompt: {n_words} words (~{n_words} tokens), {len(prompt)} chars\n", flush=True)
for tb in tb_values:
print(f"\n{'='*60}")
print(f"threads-batch = {tb}")
print(f"{'='*60}", flush=True)
kill_all()
print(" Starting server...", flush=True)
lib_path = ENV.get("LD_LIBRARY_PATH", "")
cmd = [
"/usr/bin/env",
"HSA_OVERRIDE_GFX_VERSION=11.5.1",
"HIP_VISIBLE_DEVICES=0",
f"LD_LIBRARY_PATH={lib_path}",
f"ROCM_PATH={ROCM_PATH}",
SERVE, "--model", MODEL, "--port", str(PORT), "--jinja",
"--device", "ROCm0", "--gpu-layers", "999", "--fit", "off",
"--threads", "16", "--threads-batch", str(tb),
"--parallel", "2", "--ctx-size", "131072",
"--batch-size", "4096", "--ubatch-size", "4096",
"--flash-attn", "on",
"--cache-type-k", "f16", "--cache-type-v", "f16",
"--cache-prompt", "--cache-idle-slots", "--metrics"
]
srv = subprocess.Popen(cmd, env=ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
ready = wait_ready()
if not ready:
print(" FAILED: server did not become ready", flush=True)
srv.terminate()
srv.wait()
for line in (srv.stdout.readlines() or [])[-20:]:
print(f" LOG: {line.rstrip()}", flush=True)
continue
print(" Server ready.", flush=True)
# Warmup (small request)
try:
send_request("Hi", 16)
except Exception as e:
print(f" Warmup failed: {e}", flush=True)
srv.terminate()
srv.wait()
continue
# Benchmark
print(f" Generating {N_GEN} tokens...", flush=True)
try:
pt, gt, wall, gtps = send_request(prompt, N_GEN)
print(f" {gt}/{N_GEN} gen tokens in {wall:.1f}s = {gtps:.1f} tok/s")
print(f" Prompt tokens: {pt}")
results.append({"tb": tb, "wall": wall, "gtps": gtps, "gt": gt, "pt": pt})
except Exception as e:
print(f" FAILED: {e}", flush=True)
srv.terminate()
srv.wait()
time.sleep(2)
# Summary
print(f"\n{'='*60}")
print("SUMMARY")
print(f"{'='*60}")
for r in results:
print(f" tb={r['tb']:>3}: {r['gt']} gen tokens, {r['wall']:.1f}s, {r['gtps']:.1f} tok/s")
if results:
best = max(results, key=lambda r: r['gtps'])
print(f"\nBest: tb={best['tb']} at {best['gtps']:.1f} tok/s")
with open("/tmp/bench_decode_results.txt", "w") as f:
f.write("tb,gen_tokens,wall_s,gen_tps,prompt_tokens\n")
for r in results:
f.write(f"{r['tb']},{r['gt']},{r['wall']:.1f},{r['gtps']:.1f},{r['pt']}\n")
print(f"\nResults: /tmp/bench_decode_results.txt")
if __name__ == "__main__":
main()
@@ -1,268 +0,0 @@
#!/usr/bin/env python3
"""
Benchmark n-gram speculative decoding vs no-spec for frank-dragoon.
Tests prompt-processing throughput with 16K token prompts (manageable request size).
Code-heavy prompt (high n-gram redundancy) vs prose prompt (low redundancy).
Usage:
# Generate prompts: python3 scripts/bench-ngram-mod.py generate /tmp/prompts/
# Run: python3 scripts/bench-ngram-mod.py /tmp/prompts/
"""
import json
import os
import sys
import time
import subprocess
import urllib.request
import urllib.error
from pathlib import Path
MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf"
CHAT_TEMPLATE = "/home/dank/serverAppConfigs/scripts/chat-template.jinja"
BUILD_DIR = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin"
LLAMA_BIN = os.path.join(BUILD_DIR, "llama-server")
# 16K tokens ~= 64KB text
TARGET_PROMPT_TOKENS = 16000
TOKEN_CHAR_RATIO = 4
TARGET_PROMPT_BYTES = TARGET_PROMPT_TOKENS * TOKEN_CHAR_RATIO
CONTEXT = 65536
BATCH = 4096
UBATCH = 4096
THREADS = 16
THREAD_BATCH = 32
NGRAM_N_MAX = 24
NGRAM_N_MIN = 4
TEST_TOKENS = 128
SERVER_WAIT = 300
def start_server(port, spec_type):
cmd = [
LLAMA_BIN,
"--model", MODEL,
"--chat-template-file", CHAT_TEMPLATE,
"--jinja",
"--device", "ROCm0",
"--gpu-layers", "999",
"--parallel", "1",
"--ctx-size", str(CONTEXT),
"--batch-size", str(BATCH),
"--ubatch-size", str(UBATCH),
"--fit", "off",
"--flash-attn", "on",
"--cache-type-k", "f16",
"--cache-type-v", "f16",
"--threads", str(THREADS),
"--threads-batch", str(THREAD_BATCH),
"--cache-prompt",
"--cache-idle-slots",
"--metrics",
"--host", "127.0.0.1",
"--port", str(port),
"--temp", "0",
]
if spec_type == "ngram-mod":
cmd.extend([
"--spec-type", "ngram-mod",
"--spec-ngram-mod-n-max", str(NGRAM_N_MAX),
"--spec-ngram-mod-n-min", str(NGRAM_N_MIN),
])
else:
cmd.extend(["--spec-type", "none"])
env = os.environ.copy()
env["ROCM_PATH"] = "/opt/rocm"
env["LD_LIBRARY_PATH"] = f"{BUILD_DIR}:/opt/rocm/lib:/opt/rocm/lib/rocm_sysdeps/lib:/opt/rocm/lib/llvm/lib"
env["HSA_OVERRIDE_GFX_VERSION"] = "11.5.1"
env["HIP_VISIBLE_DEVICES"] = "0"
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(f" Waiting for server on port {port} (spec={spec_type})...")
for i in range(SERVER_WAIT):
try:
req = urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
resp = json.loads(req.read().decode())
if resp.get("status") == "ok":
print(f" Server ready after {i+1}s")
return proc
except:
time.sleep(1)
proc.terminate()
out, err = proc.communicate()
print(f"ERROR: Server failed to start in {SERVER_WAIT}s")
print(err.decode()[-2000:])
sys.exit(1)
def run_test(prompt_text, spec_type, port):
"""Send a request and extract prompt processing metrics."""
data = json.dumps({
"model": "frank-dragoon",
"messages": [{"role": "user", "content": prompt_text}],
"max_tokens": TEST_TOKENS,
"stream": False,
"temperature": 0,
}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/v1/chat/completions",
data=data,
headers={"Content-Type": "application/json"},
)
start = time.monotonic()
resp = urllib.request.urlopen(req, timeout=600)
elapsed = time.monotonic() - start
result = json.loads(resp.read().decode())
timings = result.get("timings", {})
usage = result.get("usage", {})
return {
"prompt_tokens": usage.get("prompt_tokens", 0),
"prompt_ms": timings.get("prompt_ms", 0),
"prompt_per_second": timings.get("prompt_per_second", 0),
"prompt_per_token_ms": timings.get("prompt_per_token_ms", 0),
"predicted_per_second": timings.get("predicted_per_second", 0),
"total_time_s": elapsed,
"cache_n": timings.get("cache_n", 0),
}
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <prompt_dir>")
sys.exit(1)
prompt_dir = Path(sys.argv[1])
prompt_dir.mkdir(parents=True, exist_ok=True)
code_prompt_file = prompt_dir / "prompt_code.txt"
prose_prompt_file = prompt_dir / "prompt_prose.txt"
# Generate prompts if they don't exist
if not code_prompt_file.exists() or not prose_prompt_file.exists():
print("Generating prompts...")
# Generate deterministic prompts
# Code prompt: structured algorithms + data generation + config schemas
code_lines = []
for i in range(50):
code_lines.append(f"# Algorithm {i}\n")
code_lines.append(f"def algo_{i}(x, y):\n")
code_lines.append(f" \"\"\"Algorithm {i} processing.\"\"\"\n")
code_lines.append(f" result = 0\n")
code_lines.append(f" for j in range({i * 10}):\n")
code_lines.append(f" result += x * y + j\n")
code_lines.append(f" return result\n\n")
code_text = "".join(code_lines)
# Pad/truncate to target
while len(code_text) < TARGET_PROMPT_BYTES:
code_text += "# Additional algorithm block\n"
code_text += f"def extra_{len(code_text)//100}():\n"
code_text += " x = 1\n"
code_text += " y = 2\n"
code_text += " return x + y\n\n"
code_text = code_text[:TARGET_PROMPT_BYTES]
# Prose prompt: historical/scientific/philosophical text
prose_text = ""
topics = [
"The Renaissance brought profound transformation to European civilization. Artists and scholars pushed the boundaries of human expression.",
"Quantum mechanics describes the behavior of matter and energy at atomic scales. The uncertainty principle limits measurement precision.",
"Ethical philosophy grapples with questions of right and wrong. Utilitarianism judges actions by their consequences and the happiness they produce.",
"Mountains rise like ancient sentinels, their peaks lost in clouds that swirl and dance across the sky above the valleys below.",
"The ocean whispers secrets to the shore, each wave carrying fragments of distant lands across the water before retreating back to sea.",
]
for _ in range(500):
prose_text += f"{topics[len(prose_text) % len(topics)]} "
prose_text = prose_text[:TARGET_PROMPT_BYTES]
code_prompt_file.write_text(code_text)
prose_prompt_file.write_text(prose_text)
print(f" Code: {code_prompt_file.stat().st_size:,} bytes")
print(f" Prose: {prose_prompt_file.stat().st_size:,} bytes")
print("=" * 60)
print(" N-gram Mod Benchmark — frank-dragoon")
print(f" STRIX ROCmFP4 · 64K context · 16K prompts")
print("=" * 60)
results = []
port_base = 7772
port_ngram = 7773
tests = [
("code", "none", port_base, code_prompt_file),
("prose", "none", port_ngram, prose_prompt_file),
("code", "ngram-mod", port_base, code_prompt_file),
("prose", "ngram-mod", port_ngram, prose_prompt_file),
]
for prompt_type, spec_type, port, pfile in tests:
print(f"\n=== {spec_type} ({prompt_type}) ===")
print(f" File: {pfile} ({pfile.stat().st_size:,} bytes)")
print(f" Spec: {spec_type}")
if spec_type == "ngram-mod":
print(f" n_max={NGRAM_N_MAX}, n_min={NGRAM_N_MIN}")
proc = start_server(port, spec_type)
try:
# Warm-up
print(" Warm-up...")
run_test("Hello test", spec_type, port)
# Full prompt
print(f" Sending {TARGET_PROMPT_TOKENS} token prompt...")
with open(pfile) as f:
prompt_text = f.read()
result = run_test(prompt_text, spec_type, port)
tps = result["prompt_per_second"]
print(f" Tokens: {result['prompt_tokens']}")
print(f" Prompt: {result['prompt_ms']:.0f}ms")
print(f" TPS: {tps:.1f} tok/s")
print(f" Total: {result['total_time_s']:.1f}s")
results.append({
"prompt_type": prompt_type,
"spec_type": spec_type,
"prompt_tokens": result["prompt_tokens"],
"prompt_ms": result["prompt_ms"],
"tps": tps,
"total_time_s": result["total_time_s"],
})
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except:
proc.kill()
# Summary
print("\n" + "=" * 60)
print(" RESULTS SUMMARY")
print("=" * 60)
for ptype in ["code", "prose"]:
baseline = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "none"), None)
ngram = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "ngram-mod"), None)
if baseline and ngram:
gain = ((ngram["tps"] - baseline["tps"]) / baseline["tps"]) * 100 if baseline["tps"] > 0 else 0
print(f"\n{ptype.upper()} PROMPT:")
print(f" Baseline TPS: {baseline['tps']:.1f} tok/s ({baseline['prompt_ms']:.0f}ms)")
print(f" N-gram TPS: {ngram['tps']:.1f} tok/s ({ngram['prompt_ms']:.0f}ms)")
print(f" Speedup: {gain:+.1f}%")
print("\n" + "=" * 60)
if __name__ == "__main__":
main()
@@ -1,304 +0,0 @@
#!/usr/bin/env bash
# Benchmark n-gram speculative decoding vs no-spec for frank-dragoon.
#
# This tests prompt-processing throughput at 64k context with two distinct
# prompts: one code-heavy and one prose-heavy. Uses llama-server on scratch
# ports with the OpenAI-compatible API to measure prompt_eval_duration_ms.
#
# Run on Dankclaw after stopping the production service:
# sudo systemctl stop frank-dragoon
# ./scripts/bench-ngram-mod.sh
# sudo systemctl start frank-dragoon
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a; source "$ENV_FILE"; set +a
fi
: "${LLAMA_BIN:=/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}"
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}"
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
: "${CHAT_TEMPLATE_KWARGS:={}}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=4096}"
: "${CTX_SIZE:=65536}"
: "${NUM_THREADS:=16}"
: "${THREAD_BATCH:=32}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${FIT:=off}"
: "${TEMPERATURE:=0.7}"
: "${TOP_P:=0.8}"
: "${TOP_K:=20}"
: "${MIN_P:=0.0}"
: "${REASONING:=on}"
: "${REASONING_FORMAT:=deepseek}"
MODEL="$MODEL_DIR/$MODEL_FILE"
HOSTNAME=$(hostname 2>/dev/null || echo "dankclaw")
# Prompt files (generated by generate-prompts.py or passed via args)
PROMPT_CODE="${1:-}"
PROMPT_PROSE="${2:-}"
if [[ -z "$PROMPT_CODE" || -z "$PROMPT_PROSE" ]]; then
echo "ERROR: Need two prompt file paths."
echo "Usage: $0 <prompt_code.txt> <prompt_prose.txt>"
echo "Generate with: python3 scripts/generate-prompts.py"
exit 1
fi
if [[ ! -r "$MODEL" ]]; then
echo "missing or unreadable model: $MODEL" >&2
exit 1
fi
SERVER_DIR="$(cd -- "$(dirname -- "$LLAMA_BIN")" && pwd)"
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
# Scratch ports
PORT_NGRAM=7771
PORT_BASELINE=7772
cleanup() {
echo "Cleaning up servers..."
# Kill any leftover llama-server on our ports
for port in $PORT_NGRAM $PORT_BASELINE; do
fuser -k "$port"/tcp 2>/dev/null || true
done
}
trap cleanup EXIT
wait_for_server() {
local port=$1
local max_wait=120
local elapsed=0
while (( elapsed < max_wait )); do
if curl -s "http://127.0.0.1:$port/health" | grep -q '"running"'; then
return 0
fi
sleep 1
(( elapsed++ ))
done
echo "ERROR: Server on port $port did not start within ${max_wait}s" >&2
return 1
}
get_prompt_tokens() {
local file=$1
# Count tokens approximately (1 token ~ 4 chars for English/code)
wc -c < "$file" | awk '{printf "%d", $1/4}'
}
run_single_test() {
local port=$1
local spec_type="$2"
local prompt_file=$3
local label=$4
local ngram_n_max="${5:-24}"
local ngram_n_min="${6:-4}"
echo "=== $label ==="
echo " spec-type: $spec_type"
echo " ngram-mod n-max: $ngram_n_max (if enabled)"
echo " ngram-mod n-min: $ngram_n_min (if enabled)"
echo " prompt: $prompt_file ($(wc -c < "$prompt_file") bytes)"
# Build command
local cmd=(
/usr/bin/env
ROCM_PATH="$ROCM_PATH"
HIP_VISIBLE_DEVICES=0
LD_LIBRARY_PATH="$LIBRARY_PATH"
HSA_OVERRIDE_GFX_VERSION="${HSA_OVERRIDE_GFX_VERSION:-11.5.1}"
"$LLAMA_BIN"
--model "$MODEL"
--chat-template-file "$CHAT_TEMPLATE_FILE"
--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS"
--host 127.0.0.1
--port "$port"
--jinja
--device ROCm0
--gpu-layers 999
--parallel 1
--ctx-size "$CTX_SIZE"
--batch-size "$BATCH_SIZE"
--ubatch-size "$UBATCH_SIZE"
--fit "$FIT"
--flash-attn on
--cache-type-k "$CACHE_TYPE_K"
--cache-type-v "$CACHE_TYPE_V"
--threads "$NUM_THREADS"
--threads-batch "$THREAD_BATCH"
--cache-prompt
--cache-idle-slots
--metrics
--temp "$TEMPERATURE"
--top-p "$TOP_P"
--top-k "$TOP_K"
--min-p "$MIN_P"
--reasoning "$REASONING"
--reasoning-format "$REASONING_FORMAT"
--reasoning-budget -1
)
# Add spec flags
if [[ "$spec_type" == "ngram-mod" ]]; then
cmd+=(
--spec-type "ngram-mod"
--spec-ngram-mod-n-max "$ngram_n_max"
--spec-ngram-mod-n-min "$ngram_n_min"
)
else
cmd+=(
--spec-type "none"
)
fi
# Start server with stderr to log
local log_file="/tmp/ngram-bench-${label//[^a-zA-Z0-9]/-}.log"
echo " Starting server (log: $log_file)..."
"${cmd[@]}" 2> "$log_file" &
local server_pid=$!
if ! wait_for_server "$port"; then
echo "ERROR: Server failed to start" >&2
kill "$server_pid" 2>/dev/null || true
return 1
fi
echo " Server ready."
# Send prompt via API
echo " Sending prompt..."
local prompt_text
prompt_text=$(cat "$prompt_file")
local response
response=$(curl -s -X POST "http://127.0.0.1:$port/v1/completions" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"frank-dragoon\",
\"prompt\": \"$prompt_text\",
\"max_tokens\": 128,
\"temperature\": 0,
\"stream\": false
}")
# Extract metrics
local prompt_tokens prompt_eval_duration_ms total_duration_ms
prompt_tokens=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_count'])" 2>/dev/null || echo "0")
prompt_eval_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_duration_ms'])" 2>/dev/null || echo "0")
total_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['total_duration_ms'])" 2>/dev/null || echo "0")
# Calculate throughput
local prompt_tps=0
if [[ "$prompt_eval_duration_ms" -gt 0 ]]; then
prompt_tps=$(python3 -c "print(f'{($prompt_tokens / $prompt_eval_duration_ms) * 1000:.1f}')")
fi
echo " Prompt tokens: $prompt_tokens"
echo " Prompt eval: ${prompt_eval_duration_ms}ms"
echo " Prompt TPS: $prompt_tps tok/s"
echo " Total duration: ${total_duration_ms}ms"
# Kill server
kill "$server_pid" 2>/dev/null || true
sleep 2
echo " Done."
echo "---"
# Output structured results for parsing
echo "RESULT|$label|$prompt_file|$prompt_tokens|$prompt_eval_duration_ms|$total_duration_ms|$prompt_tps"
}
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ N-gram Mod Benchmark — frank-dragoon ║"
echo "║ STRIX ROCmFP4 · 64k context · two prompts ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
echo "Host: $HOSTNAME"
echo "Model: $MODEL"
echo "Build: $(basename "$LLAMA_BIN")"
echo "Context: $CTX_SIZE"
echo ""
# Run baseline (no spec)
echo "=== BASELINE (no speculative decoding) ==="
echo ""
BASELINE_CODE_RESULT=$(run_single_test $PORT_BASELINE "none" "$PROMPT_CODE" "Baseline-Code" 24 4)
BASELINE_PROSE_RESULT=$(run_single_test $PORT_NGRAM "none" "$PROMPT_PROSE" "Baseline-Prose" 24 4)
echo ""
echo "=== NGRAM-MOD (speculative decoding enabled) ==="
echo ""
# Run n-gram mod
NGRAM_CODE_RESULT=$(run_single_test $PORT_NGRAM "ngram-mod" "$PROMPT_CODE" "Ngram-Code" 24 4)
NGRAM_PROSE_RESULT=$(run_single_test $PORT_BASELINE "ngram-mod" "$PROMPT_PROSE" "Ngram-Prose" 24 4)
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ RESULTS SUMMARY ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
# Parse and display results
python3 << 'PYEOF'
import sys
results = []
for line in sys.stdin:
line = line.strip()
if not line.startswith("RESULT|"):
continue
parts = line.split("|")
if len(parts) != 8:
continue
label, prompt_file, prompt_tokens, prompt_eval_ms, total_ms, tps = parts[1], parts[2], int(parts[3]), int(parts[4]), int(parts[5]), float(parts[6])
results.append({
'label': label,
'prompt_file': prompt_file,
'prompt_tokens': prompt_tokens,
'prompt_eval_ms': prompt_eval_ms,
'total_ms': total_ms,
'tps': tps
})
# Compare baseline vs ngram for each prompt type
for code_result in results:
if not code_result['label'].startswith('Ngram-'):
continue
prompt_type = code_result['label'].replace('Ngram-', '').replace('Ngram', 'Baseline').replace('-', '-')
baseline = next((r for r in results if r['label'] == 'Baseline-' + prompt_type), None)
if not baseline:
continue
spec = code_result['label'] == 'Ngram-' + prompt_type
print(f"Prompt: {prompt_type}")
print(f" Tokens: {code_result['prompt_tokens']}")
if spec:
print(f" Spec: ngram-mod (n_max=24, n_min=4)")
print(f" Prompt: {code_result['prompt_eval_ms']}ms | {code_result['tps']:>10.1f} tok/s")
baseline_tps = baseline['tps']
if baseline_tps > 0:
improvement = ((code_result['tps'] - baseline_tps) / baseline_tps) * 100
print(f" Speedup: {improvement:+.1f}% vs baseline ({baseline_tps:.1f} tok/s)")
else:
print(f" Spec: none (baseline)")
print(f" Prompt: {baseline['prompt_eval_ms']}ms | {baseline['tps']:>10.1f} tok/s")
print()
PYEOF
echo ""
echo "Raw data lines above (RESULT|... format) can be parsed for automation."
echo ""
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""Benchmark decode throughput across speculative decoding configs.
Sweeps 4 configs: none, ngram-mod, draft-mtp, draft-mtp+ngram-mod.
Each config generates 16k tokens from a minimal prompt at temp 0.
Runs ON the target host (Dankclaw). Service management uses local sudo.
Usage:
python3 bench-spec-decode.py [--max-tokens N] [--host H] [--port P]
"""
import json
import subprocess
import sys
import time
import urllib.request
import urllib.error
SERVICE = "frank-dragoon"
ENV_FILE = "/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
ALIAS = "frank-dragoon"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 7770
DEFAULT_MAX_TOKENS = 16384
HEALTH_TIMEOUT = 1200 # 20 min for cold load
HEALTH_INTERVAL = 5
PROMPT = (
"Write a comprehensive, detailed essay about the complete history of "
"computing, from the earliest mechanical calculators through modern AI. "
"Cover every major era in detail, including key figures, machines, and "
"breakthroughs. Be as thorough as possible."
)
CONFIGS = [
("none", "none"),
("ngram-mod", "ngram-mod"),
("draft-mtp", "draft-mtp"),
("draft-mtp+ngram-mod", "draft-mtp,ngram-mod"),
]
def local(cmd):
"""Run a command locally (the script runs on the target host)."""
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120)
if r.returncode != 0:
print(f" WARNING: {cmd} -> exit {r.returncode}: {r.stderr.strip()}",
file=sys.stderr)
return r
def sed_spec_type(value):
local(f"sudo sed -i 's/^SPEC_TYPE=.*/SPEC_TYPE={value}/' {ENV_FILE}")
def wait_health(host, port, timeout=HEALTH_TIMEOUT):
start = time.time()
while time.time() - start < timeout:
try:
resp = urllib.request.urlopen(
f"http://{host}:{port}/health", timeout=10
)
if resp.status == 200:
return True
except (urllib.error.URLError, urllib.error.HTTPError, ConnectionError,
OSError):
pass
time.sleep(HEALTH_INTERVAL)
return False
def benchmark(host, port, alias, max_tokens):
body = json.dumps({
"model": alias,
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": max_tokens,
"temperature": 0,
"stream": False,
}).encode()
req = urllib.request.Request(
f"http://{host}:{port}/v1/chat/completions",
data=body,
headers={"Content-Type": "application/json"},
)
t0 = time.time()
resp = urllib.request.urlopen(req, timeout=1200)
wall = time.time() - t0
data = json.loads(resp.read())
return data, wall
def main():
import argparse
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--host", default=DEFAULT_HOST)
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
args = ap.parse_args()
# Read original SPEC_TYPE from the env file
r = local(f"grep '^SPEC_TYPE=' {ENV_FILE}")
original_spec = r.stdout.strip()
orig_val = (original_spec.split("=", 1)[1]
if "=" in original_spec else "draft-mtp,ngram-mod")
print(f"Original: {original_spec}")
print(f"Max tokens: {args.max_tokens}")
print(f"Configs: {[c[0] for c in CONFIGS]}")
print()
# Stop + mask the production service
print("Stopping + masking production service...")
local(f"sudo systemctl stop {SERVICE}")
local(f"sudo systemctl mask {SERVICE}")
time.sleep(2)
results = []
for name, spec_type in CONFIGS:
print(f"\n{'='*60}")
print(f"Config: {name} (SPEC_TYPE={spec_type})")
print(f"{'='*60}")
# Set SPEC_TYPE
sed_spec_type(spec_type)
print(f"Set SPEC_TYPE={spec_type}")
# Start the service
local(f"sudo systemctl unmask {SERVICE}")
local(f"sudo systemctl start {SERVICE}")
print("Starting service...")
# Wait for health
print(f"Waiting for health (up to {HEALTH_TIMEOUT}s)...")
if not wait_health(args.host, args.port):
print(f"FAILED: service did not become healthy for {name}")
results.append({
"config": name, "spec_type": spec_type,
"error": "health timeout",
})
local(f"sudo systemctl stop {SERVICE}")
local(f"sudo systemctl mask {SERVICE}")
continue
print("Healthy. Running benchmark...")
# Run benchmark
try:
data, wall = benchmark(
args.host, args.port, ALIAS, args.max_tokens
)
except Exception as e:
print(f"FAILED: {e}")
results.append({
"config": name, "spec_type": spec_type,
"error": str(e),
})
local(f"sudo systemctl stop {SERVICE}")
local(f"sudo systemctl mask {SERVICE}")
continue
timings = data.get("timings", {})
usage = data.get("usage", {})
pred_n = timings.get("predicted_n", usage.get("completion_tokens", 0))
pred_ms = timings.get("predicted_ms", 0)
pred_ps = timings.get("predicted_per_second", 0)
prompt_n = timings.get("prompt_n", usage.get("prompt_tokens", 0))
prompt_ms = timings.get("prompt_ms", 0)
print(f" Generated: {pred_n} tokens")
print(f" Wall time: {wall:.1f}s")
print(f" Decode: {pred_ps:.2f} tok/s")
print(f" Prefill: {prompt_n} tokens in {prompt_ms:.0f}ms"
f" ({prompt_n / max(prompt_ms, 1) * 1000:.1f} tok/s)")
results.append({
"config": name,
"spec_type": spec_type,
"predicted_tokens": pred_n,
"predicted_ms": pred_ms,
"predicted_per_second": pred_ps,
"prompt_tokens": prompt_n,
"prompt_ms": prompt_ms,
"wall_time": wall,
})
# Stop + mask between configs
local(f"sudo systemctl stop {SERVICE}")
local(f"sudo systemctl mask {SERVICE}")
time.sleep(2)
# Restore original SPEC_TYPE
sed_spec_type(orig_val)
print(f"\nRestoring SPEC_TYPE={orig_val}")
print("Unmasking + starting production service...")
local(f"sudo systemctl unmask {SERVICE}")
local(f"sudo systemctl start {SERVICE}")
# Health check
if wait_health(args.host, args.port):
print("Production service healthy.")
else:
print("WARNING: production service not healthy after restore!")
# Print results table
print(f"\n{'='*70}")
print("RESULTS")
print(f"{'='*70}")
print(f"{'Config':<30} {'Tokens':>8} {'Wall(s)':>10} {'Decode tok/s':>14} "
f"{'Error':>20}")
print(f"{'-'*30} {'-'*8} {'-'*10} {'-'*14} {'-'*20}")
for r in results:
if "error" in r:
print(f"{r['config']:<30} {'':>8} {'':>10} {'':>14} "
f"{r['error']:>20}")
else:
print(f"{r['config']:<30} {r['predicted_tokens']:>8} "
f"{r['wall_time']:>10.1f} {r['predicted_per_second']:>14.2f} "
f"{'':>20}")
print(f"{'='*70}")
# Save JSON results
ts = time.strftime("%Y%m%d-%H%M%S")
out = f"/tmp/bench-spec-decode-{ts}.json"
with open(out, "w") as f:
json.dump({"results": results, "max_tokens": args.max_tokens,
"timestamp": ts}, f, indent=2)
print(f"Results saved: {out}")
if __name__ == "__main__":
main()
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""bench-vllm-corpus.py — build a code-shaped benchmark corpus from the repo.
Why this exists: `vllm bench serve --dataset-name random` produces synthetic
token soup, and speculative decoding accepts it nothing like real code — measured
on frank-medium, random prompts accepted 2.9-3.6 tokens/update against the ~5.8
the upstream reference reports on a code mix, which makes the random corpus
under-report throughput by a wide margin and would pick the wrong SPEC depth.
The prompts are the shape agents actually send: a repository artifact plus an
instruction that requires reading it. Output length is fixed at bench time
(`--custom-output-len`), so the corpus carries prompts only.
Usage:
bench-vllm-corpus.py [repo-root] [out.jsonl]
Then feed it to the endpoint (see scripts/bench-vllm-serve.sh, which passes the
tokenizer the client needs and salts prompts so no run inherits a warm prefix
cache). Methodology and the numbers these corpora produced are in
serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
"""
from __future__ import annotations
import json
import pathlib
import sys
REPO = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "/home/dank/serverAppConfigs")
OUT = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else "/home/dank/bench/corpus-code.jsonl")
TARGETS = [
"scripts/deploy-remote.sh",
"scripts/deploy-plan.py",
"scripts/deploy.sh",
"dankserver/frank-medium/radiance-entrypoint.sh",
"dankserver/frank-medium/quadlet/frank-medium-a.container",
"dankserver/frank-medium/proxy/affinity-proxy.py",
"dankserver/frank-medium/proxy/routing.py",
"tests/frank_medium_quadlet.bats",
"tests/deployment_scoping.bats",
"dankclaw/hermes/quadlet/hermes.container",
"dankclaw/anubis/quadlet/anubis.container",
"shared/prometheus/prometheus.yml",
"shared/prometheus/rules/llm-fleet.yml",
"shared/loki/loki-config.yaml",
"dankclaw/compose.yaml",
"dankserver/copyparty/quadlet/copyparty.container",
]
PROMPTS = [
"Read this repository artifact and summarise what it does, then list every "
"assumption it makes about the host it runs on.\n\n```\n{body}\n```",
"Here is a file from our homelab repository. Identify any bug, race or "
"missing error path, and show the minimal patch that fixes it.\n\n```\n{body}\n```",
"Explain this configuration to a new operator, then rewrite it with "
"comments that state the constraint each line protects.\n\n```\n{body}\n```",
"This file changed in a recent deploy. Write the changelog entry and a test "
"that would fail if the change regressed.\n\n```\n{body}\n```",
]
MAX_LINES = 220
def main() -> int:
samples = []
for i, rel in enumerate(TARGETS):
path = REPO / rel
if not path.is_file():
continue
body = "\n".join(path.read_text(errors="replace").splitlines()[:MAX_LINES])
samples.append({"prompt": PROMPTS[i % len(PROMPTS)].format(body=body)})
if not samples:
print(f"no corpus sources found under {REPO}", file=sys.stderr)
return 1
OUT.parent.mkdir(parents=True, exist_ok=True)
with OUT.open("w") as fh:
for sample in samples:
fh.write(json.dumps(sample) + "\n")
print(f"wrote {len(samples)} prompts to {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
-63
View File
@@ -1,63 +0,0 @@
#!/bin/bash
# bench-vllm-serve.sh — concurrency sweep against a vLLM endpoint.
#
# Usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]
# e.g. bench-vllm-serve.sh baseline 7770 1 4 8
#
# Runs `vllm bench serve` from inside the serving image (so the client matches
# the server build) through an OpenAI-compatible endpoint, on the code corpus
# scripts/bench-vllm-corpus.py writes.
#
# Two traps this script exists to encode, both of which produced silent garbage
# during the 2026-09-16 frank-medium work:
#
# 1. The tokenizer must be passed explicitly and /models mounted. With only
# `--model frank-medium` the client tries to resolve that alias on
# HuggingFace and dies before sending a request — a run that looks like a
# benchmark and reports nothing.
# 2. `--ignore-eos` is deliberate: it measures decode throughput at a fixed
# output length. Drop it when you care about real stop behaviour, but then
# do not compare the numbers with anything measured under it.
#
# Also note the corpora are code-shaped on purpose (see the generator's header):
# synthetic random prompts misreport speculative acceptance badly.
#
# Methodology, and the numbers these runs produced on 2x R9700, are in
# serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
set -u
TAG=${1:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]}
PORT=${2:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]}
shift 2
CONCS=("$@")
[ "${#CONCS[@]}" -eq 0 ] && CONCS=(1 4 8)
IMG=${IMG:-localhost/radiance-vllm:0.9.3}
MODELS_DIR=${MODELS_DIR:-/home/dank/models}
TOK=${TOK:-/models/Qwen3.8-27B-Uncensored-MXFP4-awq}
BENCH_DIR=${BENCH_DIR:-/home/dank/bench}
CORPUS=${CORPUS:-$BENCH_DIR/corpus-code.jsonl}
OUT=${OUT:-$BENCH_DIR/$TAG}
mkdir -p "$OUT"
if [ ! -f "$CORPUS" ]; then
echo "corpus $CORPUS is missing; run scripts/bench-vllm-corpus.py first" >&2
exit 1
fi
for c in "${CONCS[@]}"; do
echo "== $TAG conc=$c (port $PORT)"
podman run --rm --network=host -v "$MODELS_DIR":/models:ro -v "$BENCH_DIR":/bench:ro \
-e HF_HUB_OFFLINE=1 --entrypoint bash "$IMG" -lc "
vllm bench serve --backend openai-chat \
--base-url http://127.0.0.1:$PORT --endpoint /v1/chat/completions \
--model frank-medium --tokenizer $TOK \
--dataset-name custom --dataset-path /bench/$(basename "$CORPUS") \
--custom-output-len 512 --num-prompts 16 --max-concurrency $c \
--disable-shuffle --seed 0 --ignore-eos \
--percentile-metrics ttft,tpot --metric-percentiles 50,99 \
--disable-tqdm 2>&1 | grep -E 'Successful requests|Benchmark duration|Output token throughput|Total Token throughput|Mean TTFT|P50 TTFT|P50 TPOT|Acceptance rate|Acceptance length'
" 2>&1 | sed 's/^/ /'
echo
done
echo "results for $TAG written under $OUT"
@@ -1,453 +0,0 @@
{%- set template_version = "qwen3.8-froggeric-v22.4.0" %}
{%- set _tool_format = tool_call_format if tool_call_format is defined else 'xml' %}
{%- set image_count = namespace(value=0) %}
{%- set video_count = namespace(value=0) %}
{%- set add_vision_id = add_vision_id if add_vision_id is defined else false %}
{%- set enable_thinking = enable_thinking if enable_thinking is defined else true %}
{%- set auto_disable_thinking_with_tools = auto_disable_thinking_with_tools if auto_disable_thinking_with_tools is defined else false %}
{%- if preserve_reasoning is defined and preserve_reasoning is not none %}
{%- set _preserve_thinking = preserve_reasoning %}
{%- elif preserve_thinking is defined and preserve_thinking is not none %}
{%- set _preserve_thinking = preserve_thinking %}
{%- else %}
{%- set _preserve_thinking = true %}
{%- endif %}
{%- set max_tool_arg_chars = max_tool_arg_chars if max_tool_arg_chars is defined else 0 %}
{%- set max_tool_response_chars = max_tool_response_chars if max_tool_response_chars is defined else 0 %}
{%- set _default_reasoning_effort = 'medium' %}
{%- set _has_tools = (tools is defined and tools and tools is iterable and tools is not mapping) %}
{%- set _effort_raw = (reasoning_effort | string | lower) if reasoning_effort is defined and reasoning_effort is not none else _default_reasoning_effort %}
{%- set _initial_thinking = enable_thinking %}
{%- if _effort_raw in ('none', 'off') %}
{%- set _initial_thinking = false %}
{%- set _initial_effort = 'medium' %}
{%- elif _effort_raw in ('minimal', 'low') %}
{%- set _initial_effort = 'low' %}
{%- elif _effort_raw in ('high', 'xhigh', 'max', 'ultracode', 'extreme') %}
{%- set _initial_effort = 'xhigh' %}
{%- else %}
{%- set _initial_effort = 'medium' %}
{%- endif %}
{%- set ns_state = namespace(thinking=_initial_thinking, effort=_initial_effort) %}
{%- if auto_disable_thinking_with_tools and _has_tools %}
{%- set ns_state.thinking = false %}
{%- endif %}
{%- for msg in messages %}
{%- if msg.role == 'system' or msg.role == 'developer' or msg.role == 'user' %}
{%- if msg.content is string %}
{%- if '<|think_off|>' in msg.content %}
{%- set ns_state.thinking = false %}
{%- elif '<|think_on|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- elif '<|think_xhigh|>' in msg.content or '<|think_high|>' in msg.content or '<|think_ultracode|>' in msg.content or '<|think_extreme|>' in msg.content or '<|think_max|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'xhigh' %}
{%- elif '<|think_low|>' in msg.content or '<|think_minimal|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'low' %}
{%- elif '<|think_medium|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'medium' %}
{%- endif %}
{%- elif msg.content is iterable and msg.content is not mapping %}
{%- for item in msg.content %}
{%- if item is string %}
{%- set _item_text = item %}
{%- elif item is mapping and 'text' in item and item.text is string %}
{%- set _item_text = item.text %}
{%- else %}
{%- set _item_text = '' %}
{%- endif %}
{%- if _item_text %}
{%- if '<|think_off|>' in _item_text %}
{%- set ns_state.thinking = false %}
{%- elif '<|think_on|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- elif '<|think_xhigh|>' in _item_text or '<|think_high|>' in _item_text or '<|think_ultracode|>' in _item_text or '<|think_extreme|>' in _item_text or '<|think_max|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'xhigh' %}
{%- elif '<|think_low|>' in _item_text or '<|think_minimal|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'low' %}
{%- elif '<|think_medium|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'medium' %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- set reasoning_instructions = '' %}
{%- if ns_state.thinking %}
{%- if ns_state.effort == 'xhigh' %}
{%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
{%- elif ns_state.effort == 'low' %}
{%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
{%- endif %}
{%- endif %}
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
{%- if content is string %}
{{- content }}
{%- elif content is iterable and content is not mapping %}
{%- for item in content %}
{%- if item is mapping %}
{%- if item.type == 'image' or 'image' in item or 'image_url' in item %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain images.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set image_count.value = image_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Picture ' ~ image_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
{%- elif item.type == 'video' or 'video' in item %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain videos.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set video_count.value = video_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Video ' ~ video_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
{%- elif 'text' in item %}
{{- item.text }}
{%- else %}
{{- raise_exception('Unexpected item type in content.') }}
{%- endif %}
{%- else %}
{{- item | string }}
{%- endif %}
{%- endfor %}
{%- elif content is none or content is undefined %}
{{- '' }}
{%- else %}
{{- raise_exception('Unexpected content type.') }}
{%- endif %}
{%- endmacro %}
{%- if not messages %}
{{- raise_exception('No messages provided.') }}
{%- endif %}
{%- set head = namespace(count=0, seen_non_system=false) %}
{%- for message in messages %}
{%- set _is_sys = (message.role == 'system' or message.role == 'developer') %}
{%- if _is_sys and not head.seen_non_system %}
{%- set head.count = head.count + 1 %}
{%- else %}
{%- set head.seen_non_system = true %}
{%- endif %}
{%- endfor %}
{%- set sys_state = namespace(content='') %}
{%- for message in messages[:head.count] %}
{%- set _part = render_content(message.content, false, true) | trim %}
{%- if '<|think_off|>' in _part %}{%- set _part = _part.split('<|think_off|>') | join('') | trim %}{%- endif %}
{%- if '<|think_on|>' in _part %}{%- set _part = _part.split('<|think_on|>') | join('') | trim %}{%- endif %}
{%- if '<|think_xhigh|>' in _part %}{%- set _part = _part.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
{%- if '<|think_high|>' in _part %}{%- set _part = _part.split('<|think_high|>') | join('') | trim %}{%- endif %}
{%- if '<|think_ultracode|>' in _part %}{%- set _part = _part.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
{%- if '<|think_extreme|>' in _part %}{%- set _part = _part.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
{%- if '<|think_max|>' in _part %}{%- set _part = _part.split('<|think_max|>') | join('') | trim %}{%- endif %}
{%- if '<|think_medium|>' in _part %}{%- set _part = _part.split('<|think_medium|>') | join('') | trim %}{%- endif %}
{%- if '<|think_low|>' in _part %}{%- set _part = _part.split('<|think_low|>') | join('') | trim %}{%- endif %}
{%- if '<|think_minimal|>' in _part %}{%- set _part = _part.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
{%- if _part %}
{%- if sys_state.content %}
{%- set sys_state.content = sys_state.content ~ '\n\n' ~ _part %}
{%- else %}
{%- set sys_state.content = _part %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- set _sc = sys_state.content %}
{%- set _msgs = messages[head.count:] %}
{%- if ns_state.thinking %}
{%- set _terse_lead = 'Answer directly, after thinking. Lead with the answer, then only what it needs to be correct and usable.' %}
{%- else %}
{%- set _terse_lead = 'Answer directly and concisely. Give the answer with only what it needs to be correct and usable.' %}
{%- endif %}
{%- set _terse_core %}
Never: open with preamble or pleasantries; restate the question; add filler transitions; hedge with niceties; or repeat a point you've already made.
Always: keep essential steps, caveats, uncertainties, and specifics — never drop correctness or a needed warning for brevity. Keep the final answer lean. Use the least structure that conveys it (plain prose when short; lists or code only when they earn their place). If genuinely uncertain, say so and explain why — never omit uncertainty for the sake of brevity.
If a user request is genuinely ambiguous, ask a sharp question, don't guess.
{%- endset %}
{%- set _terse = _terse_lead ~ '\n' ~ (_terse_core | trim) %}
{#- `terse` chat-template kwarg: default true, so behaviour is unchanged unless a caller opts out.
Pass {"terse": false} via chat_template_kwargs to serve the model with only its own system
prompt -- useful for A/B-ing the terseness effect, or when a downstream prompt conflicts. #}
{%- set _terse_on = terse if terse is defined else true %}
{%- if _terse_on %}
{%- if not _sc %}
{%- set _sc = _terse | trim %}
{%- else %}
{%- set _sc = (_sc | trim) ~ '\n\n' ~ (_terse | trim) %}
{%- endif %}
{%- endif %}
{%- if _has_tools %}
{{- '<|im_start|>system\n' }}
{%- if reasoning_instructions %}
{{- reasoning_instructions + '\n\n' }}
{%- endif %}
{{- '# Tools\n\nYou have access to the following functions:\n\n<tools>' }}
{%- for tool in tools %}
{{- '\n' }}
{{- tool | tojson }}
{%- endfor %}
{{- '\n</tools>' }}
{%- if _tool_format == 'json' %}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n{"name": "example_function_name", "arguments": {"example_parameter_1": "value_1", "example_parameter_2": "This is the value for the second parameter"}}\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: a single JSON object with "name" and "arguments" keys inside <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> tag MUST be at the very beginning of a new line, with NO spaces or indentation before it.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
{%- else %}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> and <function> tags MUST be at the very beginning of a new line, with NO spaces or indentation before them.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
{%- endif %}
{%- if _sc %}
{{- '\n\n' + _sc }}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- if _sc %}
{{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + _sc + '<|im_end|>\n' }}
{%- elif reasoning_instructions %}
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- set _last_idx = _msgs | length - 1 %}
{%- set ns = namespace(multi_step_tool=true, last_query_index=_last_idx) %}
{%- for message in _msgs[::-1] %}
{%- set index = (_msgs | length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == 'user' %}
{%- set _rc = render_content(message.content, false) | trim %}
{%- if not (_rc.startswith('<tool_response>') and _rc.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if ns.multi_step_tool %}
{%- if _last_idx > 50 %}
{%- set ns.last_query_index = _last_idx %}
{%- else %}
{%- set ns.last_query_index = 0 %}
{%- endif %}
{%- endif %}
{%- set ns2 = namespace(prev_role='', consecutive_failures=0) %}
{%- for message in _msgs %}
{%- set is_system = (message.role == "system" or message.role == "developer") %}
{%- set content = render_content(message.content, true, is_system) | trim %}
{%- if is_system or message.role == 'user' %}
{%- if '<|think_off|>' in content %}{%- set content = content.split('<|think_off|>') | join('') | trim %}{%- endif %}
{%- if '<|think_on|>' in content %}{%- set content = content.split('<|think_on|>') | join('') | trim %}{%- endif %}
{%- if '<|think_xhigh|>' in content %}{%- set content = content.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
{%- if '<|think_high|>' in content %}{%- set content = content.split('<|think_high|>') | join('') | trim %}{%- endif %}
{%- if '<|think_ultracode|>' in content %}{%- set content = content.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
{%- if '<|think_extreme|>' in content %}{%- set content = content.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
{%- if '<|think_max|>' in content %}{%- set content = content.split('<|think_max|>') | join('') | trim %}{%- endif %}
{%- if '<|think_medium|>' in content %}{%- set content = content.split('<|think_medium|>') | join('') | trim %}{%- endif %}
{%- if '<|think_low|>' in content %}{%- set content = content.split('<|think_low|>') | join('') | trim %}{%- endif %}
{%- if '<|think_minimal|>' in content %}{%- set content = content.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
{%- endif %}
{%- if is_system %}
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
{%- elif message.role == 'user' %}
{%- set ns2.consecutive_failures = 0 %}
{{- '<|im_start|>user\n' + content + '<|im_end|>\n' }}
{%- elif message.role == 'assistant' %}
{%- set reasoning_content = '' %}
{%- set _explicit_reasoning = '' %}
{%- if message.reasoning_content is defined and message.reasoning_content is not none %}
{%- if message.reasoning_content is string %}
{%- set _explicit_reasoning = message.reasoning_content %}
{%- else %}
{%- set _explicit_reasoning = message.reasoning_content | string %}
{%- endif %}
{%- elif message.thinking is defined and message.thinking is not none %}
{%- if message.thinking is string %}
{%- set _explicit_reasoning = message.thinking %}
{%- else %}
{%- set _explicit_reasoning = message.thinking | string %}
{%- endif %}
{%- elif message.reasoning is defined and message.reasoning is not none %}
{%- if message.reasoning is string %}
{%- set _explicit_reasoning = message.reasoning %}
{%- else %}
{%- set _explicit_reasoning = message.reasoning | string %}
{%- endif %}
{%- endif %}
{%- if _explicit_reasoning %}
{%- set _lead_end = '' %}
{%- if content.startswith('<think>') and '</think>' in content %}
{%- set _lead_end = '</think>' %}
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
{%- set _lead_end = '</thinking>' %}
{%- elif content.startswith('</think>') %}
{%- set _lead_end = '</think>' %}
{%- elif content.startswith('</thinking>') %}
{%- set _lead_end = '</thinking>' %}
{%- endif %}
{%- if _lead_end %}
{%- set content = content.split(_lead_end)[-1].lstrip('\n') %}
{%- endif %}
{%- set reasoning_content = _explicit_reasoning %}
{%- else %}
{%- set _think_end = '' %}
{%- if content.startswith('</think>') %}
{%- set _think_end = '</think>' %}
{%- elif content.startswith('</thinking>') %}
{%- set _think_end = '</thinking>' %}
{%- elif '\n</think>' in content %}
{%- set _think_end = '\n</think>' %}
{%- elif '\n</thinking>' in content %}
{%- set _think_end = '\n</thinking>' %}
{%- elif '\n</ think>' in content %}
{%- set _think_end = '\n</ think>' %}
{%- elif '\n</think >' in content %}
{%- set _think_end = '\n</think >' %}
{%- elif content.startswith('<think>') and '</think>' in content %}
{%- set _think_end = '</think>' %}
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
{%- set _think_end = '</thinking>' %}
{%- endif %}
{%- if _think_end %}
{%- if 'thinking' in _think_end %}
{%- set _think_start = '<thinking>' %}
{%- else %}
{%- set _think_start = '<think>' %}
{%- endif %}
{%- set reasoning_content = content.split(_think_end)[0].rstrip('\n') %}
{%- if _think_start in reasoning_content %}
{%- set reasoning_content = reasoning_content.split(_think_start)[-1].lstrip('\n') %}
{%- endif %}
{%- set content = content.split(_think_end)[-1].lstrip('\n') %}
{%- endif %}
{%- endif %}
{%- set reasoning_content = reasoning_content | trim %}
{%- if (_preserve_thinking or loop.index0 > ns.last_query_index) %}
{{- '<|im_start|>assistant\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
{%- else %}
{{- '<|im_start|>assistant\n' + content }}
{%- endif %}
{%- if message.tool_calls is defined and message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
{%- for tool_call in message.tool_calls %}
{%- if tool_call.function is defined and tool_call.function is not none %}
{%- set tc = tool_call.function %}
{%- else %}
{%- set tc = tool_call %}
{%- endif %}
{%- set tc_name = tc.name if (tc.name is defined and tc.name is not none) else '' %}
{%- if _tool_format == 'json' %}
{%- if loop.first %}
{%- if content | trim %}
{{- '\n\n' }}
{%- endif %}
{%- else %}
{{- '\n' }}
{%- endif %}
{%- set _args = '{}' %}
{%- if tc.arguments is defined and tc.arguments is not none %}
{%- if tc.arguments is mapping %}
{%- set _args = tc.arguments | tojson %}
{%- elif tc.arguments is string %}
{%- if tc.arguments %}
{%- set _args = tc.arguments %}
{%- endif %}
{%- else %}
{%- set _args = tc.arguments | tojson %}
{%- endif %}
{%- endif %}
{{- '<tool_call>\n{"name": ' }}{{- tc_name | tojson }}{{- ', "arguments": ' }}{{- _args }}{{- '}\n</tool_call>' }}
{%- else %}
{%- if loop.first %}
{%- if content | trim %}
{{- '\n\n<tool_call>\n<function=' + tc_name + '>\n' }}
{%- else %}
{{- '<tool_call>\n<function=' + tc_name + '>\n' }}
{%- endif %}
{%- else %}
{{- '\n<tool_call>\n<function=' + tc_name + '>\n' }}
{%- endif %}
{%- if tc.arguments is defined and tc.arguments is not none %}
{%- if tc.arguments is mapping %}
{%- for args_name, args_value in tc.arguments.items() %}
{{- '<parameter=' + args_name + '>\n' }}
{%- if args_value is string %}
{%- set _av = args_value %}
{%- else %}
{%- set _av = args_value | tojson %}
{%- endif %}
{%- if max_tool_arg_chars > 0 and _av | length > max_tool_arg_chars %}
{{- _av[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_av | length | string) ~ ' chars]' }}
{%- else %}
{{- _av }}
{%- endif %}
{{- '\n</parameter>\n' }}
{%- endfor %}
{%- else %}
{%- if tc.arguments is string %}
{%- set _raw_args = tc.arguments %}
{%- else %}
{%- set _raw_args = tc.arguments | tojson %}
{%- endif %}
{%- if _raw_args %}
{%- if max_tool_arg_chars > 0 and _raw_args | length > max_tool_arg_chars %}
{{- _raw_args[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_raw_args | length | string) ~ ' chars]' }}
{%- else %}
{{- _raw_args }}
{%- endif %}
{%- endif %}
{%- endif %}
{%- endif %}
{{- '</function>\n</tool_call>' }}
{%- endif %}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- elif message.role == 'tool' %}
{%- set _content_lower = content | lower %}
{%- set _content_head = _content_lower[:120] %}
{%- set _is_code_or_grep = ('throw new ' in _content_lower or 'throw error' in _content_lower or 'console.error' in _content_lower or 'logger.error' in _content_lower or 'logging.error' in _content_lower or 'import ' in _content_head or 'def ' in _content_head or 'function ' in _content_head) %}
{%- set _exit_code_zero = ('exit code: 0' in _content_head or 'process exited with code 0' in _content_head) %}
{%- set _error_field_ok = ('"error": null' in _content_head or '"error":null' in _content_head or '"error": false' in _content_head or '"error":false' in _content_head or '"error": ""' in _content_head or '"error":""' in _content_head) %}
{%- set _strong_error = (('"error":' in _content_head and not _error_field_ok) or '"status": "error"' in _content_head or '"status":"error"' in _content_head or 'traceback (most recent call last):' in _content_head or 'command not found' in _content_head or 'invalid syntax' in _content_head or 'fatal:' in _content_head or (('exit code: ' in _content_head or 'process exited with code' in _content_head) and not _exit_code_zero) or _content_head.startswith('exception:') or _content_head.startswith('failed to ')) %}
{%- set _weak_error = ('error:' in _content_head or 'err!' in _content_head) %}
{%- set _weak_suppressed = ('$ ' in _content_head or 'took ' in _content_head or content | length >= 600) %}
{%- if not _is_code_or_grep and (_strong_error or (_weak_error and not _weak_suppressed)) %}
{%- set ns2.consecutive_failures = ns2.consecutive_failures + 1 %}
{%- else %}
{%- set ns2.consecutive_failures = 0 %}
{%- endif %}
{%- if ns2.prev_role != 'tool' %}
{{- '<|im_start|>user' }}
{%- endif %}
{%- if _tool_format != 'json' and max_tool_response_chars > 0 and content | length > max_tool_response_chars %}
{%- set content = content[:max_tool_response_chars] + '\n[TRUNCATED - original length ' ~ (content | length | string) ~ ' chars]' %}
{%- endif %}
{{- '\n<tool_response>\n' + content }}
{%- if ns2.consecutive_failures >= 2 %}
{{- '\n\n⚠️ SYSTEM WARNING: ' ~ ns2.consecutive_failures ~ ' consecutive tool errors detected. Your previous approach is incorrect. You MUST use a fundamentally different approach or corrected arguments.' }}
{%- elif ns2.consecutive_failures == 1 %}
{{- '\n\n⚠️ SYSTEM WARNING: The previous tool call returned an error. Diagnose the failure and retry with completely corrected arguments.' }}
{%- endif %}
{{- '\n</tool_response>' }}
{%- if loop.last %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- set _next_role = _msgs[loop.index0 + 1].role %}
{%- if _next_role != 'tool' %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- else %}
{{- '<|im_start|>user\n[' + message.role + ']: ' + content + '<|im_end|>\n' }}
{%- endif %}
{%- set ns2.prev_role = message.role %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if not ns_state.thinking %}
{{- '<think>\n\n</think>\n\n' }}
{%- else %}
{{- '<think>\n' }}
{%- endif %}
{%- endif %}
@@ -1,86 +0,0 @@
#!/usr/bin/env bash
# Download the kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT payload for
# frank-medium from HuggingFace into
# /home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT.
#
# Run on Dankserver (the box that serves it). The repo is public, so the
# download works anonymously; a token in ~/.cache/huggingface/token is used
# when present, which keeps the ~28 GB main file clear of the anonymous
# rate limit at full speed.
#
# bash scripts/download-q8agent.sh
# bash scripts/download-q8agent.sh --verify-only
#
# The script is resumable (curl -C -) and verifies every artifact's size
# afterwards, so a truncated file fails loudly instead of load-crashing.
set -euo pipefail
REPO="kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF"
DIR="/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT"
EXPECTED=(
"Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf:28214425952"
"mmproj-Qwen3.8-27B-BF16.gguf:931145888"
"mtp-Qwen3.8-27B-Q4_0.gguf:1680271648"
)
# The model card's MTP prompt-cache fix ships in the same repo as a patch;
# fetched for reference only. This fork's main already carries the
# speculative boundary in the context checkpoint, so nothing applies it.
EXTRA=(
"patches/mtp-prompt-cache-fix.patch"
)
cd -- "$(dirname -- "$0")/.."
main() {
local mode="download"
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
mkdir -p "$DIR"
cd "$DIR"
local token="" auth=()
if [[ -r "$HOME/.cache/huggingface/token" ]]; then
token="$(cat "$HOME/.cache/huggingface/token")"
auth=(-H "Authorization: Bearer $token")
else
echo "no HF token at $HOME/.cache/huggingface/token; downloading anonymously"
fi
dl() {
local f="$1"
mkdir -p "$(dirname "$f")"
curl -fsSL -C - -o "$f" --retry 10 --retry-delay 5 --retry-max-time 10800 \
"${auth[@]}" \
"https://huggingface.co/$REPO/resolve/main/$f"
echo " done: $f ($(du -h "$f" | cut -f1))"
}
if [[ "$mode" == "download" ]]; then
echo "downloading $REPO into $DIR"
for entry in "${EXPECTED[@]}"; do
dl "${entry%%:*}"
done
for f in "${EXTRA[@]}"; do
dl "$f"
done
fi
echo "verifying sizes"
local ok=1 f want act
for entry in "${EXPECTED[@]}"; do
f="${entry%%:*}"; want="${entry##*:}"
act="$(stat -c%s "$f" 2>/dev/null || echo 0)"
if [[ "$act" -eq "$want" ]]; then
echo " OK $f ($act bytes)"
else
echo " BAD $f ($act bytes, want $want)" >&2
ok=0
fi
done
if [[ -z "$token" ]]; then
echo "note: no token was used; sizes above are the integrity check"
fi
[[ "$ok" -eq 1 ]] || exit 1
echo "all files present and sized correctly"
}
main "$@"
-17
View File
@@ -1,17 +0,0 @@
# Tunables for frank-default-npu (FastFlowLM NPU endpoint, Dankclaw)
# Runtime: FLM 1.0.1 on the XDNA2 NPU (50 TOPS). Model: Gemma4-E4B-IT
# (8B, Q4_1, vision + audio + reasoning). The NPU allocates from the
# unified pool shared with frank-default-large (the CIRU llama.cpp
# service on the iGPU). See serverAppConfigs-docs/FastFlowLM NPU Inference.md.
#
# 2026-08-20: gemma4-it:e4b deployed. Footprint ~9.1 GB. 64k context.
FLM_MODEL=gemma4-it:e4b
FLM_HOST=0.0.0.0
FLM_PORT=52626
FLM_PMODE=performance
FLM_ASR=0
# 64K context (native max for gemma4-it is 32768, but NPU supports up to 256k).
# The model's max_prefill_len is 4096, so prompts longer than that get
# truncated at prefill time.
FLM_CTX_LEN=65536
@@ -1,17 +0,0 @@
[Unit]
Description=FastFlowLM NPU inference (frank-default-npu) on Dankclaw
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
Environment="FLM_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-npu.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-default-npu.sh
LimitMEMLOCK=infinity
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,105 +0,0 @@
# Tunables for frank-dragoon (mradermacher i1-Q4_K_M of
# orcarouter/Qwen3.8-Flash-Next-Uncensored on ik_llama.cpp, Dankclaw,
# RTX 3090 CUDA + Strix Halo CPU path, since 2026-09-15).
#
# Runtime: ik_llama.cpp master d85a1d7 (2026-09-15) with full qwen4exp
# support (upstream PRs #2365/#2369/#2373 merged). Build recipe (CUDA for
# the 3090 at sm_86, Zen5 AVX-512 IQK kernels for the CPU expert path):
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
# -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 \
# -DGGML_AVX512=ON -DGGML_AVX512_VBMI=ON -DGGML_AVX512_VNNI=ON \
# -DGGML_AVX512_BF16=ON -DLLAMA_CURL=OFF
# The startup banner must print HAVE_FANCY_SIMD is defined — without those
# macros the quantized GEMM silently falls back to AVX2 (no warning).
#
# Model: mradermacher imatrix quant of the uncensored Flash-Next (qwen4exp
# MoE: 512 experts / 10 used, 48 layers = 12 attention + 36 GDN/ssm, QSA
# indexer, hyper-connections, PLE n-gram table ~320M rows). 111 GiB shipped
# as 3 parts, concatenated on disk; each part's sha256 was verified against
# its HF LFS oid at download time.
#
# Placement (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE):
# - -ot "exps=CPU": ALL 512-expert tensors (121B elems, ~84 GB) live in
# the Strix Halo's LPDDR5X via the plain CPU backend buffer — mmap-backed
# page cache, ik's fastest known configuration for this shape (the
# author's 2x3090 + Threadripper reference box measures ~50 t/s on the
# same model family). The shared experts (shexp) keep their GPU-side
# default: they run on every token.
# - -ot "per_layer_token_embd=CPU": the ~32 GB PLE n-gram table follows —
# only a tiny slice is touched per token, so it streams from page cache.
# - NEVER use --cpu-moe on this host: it routes the CPU-overridden
# weights through the CUDA_Host PINNED buffer (measured 2026-09-15:
# 108.12 GiB page-locked, evicted every co-tenant into swap and starved
# sshd into unreachability until a power cycle). -ot ...=CPU is the
# mmap-backed path with identical DDR5 bandwidth for resident pages,
# and the kernel degrades gracefully under pressure (page cache evict
# + re-read) instead of stranding anyone.
# - GGML_CUDA_NO_PINNED_WEIGHTS=1 (src/llama-load-tensors.cpp) keeps the
# CPU-overridden weights mmapped and leaves only the small staging
# buffers pinned. (GGML_CUDA_NO_PINNED would also unpin staging —
# unnecessary here and slower for prefill transfers.)
#
# Sampling: greedy-by-default to preserve the halogen-era client contract
# (a request without temperature decodes greedy; temp 0.0 = argmax).
# Clients that send their own temperature get their own values.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (see
# the 2026-08-13 HIP incident in AGENTS.md). llama.cpp reads --host and
# --temp as CLI flags, never as environment variables.
# ik_llama.cpp runtime binary (master d85a1d7 build).
LLAMA_BIN=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server
# Model package from
# https://huggingface.co/mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUF
# (parts concatenated). The mmproj is the matching vision tower from the
# static repo mradermacher/Qwen3.8-Flash-Next-Uncensored-GGUF — ik_llama.cpp
# links examples/mtmd, so image input survives the runtime swap.
MODEL_DIR=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M
MODEL_FILE=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf
MMPROJ_PATH=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The
# server emits reasoning in DeepSeek format so clients receive
# reasoning_content separately instead of literal think tags in content.
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
# Alias + network.
ALIAS=frank-dragoon
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
# Serving: single 262144-token slot (the model's full context window),
# batch 4096 / ubatch 512, 16+16 threads (the Halo CPU has 16 cores, no
# SMT), f16 KV. The KV cache scales with the 12 full-attention layers only
# (~6 GB f16 at 262k); the GDN/ssm layers keep constant-size state. If
# VRAM headroom is needed, --cache-type-k/v q8_0 halves the KV footprint.
CTX_SIZE=262144
PARALLEL=1
BATCH_SIZE=4096
UBATCH_SIZE=512
NUM_THREADS=16
THREAD_BATCH=16
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
# GPU/MoE placement: everything on the 3090 except the experts and the PLE
# table, which both live in host RAM via the CPU backend buffer (see the
# placement notes above).
NGL=999
# ik_llama.cpp CUDA host-memory policy: CPU-overridden weights stay mmapped;
# only the small staging buffers are pinned (see the placement notes above).
GGML_CUDA_NO_PINNED_WEIGHTS=1
# Prompt cache (RAM budget in MiB) and slot state dir.
CACHE_RAM=8192
SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state
# Greedy-by-default (halogen contract). See sampling note above.
TEMPERATURE=0.0
# Reasoning on: DeepSeek format projects thinking into the API's
# reasoning_content field instead of literal think tags in content;
# unlimited reasoning budget (the model decides).
REASONING_FORMAT=deepseek
REASONING_BUDGET=-1
@@ -1,21 +0,0 @@
[Unit]
Description=ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M 177B MoE (dankclaw, frank-dragoon)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
# Staging buffers for the CUDA transfers are page-locked; the weights
# themselves stay mmapped (GGML_CUDA_NO_PINNED_WEIGHTS=1), but the staging
# budget still needs room. Parity with the halogen quadlet's
# Ulimit=memlock=-1:-1.
LimitMEMLOCK=infinity
Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,138 +0,0 @@
# Tunables for frank-medium (kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF,
# charlie12345/ROCmFPX llama.cpp, Dankserver, 2x AMD Radeon AI PRO R9700).
#
# Hardware (2026-09-15): the RTX 3090 left Dankserver and two Radeon AI PRO
# R9700 cards (Navi 48, gfx1201, RDNA4, 32,624 MiB each, PCIe 5.0 x16) took
# its place. The Raphael iGPU (gfx1036) is still enumerated by HIP as the
# third device. There is no XGMI link between the two cards and they sit in
# different IOMMU groups, so any cross-GPU traffic goes over PCIe.
#
# Runtime: charlie12345/ROCmFPX (llama.cpp fork, ROCmFPX quant family; the
# Q8_0_ROCMFPX_AGENT tensor type exists nowhere else - stock llama.cpp cannot
# load this GGUF). Built for gfx1201 with the fork's self-contained ROCm
# 7.14 toolchain so the host's stale /opt/rocm-6.3.3 install is never used:
#
# cd /home/dank/GitPackages/charlie-ROCmFPX
# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh
#
# gfx1201 must be the build target: a gfx1200 binary links, loads the model
# and then segfaults on this card (fork's docs/BUILD-AMD-ARCHITECTURES.md).
#
# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT - Q8_0_ROCMFPX_AGENT
# (ftype 115), 26.28 GiB, 8.39 bpw, arch qwen35 (MoE), quantized from a
# 51.3 GiB BF16 source. The AGENT routing keeps 12 more attention-output
# projections at full Q8_0 than the plain 8-bit build, which buys a higher
# MTP draft acceptance (0.953 vs 0.911) and that is the whole point of this
# checkpoint. Model card documents 131,072 context.
#
# Parallelism (--split-mode layer, operator call 2026-09-15 "pipeline
# parallelism"): layers are distributed across the two cards in order and
# activations hop PCIe at the boundary. Measured with llama-bench
# (pp4096 / tg128 / same at depth 32768, f16 KV, fa on, 3 reps):
#
# single R9700 1160.9 / 19.42 (fits: 26.27 GiB weights, ~5 GiB left)
# layer 2x 1991.2 / 18.76 859.1 / 17.64
# tensor 2x 271.8 / 26.26 228.6 / 25.09
#
# Tensor parallel wins decode (+40 %) but collapses prefill 7x: this build
# has no RCCL (llama.cpp logs "AllReduce init failed; falling back to
# meta-backend butterfly"), so every layer pays a slow cross-card reduction.
# Agentic traffic is prefill-heavy, so layer split wins the workload. Revisit
# if the fork ships a working RCCL tensor path.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as
# env vars.
# charlie12345/ROCmFPX llama-server (self-contained ROCm 7.14 build, RPATH
# points at the bundled runtime libs; no LD_LIBRARY_PATH needed).
LLAMA_BIN=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server
# Model package, fetched with scripts/download-q8agent.sh:
# Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf 26.28 GiB main weights
# mtp-Qwen3.8-27B-Q4_0.gguf 1.68 GiB MTP draft head
# mmproj-Qwen3.8-27B-BF16.gguf 0.93 GiB vision tower (unused)
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT
MODEL_FILE=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf
DRAFT_MODEL_FILE=mtp-Qwen3.8-27B-Q4_0.gguf
# Alias + network. The port is the fleet's dankserver endpoint; the alias is
# what clients ask for, and it replaces the retired `frank-small`.
ALIAS=frank-medium
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
# Device placement. HIP_VISIBLE_DEVICES hides the iGPU (device 2) so nothing
# can drift onto shared system RAM; DEVICES is what llama.cpp splits across.
HIP_VISIBLE_DEVICES=0,1
DEVICES=ROCm0,ROCm1
SPLIT_MODE=layer
# TENSOR_SPLIT empty = layers distributed by free VRAM (equal here).
TENSOR_SPLIT=
NGL=999
# Auto-fit has no business resizing a context we set explicitly.
FIT=off
FLASH_ATTN=on
# 131,072 tokens, one slot (the model card's documented window; the GGUF
# reports n_ctx_train 262144, untested by the model author). Layer split
# keeps each layer's KV on the card that owns the layer, so the window is
# bounded by per-card free memory: ~5.6 GiB of KV per card at 131072 f16.
CTX_SIZE=131072
PARALLEL=1
# 26.28 GiB of weights + MTP draft + KV cache leaves ~15 GiB per card at this
# context, so the batch shape is chosen for prefill throughput, not memory.
BATCH_SIZE=2048
UBATCH_SIZE=512
NUM_THREADS=8
THREAD_BATCH=8
# f16 K/V: an agent checkpoint is the wrong place to spend KV precision.
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
CTX_CHECKPOINTS=8
CACHE_RAM=8192
# Sampling: the checkpoint's instruct table.
TEMPERATURE=0.7
TOP_P=0.8
TOP_K=20
MIN_P=0.0
PRESENCE_PENALTY=1.5
# Reasoning on; DeepSeek format projects the trace into reasoning_content
# instead of literal thinking tags in content.
REASONING=on
REASONING_FORMAT=deepseek
REASONING_BUDGET=-1
# Chat template: empty = the template embedded in the GGUF. Point this at
# /home/dank/serverAppConfigs/scripts/chat-template.jinja to force the repo's
# Sharp agent template instead.
CHAT_TEMPLATE_FILE=
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}'
# MTP speculative decoding. n_max 6 / p_min 0.6 is the fork's measured
# sweet spot for a MTP head with high acceptance; p_min 0.0 drafts
# low-probability tokens that get rejected and makes MTP a net loss.
SPEC_TYPE=draft-mtp
SPEC_DRAFT_N_MAX=6
SPEC_DRAFT_N_MIN=0
SPEC_DRAFT_P_MIN=0.6
SPEC_DRAFT_P_SPLIT=0.10
SPEC_DRAFT_NGL=999
SPEC_DRAFT_TYPE_K=f16
SPEC_DRAFT_TYPE_V=f16
SPEC_DRAFT_POLL=1
SPEC_DRAFT_POLL_BATCH=1
# The draft head rides the card that holds the target model's LAST layers:
# the MTP head consumes the final hidden state, so pinning it to ROCm1 keeps
# the per-token hop off the wire. (Under the abandoned tensor split the
# draft must also be pinned to exactly one device or the meta-device KV
# allocation aborts: GGML_ASSERT(buffer) in ggml-backend.cpp, measured
# 2026-09-15.)
SPEC_DRAFT_DEVICE=ROCm1
@@ -1,16 +0,0 @@
[Unit]
Description=charlie12345/ROCmFPX llama.cpp serving kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT qwen35 MoE (dankserver, frank-medium, 2x Radeon AI PRO R9700)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Serve the FastFlowLM (FLM) NPU endpoint — frank-default-npu.
#
# Runs flm serve with the model tag in frank-default-npu.env. The NPU
# (XDNA2, 50 TOPS) is a single-lane device: generations serialize in FIFO
# order, so this is a background/triage endpoint. The NPU shares the ~30 GiB
# unified pool with frank-default-large (the CIRU llama.cpp service on the
# iGPU); see serverAppConfigs-docs/FastFlowLM NPU Inference.md for the memory budget.
#
# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FLM_NPU_ENV_FILE:-$SCRIPT_DIR/frank-default-npu.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -------------------
: "${FLM_BIN:=/usr/bin/flm}"
: "${FLM_MODEL:=gemma4-it:e4b}"
: "${FLM_HOST:=0.0.0.0}"
: "${FLM_PORT:=52626}"
: "${FLM_PMODE:=performance}"
: "${FLM_ASR:=0}"
: "${FLM_CTX_LEN:=-1}"
if [[ ! -x "$FLM_BIN" ]]; then
echo "missing flm binary: $FLM_BIN" >&2
exit 1
fi
# --- optional flags ----------------------------------------------------------
OPT_ARGS=()
if [[ "${FLM_ASR}" != "0" ]]; then
OPT_ARGS+=(-a "${FLM_ASR}")
fi
if [[ "${FLM_CTX_LEN}" != "-1" ]]; then
OPT_ARGS+=(-c "${FLM_CTX_LEN}")
fi
exec "$FLM_BIN" serve "$FLM_MODEL" \
--host "$FLM_HOST" \
-p "$FLM_PORT" \
--pmode "$FLM_PMODE" \
"${OPT_ARGS[@]}"
@@ -1,123 +0,0 @@
#!/usr/bin/env bash
# Serve mradermacher/Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M as frank-dragoon
# via ik_llama.cpp on Dankclaw.
#
# Topology (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE):
# the RTX 3090 (CUDA, sm_86) takes the dense/attention/output weights and
# the KV cache (-ngl 999); ALL MoE expert tensors and the ~32 GB PLE
# n-gram table stay in the Strix Halo's LPDDR5X via the plain CPU backend
# buffer (-ot ...=CPU — mmap-backed page cache; only a tiny slice of PLE is
# touched per token). NEVER use --cpu-moe on this host: it routes the
# CPU-overridden weights through the CUDA_Host PINNED buffer (measured
# 108.12 GiB page-locked on 2026-09-15, which evicted every co-tenant into
# swap and starved sshd into unreachability until a power cycle). NEVER add
# --no-mmap — the STRIX-era lesson transfers directly (the cgroup killer
# takes the process with nothing in the server log).
#
# Runtime: ik_llama.cpp master d85a1d7 built with CUDA (sm_86) + Zen5
# AVX-512 IQK kernels; the startup banner must show HAVE_FANCY_SIMD
# is defined. Flag set verified against this binary's --help before this
# script was written. ik has no --cache-prompt/--cache-idle-slots (prompt
# caching is its default behavior) and no --alias: it is a single-model
# server and reports the GGUF's general.name at /v1/models.
#
# Sampling: greedy-by-default (halogen client contract) — clients that
# send their own temperature get their own values.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -------------------
: "${LLAMA_BIN:=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M}"
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf}"
: "${MMPROJ_PATH:=$MODEL_DIR/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf}"
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
: "${CTX_SIZE:=262144}"
: "${PARALLEL:=1}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=512}"
: "${NUM_THREADS:=16}"
: "${THREAD_BATCH:=16}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${NGL:=999}"
: "${CPU_MOE:=on}"
: "${FLASH_ATTN:=on}"
: "${TEMPERATURE:=0.0}"
: "${REASONING:=on}"
: "${REASONING_FORMAT:=deepseek}"
: "${REASONING_BUDGET:=-1}"
: "${CACHE_RAM:=8192}"
: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}"
# Hard guard: on this host CUDA pinned host memory strands the co-tenants
# (measured 2026-09-15: a default-policy start tried to page-lock 108.12 GiB,
# evicted every co-tenant into swap and starved sshd into unreachability
# until a power cycle). GGML_CUDA_NO_PINNED_WEIGHTS=1 keeps the
# CPU-overridden weights mmapped (src/llama-load-tensors.cpp); the env file
# sets it. Refuse to run without it.
if [[ "${GGML_CUDA_NO_PINNED_WEIGHTS:-}" != "1" ]]; then
echo "frank-dragoon: refusing to start — GGML_CUDA_NO_PINNED_WEIGHTS=1 is mandatory on this host" >&2
exit 1
fi
mkdir -p "$SLOT_SAVE_PATH"
# The UVM device node is created lazily by nvidia-modprobe on the first
# CUDA open after boot; make sure it exists before the server needs it.
nvidia-modprobe -u -c=0 2>/dev/null || true
CPU_MOE_ARGS=()
if [[ "$CPU_MOE" == "on" ]]; then
# -ot ...=CPU (plain CPU backend buffer) is the mmap-backed path. NEVER
# swap this for --cpu-moe: that routes the weights through the CUDA_Host
# PINNED buffer (108.12 GiB measured) and strands the co-tenants.
CPU_MOE_ARGS=(-ot "exps=CPU" -ot "per_layer_token_embd=CPU")
fi
MMPROJ_ARGS=()
if [[ -n "${MMPROJ_PATH:-}" && -f "$MMPROJ_PATH" ]]; then
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
fi
TEMPLATE_ARGS=()
if [[ -n "${CHAT_TEMPLATE_FILE:-}" && -f "$CHAT_TEMPLATE_FILE" ]]; then
TEMPLATE_ARGS=(--chat-template-file "$CHAT_TEMPLATE_FILE")
else
TEMPLATE_ARGS=(--jinja)
fi
exec "$LLAMA_BIN" \
--model "$MODEL_DIR/$MODEL_FILE" \
"${TEMPLATE_ARGS[@]}" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
--gpu-layers "$NGL" \
"${CPU_MOE_ARGS[@]}" \
--flash-attn "$FLASH_ATTN" \
--parallel "$PARALLEL" \
--ctx-size "$CTX_SIZE" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--threads "$NUM_THREADS" \
--threads-batch "$THREAD_BATCH" \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--cache-ram "$CACHE_RAM" \
--slot-save-path "$SLOT_SAVE_PATH" \
--ctx-checkpoints 16 \
--metrics \
--temp "$TEMPERATURE" \
--reasoning "$REASONING" \
--reasoning-format "$REASONING_FORMAT" \
--reasoning-budget "$REASONING_BUDGET" \
"${MMPROJ_ARGS[@]}"
@@ -1,215 +0,0 @@
#!/usr/bin/env bash
# Serve kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF as frank-medium via
# the charlie12345/ROCmFPX llama.cpp runtime on Dankserver
# (2x AMD Radeon AI PRO R9700, Navi 48, gfx1201, 32 GB each).
#
# Runtime: charlie12345/ROCmFPX main (c49ebdb, 2026-08-22), built for
# gfx1201 with the fork's self-contained ROCm toolchain:
#
# cd /home/dank/GitPackages/charlie-ROCmFPX
# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh
# -> build-rdna4-rocm714/bin/llama-server
#
# That script downloads the gfx120X-all TheRock ROCm 7.14 toolchain into
# ~/.local/share/rocm-7.14.0a20260624, builds with
# -DCMAKE_HIP_ARCHITECTURES="gfx1200;gfx1201", copies the ROCm runtime
# (hip/hsa/rocblas/hipblaslt + their kernel libraries) next to the binary and
# sets RPATH=$ORIGIN/../lib on it. The resulting service is self-contained:
# no LD_LIBRARY_PATH, and the host's stale /opt/rocm-6.3.3 cannot leak in.
# gfx1201 is NOT interchangeable with gfx1200 on this card (the fork's
# docs/BUILD-AMD-ARCHITECTURES.md: a gfx1200 build links, loads and then
# segfaults), and the build verifies the emitted code objects afterwards.
#
# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT (Q8_0_ROCMFPX_AGENT,
# ftype 115, arch qwen35 MoE), 26.28 GiB main file, plus a separate
# MTP draft head and an mmproj vision tower. 8.39 bpw; the AGENT routing
# keeps 13 of the 17 attention-output projections at full Q8_0, which is
# what makes the MTP draft land ~0.95 of its guesses. A stock llama.cpp
# cannot load this GGUF at all - Q8_0_ROCMFPX exists only in this fork.
#
# Parallelism: --split-mode layer across both cards (operator call
# 2026-09-15). Layers are distributed in order and activations hop PCIe at
# the boundary; measured llama-bench: layer 1991/18.8 tok/s (pp4096/tg128)
# vs tensor 272/26.3 and single-card 1161/19.4. Tensor wins decode +40 %
# but its prefill collapses 7x on this build (no RCCL: "AllReduce init
# failed; falling back to meta-backend butterfly"), so layer split wins the
# prefill-heavy agentic workload.
#
# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE
# overrides that path.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) -----------------
: "${LLAMA_BIN:=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT}"
: "${MODEL_FILE:=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf}"
: "${DRAFT_MODEL_FILE:=mtp-Qwen3.8-27B-Q4_0.gguf}"
: "${ALIAS:=frank-medium}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
# Devices: ROCm0/ROCm1 are the two R9700s; the Raphael iGPU enumerates as
# ROCm2 (gfx1036) and is hidden from HIP so nothing can drift onto it.
: "${HIP_VISIBLE_DEVICES:=0,1}"
: "${DEVICES:=ROCm0,ROCm1}"
: "${SPLIT_MODE:=tensor}"
: "${TENSOR_SPLIT:=}"
: "${NGL:=999}"
: "${FIT:=off}"
: "${FLASH_ATTN:=on}"
: "${CTX_SIZE:=131072}"
: "${PARALLEL:=1}"
: "${BATCH_SIZE:=2048}"
: "${UBATCH_SIZE:=512}"
: "${NUM_THREADS:=8}"
: "${THREAD_BATCH:=8}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${CTX_CHECKPOINTS:=8}"
: "${CACHE_RAM:=8192}"
# Sampling: the checkpoint's instruct table.
: "${TEMPERATURE:=0.7}"
: "${TOP_P:=0.8}"
: "${TOP_K:=20}"
: "${MIN_P:=0.0}"
: "${PRESENCE_PENALTY:=1.5}"
# Reasoning: the model is an agent checkpoint; keep the trace and project it
# into the API's reasoning_content field rather than literal thinking tags.
: "${REASONING:=on}"
: "${REASONING_FORMAT:=deepseek}"
: "${REASONING_BUDGET:=-1}"
# Chat template: empty means "use the template embedded in the GGUF".
: "${CHAT_TEMPLATE_FILE:=}"
: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}"
# MTP draft head (separate GGUF, --spec-type draft-mtp). SPEC_DRAFT_DEVICE
# must name exactly one device: under tensor mode an unsplit draft joins the
# meta-device KV allocation and aborts while building the KV cache
# (GGML_ASSERT(buffer) in ggml-backend.cpp, 2026-09-15).
: "${SPEC_TYPE:=draft-mtp}"
: "${SPEC_DRAFT_N_MAX:=6}"
: "${SPEC_DRAFT_N_MIN:=0}"
: "${SPEC_DRAFT_P_MIN:=0.6}"
: "${SPEC_DRAFT_P_SPLIT:=0.10}"
: "${SPEC_DRAFT_NGL:=999}"
: "${SPEC_DRAFT_DEVICE:=}"
: "${SPEC_DRAFT_TYPE_K:=f16}"
: "${SPEC_DRAFT_TYPE_V:=f16}"
: "${SPEC_DRAFT_POLL:=1}"
: "${SPEC_DRAFT_POLL_BATCH:=1}"
# Text only: the server must not try to autoload the vision tower. Vision
# stays on frank-dragoon (Dankclaw), which is where the fleet routes images.
NO_MMPROJ="${NO_MMPROJ:-1}"
export HIP_VISIBLE_DEVICES
MODEL="$MODEL_DIR/$MODEL_FILE"
if [[ ! -x "$LLAMA_BIN" ]]; then
echo "missing executable runtime: $LLAMA_BIN" >&2
echo "build it with: cd $(dirname "$(dirname "$LLAMA_BIN")")/.. && env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh" >&2
exit 1
fi
if [[ ! -r "$MODEL" ]]; then
echo "missing or unreadable required artifact: $MODEL" >&2
echo "fetch it with: bash $SCRIPT_DIR/download-q8agent.sh" >&2
exit 1
fi
if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then
echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2
exit 1
fi
# --- speculative decoding (MTP draft head) --------------------------------
SPEC_ARGS=()
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
SPEC_ARGS=(
--spec-type "$SPEC_TYPE"
--spec-draft-n-max "$SPEC_DRAFT_N_MAX"
--spec-draft-n-min "$SPEC_DRAFT_N_MIN"
--spec-draft-p-min "$SPEC_DRAFT_P_MIN"
--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT"
)
if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then
DRAFT_MODEL="$MODEL_DIR/$DRAFT_MODEL_FILE"
if [[ ! -r "$DRAFT_MODEL" ]]; then
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
exit 1
fi
SPEC_ARGS+=(
--spec-draft-model "$DRAFT_MODEL"
--spec-draft-ngl "$SPEC_DRAFT_NGL"
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
--spec-draft-poll "$SPEC_DRAFT_POLL"
--spec-draft-poll-batch "$SPEC_DRAFT_POLL_BATCH"
)
if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then
SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE")
fi
fi
fi
# --- template + placement -------------------------------------------------
TEMPLATE_ARGS=()
if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then
TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE")
TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS")
fi
PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE")
if [[ -n "$TENSOR_SPLIT" ]]; then
PLACEMENT_ARGS+=(--tensor-split "$TENSOR_SPLIT")
fi
MMPROJ_ARGS=()
if [[ "$NO_MMPROJ" == "1" ]]; then
MMPROJ_ARGS=(--no-mmproj)
fi
exec "$LLAMA_BIN" \
--model "$MODEL" \
--alias "$ALIAS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
--jinja \
--gpu-layers "$NGL" \
--fit "$FIT" \
--parallel "$PARALLEL" \
--ctx-size "$CTX_SIZE" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--flash-attn "$FLASH_ATTN" \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--threads "$NUM_THREADS" \
--threads-batch "$THREAD_BATCH" \
--ctx-checkpoints "$CTX_CHECKPOINTS" \
--cache-ram "$CACHE_RAM" \
--cache-prompt \
--metrics \
--temp "$TEMPERATURE" \
--top-p "$TOP_P" \
--top-k "$TOP_K" \
--min-p "$MIN_P" \
--presence-penalty "$PRESENCE_PENALTY" \
--reasoning "$REASONING" \
--reasoning-format "$REASONING_FORMAT" \
--reasoning-budget "$REASONING_BUDGET" \
"${TEMPLATE_ARGS[@]}" \
"${PLACEMENT_ARGS[@]}" \
"${MMPROJ_ARGS[@]}" \
"${SPEC_ARGS[@]}"
+42 -3
View File
@@ -1208,14 +1208,51 @@ ensure_media_view() {
# array below stays read-only and every sidecar Jellyfin writes lands on the
# NVMe.
# Raise it BEFORE either container starts — a start against a bare view
# directory would let a scan clear the library database. The units also carry
# ConditionPathIsMountPoint, so a missing view skips the start rather than
# damaging anything. A relayout must be followed by `media-view.sh reap`
# directory would let a scan clear the library database. Each unit gates on
# scripts/wait-for-mount.sh, which waits and then refuses the start. A
# relayout must be followed by `media-view.sh reap`
# (see the Media Relayout page), which this is not.
# A fresh host has no upper base, and `install` refuses to invent one — so
# without this the media deploy dies on a rebuilt machine with
# "put it on the SSD first" (measured 2026-10-31).
run_privileged bash scripts/media-view.sh ensure-base
run_privileged bash scripts/media-view.sh install
bash scripts/media-view.sh verify
}
ensure_user_io_delegate() {
# The media servers ask for an I/O share (IOWeight= in their quadlets) so a
# library scan cannot starve playback or the array's other reader. Two system
# drop-ins are what make that request real, and both are host state, so the
# deployer places them:
# user.slice.d/io-accounting.conf io enters user.slice's subtree
# user@1000.service.d/io-delegate.conf the user manager gets the controller
# Without the first, `io` never appears in user.slice/cgroup.subtree_control,
# so no container under it can have an io.weight file at all; without the
# second the user manager never asks for the controller. systemd hands
# user@.service cpu/memory/pids by default and drops a line it cannot honour
# without a word — IOWeight= on its own is inert config.
local src dst
for src in \
dankserver/host/systemd/user.slice.d/io-accounting.conf \
dankserver/host/systemd/user@1000.service.d/io-delegate.conf; do
dst=/etc/systemd/system/${src#dankserver/host/systemd/}
run_privileged mkdir -p "$(dirname "$dst")"
if ! run_privileged cmp -s "$src" "$dst"; then
run_privileged install -m 644 "$src" "$dst"
run_privileged systemctl daemon-reload
fi
done
# Accounting lands immediately; the delegation is read when user@1000.service
# starts, and restarting that unit would kill every rootless container on this
# host. So the share becomes real at the next reboot — say so out loud rather
# than let a quadlet line read as live config.
if ! systemctl show user@1000.service -p DelegateControllers | grep -qw io; then
echo "NOTE: io is not delegated to user@1000.service yet - IOWeight= is"
echo "NOTE: inert until the next reboot (see [[Quadlet Lifecycle]])"
fi
}
ensure_provider_keys() {
# Jellyfin's metadata-provider keys live in the container-owned plugin
# configuration under the data root, never in git; scripts/jellyfin-providers.sh
@@ -1237,6 +1274,7 @@ ensure_provider_keys() {
deploy_jellyfin() {
ensure_media_view
ensure_user_io_delegate
ensure_provider_keys
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
systemctl --user daemon-reload
@@ -1326,6 +1364,7 @@ deploy_copyparty() {
deploy_plex() {
ensure_media_view
ensure_user_io_delegate
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
systemctl --user daemon-reload
if ! systemctl --user is-active -q plex.service; then
+68 -9
View File
@@ -59,10 +59,18 @@ def journal_default(root):
class Journal:
"""Append-only JSONL of intents. Writes are fsynced before the tool acts."""
"""Append-only JSONL of intents. Writes are fsynced before the tool acts.
def __init__(self, path, writing):
``batch`` is how many intents may be written before one fsync covers them.
The rule the file exists for is unchanged — an intent is on disk before the
move it authorises runs — so the caller must call ``sync()`` before it
performs a batch's renames; only the number of flushes falls. 1 = a flush
per intent (the original behaviour).
"""
def __init__(self, path, writing, batch=1):
self.path, self.writing = path, writing
self.batch = max(1, int(batch))
self.records = []
if os.path.exists(path):
for line in open(path):
@@ -70,11 +78,15 @@ class Journal:
if line:
self.records.append(json.loads(line))
self._fh = None
self._unsynced = 0
# (src, dst) index: already() is called three times per move, so an
# any() over every record written so far made a 58k-move pass quadratic.
self._intents = {(r.get("src"), r.get("dst"))
for r in self.records if r.get("op") == "move"}
def already(self, dst, src):
"""An intent for this exact (src, dst) was written by an earlier run."""
return any(r.get("op") == "move" and r.get("dst") == dst
and r.get("src") == src for r in self.records)
return (src, dst) in self._intents
@property
def rolled_back(self):
@@ -82,16 +94,28 @@ class Journal:
def record(self, **fields):
self.records.append(fields)
if fields.get("op") == "move":
self._intents.add((fields.get("src"), fields.get("dst")))
if not self.writing:
return
if self._fh is None:
self._fh = open(self.path, "a")
self._fh.write(json.dumps(fields) + "\n")
self._fh.flush()
self._unsynced += 1
if self._unsynced >= self.batch:
self.sync()
def sync(self):
"""Make every intent written so far durable. No-op when nothing pending."""
if not self.writing or self._fh is None or not self._unsynced:
return
os.fsync(self._fh.fileno())
self._unsynced = 0
def close(self):
if self._fh:
self.sync()
self._fh.close()
@@ -177,14 +201,30 @@ def do_apply(args, root, journal, mkdirs, keeps, moves):
print(f" would move {src} -> {dst}")
return 0
batch = max(1, args.batch)
made = {r.get("dst") for r in journal.records if r.get("op") == "mkdir"}
dirs_new = 0
queued = []
def drain_dirs():
"""Create the queued folders only after their intents are durable."""
nonlocal dirs_new
if not queued:
return
journal.sync()
for d in queued:
os.makedirs(d, exist_ok=True)
dirs_new += 1
queued.clear()
for d in sorted(mkdirs, key=lambda q: q.count(os.sep)):
if d in made or os.path.isdir(d):
continue
journal.record(op="mkdir", dst=d)
os.makedirs(d, exist_ok=True)
dirs_new += 1
queued.append(d)
if len(queued) >= batch:
drain_dirs()
drain_dirs()
# same-filesystem gate: rename cannot cross a device, and a silent copy of a
# library this size is not an option anyone would choose.
@@ -201,6 +241,19 @@ def do_apply(args, root, journal, mkdirs, keeps, moves):
return 2
moved = done = 0
pending = []
def drain_moves():
"""Perform the queued renames only after their intents are on disk."""
nonlocal moved
if not pending:
return
journal.sync()
for s, d in pending:
os.rename(s, d)
moved += 1
pending.clear()
for src, dst, _kind in moves:
if journal.already(dst, src):
if os.path.exists(dst) and not os.path.exists(src):
@@ -213,8 +266,10 @@ def do_apply(args, root, journal, mkdirs, keeps, moves):
st = os.stat(src)
journal.record(op="move", src=src, dst=dst, bytes=st.st_size, mode=st.st_mode,
mtime=st.st_mtime, ino=st.st_ino, dev=st.st_dev)
os.rename(src, dst)
moved += 1
pending.append((src, dst))
if len(pending) >= batch:
drain_moves()
drain_moves()
print(f"applied: {moved} moved, {done} already done in an earlier run,"
f" {dirs_new} folders created, {len(keeps)} files stayed put")
print(f"journal: {journal.path} — undo with: media-apply.py rollback"
@@ -418,13 +473,17 @@ def main():
ap.add_argument("--metadata-view", default=METADATA_VIEW,
help="overlay the media servers read through; the sidecar"
" note prints only when this path is a mountpoint")
ap.add_argument("--batch", type=int, default=64,
help="journal intents per fsync (1 = flush before every move)."
" An intent is still durable before the move it authorises;"
" only the number of flushes falls")
args = ap.parse_args()
root = os.path.abspath(args.root)
if not os.path.isdir(root):
raise Refuse(f"no library at {root}")
journal_path = args.journal or journal_default(root)
journal = Journal(journal_path, args.apply)
journal = Journal(journal_path, args.apply, batch=args.batch)
try:
if args.cmd in ("rollback", "verify", "sweep") and not os.path.exists(journal_path):
raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}")
+22 -14
View File
@@ -130,6 +130,26 @@ where p.file like ? and p.deleted_at is null and mi.deleted_at is null
and al.deleted_at is null
"""
def open_plex_ro(db):
"""Plex's library, read the way Plex writes it.
`immutable=1` tells sqlite the file never changes, so it skips the
write-ahead log: every row a scan has committed but not yet checkpointed
is invisible, and reading a live database that way can fail outright as
"database disk image is malformed". Plain ro reads the WAL through its
-shm; immutable is only the fallback for a database whose -shm cannot be
read. The same law scripts/media-verify.py states.
"""
last = None
for opt in ("mode=ro", "mode=ro&immutable=1"):
try:
return sqlite3.connect(f"file:{db}?{opt}", uri=True)
except sqlite3.Error as e:
last = e
sys.exit(f"cannot open the Plex database read-only ({db}): {last}\n"
"Plex runs rootless here: run this as the container's owner, never "
"with sudo — rootless storage is per-user, so root sees nothing.")
def enumerate_lib(root, out):
"""One row per real file under root: size, extension, path."""
@@ -229,13 +249,7 @@ def probe(enum, out, jobs):
def plex(root, db, out):
"""Plex's matched titles for files under root, from its DB, read-only."""
uri = f"file:{db}?mode=ro&immutable=1"
try:
con = sqlite3.connect(uri, uri=True)
except sqlite3.Error as e:
sys.exit(f"cannot open the Plex database read-only ({db}): {e}\n"
"Plex runs rootless here: run this as the container's owner, never "
"with sudo — rootless storage is per-user, so root sees nothing.")
con = open_plex_ro(db)
with open(out, "w", newline="") as fh:
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
w.writerow(PLEX_COLS)
@@ -426,13 +440,7 @@ def audiobooks(root, db, out):
at least one un-indexed chapter, so the relayout that reads this file must
not renumber anything from it.
"""
uri = f"file:{db}?mode=ro&immutable=1"
try:
con = sqlite3.connect(uri, uri=True)
except sqlite3.Error as e:
sys.exit(f"cannot open the Plex database read-only ({db}): {e}\n"
"Plex runs rootless here: run this as the container's owner, never "
"with sudo — rootless storage is per-user, so root sees nothing.")
con = open_plex_ro(db)
tables = {r[0] for r in con.execute(
"select name from sqlite_master where type='table'")}
missing = {"media_parts", "media_items", "metadata_items"} - tables
+35
View File
@@ -0,0 +1,35 @@
# The operator's tvdb id map: `folder <TAB> tvdb id <TAB> note`.
#
# These are the pack folders whose show tvdb's NAME SEARCH cannot reach: the
# record exists, but tvdb titles it in the show's own language and the English
# name rides in aliases the search never reads. Without a row here the folder
# keeps the release folder's words and no episode title; with one, both matchers
# read `[tvdbid-NNNN]` and the files rename to tvdb's own name and titles.
#
# Every id was read from tvdb's own record (`/series/<id>/extended`) on
# 2026-10-31, and the note states what that record says plus the neighbouring
# record a name search would have picked instead — the row is checkable without
# re-searching, because one wrong id renames a whole pack to the wrong show. The
# plan refuses a line whose id is not a number and lists every folder the map
# touched in the exceptions file.
#
# Folders the name search DOES reach (the EXACT and PUNCT classes of the proposal
# table) stay off this file on purpose: a map row would hide a live lookup behind
# a number nobody re-checks.
[Anime Time] Pokemon Complete Series (Season 01-25+Movies+Specials+Shorts+OST) [DVD+BD][1080p][HEVC 10bit x265][AAC] 76703 tvdb: ポケットモンスター (1997), the umbrella record for every era this pack spans; 1268 files
[Anime Time] One Piece (0001-1071+Movies+Specials) [BD+CR] [Dual Audio] [1080p][HEVC 10bit x265][AAC][Multi Sub] 81797 tvdb: ワンピース (1999), alias ONE PIECE; the bare alias "One Piece" belongs to 392276, the 2023 live action; 1072 files
[Judas] Naruto (Complete Series) [BD 1080p][HEVC x265 10bit][Multi-Audio][Multi-Subs] 78857 tvdb: NARUTO-ナルト- (2002), alias "Naruto" carries the full-width dashes; 220 files
Star Trek Deep Space 9 Deluxe DVD Boxset + Extras DVDRip TSV 72073 tvdb: Star Trek: Deep Space Nine (1993), alias "Star Trek Deep Space 9"; the box set's own words match nothing; 279 files
Top Gear UK 1-17 74608 tvdb: Top Gear (2002), no "UK" alias — the US version is 199051; 131 files
Black Clover 001 331753 tvdb: ブラッククローバー (2017), which holds NO alias at all; 378762 is Mugyutto! Black Clover (2019), a different show; 108 files
Shin Chan - Complete Eng Dub Series 2160p Ai Upscaled -Mesc 437356 tvdb: Shin Chan (2006) — the English dub record this pack is; 79654 is the 1992 Japanese original; 78 files
Fairy Tail (2009-2014) (Seasons 1-8 + OVAs) (BD 1080p)(HEVC x265 10bit)(Dual-Audio)(Eng-Subs)-Judas[TGx] 114801 tvdb: フェアリーテイル (2009), alias FAIRY TAIL; 410031 is FAIRY TAIL 100 NEN QUEST (2024), the sequel; 59 files
(CBB) Tokyo Ghoul - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-10bit-x265) 281249 tvdb: 東京喰種トーキョーグール (2014) — :re and √A are seasons of THIS record, confirmed by search; 58 files
[Anime Time] Demon Slayer - Kimetsu no Yaiba (S01+S02+S03+S04+Mugen Train Arc+Movie) [BD] [Dual Audio][1080p][HEVC 10bit x265][AAC][Eng Sub] 348545 tvdb: 鬼滅の刃 (2019), alias "Demon Slayer: Kimetsu no Yaiba" — the folder's hyphen splits it differently; 56 files
(CBB) Tengen Toppa Gurren Lagann - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-x265-10bit) 80096 tvdb: 天元突破グレンラガン (2007), alias Tengen Toppa Gurren Lagann; 51 files
Fullmetal Alchemist - Brotherhood 85249 tvdb: 鋼の錬金術師 FULLMETAL ALCHEMIST (2009), alias "Full Metal Alchemist Brotherhood" — spaced, unlike this folder; 64 files
[NewbSubs] Attack on Titan~Shingeki no Kyojin Series (1080p Blu-ray x265 Dual Audio) 267440 tvdb: 進撃の巨人 (2013), aliases carry "Attack on Titan" and "Shingeki no Kyojin", joined here by a tilde; 27 files
Kimetsu no Yaiba 348545 the same record as the Anime Time pack above: two release folders, ONE show; 12 files
Rick and Morty The Anime 420279 tvdb: リック・アンド・モーティ:ジ・アニメ (2024), no English alias on the record; 10 files
[Anime Time] Dorohedoro (Season 1+OVA+Extra) [Dual Audio][BD][1080p][HEVC 10bit x265][AAC][Eng Sub] 370761 tvdb: ドロヘドロ (2020), alias Dorohedoro; 9 files
Ken Il Guerriero HOKUTO NO KEN 79156 tvdb: 北斗の拳 (1984), alias "Hokuto no Ken"; the Italian title matches nothing and 465216 is the 2026 remake; 5 files
Can't render this file because it contains an unexpected character in line 20 and column 182.
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
r"""Ask tvdb what it calls the shows a plan renamed WITHOUT a tvdb id.
media-shows-tvdb.py [--plan TSV] [--root DIR] [--park DIR] [--map TSV]
[--out TSV] [--credentials FILE]
`media-shows.py` renames a series to tvdb's own words only when an id backs the
name; everything else keeps the metadata provider's words and no episode title,
and the plan's mkdir rows are where those folders show up. This script turns that
list into a decision sheet: one row per destination series folder that carries no
`[tvdbid-NNNN]`, with what tvdb's search answers for it and a verdict that says
HOW MUCH was guessed to get there.
It changes nothing. The sheet is for the operator; the file that changes
behaviour is `scripts/media-shows-tvdb-ids.tsv`, whose rows he approves. That is
the loop: plan -> this sheet -> approved map rows -> re-plan.
Verdicts, weakest last:
EXACT tvdb's own name equals the folder's words as they stand.
PUNCT it equals them only after release junk is stripped (pack numbers,
codecs, group names, `Complete Series`); the difference is spelling,
not identity.
NEAR tvdb answered something whose name is NOT the query. The best hit is
printed so a human can judge it, and it is never applied by anything.
NONE nothing left to ask with, or no answer at all.
Acceptance is deliberately unforgiving — a hit counts only when tvdb's name
equals the query with every non-alphanumeric dropped from both sides, and the
series' year still has to fall inside any year span the folder names. The
cleaner is the only thing that got smarter over the passes this tool replaces;
the acceptance test never moved, because a wrong id renames a whole pack to the
wrong show. A query that needs the cleaner is reported as PUNCT for exactly that
reason: the machine stripped words, so a human reads the row.
Read-only against tvdb. The credential file is the planner's own
(`api_key=`, optional `pin=`), read here and never printed.
"""
import argparse
import collections
import importlib.util
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
ARGS = argparse.ArgumentParser(description=__doc__.splitlines()[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
ARGS.add_argument("--plan", default="/tmp/tv-plan.tsv",
help="the plan media-shows.py just wrote (its mkdir rows are "
"the folders this sheet asks about)")
ARGS.add_argument("--root", default=os.environ.get("TV_ROOT", "/mnt/entropy/TVShows"),
help="the TV library the plan renames")
ARGS.add_argument("--park", default="",
help="the review pile inside it (its folders are not shows)")
ARGS.add_argument("--map",
default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
"media-shows-tvdb-ids.tsv"),
help="the operator's approved map; its folders are reported as "
"MAPPED and never asked again")
ARGS.add_argument("--credentials",
default=os.path.expanduser("~/.config/secrets/tvdb"),
help="file with an api_key= line (and a pin= for a user-issued "
"key); mode 600, and never printed")
ARGS.add_argument("--out", default="/tmp/tvdb-proposal.tsv",
help="the decision sheet")
ARGS = ARGS.parse_args()
ROOT = os.path.abspath(ARGS.root)
PARK = os.path.abspath(ARGS.park or os.path.join(ROOT, "_unsorted"))
_spec = importlib.util.spec_from_file_location(
"eng", os.path.join(os.path.dirname(os.path.abspath(__file__)),
"media-titles.py"))
eng = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(eng)
# Release metadata, not the show: everything a scene folder name says about the
# ENCODING, the packaging or the languages, which tvdb has never heard of.
JUNK = re.compile(
r"complete|series|dual|multi|audio|sub|subs|multisub|dualaudio|"
r"\d{3,4}p|1080p|720p|2160p|480p|4k|hd|hdtv|cam|hdcam|telesync|x264|x265|hevc|avc|"
r"\d+bit|bit|bluray|blu-?ray|bdrip|web-?rip|web-?dl|webrip|hdtvrip|"
r"dvdrip|dvdr|dvdscr|tsr|tsv|mkv|avi|mp4|aac|ac3|dts|eac3|flac|ddp?5[.1]1|"
r"bd|dvd|rip|boxset|box|season|seasons|episodes?|specials?|shorts|ost|"
r"ova|ovas|oad|repack|proper|internal|mi?xed|budgetbits|deluxe|remux|"
r"remastered|uncut|collector|edition|extras?|volume|volumes|part|"
r"disc|disk|cd|ntsc|pal|region|fixed|patched|proof|english|esp|"
r"espagnol|french|vostfr|subtitul\w*|spanish|german|italian|ita|eng|fre|spa|lat|"
r"upscaled?|ai|dd|movie|movies",
re.I)
GROUP = re.compile(r"KONTRAST|TGx|Judas|Silence|FreetheFilms|NTb|DEADPOOL|Xploit|"
r"SM737|TSA|3OISL3R|PLAYWEB|EPiC|FLUX|HONEY|Zaxton|Cca3rW|"
r"monikeres|frank|hdh|psi|rtfpemaker|cbm|web|asser|astro|"
r"yfn|uindex|www|org|com|net|mesc|ataraxiaprime|seph\d*|"
r"[a-z]+subs?|edge", re.I)
BRACKET = re.compile(r"[\[({]([^\])}]*)[\])}]")
NUM_RANGE = re.compile(r"(?<!\w)\d{1,4}\s*(?:-|–|to|through)\s*\d{1,4}(?!\w)", re.I)
INSIDE = re.compile(r"all|complete|full|dual|multi|audio|sub|subs|ova|ovas|"
r"movies?|episodes?|extras?|bonus|pack|remux|region\s*\d*|"
r"bd|dvd|cd|ntsc|pal|hr-?dvd", re.I)
def drop_bracket(m):
"""A bracket says nothing when its words are junk; keep it when they aren't."""
toks = [t for t in re.split(r"[\s_.,+~&]+", m.group(1)) if t]
if not toks or all(INSIDE.fullmatch(t) or JUNK.fullmatch(t)
or GROUP.fullmatch(t)
or re.fullmatch(r"\d+[a-z]?", t, re.I)
or re.fullmatch(r"(19|20)\d\d", t) for t in toks):
return " "
return " " + m.group(1) + " "
def clean(name):
"""The folder's words with everything about the RELEASE taken out, so what is
left is what a person would type into tvdb. `Star Trek Deep Space 9 Deluxe
DVD Boxset + Extras DVDRip TSV` becomes `Star Trek Deep Space 9`; `SNL 16-20`
becomes `SNL`."""
name = re.sub(r"^\s*www\b.*?\b(org|com|net)\b\s*[-–]\s*", " ", name, flags=re.I)
name = re.sub(r"\bS\d{1,2}([ .Ee]\d+)?[-–Ee\d.]*", " ", name)
name = re.sub(r"\b\d{1,2}[xX]\d{1,3}\b", " ", name)
name = NUM_RANGE.sub(" ", name)
name = re.sub(r"(19|20)\d\d(?:\s*[-/–]\s*(?:19|20)\d\d)?", " ", name)
name = re.sub(r"\bby\s+[A-Za-z0-9_.-]+$", " ", name)
name = BRACKET.sub(drop_bracket, name)
name = re.sub(r"[\[()\]:,;!?*]", " ", name)
toks = re.split(r"[\s_.\-+~&]+", name)
out = []
for i, t in enumerate(toks):
if not t or JUNK.fullmatch(t) or GROUP.fullmatch(t):
continue
if re.fullmatch(r"\d+", t):
after = sum(1 for x in toks[i + 1:] if x
and not JUNK.fullmatch(x) and not GROUP.fullmatch(x)
and not re.fullmatch(r"\d+", x))
# A pack index (`001`) and a lead-in number over a real title
# (`01 Charles Barkley`) are noise; `30 Rock`, `24`, `Deep Space 9`
# are the title itself.
if len(t) >= 3 or (not out and after >= 2):
continue
out.append(t)
while out and out[-1].lower() in ("and", "with", "the", "a", "an", "to", "of"):
out.pop()
return " ".join(out).strip()
def years(name):
return [int(y) for y in re.findall(r"\b(19\d\d|20\d\d)\b", name)]
def bare(s):
return re.sub(r"[^a-z0-9]", "", s.lower())
CRED = {}
try:
for _line in open(ARGS.credentials):
if "=" in _line:
k, v = _line.strip().split("=", 1)
CRED[k.strip()] = v.strip()
except OSError:
print(f"tvdb: no credentials at {ARGS.credentials} — the sheet would be all "
"NEAR and NONE, which is not a decision sheet", file=sys.stderr)
sys.exit(2)
if not CRED.get("api_key"):
print(f"tvdb: no api_key in {ARGS.credentials}", file=sys.stderr)
sys.exit(2)
TOKEN = {"jwt": ""}
def call(path):
"""One tvdb v4 GET, logging in again once when the JWT expires mid-run."""
for attempt in (1, 2):
if not TOKEN["jwt"]:
body = {"apiKey": CRED["api_key"]}
if CRED.get("pin"):
body["token"] = CRED["pin"]
req = urllib.request.Request("https://api4.thetvdb.com/v4/login",
data=json.dumps(body).encode(),
headers={"Content-Type":
"application/json"})
TOKEN["jwt"] = json.load(
urllib.request.urlopen(req, timeout=30))["data"]["token"]
try:
req = urllib.request.Request("https://api4.thetvdb.com/v4" + path,
headers={"Authorization":
"Bearer " + TOKEN["jwt"]})
return json.load(urllib.request.urlopen(req, timeout=30))["data"]
except urllib.error.HTTPError as e:
if e.code == 401 and attempt == 1:
TOKEN["jwt"] = ""
continue
raise
MAPPED = {}
if ARGS.map and os.path.exists(ARGS.map):
for _line in open(ARGS.map):
_line = _line.rstrip("\n")
if not _line.strip() or _line.startswith("#"):
continue
cols = _line.split("\t")
if len(cols) > 1 and cols[1].strip().isdigit():
MAPPED[cols[0].strip()] = cols[1].strip()
# The folders the plan created inside the library, with the file count each one
# will hold. The review pile is not a show: its files belong to no identity, and
# naming the pile would put one junk series on the sheet for all of them.
shows = collections.Counter()
for line in open(ARGS.plan).read().splitlines()[1:]:
parts = line.split("\t")
if len(parts) < 4 or parts[0] != "mkdir":
continue
dst = os.path.abspath(parts[2])
if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep):
continue
top = os.path.relpath(dst, ROOT).split(os.sep)[0]
if top.startswith((".", "_")):
continue
if "[tvdbid-" in top:
continue # an id already backs it
shows.setdefault(top, 0)
for line in open(ARGS.plan).read().splitlines()[1:]:
parts = line.split("\t")
if len(parts) < 4 or parts[0] != "move":
continue
dst = os.path.abspath(parts[2])
if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep):
continue
top = os.path.relpath(dst, ROOT).split(os.sep)[0]
if "[tvdbid-" in top:
continue
shows[top] += 1
tally = collections.Counter()
rows = []
for folder in sorted(shows, key=lambda f: (-shows[f], f)):
files, tag, note = shows[folder], "NONE", ""
if folder in MAPPED:
rows.append([files, "MAPPED", folder, "", "", "", MAPPED[folder],
"the operator's map already states this id"])
tally["MAPPED"] += 1
continue
query, untouched = clean(folder), clean(folder) == folder
ys = years(folder)
if not query:
rows.append([files, "NONE", folder, "", "", "", "",
"nothing is left of the folder's name once release junk"
" is gone"])
else:
try:
hits = call("/search?query=" + urllib.parse.quote(query)
+ "&type=series") or []
except Exception as e: # noqa: BLE001
hits = []
note = f"tvdb did not answer: {e}"
want = bare(query)
pick = None
for h in hits:
if bare(h.get("name") or "") != want:
continue
hy = str(h.get("year") or "")
if ys and hy.isdigit() and not (min(ys) <= int(hy) <= max(ys)):
continue
pick = h
break
if pick:
tag = "EXACT" if untouched else "PUNCT"
rows.append([files, tag, folder, query, pick.get("name", ""),
str(pick.get("year", "") or ""),
str(pick.get("tvdb_id", "") or ""), ""])
elif hits:
best = hits[0]
rows.append([files, "NEAR", folder, query, best.get("name", ""),
str(best.get("year", "") or ""),
str(best.get("tvdb_id", "") or ""),
f"{len(hits)} answer(s); tvdb's own name is not the "
"query, so nothing would take it"])
else:
rows.append([files, "NONE", folder, query, "", "", "",
note or "tvdb has no series by that name"])
tally[rows[-1][1]] += 1
time.sleep(0.12)
# Two folders mapping to one record is not two shows; it is one show the release
# folders split. Say it on every row involved, or the operator approves the same
# id twice and reads the second as a mistake.
by_id = collections.Counter(r[6] for r in rows if r[6] and r[1] != "MAPPED")
for r in rows:
if r[6] and r[1] != "MAPPED" and by_id[r[6]] > 1:
r[7] = (f"MERGE: {by_id[r[6]]} folders map to tvdb {r[6]} — one show, "
"the releases split it; " + r[7]).strip("; ")
with open(ARGS.out, "w") as fh:
fh.write("files\tverdict\tfolder\tquery\ttvdb_name\ttvdb_year\ttvdb_id\tnote\n")
for r in rows:
fh.write("\t".join(str(c) for c in r) + "\n")
print(f"sheet: {len(rows)} folders"
+ "".join(f" {t}={n}" for t, n in sorted(tally.items()))
+ f" -> {ARGS.out}")
+275 -26
View File
@@ -3,7 +3,7 @@ r"""Plan a Jellyfin-format relayout of a TV library. DRY RUN ONLY: nothing moves
media-shows.py [--root DIR] [--enum TSV] [--titles TSV] [--probed TSV]
[--tmdb-cache JSON] [--tmdb] [--movies DIR] [--park DIR]
[--out TSV] [--exceptions TXT]
[--tvdb-ids TSV] [--out TSV] [--exceptions TXT]
Inputs are the TSVs scripts/media-census.py exports: `enumerate` lists what
exists, `jellyfin` says which episode each file IS (its series' TMDb and Tvdb
@@ -24,6 +24,14 @@ The series name and the episode title are the ones the IDENTITY authority — tv
— states for the id the metadata server matched, not the words a release folder
or a server's folder-derived Series name happens to carry.
A folder tvdb's search cannot state is not a folder tvdb does not know: the
record exists, it is titled in the show's own language, and the English name
rides in aliases the search never reads. --tvdb-ids is the human door for those:
one line per pack folder, `folder <TAB> tvdb id <TAB> note`, every id checked by
hand against the record it names. It is an input file like the census, so a plan
stays reproducible, and every folder it touched is listed in the exceptions —
one wrong row is a whole pack renamed to the wrong show.
PLAN is the TSV scripts/media-apply.py replays: `action src dst kind`, every
mkdir row before any move, and `keep` for a file already where the contract puts
it. EXCEPTIONS is the review list: everything parked, and why.
@@ -106,6 +114,16 @@ ARGS.add_argument("--tvdb-credentials",
default=os.path.expanduser("~/.config/secrets/tvdb"),
help="file with an api_key= line (and a pin= line for a "
"user-issued key); mode 600, and never the cache")
ARGS.add_argument("--tvdb-ids",
default=os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"media-shows-tvdb-ids.tsv"),
help="the operator's map: folder <TAB> tvdb id <TAB> note, for the\n"
"packs whose name tvdb's search cannot state (anime is titled\n"
"by its own record, the English name rides in aliases). Used\n"
"only when the file exists; a line without a numeric id stops\n"
"the plan, because a typo renames a whole pack to the wrong\n"
"show.")
ARGS.add_argument("--movies", default="/mnt/entropy/Movies",
help="movie library a mis-filed film moves into")
ARGS.add_argument("--park", default="",
@@ -150,12 +168,16 @@ SEASON_META = re.compile(r"^season\.nfo$", re.I)
FOLDER_IMAGE = re.compile(r"^folder\.(jpg|jpeg|png|webp|bmp)$", re.I)
SEASON_DIR_RE = re.compile(r"(?i)^\s*(?:season[\s._-]*(\d{1,3})|s(\d{1,2}))\s*$")
BAD_CHARS = re.compile(r'[<>:"/|?*]')
# SxxEyy with an optional second episode, plus the 1x04 form. The second number
# is guarded so a resolution never reads as an episode: "S01E01-1080p" is ONE
# episode, and the longest-match backtracking of \d{1,3} is what the (?![\dpP])
# after it closes off.
# SxxEyy with an optional second episode, plus the 1x04 form. Both episode numbers
# are guarded so a longer digit run never reads as an episode. A resolution glued
# on stays legal — "S01E01-1080p" is ONE episode — while "S03E2019" and
# "S04E47370355" (both real names on the array, from a box set whose provider rows
# say the same junk) state no episode at all: the run is an airdate or a pack
# index, and the first three digits of it would steal E201 and E473 from the
# episodes that own them. No marker means the file is numbered by its provider
# row, or by tvdb's title, or it parks.
EP_RANGE = re.compile(r"(?i)(?<![a-z0-9])s(?:eason[\s._-]*)?(\d{1,2})[\s._-]*"
r"e(?:p(?:isode)[\s._-]*)?(\d{1,3})"
r"e(?:p(?:isode)[\s._-]*)?(\d{1,3})(?![\dpP])"
r"(?:[\s._-]*(?:-|to[\s._-]*|ep)[\s._-]*(?:s\d{1,2}[\s._-]*)?e?"
r"(\d{1,3})(?![\dpP]))?")
NX_RE = re.compile(r"(?i)(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![a-z0-9])")
@@ -183,6 +205,11 @@ EXTRA_KIND = {"trailer": "trailers", "tmbd": "trailers", "intro": "other",
"deleted": "deleted scenes", "other": "other", "epilogue": "other",
"opening": "other", "featurette": "featurettes", "short": "shorts"}
SHORT_MAX = 45 * 60 # over this, a file with no episode evidence is film-shaped
# An episode number above this is not an episode number: the array's box sets
# carry 2019 (an airdate fragment) and 47370355 (a pack index) in the column the
# metadata server recorded as the match. tvdb's episode list is the only door on
# such a row; nothing files at E1000 or beyond.
EP_MAX = 999
def rel(p):
@@ -404,19 +431,79 @@ def tvdb_search(name):
return None
# Tvdb's default episode query answers AT MOST this many rows and states no
# total: One Piece (1999), Crayon Shin-chan (1992) and Detective Conan (1996)
# each answer exactly 500 while holding over a thousand. A list that long bounds
# nothing above itself, so it must never be read as "the series holds 500".
EPISODE_PAGE_CAP = 500
def tvdb_episodes(sid):
"""`season/episode` -> title for one series, in the one call tvdb answers."""
"""`season/episode` -> title for one series, in the one call tvdb answers.
Whether that call came back a cut-off page is recorded in the cache beside
it, so a warm cache answers the same question a cold one did."""
ck = f"tvdb:episodes:{sid}"
if ck in CACHE:
return CACHE[ck]
data = tvdb(f"/series/{sid}/episodes/default")
if not data:
return None
CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}":
(e.get("name") or "").strip()
for e in data.get("episodes") or []}
if ck not in CACHE:
data = tvdb(f"/series/{sid}/episodes/default")
if not data:
return None
rows = data.get("episodes") or []
CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}":
(e.get("name") or "").strip() for e in rows}
CACHE[f"tvdb:capped:{sid}"] = len(rows) >= EPISODE_PAGE_CAP
return CACHE[ck]
def tvdb_list_capped(sid):
"""True when tvdb's episode answer for this series is a page, not a list."""
rows = tvdb_episodes(sid)
if not rows:
return False
return CACHE.get(f"tvdb:capped:{sid}", len(rows) >= EPISODE_PAGE_CAP)
# tvdb's titles read backwards, for the rows that carry a title and no numbering.
_TITLE_IDX = {}
def tvdb_title_index(sid):
"""tvdb's episode list as `title -> (season, episode)`. A title two episodes
share names no episode: both halves are dropped, because a file that parks is
honest and a file filed at the wrong number is a lie the server believes."""
if sid in _TITLE_IDX:
return _TITLE_IDX[sid]
index, tie = {}, set()
for key, name in (tvdb_episodes(sid) or {}).items():
season, _, ep = key.partition("/")
t = eng.norm(name)
if not t or t in tie:
continue
if t in index:
del index[t]
tie.add(t)
continue
try:
n_season, n_ep = int(season), int(ep)
except ValueError:
continue # half-numbers ("12.5") state no slot
index[t] = (n_season, n_ep)
_TITLE_IDX[sid] = index
return index
def tvdb_number(t):
"""The (season, episode) tvdb states for a provider row whose own numbering is
not a season at all. Jellyfin's date matcher files specials by AIRDATE, so the
row says season 2002 — a YEAR — and only the title the provider recorded says
which episode it is. No id, no title, or a title tvdb gives twice: no number,
and the file parks with the reason it always carried."""
sid = (t.get("tvdb") or "").strip()
title = (t.get("title") or "").strip()
if not sid or not title:
return None
return tvdb_title_index(sid).get(eng.norm(title))
# ------------------------------------------------------------------- the inputs
rows = []
for line in open(ARGS.enum):
@@ -551,6 +638,63 @@ def extras_kind(path, extra_type):
return "extras"
# --------------------------------------------------------- the operator's map
# The map is the human answer to a name the authority cannot be searched by. It
# is read as an input file — like the census and the cache — so a plan stays
# reproducible from the files beside it, and it outranks a provider id: a box
# set's provider id is the server's own guess at a folder name, while a map row
# is an id someone checked against the record it names.
MAP = {} # top-level pack folder -> {"id", "note"}
MAP_SEEN, MAP_CLASH = set(), [] # what the map actually touched
if ARGS.tvdb_ids and os.path.exists(ARGS.tvdb_ids):
for _n, _line in enumerate(open(ARGS.tvdb_ids), 1):
_line = _line.rstrip("\n")
if not _line.strip() or _line.startswith("#"):
continue
cols = _line.split("\t")
folder = cols[0].strip()
sid = cols[1].strip() if len(cols) > 1 else ""
if not folder or not sid.isdigit():
print(f"{ARGS.tvdb_ids}:{_n}: not a numeric tvdb id: {_line}",
file=sys.stderr)
sys.exit(2)
if folder in MAP:
print(f"{ARGS.tvdb_ids}:{_n}: folder named twice: {folder}",
file=sys.stderr)
sys.exit(2)
MAP[folder] = {"id": sid,
"note": cols[2].strip() if len(cols) > 2 else ""}
def top_folder(path):
"""The library folder a file's pack sits in. An id belongs to a show, and a
show is a folder here, so that is what the map keys on."""
return os.path.relpath(path, ROOT).split(os.sep)[0]
def map_id_for(path, provider_id):
"""The id the operator states for this file's folder. The map wins over the
provider's — it is the only door for a name tvdb cannot state — and a
disagreement between the two is written where the operator reads it, never
resolved in silence."""
if not MAP:
return provider_id
top = top_folder(path)
entry = MAP.get(top)
if not entry:
return provider_id
MAP_SEEN.add(top)
if provider_id and provider_id != entry["id"]:
MAP_CLASH.append((top, provider_id, entry["id"]))
return entry["id"]
def map_note():
if not MAP:
return ""
return f" (+{len(MAP_SEEN)} folders from the operator's map)"
# ----------------------------------------------------------- classify every file
series = {} # norm(name) -> {name, year, tmdb}
claimants = [] # (row, name, season, [episodes])
@@ -584,7 +728,7 @@ def as_extra(r, owner, extra_type):
if not owner:
unknown.append((r, "bonus material with no series to own it"))
return
note(owner)
note(owner, tvdb_id=map_id_for(r["path"], ""))
extras.append((r, owner, extras_kind(r["path"], extra_type)))
for r in video:
@@ -613,18 +757,26 @@ for r in video:
else:
season = fs if fs is not None else num(t["season"])
ep = leading_index(r["base"]) or num(t["episode"])
if not 0 < season <= 200 or ep <= 0:
unknown.append((r, f"a provider row of season {season} episode {ep}"))
continue
if not 0 <= season <= 200 or not 1 <= ep <= EP_MAX:
# Season 0 IS a season: specials live in Season 00 by Jellyfin's own
# contract. A season that is a YEAR is the date matcher's doing and an
# episode above EP_MAX is a box set's airdate or pack index; neither is
# a number, and the only authority on either is tvdb's episode list.
recovered = tvdb_number(t)
if not recovered:
unknown.append(
(r, f"a provider row of season {season} episode {ep}"))
continue
season, ep = recovered
eps = [ep]
claimants.append((r, owner, season, eps))
note(owner, t["year"] if t else "", t["tmdb"] if t else "",
t["tvdb"] if t else "")
map_id_for(r["path"], t["tvdb"] if t else ""))
continue
owner = series_of_dir(os.path.dirname(p))
if fs is not None and owner and leading_index(r["base"]):
claimants.append((r, owner, fs, [leading_index(r["base"])]))
note(owner)
note(owner, tvdb_id=map_id_for(r["path"], ""))
continue
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
as_extra(r, owner, "")
@@ -705,11 +857,41 @@ def label_of(s):
return f"{nm} ({yr})"
def contradicted_seasons(s):
"""Seasons whose numbering tvdb's own episode list contradicts — the one place
a tvdb title can be a confident lie. An anime pack numbered by its release group
books S05E01 in a season tvdb does not hold, or books more episodes there than
tvdb lists, and the slot its number points at is a DIFFERENT episode. Books <=
tvdb's count stays plausible: a partial pack is the normal shape. A list the
authority cut off at its page limit (EPISODE_PAGE_CAP) contradicts nothing —
it says nothing about the episodes it never sent."""
if "contradicted" in s:
return s["contradicted"]
bad = set()
eps = None if not s["tvdb"] or tvdb_list_capped(s["tvdb"]) \
else tvdb_episodes(s["tvdb"])
if eps:
have = collections.Counter()
for k in eps:
season, _, _ep = k.partition("/")
if season.isdigit():
have[int(season)] += 1
booked = collections.defaultdict(set)
for _r, name, season, claimed in claimants:
if canon.get(series_key(name)) is s:
booked[season].update(claimed)
bad = {n for n, seen in booked.items() if len(seen) > have.get(n, 0)}
s["contradicted"] = bad
return bad
def episode_title(r, s, season, ep):
"""The title this episode carries: tvdb's for the id the series resolved to,
else the title the matched row states, else nothing at all — a dangling ` - `
is not a title, and an invented one is worse than none."""
if s["tvdb"]:
else the title the matched row states, else nothing at all — a dangling ` - ` is
not a title, and an invented one is worse than none. A season tvdb's own list
contradicts gets no tvdb title: its number points somewhere else, and a wrong
title is worse than a bare one."""
if s["tvdb"] and season not in contradicted_seasons(s):
t = ((tvdb_episodes(s["tvdb"]) or {}).get(f"{season}/{ep}") or "").strip()
if t:
return clean(t)
@@ -718,8 +900,17 @@ def episode_title(r, s, season, ep):
def series_dir(name):
"""One series, one folder — and the folder CARRIES the tvdb id. Both matchers
read `[tvdbid-NNNN]` (Jellyfin's Tvdb agent, Plex's scanner), so a show whose
title is ambiguous resolves by number instead of by a human guessing which
folder is which. The id rides the FOLDER only: Jellyfin gives a file name 180
characters and the episode title needs the room. A series nothing resolved
keeps the provider's words with no tag at all."""
s = canon.get(series_key(name))
return f"{ROOT}/{label_of(s)}" if s else f"{ROOT}/{clean(series_name(name))}"
if not s:
return f"{ROOT}/{clean(series_name(name))}"
tag = f" [tvdbid-{s['tvdb']}]" if s["tvdb"] else ""
return f"{ROOT}/{label_of(s)}{tag}"
# ------------------------------------------------------------------- the plan
@@ -967,12 +1158,14 @@ def write_plan():
sized = sum(held[s]["bytes"] for s, _d, _k in moves if s in held)
print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked"
f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed,"
f" {len(dirs)} folders{provider_note()} -> {ARGS.out}")
f" {len(dirs)} folders{map_note()}{provider_note()} -> {ARGS.out}")
def write_exceptions():
no_year = sorted({s["name"] for s in canon.values() if not s["year"]})
no_tvdb = sorted({identity(s)[0] for s in canon.values() if not s.get("canon")})
clash = sorted({(identity(s)[0], tuple(sorted(contradicted_seasons(s))))
for s in canon.values() if contradicted_seasons(s)})
with open(ARGS.exceptions, "w") as fh:
fh.write(f"TV relayout exceptions for {ROOT}\n\n")
fh.write(f"Everything below is parked under {PARK}, keeping the relative path it\n")
@@ -1002,6 +1195,62 @@ def write_exceptions():
fh.write(" title was taken. Nothing was invented: give these a resolvable\n")
fh.write(" id (--tvdb) and re-plan; a rename is idempotent.\n")
fh.write(" " + ", ".join(no_tvdb[:300]) + "\n")
if clash:
fh.write(f"\n{len(clash):>6} series carry seasons whose numbering tvdb"
" contradicts:\n")
fh.write(" a pack books more episodes in a season than tvdb holds"
" there — an anime\n pack numbered by its release group is"
" the usual shape. Those files still\n rename to the"
" standard shape, but they do NOT ride with tvdb's titles:"
"\n the slot tvdb answers for that number is a different"
" episode. Correct the\n numbering and re-plan; a rename"
" is idempotent.\n")
fh.write(" " + "; ".join(
f"{n} S{'/'.join(f'{x:02d}' for x in ss)}"
for n, ss in clash[:300]) + "\n")
map_ids = {e["id"] for e in MAP.values()}
on_array = {rel(p).split("/")[0] for p in held}
stalled = sorted(f for f in MAP if f not in MAP_SEEN and f in on_array)
unseen = sorted(f for f in MAP if f not in MAP_SEEN and f not in on_array)
unanswered = sorted({f"{identity(s)[0]} ({s['tvdb']})"
for s in canon.values()
if s.get("tvdb") in map_ids and not s.get("canon")})
if MAP:
fh.write(f"\n{len(MAP_SEEN):>6} folders took their tvdb id from the\n")
fh.write(" operator's map — the human answer to a name tvdb's search\n"
" cannot state. A wrong row renames a whole pack to the wrong\n"
" show, so every folder it touched is listed here; fix the map\n"
" and re-plan, because a rename is idempotent.\n")
fh.write(" " + "; ".join(
f"{f} -> {MAP[f]['id']}" for f in sorted(MAP_SEEN)) + "\n")
if stalled:
fh.write(f"\n{len(stalled):>6} map rows name a folder on the array that\n")
fh.write(" never became a series: every file in it parked, so the id\n"
" had nothing to name. The map names a RECORD — it numbers\n"
" no episodes and owns no bonus material, so read the park\n"
" reasons above this line: a folder of openings, endings and\n"
" promos with no show to claim them is the usual shape.\n")
fh.write(" " + "; ".join(
f"{f} -> {MAP[f]['id']}" for f in stalled[:300]) + "\n")
if unseen:
fh.write(f"\n{len(unseen):>6} map rows name no folder on the array: the\n")
fh.write(" pack moved (into the review pile counts: the map reads\n"
" top-level folders only), or the row carries a name the\n"
" array never held.\n")
fh.write(" " + "; ".join(unseen[:300]) + "\n")
if MAP_CLASH:
fh.write(f"\n{len(MAP_CLASH):>6} folders carry a provider tvdb id the map\n")
fh.write(" contradicts. The map won: a box set's provider id is the\n")
fh.write(" server's guess at a folder name. Read each one:\n")
fh.write(" " + "; ".join(
f"{f} provider {p} map {m}"
for f, p, m in sorted(MAP_CLASH)[:300]) + "\n")
if unanswered:
fh.write(f"\n{len(unanswered):>6} map ids tvdb never answered, so those\n")
fh.write(" folders carry the id and the provider's own name; both\n")
fh.write(" matchers resolve by the id in the folder tag, so the next\n"
" re-plan takes tvdb's title. Nothing was invented.\n")
fh.write(" " + "; ".join(unanswered[:300]) + "\n")
print(f"exceptions -> {ARGS.exceptions}")
+18 -1
View File
@@ -60,7 +60,7 @@ die() {
}
usage() {
printf 'usage: %s plan|verify|install|reap|status [options]\n' "$0" >&2
printf 'usage: %s plan|ensure-base|verify|install|reap|status [options]\n' "$0" >&2
exit 2
}
@@ -98,6 +98,22 @@ require_upper_base() {
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)"
}
# The SSD placement of the metadata layer is a human choice, so `install` refuses
# to invent a base directory: a mistyped --upper-base must not quietly create a
# tree on whatever filesystem the caller landed on. The deployer owns the
# documented location, so it calls this first — measured 2026-10-31, when a
# fresh-host `install` died with "put it on the SSD first" and nothing in git
# would have created it.
do_ensure_base() {
if [ -d "$upper_base" ]; then
printf 'base: %s exists\n' "$upper_base"
return 0
fi
install -d -m 0755 "$upper_base" 2>/dev/null \
|| die "refused: cannot create $upper_base — it lives outside the checkout, run this as root"
printf 'base: created %s\n' "$upper_base"
}
do_plan() {
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
@@ -234,6 +250,7 @@ do_status() {
case "$cmd" in
plan) do_plan ;;
verify) do_verify ;;
ensure-base) do_ensure_base ;;
install) do_install ;;
reap) do_reap ;;
status) do_status ;;
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# wait-for-mount.sh — refuse a container start until what it binds is mounted.
#
# Why this exists (live 2026-10-31, Dankserver): a rootless quadlet cannot order
# itself against a SYSTEM mount unit. `RequiresMountsFor=/mnt/entropy` in a user
# unit resolves to nothing and is inert, so when the array's mount finished a
# second after podman resolved the bind, Jellyfin and Plex started against the
# pre-mount directory. Their library root looked EMPTY and Jellyfin began
# deleting metadata by the thousand (`Removing item, Type: "Season"`). A blind
# bind is not a cosmetic fault; it is a metadata-deleting machine.
#
# A `ConditionPathIsMountPoint=` is NOT the fix, measured on the same host: an
# unmet condition logs "skipped, unmet condition check", NRestarts=0, and no
# start attempt ever follows — the unit stays dead until a human starts it.
# That is fail-dead. This script WAITS (the mount usually lands within seconds)
# and only then refuses, so `Restart=always` carries the unit across the race.
#
# usage: wait-for-mount.sh [-t SECONDS] MOUNTPOINT [MOUNTPOINT...]
# exit 0 every path is a mountpoint
# exit 1 a path is still not a mountpoint after the window (named on stderr)
# exit 2 usage error
set -euo pipefail
usage() { printf 'usage: %s [-t SECONDS] MOUNTPOINT [MOUNTPOINT...]\n' "$0"; }
window=90
while getopts ":t:h" opt; do
case "$opt" in
t) window=$OPTARG ;;
h) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
[ "$#" -ge 1 ] || { usage >&2; exit 2; }
case "$window" in
'' | *[!0-9]*) printf 'refused: -t wants whole seconds, got %s\n' "$window" >&2; exit 2 ;;
esac
is_mount() { findmnt -M "$1" -rno TARGET >/dev/null 2>&1; }
report() {
local why=$1; shift
printf '%s:' "$why"
for p in "$@"; do printf ' %s' "$p"; done
printf '\n'
}
deadline=$(( $(date +%s) + window ))
warned=0
while :; do
missing=()
for path in "$@"; do
is_mount "$path" || missing+=("$path")
done
[ "${#missing[@]}" -eq 0 ] && break
if [ "$(date +%s)" -ge "$deadline" ]; then
report "refused: still not mounted after ${window}s" "${missing[@]}" >&2
printf 'refusing to start: a container bind-mounted here would see the empty\n' >&2
printf 'directory underneath and conclude the library is empty — which is how a\n' >&2
printf 'scan begins deleting metadata it cannot find.\n' >&2
exit 1
fi
if [ "$warned" -eq 0 ]; then
report "waiting for mount(s) this unit depends on" "${missing[@]}"
warned=1
fi
sleep 1
done
[ "$warned" -eq 0 ] || printf 'mounted; proceeding.\n'
exit 0
@@ -100,3 +100,12 @@ aborts with `DEPLOY INCOMPLETE: e2e gate FAILED` when it does not pass
hand-off list (`HANDOFFS`) instead of a single host. The guard contract is
untouched: challenge cleared, no authentik `uidField`, status < 500, and every
host NOT in the list still has to land on its own vhost.
7. **The runner's resolver is part of the suite.** A 2026-10-31 deploy of one
Dankserver quadlet failed the gate with 21 reds spread over SSO, homepage RBAC
and every vhost crawl — precisely the specs that navigate to an absolute
`*.franksdank.cloud` name, while every spec on the relative `baseURL` stayed
green. The error was `net::ERR_NAME_NOT_RESOLVED`: the workstation had lost its
wired link, and on the fallback its resolver was no longer pinned to Pi-hole.
Nothing on the fleet was wrong — the same suite re-run minutes later passed
37/37. Before a mass red gets blamed on the guard chain, run
`resolvectl query grafana.franksdank.cloud` on the box that runs the gate.
@@ -167,6 +167,43 @@ Gate: `tests/quadlet_image_pins.bats`. Where upstream publishes no version
tags (copyparty-ac stops at 1.19.15), pin `:latest@sha256:` and name the
version the config label carries in the comment beside it.
## 4b. Resource control needs host permission (measured 2026-10-31, Dankserver)
`IOWeight=` in a rootless quadlet does nothing on its own, and nothing says so.
The system manager delegates `cpu memory pids` to `user@.service` and **not** `io`;
systemd drops a resource line it cannot honour **without a word** — the generator
still emits `IOWeight=10`, the container still starts and answers its health check,
and the cgroup simply has no `io.weight` file. Same family as
`RequiresMountsFor=` (inert in the user manager) and `ConditionPathIsMountPoint=`
(fail-dead): the line is accepted, the unit is happy, the setting is dead.
Two system drop-ins make it real, both placed by `ensure_user_io_delegate` in
`scripts/deploy-remote.sh` (repo copies under `dankserver/host/systemd/`):
| File | Effect |
|---|---|
| `user.slice.d/io-accounting.conf` → `[Slice] IOAccounting=yes` | puts `io` into `user.slice/cgroup.subtree_control`; lands immediately, no restart |
| `user@1000.service.d/io-delegate.conf` → `[Service] DelegateControllers=cpu memory pids io` | the user manager can only request controllers it was delegated; read when it starts, so it lands on **the next reboot** (restarting it kills every rootless container on the host) |
Read the chain in this order — each line is the failure mode of the one after it:
```bash
cat /sys/fs/cgroup/user.slice/cgroup.subtree_control # wants: io
systemctl show user@1000.service -p DelegateControllers # wants: io
cat /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/\<unit\>.service/libpod-payload-*/io.weight
```
`io` missing from the first → the accounting drop-in is absent. Missing from the
second → the host has not rebooted since the delegation landed (the deployer prints
`NOTE: io is not delegated …` for exactly this). No file at the third → that unit
carries no weight.
Two limits that survive the fix: a container's weight covers **everything** in its
cgroup, so a library scan and a playback stream share one weight; and the comparison
is made *inside* `user.slice`, so a rootless unit's weight cannot push it below a
root unit (`frank-archon`). Shaping that pairing is the weight of `user.slice`
itself — a fleet decision, not a unit line.
## 5. Rootless Quadlet log retrieval
Step 0 — establish ownership: `systemctl --user status <name>.service`
@@ -1,51 +1,68 @@
---
type: reference
description: "The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at"
description: "Dankserver's /mnt/entropy volume: the LUKS/array chain, the crypttab + fstab boot path, and the containers bound to it"
tags:
- dankserver
timestamp: 2026-08-28
timestamp: 2026-10-31
---
# Entropy Volume
The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at
`/mnt/entropy`. The command-line helper reuses the host's desktop-integrated
`udisks2`, `crypttab`, and `fstab` configuration; it does not store or handle
the passphrase itself.
`/mnt/entropy` is the ext4 filesystem on the LUKS container holding `/dev/md0`
(RAID6, seven disks). The media stack's data lives there, so the mount is a boot
dependency for those containers rather than a convenience.
## Command-line mount
```bash
/home/dank/serverAppConfigs/scripts/mount-entropy.sh
```
Run it as the normal desktop user (`dank`), not from a root shell. `udisksctl`
will securely prompt for the LUKS passphrase. `sudo mount /mnt/entropy` then
uses the existing `/etc/fstab` entry, preserving its options:
## Chain
```text
nosuid,nodev
/dev/md0 <- mdadm (its mdadm.conf ARRAY is what makes md0 exist at boot)
-> LUKS UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec keyfile: /etc/entropy
-> /dev/mapper/entropy <- systemd-cryptsetup@entropy.service
-> ext4 at /mnt/entropy <- mnt-entropy.mount, from /etc/fstab
```
The existing mountpoint is owned by `dank:dankserver` with mode `0755`; the
helper deliberately does not run `chmod` or `chown`, so it cannot silently
change those permissions.
## Files that own it
## Device path
| Repo file | Installs to | Produces at boot |
|---|---|---|
| `dankserver/host/crypttab` | `/etc/crypttab` | `systemd-cryptsetup@entropy.service` → `/dev/mapper/entropy` |
| `dankserver/host/fstab` | `/etc/fstab` | `mnt-entropy.mount` (mountpoint `dank:dankserver`, `0755`) |
The current storage chain is:
Deploy a change by copying the file onto the host, then
`systemctl daemon-reload && update-initramfs -u`. The initramfs step is not
optional: the cryptsetup hook bakes `/etc/crypttab` into the initrd, and the
array is unlocked there, before the real root mounts. `scripts/mount-entropy.sh`
(`udisksctl unlock` + `mount`) stays as the manual path for a volume that was
never unlocked; on a correct boot it is redundant.
```text
/dev/md0 -> LUKS UUID cd86c728-459e-4f3a-bcc2-41ecf84685ec
-> ext4 label entropy
-> /mnt/entropy
```
## Law
The helper checks that `/dev/md0` exists and exits successfully if the volume
is already mounted. It is intentionally specific to this host and volume:
that avoids accidentally unlocking or mounting a different disk.
**`crypttab`'s device field takes the LUKS UUID, never the md array UUID.**
`blkid` prints both on adjacent lines for a RAID-backed container, and the file
shipped since 2025 carried the array UUID (`61bd6eaf-…`) where the LUKS container
is `cd86c728-…`. The generator produced a perfectly valid unit pointing at a
device that never appears, so the mapper came into existence only through a
desktop `udisks2` session — the volume looked mounted on every login while every
boot failed to mount it. The matching `fstab` line named the same wrong UUID
directly instead of the mapper.
## Related
**A LUKS keyfile is hashed whole, so a trailing newline is a different
passphrase.** The shipped `/etc/entropy` was 27 bytes — 26 characters plus `\n` —
and `cryptsetup` rejected it even after the UUID was corrected. Keyfiles stay
`0600` with no trailing newline; prove it with
`cryptsetup open --test-passphrase --key-file /etc/entropy UUID=…` *before*
relying on it for a boot.
- [[Storage Policy]]
- [[Dankserver Stack]]
**Prove a mount fix with the artifacts systemd generates, not with a live
mount.** The regenerated `systemd-cryptsetup@entropy.service` naming the right
device, `findmnt /mnt/entropy` sourcing `/dev/mapper/entropy`, and a matching
checksum between the repo copy and the host copy are the evidence. A mount that
exists because someone logged in proves nothing about the boot path — that
exactly describes the year this bug survived.
## Consumers
`jellyfin`, `copyparty`, and `plex` bind paths under `/mnt/entropy`; with the
volume absent they start `active (healthy)` against empty directories, which is
how a missing 91 TiB volume hides. See [[Dankserver Stack]] for the stack and
[[Storage Policy]] for what may be written here.
+21 -14
View File
@@ -18,7 +18,7 @@ clients.
public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
[[Authentik]] outpost (post-auth route proxies `localhost:8096`).
## Facts (the repo quadlet; the live unit is still the pre-view generation — see Data layout)
## Facts (the repo quadlet — deployed as the live unit on 2026-10-31)
| Item | Value |
|------|-------|
@@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
| Image | `ghcr.io/jellyfin/jellyfin:latest` |
| Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp |
| Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` |
| Media | `Volume=/var/lib/media-meta/view:/data` in git — the [[Media Metadata View]] overlay, **writable**, sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched. **The live unit is still pre-view: `/mnt/entropy:/data:ro`** — nothing is written into the library until the view is deployed |
| Media | `Volume=/var/lib/media-meta/view:/data` — the [[Media Metadata View]] overlay, **writable**, live since the 2026-10-31 cutover: sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched |
| GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) |
| Env quirks | `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`, `LD_PRELOAD=libjemalloc.so.2`, `MALLOC_TRIM_THRESHOLD_=131072` kept verbatim from the compose-era container (zero-drift cutover) |
| Health | `curl -f http://localhost:8096/health` every 30 s |
@@ -37,26 +37,34 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
/home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR)
/home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME)
/var/lib/media-meta/view -> /data (git: the overlay view)
/mnt/entropy -> /data (live today: the array, read-only)
/mnt/entropy -> /data (the overlay's lower layer; also the rollback bind)
```
Library folders point at `/data` paths: stable across container recreation and
identical to the raw array layout below. Which side of that path the container
is on is the open question on this host — the installed unit binds the read-only
array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31).
sees is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover:
`/var/lib/media-meta/view:/data`, read-write.
`Local metadata` — save metadata to media folders — is enabled, and it is the
reason the view exists: `movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork
written once on the NVMe upper layer are files [[Plex]] can read too. While
`/data` is bound read-only every item update logs the error below and no sidecar
is written; Jellyfin becomes the fleet's only metadata writer with the deploy
that raises the view.
written once on the NVMe upper layer are files [[Plex]] can read too — and since
the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only
metadata writer, and every sidecar it saves is an SSD byte the array never sees.
**Library coverage is the first thing to check when a title is missing.** The
four libraries are `movies → /data/Movies`, `tvshows → /data/TVShows`,
`music → /data/Music`, `boxsets → /config/data/collections`
(`GET /Library/VirtualFolders`). `/data/media-park` is in **none** of them, so
everything filed under it — the `_unsorted` release bundles and the `extras/`
folders the relayout wrote there — is invisible to Jellyfin by configuration,
not by fault: a recursive refresh answers 204, burns CPU for minutes and
indexes nothing, because it never looks there. Check coverage before re-scanning.
**Failure signature:** `System.IO.IOException: Read-only file system :
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array
instead of the view. On Dankserver that is today's expected state — the view has
never been installed (`scripts/media-view.sh status`). Once it is deployed, the
same line is a fault: the quadlet drifted or the mount died.
instead of the view — always a fault on this host since the 2026-10-31 cutover.
`scripts/media-view.sh status` says which: the quadlet drifted back to the raw
array, or the mount died.
## Ports & access
@@ -212,8 +220,7 @@ receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answe
One accepted refresh is the whole procedure. A second one while a scan runs moves
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
later `204` is accepted and then discarded — the status code stops meaning
anything. The fix is `systemctl --user restart jellyfin.service` (never
`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after
anything. The fix is `systemctl --user restart jellyfin.service`, one POST after
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
response code.
@@ -0,0 +1,74 @@
---
type: reference
title: Media Audiobook Relayout
description: "Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals."
tags: [dankserver, media-server, plex, media-relayout]
timestamp: 2026-10-31T00:00:00Z
---
# Media Audiobook Relayout
`/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read
at all — its music scanner has no audiobook concept — while [[Plex]] indexes all
of it as Artist → Album → Track: the album is the BOOK, that book's single artist
is the NARRATOR, both matched out of the files' own tags. Today one book is split
across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection
pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py`
re-files the tree so the property both servers' book scanners assume is true —
Audiobooks/<Narrator>/<Book> (<year>)/<the file keeps its own name>
The shared gates, the executor, the journal law and the rollback live in
[[Media Relayout]]; this page is the audiobook arm only.
```bash
# 1. The authority: Plex's chapter rows for this root (the DB opens read-only).
python3 scripts/media-census.py audiobooks --root /mnt/entropy/Audiobooks \
--out /tmp/ab_rows.tsv
# 2. What is on disk (stat only).
python3 scripts/media-census.py enumerate --root /mnt/entropy/Audiobooks \
--out /tmp/ab_enum.tsv
# 3. The plan.
python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \
--enum /tmp/ab_enum.tsv --rows /tmp/ab_rows.tsv --out /tmp/ab_plan.tsv \
--exceptions /tmp/ab_exceptions.txt
# 4. Read the exceptions file FIRST. Then the same executor, journal and rollback
# as [[Media Relayout]] shows, on its own journal, and refresh the Plex section
# as in step 5 of the movie Procedure — from inside the container, never over
# the host port.
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
```
The group is the identity Plex asserts in its own database — **(narrator, book,
year)** — so one book arriving in four part folders becomes one folder, and a
1965 and a 2020 recording that share a title stay two books.
Two laws are forced by what the rows actually hold:
+- **A chapter is never renumbered.** Most books contain chapters whose index Plex
does not know, so a number written into a name would be invented — and it would
buy nothing, because both servers order playback by the embedded track tag, not
by the file name. A wrong number in a name is the number a human later trusts.
Fix order in the tags: a Plex scan re-reads tags and never renames a file.
+- **The shelf is the narrator, not the author.** Every book has exactly one artist
row and it is the narrator; the author survives only inside some album titles
(`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving it would
mean parsing titles where a ripper wrote one and inventing it everywhere else.
The author stays visible in the folder name Plex itself shows.
What it refuses, the music arm's list with one addition: a video container, audio
no chapter row names, and a loose file in a folder holding two books all park under
`_unsorted` with the path they have today — and so does a chapter whose row names
**no book or no narrator**: a placeholder (`[Unknown Album]`, `[Unknown Artist]`)
that unrelated books share, or a chapter number mistagged as the artist (`0.4`).
`Various Artists` is not a placeholder — that is a multi-cast read.
The reviewed plan lives at `/mnt/entropy/media-relayout-audiobooks-plan.tsv`; it
applies on its own journal, behind the music writer, and then refreshes the Plex
section. Verify it with `--db-kind plex` — Jellyfin holds no rows here, so a
Jellyfin pass proves nothing either way.
## See Also
[[Media Relayout]], [[Media Music Relayout]], [[Plex]]
@@ -20,7 +20,16 @@ The mount point is not a volume and holds nothing while unmounted. It sits insid
merged door are one directory — and so the mount table grows no new `/mnt` entry
next to the array.
**Deployed on Dankserver — verified 2026-11-01.** `mount` shows the overlay on
**Deployed on Dankserver — 2026-10-31, both servers.** `plex` binds
`/var/lib/media-meta/view:/mnt/entropy:ro` and `jellyfin` binds
`/var/lib/media-meta/view:/data:rw`; `findmnt` reports the overlay with
`lowerdir=/mnt/entropy upperdir=/var/lib/media-meta/upper`, and each container
was proven to resolve a real media file through it — `podman exec <svc> ls -la
<its data path>` is the whole proof, because both servers keep the paths their
databases recorded. Two service-scoped deploys, each gated by
`media-view.sh verify` and the Playwright suite.
Re-verified 2026-11-01: `mount` shows the overlay on
`/var/lib/media-meta/view` (`lowerdir=/mnt/entropy`,
`upperdir=/var/lib/media-meta/upper`), and the installed quadlets under
`~/.config/containers/systemd/` carry the view binds:
@@ -29,6 +38,14 @@ next to the array.
order: `scripts/media-view.sh verify` (the deploy gate), then
`grep -n Volume ~/.config/containers/systemd/{jellyfin,plex}.container`.
Moving a bind means restarting the server, and **a restart discards that
server's in-flight library scan** — Jellyfin's 7 h 25 m walk ended
`"Status": "Aborted"` in `data/ScheduledTasks/<task-id>.js` and nothing resumes
it. Renames made on the array while the view was unmounted are invisible to both
watchers too, so the additive index has to be asked for by hand; the door is an
API key, and the row in the failure table below says what to check when it
refuses.
The cutover itself is an ordinary deploy: `deploy.sh dankserver --service
jellyfin|plex` runs `ensure_media_view` (`install` then `verify`) before it
restarts the container, so it must not be fired through the middle of a library
@@ -51,10 +68,11 @@ leaves the upper layer untouched, and `reap` correctly finds nothing.
| Installer | `scripts/media-view.sh` — `plan` / `install` / `verify` / `status` / `reap` |
| Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it |
Both consumers guard the view with
`UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view`, and
`scripts/deploy.sh dankserver --service jellyfin|plex` calls `media-view.sh
install` + `verify` before it restarts either container.
Both consumers gate their start on the view with
`ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh` (it waits
for the mount, then refuses), and `scripts/deploy.sh dankserver --service
jellyfin|plex` calls `media-view.sh install` + `verify` before it restarts
either container.
| Consumer | Bind | Mode | Why that path |
|---|---|---|---|
@@ -85,11 +103,20 @@ the defaults are the host values above.
refuse via `lsblk -dno ROTA` if the upper base lives on the array: an overlay
whose upper is the spinners is the original problem with extra steps, and it
silently turns every metadata write back into a seek.
- **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy`
plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir`
presents an *empty library* as truth, and a media scan would then clear the
database of everything. systemd does not create a mount point, so `install`
creates `view` itself; without it the mount simply fails.
- **A container that binds the array must refuse to start until the mount
answers.** Live 2026-10-31: the array's mount and the media containers raced at
boot, podman resolved the bind against the pre-mount directory, and both
servers came up blind — Jellyfin read an empty library and began
`Removing item, Type: "Season"` by the thousand. Two dependencies people reach
for do not work here, both measured on that host: `RequiresMountsFor=` in a
**rootless** unit is inert (the user manager cannot order against the system's
`mnt-entropy.mount`), and `ConditionPathIsMountPoint=` is fail-*dead* — an
unmet condition logs `skipped, unmet condition check` with `NRestarts=0` and no
start attempt ever follows, so the unit waits for a human. The working guard is
`scripts/wait-for-mount.sh` as `ExecStartPre`: it waits out the race, refuses by
name after its window, and `Restart=always` carries the unit from there.
systemd does not create a mount point, so `install` creates `view` itself;
without it the mount simply fails.
- **Never delete through the view.** A delete writes an overlayfs whiteout
(`.wh.<name>`) in the upper layer, which hides the array's copy without
removing it — the file reappears the moment the view is unmounted, and the
@@ -112,10 +139,14 @@ the defaults are the host values above.
| Symptom | Meaning |
|---|---|
| `jellyfin.service` / `plex.service` show `ConditionPathIsMountPoint=... was not met` and stay inactive | The view is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. |
| `jellyfin.service` / `plex.service` fail with `refused: still not mounted after 90s: …` | The view or the array is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. |
| Servers answer, library looks empty, Jellyfin logs `Removing item, Type: "Season"` by the thousand | Blind bind — the container started before the mount and bound the pre-mount directory. `podman exec jellyfin ls /data` against `ls /mnt/entropy` is the whole diagnosis; restarting the container re-resolves the bind. |
| Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. |
| A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. |
| `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. |
| `install` prints `refused: upper base /var/lib/media-meta does not exist (put it on the SSD first)` | A fresh host. `install` deliberately never creates the base — a mistyped `--upper-base` must not build a metadata tree on the wrong filesystem — so `scripts/media-view.sh ensure-base` makes the documented one, which the deployer calls first. |
| A media server's scan record reads `"Status": "Aborted"` | Its container restarted (a cutover, an image bump, an OOM) and the walk was discarded; systemd's `Restart=always` restarts the *server*, never the scan. Trigger a refresh — see [[Media Relayout]]. |
| The Jellyfin API answers 401 to every token header | Almost always the header **form**, not the key: 12.4 authenticates only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for every key, valid or not — and one collapsed backslash in the curl config produces the same 401. `[[Jellyfin]]` owns the block that works. Before concluding anything, prove the key against the server's own table: `select count(*) from ApiKeys where AccessToken = ?` on `jellyfin.db` opened `mode=ro&immutable=1`. |
| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. |
## Rollback
@@ -0,0 +1,68 @@
---
type: reference
title: Media Music Relayout
description: "Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals."
tags: [dankserver, media-server, jellyfin, media-relayout]
timestamp: 2026-10-31T00:00:00Z
---
# Media Music Relayout
`/mnt/entropy/Music` is rips and discography packs, and Jellyfin has matched and
tagged almost all of it. `scripts/media-music.py` re-files it into the shape
Jellyfin's music scanner reads —
Music/<album artist>/<Album> (<year>)/NN - <Title>.ext
— and the authority is the tag row of the track, never the name the ripper gave
the file. The shared gates, the executor, the journal law and the rollback live in
[[Media Relayout]]; this page is the music arm only.
```bash
# 1. The authority: Jellyfin's audio rows, one per track, with the album and
# artist ids its own database carries (the DB opens read-only).
python3 scripts/media-census.py music --root /mnt/entropy/Music \
--out /tmp/music_rows.tsv
# 2. What is on disk (stat only).
python3 scripts/media-census.py enumerate --root /mnt/entropy/Music \
--out /tmp/music_enum.tsv
# 3. The plan.
python3 scripts/media-music.py --root /mnt/entropy/Music --enum /tmp/music_enum.tsv \
--rows /tmp/music_rows.tsv --out /tmp/music_plan.tsv \
--exceptions /tmp/music_exceptions.txt
# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same
# executor, journal and rollback as [[Media Relayout]] shows, on its own journal.
scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \
--journal /mnt/entropy/media-relayout-music.jsonl --apply
```
The group is what the tags assert, not where the files sit: **(album-artist tag,
album, year)**. One album arrives as three disc folders and as two copies of one
rip, and one MusicBrainz release group is one album. A compilation is one album
whose tracks name many performers, so it files once under `Various Artists`:
keying on the performer would file one album once per artist. Two different
album-artist tags on one album name are two albums, which is what Jellyfin's two
album items say.
What it refuses:
+- **A sidecar is placed by the audio around it, never by its own name.** A
`.lrc`, `.txt` or picture that shares a track's stem follows that track and
takes the name the track now has, or Jellyfin cannot find it; anything else
goes to the album its own folder was placed in, or to the folder whose discs
sit one level below it. A folder whose audio belongs to two albums parks its
sidecar rather than guessing.
+- **An image album keeps its name.** A folder holding one audio file and a
`.cue` is a whole album in one file; numbering it `01 - …` tells Jellyfin to
index it as one track.
+- **A video container parks**, because Jellyfin will not index it as music and a
rename to `.m4a` would claim it is; **audio no provider row names parks** too,
because unmatched audio is a tagging job, not a renaming job.
ONE writer at a time on this array: two rename passes race, and neither journal
tells. Verify the run against Jellyfin's own tables — see *Proving the move
landed* in [[Media Relayout]].
## See Also
[[Media Relayout]], [[Jellyfin]], [[Media Audiobook Relayout]]
+117 -129
View File
@@ -47,8 +47,10 @@ $C plex --root /mnt/entropy/Movies --out /tmp/plex-movies.tsv
```
`plex` reads the Plex metadata cache (`--db` defaults to the path the
[[Plex]] container bind-mounts at `/var/lib/plexmediaserver`) with
`mode=ro&immutable=1`: a live server is never locked or written.
[[Plex]] container bind-mounts at `/var/lib/plexmediaserver`) with plain
`mode=ro`: a live server is never locked or written, and plain ro is also what
makes a scan's uncheckpointed rows visible. The same rule as
`scripts/media-verify.py` — see *Proving the move landed*.
Then plan — this reads only the three TSVs, so it costs seconds and can run
anywhere, including a laptop:
@@ -79,6 +81,13 @@ An apply also leaves the release folders it emptied standing where they were —
hundreds of them at the library root — which is what `sweep` removes, dry-run
first, from the same journal.
`--batch N` (default 64) makes the journal durable per batch instead of per
move: N intents are written and fsynced, then those N rename. The rule a crash
relies on — no rename runs before its intent is on disk — is unchanged, so an
interrupted apply still replays the batch and resumes with nothing lost. Batch 1
is the old behaviour; the only reason to name it is debugging, because on the
spinning array it spends most of a large apply's wall time in fsyncs.
After it lands, both servers need a scan or the change is invisible. [[Plex]] and
[[Jellyfin]] own the exact requests; two laws hold at this step: `204` is
acceptance and anything else queued nothing — re-issue the identical request
@@ -94,14 +103,34 @@ replayed against both servers' own tables: every `move` row has a row at its
replay: it opens each database read-only (there is no `sqlite3` binary on these
hosts or inside either container) and exits non-zero while a row is stale.
Both libraries live in WAL mode, so the tool opens them plain `mode=ro`:
`immutable=1` promises sqlite the file never changes, which skips the
write-ahead log — a scanner's own uncheckpointed rows then read as missing, and
a read beside a running scan can fail as "database disk image is malformed".
`immutable` stays only the fallback for a database whose `-shm` cannot be read.
```bash
scripts/media-verify.py --journal /mnt/entropy/media-relayout-music.jsonl \
--db-kind jellyfin --ignore-ext jpg,jpeg,png,log,cue,toc,nfo,txt,m3u,accurip
```
Ignore only what a server cannot index. A missing `.flac` or `.mkv` is the
fault; a row still naming its `src` means the scan has not followed the rename
yet, so re-run the check after the scan, never before.
"No row at the destination" is not one verdict but four. **never indexed**: the
server indexes no file of that extension (its own extension census says so) — a
sidecar, not a lost film. **gone**: the file sits at neither src nor dst, because
a later pass or the server's own extension sweep renamed it. **phantom**: a row
still naming src while the file has left src. **REAL**: no row at dst *and* the
file still at src — the only class that is this relayout's own damage. Phantoms
are normal for both servers (Plex carries thousands that predate any relayout);
re-scan to clear them and never read them as loss.
`media-verify.py` prints this split itself — `on disk at dst, no row names it`,
`REAL — file still at src`, `gone — at neither path` — so nobody hand-stats a
thousand paths to find out whether a film is lost or merely unindexed.
The owning server decides whether a pass can convict anything: Plex's roots are
its `sections` rows joined to their location rows (`Movies`, `TVShows`, `Music`,
`Audiobooks`, `LiveRecording`) and Jellyfin holds no audiobook library at all, so
the audiobook journal verifies with `--db-kind plex` or it proves nothing.
Three doors that look right and are not: `GET /search?query=` matches metadata
titles, never file paths; `?path=` on `/library/sections/N/all` makes Plex's
@@ -140,16 +169,15 @@ from the box-set folder. What it refuses it prints and leaves alone:
- a destination two files both claim: the executor refuses the whole plan
before its first rename, and only Frank decides which copy is the keeper.
- an episode the destination library already holds at the same byte count. The
movie share carried a second copy of the whole Monk box set — 124 episodes
both sides, the same episode list, every byte count identical, 31.5 GiB each
way, and head+tail blake2b identical on a 6-episode sample. The mover leaves
such a series in place and says so; a duplicate is deleted or kept by Frank,
never copied into one library by a tool.
movie share once carried a second copy of a whole box set — same episode list,
every byte count identical. The mover leaves such a series in place and says
so; a duplicate is deleted or kept by Frank, never copied into one library by
a tool.
A sidecar that shares an episode's own name (`.srt`, `.nfo`) moves with it.
Below the threshold the mover prints and leaves, so a lone downloaded episode of
a real show stays in the movie library on purpose — six did on 2026-10-30.
a real show stays in the movie library on purpose.
Filing one by hand is the same executor on a hand-written plan and the same
journal; the trap is the series year. Read it from the file's own name or the
folder it owns, never from the title whose bonus folder it happens to be parked
@@ -162,7 +190,8 @@ show. A name whose marker is `Part 2` is a film and stays put.
already matched and numbered most of it. `scripts/media-shows.py` re-files that
library into the shape Jellyfin reads, with **tvdb as the name authority**:
<Series> (<year>)/Season NN/<Series> (<year>) - SxxEyy - <Episode Title>.ext
<Series> (<year>) [tvdbid-NNNN]/Season NN/
<Series> (<year>) - SxxEyy - <Episode Title>.ext
It never guesses: every series name, episode number and title it writes traces to
an authority, and what has none is parked for a human to read.
@@ -208,14 +237,21 @@ scripts/media-apply.py apply --plan /mnt/entropy/media-relayout-shows2-plan.tsv
Evidence, strongest first — the order the planner believes things:
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`);
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`); a digit
run longer than three at that position is NOT a number — `S03E2019` and
`S04E47370355` are one box set's airdate fragment and pack index — so such a file
is renumbered by tvdb's title or parks, and is never truncated down to fit;
- the **`Season NN` folder it sits in and the number its name leads with inside
it**: `Season 19/01 Charles Barkley, Nirvana.avi` is S19E01;
- the **provider row**: which episode the file IS, and the numbering of a file
whose own name says nothing;
- the **row's tvdb id, else its TMDb id carried through TMDb's external ids, else
an exact tvdb name search**: that id names the SERIES FOLDER and supplies the
episode title, because a name the server read off a folder is not a show name;
episode title, because a name the server read off a folder is not a show name.
The folder CARRIES the id: `[tvdbid-NNNN]` is what Jellyfin's Tvdb agent and
Plex's scanner both read, so a show whose title is ambiguous resolves by number
instead of by a human guessing which folder is which. The id rides the folder
only — a file name gets 180 characters and the episode title needs the room.
- a **bonus folder** anywhere above the file, or Jellyfin's `ExtraType` → the
series' canonical extra folder;
- a runtime over `SHORT_MAX` makes it film-shaped, and it leaves for `--movies` only
@@ -225,7 +261,7 @@ Evidence, strongest first — the order the planner believes things:
for the word "russia", *Special Edition I* for a file saying *Edition III*. Else it
parks.
Two refusals carry the design, and a dry run on the real library found both:
Four refusals carry the design: what the planner will NOT do is the point of it.
- **Folders resolve a series; they never invent one.** A folder's words may
resolve a file to a series a provider has already named — `(CBB) Tengen Toppa
@@ -235,8 +271,27 @@ Two refusals carry the design, and a dry run on the real library found both:
- **A scraped row does not outrank the filesystem.** Jellyfin numbers
multi-season packs badly: every file under `SNL 16-20/Season 16..19` carries
season 16 episode 20, and one Gundam row says season 2004. The folder, then
the file's own leading number, win; a row that still insists on season 1989
parks its file instead of creating `Season 1989`.
the file's own leading number, win.
- **A season that is a YEAR is the date matcher, not a season.** Jellyfin files
specials by airdate, so a row can arrive saying `season 2002`; that is not a
season and it never becomes a folder. The one authority on such a row is tvdb's
episode list read backwards: the title the row carries names the (season,
episode) tvdb gives it. A title tvdb gives to two episodes names nothing, and
the file parks with the reason it always carried. `Season 00` IS a legal
season — specials live there — so a row that really says 0 files there instead
of parking.
- **A season tvdb's own list contradicts gets no tvdb title.** An anime pack
numbered by its release group books `S05E01` in a season tvdb does not hold, or
books more episodes there than tvdb lists: the slot its number points at is a
different episode, and dressing it in that episode's title is a confident lie.
The file still renames to the standard shape; the title does not ride, and the
series is listed under *seasons whose numbering tvdb contradicts* in the
exceptions. A season holding FEWER files than tvdb lists stays plausible — a
partial pack is the normal shape. Neither reading holds for a list tvdb
cut off at its own page limit: the default episode query answers at most
500 rows and states no total, and One Piece, Crayon Shin-chan and
Detective Conan each answer exactly 500 while holding over a thousand —
a page bounds nothing above itself, so it contradicts nothing.
**A provider name is evidence only when an id backs it.** Jellyfin names a Series
after the folder it scanned, so a library filed by release folders ends up with
@@ -258,117 +313,42 @@ visible series folder that real files get renamed into. `--park` outside the roo
makes the next plan re-file the pile instead of skipping it. Inside a hidden
top-level folder a file's own words may MATCH a registered show, never invent one.
## The music library's own relayout
**The operator's id map is the door for a name tvdb cannot be searched by.**
`scripts/media-shows.py` reads `scripts/media-shows-tvdb-ids.tsv` by default: one
line per pack folder, `folder <TAB> tvdb id <TAB> note`. The file exists because
tvdb titles a record in the show's own language and the English name rides in
*aliases* the name search never reads: `ブラッククローバー` (331753) answers to no
spelling of "Black Clover", and the bare alias `One Piece` belongs to 392276, the
2023 live action, not to the 1999 record (81797) the pack here is. The map
outranks a provider id — a box set's provider id is the server's own guess at a
folder name — and it merges the release folders that are one show. Everything it
touched is written to the exceptions: every folder it named, every row that
matched no folder, every disagreement with a provider id, every id tvdb never
answered. A line whose id is not a number stops the plan, because a typo renames
a whole pack to the wrong show. Only folders the search CANNOT reach belong in
it; a row for a folder the search would resolve hides a live lookup behind a
number nobody re-checks.
`/mnt/entropy/Music` is rips and discography packs, and Jellyfin has matched and
tagged almost all of it. `scripts/media-music.py` re-files it into the shape
Jellyfin's music scanner reads —
`scripts/media-shows-tvdb.py` makes the sheet the map is built from. It reads a
plan's `mkdir` rows, asks tvdb about every destination folder carrying no
`[tvdbid-`, and writes one row per folder with the verdict that says how much was
guessed to get there: `EXACT` (tvdb's own spelling), `PUNCT` (equal only after
release junk is stripped from the folder's words), `NEAR` (tvdb answered
something else — the best hit is printed for a human and nothing applies it),
`NONE`. Acceptance never moved across the passes that built this: a hit counts
only when tvdb's name equals the query with every non-alphanumeric dropped from
both sides, and the record's year still has to fall inside any year span the
folder names. That gate is what caught three confident picks — Fairy Tail's 2024
sequel (410031), the 2026 Hokuto no Ken remake (465216) and `Mugyutto! Black
Clover` (378762) offered for Black Clover. It is why the map is approved by hand
and the sheet is never fed to the planner.
Music/<album artist>/<Album> (<year>)/NN - <Title>.ext
## The music and audiobook arms
— and the authority is the tag row of the track, never the name the ripper gave
the file.
```bash
# 1. The authority: Jellyfin's audio rows, one per track, with the album and
# artist ids its own database carries (the DB opens read-only).
python3 scripts/media-census.py music --root /mnt/entropy/Music \
--out /tmp/music_rows.tsv
# 2. What is on disk (stat only).
python3 scripts/media-census.py enumerate --root /mnt/entropy/Music \
--out /tmp/music_enum.tsv
# 3. The plan.
python3 scripts/media-music.py --root /mnt/entropy/Music --enum /tmp/music_enum.tsv \
--rows /tmp/music_rows.tsv --out /tmp/music_plan.tsv \
--exceptions /tmp/music_exceptions.txt
# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same
# executor, journal and rollback as above, on its own journal.
scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \
--journal /mnt/entropy/media-relayout-music.jsonl --apply
```
The group is what the tags assert, not where the files sit: **(album-artist tag,
album, year)**. One album arrives as three disc folders and as two copies of one
rip, and one MusicBrainz release group is one album. A compilation is one album
whose tracks name many performers, so it files once under `Various Artists`:
keying on the performer would file one album once per artist. Two different
album-artist tags on one album name are two albums, which is what Jellyfin's two
album items say.
What it refuses:
+- **A sidecar is placed by the audio around it, never by its own name.** A
`.lrc`, `.txt` or picture that shares a track's stem follows that track and
takes the name the track now has, or Jellyfin cannot find it; anything else
goes to the album its own folder was placed in, or to the folder whose discs
sit one level below it. A folder whose audio belongs to two albums parks its
sidecar rather than guessing.
+- **An image album keeps its name.** A folder holding one audio file and a
`.cue` is a whole album in one file; numbering it `01 - …` tells Jellyfin to
index it as one track.
+- **A video container parks**, because Jellyfin will not index it as music and a
rename to `.m4a` would claim it is; **audio no provider row names parks** too,
because unmatched audio is a tagging job, not a renaming job.
ONE writer at a time on this array: two rename passes race, and neither journal
tells — which is why each arm above carries its own journal name.
## The audiobook library's own relayout
`/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read
at all — its music scanner has no audiobook concept — while [[Plex]] indexes all
of it as Artist → Album → Track: the album is the BOOK, that book's single artist
is the NARRATOR, both matched out of the files' own tags. Today one book is split
across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection
pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py`
re-files the tree so the property both servers' book scanners assume is true —
Audiobooks/<Narrator>/<Book> (<year>)/<the file keeps its own name>
```bash
# 1. The authority: Plex's chapter rows for this root (the DB opens read-only).
python3 scripts/media-census.py audiobooks --root /mnt/entropy/Audiobooks \
--out /tmp/ab_rows.tsv
# 2. What is on disk (stat only).
python3 scripts/media-census.py enumerate --root /mnt/entropy/Audiobooks \
--out /tmp/ab_enum.tsv
# 3. The plan.
python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \
--enum /tmp/ab_enum.tsv --rows /tmp/ab_rows.tsv --out /tmp/ab_plan.tsv \
--exceptions /tmp/ab_exceptions.txt
# 4. Read the exceptions file FIRST. Then the same executor, journal and rollback
# as above, on its own journal, and refresh the Plex section as in step 5 of
# the movie Procedure — from inside the container, never over the host port.
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
```
The group is the identity Plex asserts in its own database — **(narrator, book,
year)** — so one book arriving in four part folders becomes one folder, and a
1965 and a 2020 recording that share a title stay two books.
Two laws are forced by what the rows actually hold:
+- **A chapter is never renumbered.** Most books contain chapters whose index Plex
does not know, so a number written into a name would be invented — and it would
buy nothing, because both servers order playback by the embedded track tag, not
by the file name. A wrong number in a name is the number a human later trusts.
Fix order in the tags: a Plex scan re-reads tags and never renames a file.
+- **The shelf is the narrator, not the author.** Every book has exactly one artist
row and it is the narrator; the author survives only inside some album titles
(`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving it would
mean parsing titles where a ripper wrote one and inventing it everywhere else.
The author stays visible in the folder name Plex itself shows.
What it refuses, the music arm's list with one addition: a video container, audio
no chapter row names, and a loose file in a folder holding two books all park under
`_unsorted` with the path they have today — and so does a chapter whose row names
**no book or no narrator**: a placeholder (`[Unknown Album]`, `[Unknown Artist]`)
that unrelated books share, or a chapter number mistagged as the artist (`0.4`).
`Various Artists` is not a placeholder — that is a multi-cast read.
This arm applies behind the music writer — one writer at a time — on its own
journal, and then refreshes the Plex section.
Each is a page of its own, because each reads a different authority and refuses
for a different reason: [[Media Music Relayout]] files by Jellyfin's track tags,
[[Media Audiobook Relayout]] by Plex's chapter rows. Both share this page's
executor, the one-journal-per-arm law, the sweep and the rollback.
## Laws
@@ -442,10 +422,10 @@ journal, and then refreshes the Plex section.
scope, so the next sweep finishes what a permission problem stopped.
- **An episode-shaped name marks a SERIES, not a film — and the series is the
name BEFORE the marker.** `Monk - S05E03 - Mr. Monk and the Big Game` is series
`Monk`. Keyed on the whole title instead, every episode became a group of one
and never reached the five-episode size that calls something a series, so a
box set was filed as 124 movies in 124 folders. The threshold still earns its
keep: a lone film named `Part 2` stays a film. Episodes of a series are parked
`Monk`. Keyed on the whole title instead, every episode became a group of one,
never reaching the five-episode size that calls something a series — a whole
box set filed as one folder per episode. The threshold still earns its keep:
a lone film named `Part 2` stays a film. Episodes of a series are parked
in `_TV needs a TV library - <series>` for a human to move to a TV source —
the movie tool never files TV.
- **One journal is one undo.** The TV move runs on its own journal, and with
@@ -488,6 +468,14 @@ any folder a sweep removed, restores every source path, removes only the folders
that run created *and that are now empty*, and refuses everything if any moved
file cannot be accounted for. A rolled-back journal refuses to roll back twice.
**Roll a chain back newest journal first, or the tool refuses.** Each pass
re-files what an earlier one moved, so a journal is a clean inverse only until
something downstream touches its files; `ls -l /mnt/entropy/media-relayout*.jsonl`
is the order. Refusing is the tool working: on 2026-10-31 the movie journal could
not restore files the later TV passes had re-filed, and the stray journal could
not restore files Jellyfin's extension sweep had renamed. Roll back what still
inverts and leave the rest as the current state — never force it.
## See Also
[[Jellyfin]], [[Plex]], [[Media Metadata View]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]]
@@ -77,7 +77,7 @@ Both prerequisites survive the dnsmasq → Pi-hole swap. Do not remove them.
| Port 53 `bind: permission denied` crash loop after a reboot | sysctl conflict: the value must be 53 at boot, and remember `/etc/sysctl.conf` is applied **after** all of `sysctl.d/`. Redeploy pihole — `deploy_pihole` reinstalls the drop-in, neutralizes conflicting drop-ins, and comments out a stale `/etc/sysctl.conf` line |
| Single-label name (for example `dankclaw`) answers `127.0.1.1` | The rootless container `/etc/hosts` carries the host entry (`127.0.1.1 <host>`), and FTL serves it. Use the FQDN, or add an `/etc/hosts` entry on the client |
| Every `franksdank.cloud` name is NXDOMAIN for LAN clients while external names still answer | FTL holds no local records: `getent hosts <name>` on Dankclaw comes back empty → `podman exec pihole pihole restartdns reload` (the 2026-10-19 pitfall under **Records**) |
| One client gets nothing while every other client is fine | FTL rate-limiting that client: `podman exec pihole grep Rate-limiting /var/log/pihole/FTL.log`. A `10.0.2.x` client address is a pasta container's DNS forwarder, not a machine |
| One client gets nothing while every other client is fine | FTL rate-limiting that client: `podman exec pihole grep Rate-limiting /var/log/pihole/FTL.log`. A `10.0.2.x` client address is a pasta container's DNS forwarder, not a machine — and **every** rootless container on that host shares the one address, so a single chatty container throttles DNS for all of them (measured 2026-10-31: 8 throttle events in a day, all `10.0.2.100`) |
## See Also
+3 -1
View File
@@ -48,11 +48,13 @@ is a locator: one row, one clause, no numbers.
| [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. |
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |
| [[Dankserver Stack]] | Service inventory and routing overview for the media and library host. |
| [[Entropy Volume]] | The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at |
| [[Entropy Volume]] | Dankserver's /mnt/entropy volume: the LUKS/array chain, the crypttab + fstab boot path, and the containers bound to it |
| [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status. |
| [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. |
| [[Media Audiobook Relayout]] | Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals. |
| [[Media Duplicate Prune]] | Find and hold duplicate films on the entropy array: the hash ladder, the keep rules, the guards, the purge. |
| [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. |
| [[Media Music Relayout]] | Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals. |
| [[Media Relayout]] | Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback. |
| [[Plex]] | Plex Media Server is a **containerized media server on Dankserver**: a podman |
| [[redis]] | The Redis cache and task queue for RomM: quadlet unit, network alias topology, and the backup path. |
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env bats
# Seam: the frank-medium proxy's routing decisions.
# The proxy is the only thing between the fleet's clients and the two
# single-GPU replicas, and its routing rules carry the performance work: spread
# single-turn requests (batch members, one-shot questions) so both cards prefill
# in parallel, and pin continuations so an agent loop keeps hitting the replica
# whose prefix cache holds its context. This seam drives the routing policy
# directly; the first version of these rules shipped a bug that only appeared on
# a continuation request, which is exactly the class of mistake a test this
# cheap should catch.
load helpers
ROUTING="$REPO_ROOT/dankserver/frank-medium/proxy/routing.py"
PROXY="$REPO_ROOT/dankserver/frank-medium/proxy/affinity-proxy.py"
setup() {
DRIVER="$BATS_TEST_TMPDIR/route.py"
touch "$DRIVER"
}
@test "the proxy and its routing policy exist and compile" {
[ -f "$ROUTING" ]
[ -f "$PROXY" ]
python3 -m py_compile "$ROUTING"
python3 -m py_compile "$PROXY"
# The policy must not drag the HTTP stack in: it is imported by tests and
# reasoned about without aiohttp, a network or a GPU.
! grep -q 'import aiohttp' "$ROUTING"
}
@test "a batch of single-turn requests spreads across both cards" {
cat > "$DRIVER" <<PY
import importlib.util, json, os, sys
os.environ["ROUTING"] = "adaptive"
spec = importlib.util.spec_from_file_location("routing", "$ROUTING")
mod = importlib.util.module_from_spec(spec)
sys.modules["routing"] = mod
spec.loader.exec_module(mod)
class R:
remote = "127.0.0.1"
shared = "shared document " + "y" * 8000
picks = []
for i in range(6):
text = shared if i % 2 else f"distinct prompt {i} " + "x" * 8000
body = json.dumps({"messages": [{"role": "user", "content": text}]}).encode()
picks.append(mod.pick(*mod.affinity_key(body, R.remote), [True, True]))
print(" ".join(str(p) for p in picks))
PY
run python3 "$DRIVER"
[ "$status" -eq 0 ]
read -r -a picks <<< "$output"
used=$(printf '%s\n' "${picks[@]}" | sort -u | wc -l)
# Both cards must work on a batch, or one card prefills an entire benchmark
# while the other idles.
[ "$used" -eq 2 ]
# Consecutive members alternate (the counter is advanced only by requests
# that are not continuations), including the two that share a document.
for ((i = 0; i < 5; i++)); do
[ "${picks[i]}" != "${picks[i + 1]}" ]
done
}
@test "a continuation is pinned to the replica that served its prefix" {
cat > "$DRIVER" <<PY
import importlib.util, json, os, sys
os.environ["ROUTING"] = "adaptive"
spec = importlib.util.spec_from_file_location("routing", "$ROUTING")
mod = importlib.util.module_from_spec(spec)
sys.modules["routing"] = mod
spec.loader.exec_module(mod)
class R:
remote = "127.0.0.1"
prefix = "system prompt " + "z" * 8000
first = json.dumps({"messages": [{"role": "user", "content": prefix + " turn one"}]}).encode()
start = mod.pick(*mod.affinity_key(first, R.remote), [True, True])
# Same leading bytes, longer history and a different tail each turn: an agent
# loop resending its context.
turns = []
history = [{"role": "assistant", "content": "ack"}]
for tail in ("turn two", "turn three", "turn four"):
history.append({"role": "user", "content": "question " + tail})
body = json.dumps({"messages": [{"role": "user", "content": prefix + " turn one"}] + history}).encode()
turns.append(mod.pick(*mod.affinity_key(body, R.remote), [True, True]))
history.append({"role": "assistant", "content": "ack again"})
print(start, " ".join(str(t) for t in turns))
PY
run python3 "$DRIVER"
[ "$status" -eq 0 ]
read -r -a out <<< "$output"
# Every continuation agrees with the first turn's replica: that is where
# the prefix cache entry lives.
[ "${out[1]}" = "${out[0]}" ]
[ "${out[2]}" = "${out[0]}" ]
[ "${out[3]}" = "${out[0]}" ]
}
@test "no routing mode ever picks a replica that is down" {
for mode in adaptive affinity roundrobin; do
cat > "$DRIVER" <<PY
import importlib.util, json, os, sys
os.environ["ROUTING"] = "$mode"
spec = importlib.util.spec_from_file_location("routing", "$ROUTING")
mod = importlib.util.module_from_spec(spec)
sys.modules["routing"] = mod
spec.loader.exec_module(mod)
class R:
remote = "127.0.0.1"
body = json.dumps({"messages": [{"role": "user", "content": "x" * 400}]}).encode()
one_down = mod.pick(*mod.affinity_key(body, R.remote), [False, True])
both_down = mod.pick(*mod.affinity_key(body, R.remote), [False, False])
print(one_down, both_down)
PY
run python3 "$DRIVER"
[ "$status" -eq 0 ]
# Replica 0 down -> replica 1; both down -> -1, and the proxy answers
# 503 rather than forwarding into the void.
[ "$output" = "1 -1" ]
done
}
@test "affinity is deterministic per prefix, roundrobin is not" {
cat > "$DRIVER" <<PY
import importlib.util, json, os, sys
os.environ["ROUTING"] = "affinity"
spec = importlib.util.spec_from_file_location("routing", "$ROUTING")
mod = importlib.util.module_from_spec(spec)
sys.modules["routing"] = mod
spec.loader.exec_module(mod)
class R:
remote = "127.0.0.1"
def pick(text):
body = json.dumps({"messages": [{"role": "user", "content": text}]}).encode()
return mod.pick(*mod.affinity_key(body, R.remote), [True, True])
doc = "d" * 8000
# Same leading 4 KiB, different tails: affinity keys on the prefix, so both go
# to one replica. Different documents may hash anywhere — that is the point of
# hashing — so only the prefix property is asserted.
print(pick(doc + " a"), pick(doc + " b"))
PY
run python3 "$DRIVER"
[ "$status" -eq 0 ]
read -r -a aff <<< "$output"
[ "${aff[0]}" = "${aff[1]}" ]
}
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env bats
# Seam: the frank-medium service on dankserver.
# frank-medium became a rootless Quadlet deployment on 2026-09-15 when the
# vllm-radiance MXFP4 stack replaced the charlie12345/ROCmFPX llama.cpp
# runtime on the 2x Radeon AI PRO R9700. The intended shape is one
# tensor-parallel process across both cards (frank-medium.container, kept as
# the target); it cannot run on this host because RCCL's kernels require PCIe
# atomics and the second card's chipset path advertises AtomicOpsCap: Routing-.
# The deployed shape is therefore two single-GPU replicas behind a round-robin
# proxy, which keeps the fleet contract of one :7770 endpoint. This seam pins
# the properties that make that work: GPU access on the fixed device indices,
# the upstream launcher's podman posture, host networking with the fleet's
# client port and alias, per-replica resource shape, boot persistence, and a
# deploy handler that moves the unusable TP=2 unit into the trash.
load helpers
UNIT_A="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium-a.container"
UNIT_B="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium-b.container"
UNIT_PROXY="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium-proxy.container"
UNIT_TP2="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium.container"
ENTRYPOINT="$REPO_ROOT/dankserver/frank-medium/radiance-entrypoint.sh"
HEALTHCHECK="$REPO_ROOT/dankserver/frank-medium/frank-medium-healthcheck.py"
PROXY="$REPO_ROOT/dankserver/frank-medium/proxy/affinity-proxy.py"
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
@test "the frank-medium units live under dankserver/frank-medium/quadlet/" {
[ -f "$UNIT_A" ]
[ -f "$UNIT_B" ]
[ -f "$UNIT_PROXY" ]
}
@test "the TP=2 unit is kept as the documented target, not deleted" {
# It becomes runnable again only on a host whose second card sits on an
# atomic-capable root port, so the artifact stays in the repo.
[ -f "$UNIT_TP2" ]
grep -q '^Environment=TP=2$' "$UNIT_TP2"
}
@test "every frank-medium unit is boot-persistent (WantedBy=default.target)" {
# Generated units cannot be enabled on the podman 5.x v1 quadlet
# generator; [Install] plus linger is the entire boot-persistence story.
for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do
grep -q '^WantedBy=default.target$' "$unit"
done
}
@test "every frank-medium unit is self-healing" {
for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do
grep -q '^Restart=on-failure$' "$unit"
grep -q '^RestartSec=' "$unit"
done
}
@test "frank-medium units pin the image tag and never pull at boot" {
for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do
grep -Eq '^Image=localhost/radiance-vllm:[0-9]+\.[0-9]+\.[0-9]+$' "$unit"
grep -q '^Pull=never$' "$unit"
done
}
@test "the replicas each get exactly one GPU on the fixed device index" {
grep -q '^AddDevice=/dev/kfd$' "$UNIT_A"
grep -q '^AddDevice=/dev/dri$' "$UNIT_A"
# One index per replica: ROCR filters first and HIP indexes into the
# filtered list, so setting both to the same non-zero value leaves an
# empty device set.
grep -q '^Environment=HIP_VISIBLE_DEVICES=0$' "$UNIT_A"
grep -q '^Environment=HIP_VISIBLE_DEVICES=1$' "$UNIT_B"
! grep -q '^Environment=ROCR_VISIBLE_DEVICES=' "$UNIT_A"
! grep -q '^Environment=ROCR_VISIBLE_DEVICES=' "$UNIT_B"
}
@test "the replicas run with the upstream launcher's podman posture" {
for unit in "$UNIT_A" "$UNIT_B"; do
grep -q -- '--privileged' "$unit"
grep -q -- '--ipc=host' "$unit"
grep -q -- '--security-opt seccomp=unconfined' "$unit"
grep -q -- '--network=host' "$unit"
done
}
@test "the replicas serve single-GPU shapes that fit one card" {
for unit in "$UNIT_A" "$UNIT_B"; do
grep -q '^Environment=TP=1$' "$unit"
# 262144 does not fit one card (needs 9.61 GiB of KV, 5.14 available)
# and 131072 does not either (5.58 needed vs 5.22 available); 114688
# is the largest measured shape that boots.
grep -q '^Environment=MAXLEN=114688$' "$unit"
# KV_MEM=0 makes vLLM profile this shape: the 2x7551-32624 pin is a
# TP=2/262144 measurement and invalid here.
grep -q '^Environment=KV_MEM=0$' "$unit"
grep -q '^Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq$' "$unit"
grep -q '^Environment=SPEC_METHOD=dflash$' "$unit"
grep -q '^Environment=SPEC=7$' "$unit"
done
}
@test "the proxy keeps the fleet endpoint: host-network port 7770, alias frank-medium" {
grep -q '^Environment=PORT=7770$' "$UNIT_PROXY"
grep -q '^Environment=UPSTREAMS=http://127.0.0.1:7771,http://127.0.0.1:7772$' "$UNIT_PROXY"
grep -q -- '--network=host' "$UNIT_PROXY"
[ "$(grep -c '^PublishPort=' "$UNIT_PROXY")" -eq 0 ]
# The replicas answer as the same served model name, so a client cannot
# tell which one served a request.
grep -q '^Environment=SERVED_NAMES=frank-medium$' "$UNIT_A"
grep -q '^Environment=SERVED_NAMES=frank-medium$' "$UNIT_B"
grep -q '^Environment=PORT=7771$' "$UNIT_A"
grep -q '^Environment=PORT=7772$' "$UNIT_B"
}
@test "frank-medium entrypoint is a syntax-clean fork of the upstream launcher" {
[ -f "$ENTRYPOINT" ]
bash -n "$ENTRYPOINT"
grep -q '9735329348ae1c9319f01cbe5f7004ec5f4dba63' "$ENTRYPOINT"
}
@test "frank-medium healthcheck probes the serving endpoint on its own port" {
[ -f "$HEALTHCHECK" ]
python3 -m py_compile "$HEALTHCHECK"
# One probe serves the replicas, the proxy and the TP=2 target because it
# reads PORT instead of hardcoding 7770.
grep -q 'os.environ.get("PORT"' "$HEALTHCHECK"
for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do
grep -q 'frank-medium-healthcheck.py' "$unit"
grep -q '^HealthStartPeriod=' "$unit"
done
}
@test "the proxy unit mounts the routing policy and the server" {
# The policy lives in its own file so it can be tested without aiohttp;
# the server imports it by name from its own directory, so both must be
# mounted or the container dies on ModuleNotFoundError at start.
grep -q 'proxy/routing.py:/usr/local/bin/routing.py:ro' "$UNIT_PROXY"
grep -q 'proxy/affinity-proxy.py:/usr/local/bin/affinity-proxy.py:ro' "$UNIT_PROXY"
# Routing behaviour itself is covered by tests/frank_medium_proxy.bats,
# which drives the policy directly.
[ -f "$REPO_ROOT/tests/frank_medium_proxy.bats" ]
}
@test "the deploy handler installs the trio and retires the TP=2 unit" {
grep -q 'install_quadlet frank-medium-a dankserver/frank-medium/quadlet frank-medium-a' "$REMOTE_DEPLOY"
grep -q 'install_quadlet frank-medium-b dankserver/frank-medium/quadlet frank-medium-b' "$REMOTE_DEPLOY"
grep -q 'install_quadlet frank-medium-proxy dankserver/frank-medium/quadlet frank-medium-proxy' "$REMOTE_DEPLOY"
grep -q 'systemctl --user restart frank-medium-a.service frank-medium-b.service frank-medium-proxy.service' "$REMOTE_DEPLOY"
# The TP=2 unit cannot become healthy on this host; a leftover copy would
# leave the generator restarting it forever. It moves to the trash.
grep -q 'mv -- "$quadlet_unit_dir/frank-medium.container"' "$REMOTE_DEPLOY"
! grep -qE '(^|[^[:alnum:]_])rm (-[a-zA-Z]+ )?"?\$quadlet_unit_dir/frank-medium' "$REMOTE_DEPLOY"
}
@test "the deploy handler never tries to enable a generated unit" {
! grep -q 'systemctl --user enable frank-medium' "$REMOTE_DEPLOY"
}
@test "the deploy handler retires the old root unit without deleting it" {
grep -q 'retire_root_llm_medium' "$REMOTE_DEPLOY"
grep -q 'systemctl stop frank-medium.service' "$REMOTE_DEPLOY"
grep -q 'mv -- /etc/systemd/system/frank-medium.service' "$REMOTE_DEPLOY"
! grep -qE '(^|[^[:alnum:]_])rm (-[a-zA-Z]+ )?/etc/systemd/system/frank-medium' "$REMOTE_DEPLOY"
}
@test "the deploy handler re-tags the registry image to the localhost alias" {
grep -q 'podman tag docker.io/stilldeadcode/vllm-radiance:0.9.3 localhost/radiance-vllm:0.9.3' "$REMOTE_DEPLOY"
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Seam: a container that binds the entropy array must refuse to start until the
# thing it binds is actually mounted.
#
# Live 2026-10-31, Dankserver: the array's systemd mount STARTED eight seconds
# before the media containers were created, but a rootless quadlet cannot order
# itself against a SYSTEM mount unit — `RequiresMountsFor=/mnt/entropy` resolves
# to nothing in the user manager and is simply inert. So podman resolved the bind
# against the pre-mount directory (which held one stale subdirectory) and both
# Jellyfin and Plex came up blind: their library root looked empty, Jellyfin
# logged thousands of `Removing item, Type: "Season"`, and the array sat at 80 %
# util doing nothing useful until the host went down. A blind bind is not a
# cosmetic fault — it is a metadata-deleting machine.
#
# The measured fix is `ExecStartPre=<repo>/scripts/wait-for-mount.sh <source>`:
# it WAITS for the mount and then refuses, so `Restart=always` carries the unit
# across a slow mount. A `ConditionPathIsMountPoint=` was measured on the same
# host and is the wrong tool: an unmet condition logs `skipped, unmet condition
# check` with NRestarts=0 and never a start attempt — the unit stays dead until
# a human starts it. That is fail-dead, not fail-closed.
#
# Tests assert only observable behavior of the gate (exit status and the name it
# refuses by) and the structural law over the quadlets; never the internals.
load helpers
GATE="$REPO_ROOT/scripts/wait-for-mount.sh"
@test "the gate accepts a path that really is a mountpoint" {
run "$GATE" -t 2 /proc
[ "$status" -eq 0 ] || { printf '%s\n' "$output" >&2; return 1; }
}
@test "a plain directory is refused, and refused BY NAME" {
dir="$BATS_TEST_TMPDIR/not-a-mount"
mkdir "$dir"
run "$GATE" -t 1 "$dir"
[ "$status" -ne 0 ] || { printf 'accepted a plain directory\n' >&2; return 1; }
[[ "$output" == *"$dir"* ]] || { printf 'refusal did not name the path: %s\n' "$output" >&2; return 1; }
}
@test "a path that does not exist is refused by name" {
run "$GATE" -t 1 "$BATS_TEST_TMPDIR/does/not/exist"
[ "$status" -ne 0 ]
[[ "$output" == *"$BATS_TEST_TMPDIR/does/not/exist"* ]]
}
@test "the gate holds the deadline instead of deciding on one look" {
# A single check that failed at the wrong instant is the boot race itself:
# the mount lands a second later and the unit is dead anyway. So the window
# must be spent waiting, and that is observable from outside as elapsed time.
dir="$BATS_TEST_TMPDIR/never"
mkdir "$dir"
start=$SECONDS
run "$GATE" -t 4 "$dir"
elapsed=$((SECONDS - start))
[ "$status" -ne 0 ]
[ "$elapsed" -ge 4 ] || { printf 'gave up after %ss, window was 4s\n' "$elapsed" >&2; return 1; }
}
@test "the gate accepts a mount that arrives during its window" {
dir="$BATS_TEST_TMPDIR/later"
mkdir "$dir"
if ! mount --bind /proc "$dir" 2>/dev/null; then
skip "creating a mount needs privileges this box does not grant"
fi
run "$GATE" -t 4 "$dir"
umount "$dir" 2>/dev/null || sudo -n umount "$dir" 2>/dev/null || true
[ "$status" -eq 0 ] || { printf 'gate did not wait for a mount that arrived: %s\n' "$output" >&2; return 1; }
}
@test "no unit without the gate binds the array or the metadata view" {
bad=""
while IFS= read -r unit; do
sources=$(sed -n 's/^Volume=\([^:]*\):.*/\1/p' "$unit" \
| grep -E '^(/mnt/entropy|/var/lib/media-meta/view)$' | sort -u)
[ -n "$sources" ] || continue
for src in $sources; do
grep -q "ExecStartPre=.*wait-for-mount.sh.* $src\b" "$unit" \
|| bad="$bad$unit binds $src with no start gate"$'\n'
done
done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \
-path '*/quadlet/*.container' -not -path '*.AGENTTRASH*')
[ -z "$bad" ] || { printf 'blind binds:\n%s' "$bad" >&2; return 1; }
}
@test "no gated unit keeps the fail-dead ConditionPathIsMountPoint" {
bad=""
while IFS= read -r unit; do
grep -q "wait-for-mount.sh" "$unit" || continue
grep -q "ConditionPathIsMountPoint" "$unit" \
&& bad="$bad$unit: a gate and an unmet-condition skip cannot coexist"$'\n'
done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \
-path '*/quadlet/*.container' -not -path '*.AGENTTRASH*')
[ -z "$bad" ] || { printf 'fail-dead conditions:\n%s' "$bad" >&2; return 1; }
}
+21
View File
@@ -106,3 +106,24 @@ load helpers
# env file, not an Environment= line.
! grep -qE '^Environment=CUSTOM_(USER|PASSWORD)=' "$unit" || return 1
}
@test "a quadlet that asks for an I/O share ships the host permission that makes it real" {
# IOWeight= is silently inert unless the system manager both enables io for
# user.slice and delegates it to the user manager: systemd drops a resource
# line it cannot honour without a word and the container still starts healthy.
# So an in-repo weight is only honest together with both drop-ins AND with a
# deployer that places them. Measured 2026-10-31; verification chain in
# Architecture/Quadlet Lifecycle 4b.
if grep -lq '^IOWeight=' "$REPO_ROOT"/dankserver/*/quadlet/*.container; then
asked=1
else
skip "no dankserver quadlet asks for an I/O share"
fi
grep -q '^IOAccounting=yes$' \
"$REPO_ROOT/dankserver/host/systemd/user.slice.d/io-accounting.conf" || return 1
grep -q '^DelegateControllers=.* io' \
"$REPO_ROOT/dankserver/host/systemd/user@1000.service.d/io-delegate.conf" || return 1
for f in user.slice.d/io-accounting.conf 'user@1000.service.d/io-delegate.conf'; do
grep -q "dankserver/host/systemd/$f" "$REPO_ROOT/scripts/deploy-remote.sh" || return 1
done
}
+6 -5
View File
@@ -28,15 +28,16 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
! grep -q 'frank_npu\|frank-npu' "$REMOTE_DEPLOY"
}
@test "the retired ik chat template plans nothing, and its archive is still here" {
@test "the retired ik chat template plans nothing" {
# scripts/chat-template.jinja existed only for the ik_llama.cpp runtime
# (retired 2026-09-16 in favour of halogen-flash-server again). The
# llama.cpp frank-zealot runtime serves the template the GGUF itself
# carries, which is the one with reasoning_effort, preserve_thinking and
# the tool-call grammar. Editing the archive must deploy nothing -- and the
# archive must still be present, so this cannot pass by the file having
# vanished.
[ -f "$REPO_ROOT/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp" ]
# the tool-call grammar. The planner's rule is a PATH rule, so an edit at
# that path deploys nothing no matter where the bytes currently sit: the seam
# deliberately does not assert the archive exists. Trash is a purgeable hold
# (untracked in #111, and the deletion policy says the purger deletes it), so
# pinning it fails every fresh checkout and proves nothing about planning.
run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja
[ "$status" -eq 0 ]
[ -z "$output" ]
+45
View File
@@ -228,6 +228,51 @@ PY
[ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 1 ] # header only
}
# wal_fresh DB ROOT OUT — a row Plex matched during a scan and committed, but
# has not checkpointed: the normal state of a library being walked. The holder
# connection stops the WAL folding into the main file when the writer closes,
# and the census runs as a real subprocess against those same files.
wal_fresh() {
local db="$1" root="$2" out="$3"
python3 - "$db" "$root" "$out" "$CENSUS" <<'PY'
import sqlite3, subprocess, sys
db, root, out, census = sys.argv[1:5]
w = sqlite3.connect(db)
w.execute("pragma journal_mode=wal")
w.execute("pragma wal_autocheckpoint=0")
w.commit()
w.execute("pragma wal_checkpoint(truncate)") # the rows so far are on disk
holder = sqlite3.connect(db) # last close would checkpoint
ar = w.execute('insert into metadata_items (parent_id, metadata_type, title,'
' year, "index", deleted_at) VALUES (NULL, 8, ?, NULL, NULL, NULL)',
("Fresh Narrator",)).lastrowid
al = w.execute('insert into metadata_items (parent_id, metadata_type, title,'
' year, "index", deleted_at) VALUES (?, 9, ?, ?, NULL, NULL)',
(ar, "Fresh Book", 2026)).lastrowid
mi = w.execute('insert into metadata_items (parent_id, metadata_type, title,'
' year, "index", deleted_at) VALUES (?, 10, ?, NULL, ?, NULL)',
(al, "Fresh Chapter", 1)).lastrowid
it = w.execute("insert into media_items (metadata_item_id, deleted_at)"
" VALUES (?, NULL)", (mi,)).lastrowid
w.execute("insert into media_parts (media_item_id, file, deleted_at)"
" VALUES (?, ?, NULL)", (it, root + "/Fresh/01.mp3"))
w.commit()
sys.exit(subprocess.run([sys.executable, census, "audiobooks", "--root", root,
"--db", db, "--out", out]).returncode)
PY
}
@test "the census exports a row the scanner committed but has not checkpointed" {
DB="$BATS_TEST_TMPDIR/plex.db"
db_with_part "$DB" "$BATS_TEST_TMPDIR/Audiobooks/Old/01.mp3" \
"Old Narrator" "Old Book" 1990 1 "Old Chapter"
run wal_fresh "$DB" "$BATS_TEST_TMPDIR/Audiobooks" "$BATS_TEST_TMPDIR/exp.tsv"
[ "$status" -eq 0 ]
[ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 3 ] # header + both rows
tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \
| grep -q "^$BATS_TEST_TMPDIR/Audiobooks/Fresh/01\.mp3 Fresh Narrator Fresh Book 2026 1 Fresh Chapter$"
}
# -------------------------------------------------------------------- the plan
@test "a matched chapter files under its narrator and its book, name untouched" {
+30 -2
View File
@@ -10,7 +10,8 @@
# 4. both media units bind the VIEW and refuse to start without it;
# 5. only those two units touch the view — the LLM and library tenants keep the
# raw volume, so the blast radius is two containers;
# 6. the deployer brings the view up BEFORE it restarts either server;
# 6. the deployer creates the upper base and brings the view up BEFORE it
# restarts either server;
# 7. the planner routes a change to these files to both media services.
load helpers
@@ -61,7 +62,10 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
@test "both media servers bind the view and refuse to start without it" {
for unit in "$JELLY" "$PLEX"; do
grep -q "^Volume=$MOUNT:" "$unit" || return 1
grep -q "^UnitConfigItem=ConditionPathIsMountPoint=$MOUNT$" "$unit" || return 1
# The refusal is a start GATE, not a Condition: measured on the host, a
# unit whose Condition goes unmet is skipped forever (NRestarts=0, no
# start attempt) — fail-dead. The gate waits, then refuses.
grep -q "^ExecStartPre=.*wait-for-mount.sh .*$MOUNT" "$unit" || return 1
# The raw array must no longer be a container source: that bind is what
# made sidecar sharing impossible (read-only) and metadata slow (seeks).
! grep -q '^Volume=/mnt/entropy' "$unit"
@@ -107,6 +111,30 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
done
}
@test "ensure-base creates a base directory that install refuses to invent" {
base="$BATS_TEST_TMPDIR/fresh-base"
run bash "$VIEW" ensure-base --upper-base "$base"
[ "$status" -eq 0 ]
[ -d "$base" ]
# The deployer runs this on every media deploy, so a second call must be a
# no-op and must say so rather than recreate anything.
run bash "$VIEW" ensure-base --upper-base "$base"
[ "$status" -eq 0 ]
[[ "${lines[0]}" == *"exists"* ]]
}
@test "the media deployer creates the upper base before it installs the view" {
body="$(sed -n '/^ensure_media_view()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")"
[ -n "$body" ]
base="$(printf '%s\n' "$body" | grep -n 'media-view.sh ensure-base' | head -1 | cut -d: -f1)"
inst="$(printf '%s\n' "$body" | grep -n 'media-view.sh install' | head -1 | cut -d: -f1)"
# Order is the whole test: install refuses a missing base, so an installer
# that runs first dies on a rebuilt host (measured 2026-10-31).
[ -n "$base" ]
[ -n "$inst" ]
[ "$base" -lt "$inst" ]
}
@test "the planner routes the view files to both media services" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount
+59
View File
@@ -494,6 +494,65 @@ for r in moves:
PY
}
# The live pass paid an fsync per move on a spinning array (63 074 of them) and
# the run took 11 minutes for 58 278 renames. --batch N writes N intents, syncs
# once, then performs those N moves — the durability rule is unchanged (no move
# runs before its intent is on disk), only the number of flushes falls.
@test "a batched apply journals every move and lands the same tree" {
plan
JOURNAL="$BATS_TEST_TMPDIR/batched.jsonl"
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" \
--batch 4 --apply
[ "$status" -eq 0 ]
local planned journalled
planned=$(awk -F'\t' '$1=="move"' "$OUT" | wc -l)
journalled=$(grep -c '"op": "move"' "$JOURNAL")
[ "$journalled" -eq "$planned" ]
run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL"
[ "$status" -eq 0 ]
python3 - "$OUT" <<'PY'
import os, sys
moves = [r for r in (l.rstrip("\n").split("\t") for l in open(sys.argv[1]))
if r[0] == "move"]
assert moves, "fixture planned no moves"
for r in moves:
assert os.path.exists(r[2]), f"target missing: {r[2]}"
assert not os.path.exists(r[1]), f"source left behind: {r[1]}"
PY
}
@test "an apply stopped mid-batch resumes without losing or double-journalling a move" {
plan
JOURNAL="$BATS_TEST_TMPDIR/interrupted-batch.jsonl"
python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" \
--batch 2 --apply > "$BATS_TEST_TMPDIR/first.log" 2>&1 &
local pid=$! i
# stop the run the moment a batch of intents is on disk (it may also have
# finished already — both outcomes must resume cleanly)
for i in $(seq 1 200); do
[ -f "$JOURNAL" ] && [ "$(grep -c '"op": "move"' "$JOURNAL" 2>/dev/null || true)" -ge 2 ] && break
sleep 0.02
done
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --batch 2 --apply
[ "$status" -eq 0 ]
run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL"
[ "$status" -eq 0 ]
python3 - "$OUT" "$JOURNAL" <<'PY'
import json, os, sys
moves = [r for r in (l.rstrip("\n").split("\t") for l in open(sys.argv[1]))
if r[0] == "move"]
journal = [json.loads(l) for l in open(sys.argv[2])]
dsts = [j["dst"] for j in journal if j.get("op") == "move"]
assert len(dsts) == len(set(dsts)), "a destination was journalled twice"
assert len(dsts) == len(moves), f"journalled {len(dsts)} of {len(moves)} moves"
for r in moves:
assert os.path.exists(r[2]) and not os.path.exists(r[1]), r[2]
PY
}
@test "rollback puts every file back where the census found it" {
plan
apply_plan
+298 -6
View File
@@ -11,7 +11,11 @@
# the name caches are input files, so no test touches the network.
#
# The target contract is tvdb-grade naming:
# <Series> (<year>)/Season NN/<Series> (<year>) - SxxEyy - <Episode Title>.<ext>
# <Series> (<year>) [tvdbid-NNNN]/Season NN/<Series> (<year>) - SxxEyy - <Title>.<ext>
# The id rides the FOLDER only: it is what both matchers read the series by, and a
# 180-character ceiling on a file name is not the place to spend it. Season 00
# holds specials; a provider row whose "season" is a YEAR is renumbered by the
# episode title tvdb states for the series' id, never by a guess.
# tvdb is the IDENTITY authority: a provider name a server derived from a folder
# is not a series name, so a polluted name is undone through the series' tvdb id.
# The review pile parks OUTSIDE the library — a parked folder inside it indexes
@@ -39,6 +43,15 @@ title_row() {
"${5:-}" "${6:-}" "${7:-}" "${8:-}" "${9:-}" >> "$TITLES"
}
# file PATH SIZE — `at` plus the row the enum census would have carried had the
# file existed when setup walked the tree. A test that needs a shape the fixture
# does not ship adds it here, or the planner never sees it.
file() {
at "$1" "$2"
local b="${1##*/}"
printf '%s\t.%s\t%s\n' "$2" "${b##*.}" "$1" >> "$ENUM"
}
# dst SRC — the destination the plan gives SRC, or nothing when it does not move.
dst() {
awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $3}' "$OUT"
@@ -220,11 +233,12 @@ PY
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E01 - Pilot.mkv" ]
}
@test "the tvdb identity names the series folder, not the name a folder left behind" {
@test "the tvdb identity names the series folder, id and all, not the folder's echo" {
printf '{"tvdb:series:78850": {"name": "Show Box", "year": "2016"}}\n' > "$CACHE"
plan
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \
= "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ]
want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01"
want+="/Show Box (2016) - S01E01 - Pilot.mkv"
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" = "$want" ]
}
@test "a provider name that already carries the year is not given it twice" {
@@ -232,8 +246,9 @@ PY
# and `Acapulco (2021) (2021)` is a folder neither matcher nor human reuses.
printf '{"tvdb:series:78850": {"name": "Show Box (2016)", "year": "2016"}}\n' > "$CACHE"
plan
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \
= "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ]
want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01"
want+="/Show Box (2016) - S01E01 - Pilot.mkv"
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" = "$want" ]
run grep -F '(2016) (2016)' "$OUT"
[ -z "$output" ]
}
@@ -457,3 +472,280 @@ PY
plan
[ -z "$(awk -F '\t' '$1 == "move" {print $2}' "$OUT")" ]
}
# Specials and the year-shaped season. Jellyfin's contract puts specials in
# Season 00, and its date matcher hands back rows whose "season" is the AIRDATE
# YEAR. Both used to park — 1,412 and 1,033 files of one 52k plan — although one
# is a legal season and the other is renumberable from the title tvdb states.
@test "a provider's season 0 is a special, so it files under Season 00" {
file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000
title_row "$LIB/Show Bumps/Best Of The Examples.mkv" "The Examples" 2010 0 2
plan
[ "$(dst "$LIB/Show Bumps/Best Of The Examples.mkv")" \
= "$LIB/The Examples (2010)/Season 00/The Examples (2010) - S00E02.mkv" ]
}
# These three tests carry a tvdb id of their own. The fixture's box set is 78850 and
# ONE id means ONE show: reusing it here would merge these files into that series and
# the duplicate rule would park them before any numbering question is asked.
@test "a row whose season is a year is renumbered by the title tvdb states" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "The Examples", "year": "2010"}' \
'{"1/7": "Best Of"}' > "$CACHE"
file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000
title_row "$LIB/Show Bumps/Best Of The Examples.mkv" \
"The Examples" 2010 2010 11 "" "" "Best Of" "4242"
plan
want="$LIB/The Examples (2010) [tvdbid-4242]/Season 01"
want+="/The Examples (2010) - S01E07 - Best Of.mkv"
[ "$(dst "$LIB/Show Bumps/Best Of The Examples.mkv")" = "$want" ]
}
@test "a title tvdb gives twice never renumbers an episode" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "The Examples", "year": "2010"}' \
'{"1/7": "Best Of", "2/3": "Best Of"}' > "$CACHE"
file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000
title_row "$LIB/Show Bumps/Best Of The Examples.mkv" \
"The Examples" 2010 2010 11 "" "" "Best Of" "4242"
plan_park
run awk -F '\t' -v p="$OUTSIDE/" '$1 == "move" && index($3, p) == 1 {print $3}' "$OUT"
[[ "$output" == *"Best Of The Examples.mkv"* ]]
}
# A release pack can carry a number that is not an episode at all: two files of
# the Attack on Titan box set on the array are named S03E2019 and S04E47370355,
# and the provider rows the server recorded for them say the same junk. Reading
# either as "the first three digits" files the box set under E201 and E473 and
# steals those episode slots from their real owners, so a run too long to be an
# episode is no marker and no number: the file is renumbered from tvdb's title
# when the series' id answers it, and parks when nothing answers.
@test "an episode number longer than three digits never files as an episode" {
file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000
title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" "Attack Pack" 2013 3 2019
plan_park
run grep -c -- "- S03E201 -" "$OUT"
[ "$output" = "0" ]
[[ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" == "$OUTSIDE/"* ]]
grep -q "a provider row of season 3 episode 2019" "$EXC"
}
@test "a provider episode too long to be one is renumbered by the title tvdb states" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "Attack Pack", "year": "2013"}' \
'{"3/7": "A Title tvdb Holds"}' > "$CACHE"
file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000
title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" \
"Attack Pack" 2013 3 2019 "" "" "A Title tvdb Holds" "4242"
plan
want="$LIB/Attack Pack (2013) [tvdbid-4242]/Season 03"
want+="/Attack Pack (2013) - S03E07 - A Title tvdb Holds.mkv"
[ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" = "$want" ]
}
@test "a resolution glued to the marker still reads as one episode" {
file "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" 5000
title_row "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" \
"The Examples" 2010 1 2 "" "" "Second Look"
plan
[ "$(dst "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv")" \
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E02 - Second Look.mkv" ]
}
# Anime numbering is the one place a tvdb title can be confidently wrong: a pack
# numbered by its release group still names S05E01, and tvdb answers that slot with
# a different episode. A season the authority does not hold, or holds fewer of than
# the pack books, is CONTRADICTED: the rename still happens, the title does not.
@test "a season tvdb contradicts keeps the rename but never its titles" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "Anime Gate", "year": "2010"}' \
'{"1/1": "Pilot"}' > "$CACHE"
file "$LIB/Gate Pack/Season 01/one S01E01.mkv" 5000
file "$LIB/Gate Pack/Season 05/two S05E01.mkv" 5000
file "$LIB/Gate Pack/Season 05/three S05E02.mkv" 5000
title_row "$LIB/Gate Pack/Season 01/one S01E01.mkv" \
"Anime Gate" 2010 1 1 "" "" "" "4242"
title_row "$LIB/Gate Pack/Season 05/two S05E01.mkv" \
"Anime Gate" 2010 5 1 "" "" "" "4242"
title_row "$LIB/Gate Pack/Season 05/three S05E02.mkv" \
"Anime Gate" 2010 5 2 "" "" "" "4242"
plan
want="$LIB/Anime Gate (2010) [tvdbid-4242]/Season 01"
want+="/Anime Gate (2010) - S01E01 - Pilot.mkv"
[ "$(dst "$LIB/Gate Pack/Season 01/one S01E01.mkv")" = "$want" ]
want="$LIB/Anime Gate (2010) [tvdbid-4242]/Season 05"
want+="/Anime Gate (2010) - S05E01.mkv"
[ "$(dst "$LIB/Gate Pack/Season 05/two S05E01.mkv")" = "$want" ]
grep -A6 -i "numbering tvdb contradicts" "$EXC" | grep -q "Anime Gate"
}
# Tvdb's default episode query answers at most 500 rows and states no total, so a
# list that long is a PAGE, not a list: One Piece (1999), Crayon Shin-chan (1992)
# and Detective Conan (1996) each answer exactly 500 while holding over a thousand.
# Read as the whole truth, that page contradicts every long-running show's pack
# and strips the tvdb title from all of it.
@test "an episode list the authority paginates never contradicts the pack" {
python3 - "$CACHE" <<'PY'
import json, sys
json.dump({"tvdb:series:4242": {"name": "Long Run", "year": "1999"},
"tvdb:episodes:4242": {f"1/{n}": f"Episode {n}"
for n in range(1, 501)}},
open(sys.argv[1], "w"))
PY
for n in $(seq 1 501); do
ep=$(printf '%02d' "$n")
file "$LIB/Long Pack/Season 01/long $ep.mkv" 1000
title_row "$LIB/Long Pack/Season 01/long $ep.mkv" \
"Long Run" 1999 1 "$n" "" "" "" "4242"
done
plan
want="$LIB/Long Run (1999) [tvdbid-4242]/Season 01"
want+="/Long Run (1999) - S01E01 - Episode 1.mkv"
[ "$(dst "$LIB/Long Pack/Season 01/long 01.mkv")" = "$want" ]
run grep -i "numbering tvdb contradicts" "$EXC"
[ "$status" -ne 0 ]
}
# ------------------------------------------------------------- the operator map
# Most of this library carries no tvdb id anywhere: the authority titles anime by
# its own record — in the show's language — and the English name rides in aliases
# the planner's name search never reads, so a search that demands tvdb's own
# spelling cannot reach it. The operator's map is the other door: one line per
# folder, `folder <TAB> tvdb id <TAB> note`, each id checked by hand against the
# record it names. It outranks a provider id, it merges the release folders that
# are one show, and every folder it touched is named in the exceptions: one wrong
# row there is 1,268 renames to the wrong show, so nothing about it is silent.
plan_map() { # the plan, with the operator's map in hand
run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --titles "$TITLES" \
--probed "$PROBED" --tmdb-cache "$CACHE" --movies "$MOVIES" \
--tvdb-ids "$1" --out "$OUT" --exceptions "$EXC"
}
@test "a folder the operator mapped to a tvdb id renames to that record" {
printf '{"tvdb:series:331753": %s}\n' \
'{"name": "Black Clover", "year": "2017"}' > "$CACHE"
{
printf '# the note column carries why the name search cannot reach it\n'
printf 'Black Clover 001\t331753\tthe record holds no alias\n'
} > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
"Black Clover 001" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
want="$LIB/Black Clover (2017) [tvdbid-331753]/Season 01"
want+="/Black Clover (2017) - S01E01.mkv"
[ "$(dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv")" = "$want" ]
}
@test "release folders one id maps are ONE series folder" {
printf '{"tvdb:series:81797": %s}\n' \
'{"name": "One Piece", "year": "1999"}' > "$CACHE"
{
printf '[Anime Time] One Piece (0001-1071)\t81797\tbare "One Piece" is the 2023 live action\n'
printf 'One Piece Ship Collection\t81797\tthe same record, split by release\n'
} > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" 5000
file "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" 6000
title_row "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" \
"One Piece" 1999 1 1
title_row "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" \
"One Piece Ship Collection" "" 1 2
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01"
want+="/One Piece (1999) - S01E01.mkv"
[ "$(dst "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv")" \
= "$want" ]
want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01"
want+="/One Piece (1999) - S01E02.mkv"
[ "$(dst "$LIB/One Piece Ship Collection/One.Piece.0002.mkv")" = "$want" ]
}
@test "the map outranks a provider id and says which two disagreed" {
printf '{"tvdb:series:4242": %s, "tvdb:series:78850": %s}\n' \
'{"name": "Real Ship", "year": "1999"}' \
'{"name": "Box Junk", "year": "2016"}' > "$CACHE"
printf '%s\t4242\thand-verified against the record\n' \
"Show Box (2016) Season 1 S01 (1080p WEB-DL)" > "$BATS_TEST_TMPDIR/ids.tsv"
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv" \
| grep -q "^$LIB/Real Ship (1999) \[tvdbid-4242\]/Season 01/"
run grep -E '78850.*4242|4242.*78850' "$EXC"
[ "$status" -eq 0 ]
}
@test "a map id the authority never answered keeps the provider's name and is named" {
printf 'Black Clover 001\t331753\ttvdb never answered\n' \
> "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
"Black Clover 001" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
| grep -q "^$LIB/Black Clover 001 \[tvdbid-331753\]/Season 01/"
run grep -q "331753" "$EXC"
[ "$status" -eq 0 ]
}
@test "a map row for a folder the array no longer holds is reported" {
printf 'Gone Pack\t4242\tmoved away\n' > "$BATS_TEST_TMPDIR/ids.tsv"
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
run grep "Gone Pack" "$EXC"
[ "$status" -eq 0 ]
}
@test "a map row whose folder is on the array but never became a series says so" {
# The array holds three folders exactly like this one: a credits/extras pack
# the operator mapped to a record, whose every file is bonus material with no
# series to own it. The folder IS on the array, and telling the operator the
# pack moved sends them hunting for a folder that never left.
printf 'Credit Pack\t114801\tthe record tvdb names\n' > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Credit Pack/01. Extras/NCOP 01.mkv" 1200
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
grep -q "bonus material with no series to own it" "$EXC"
grep -q "never became a series" "$EXC"
run grep "no folder on the array" "$EXC"
[ "$status" -ne 0 ]
}
@test "a map line whose id is not a number stops the plan" {
printf 'Black Clover 001\tnotanid\ttypo\n' > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
"Black Clover 001" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
[ "$status" -ne 0 ]
[[ "$output" == *"notanid"* ]]
}
# The map the fleet ships is scripts/media-shows-tvdb-ids.tsv, and the planner
# reads it by default: an unusable line there stops every plan the operator runs
# from the checkout, so the seam validates the real file, not a fixture copy.
@test "the shipped operator map parses and never stops a plan" {
MAPS="$REPO_ROOT/scripts/media-shows-tvdb-ids.tsv"
run awk -F '\t' '!/^#/ && NF {
if (NF < 2 || $2 !~ /^[0-9]+$/) { print "unusable row: " $0; bad = 1 }
} END { exit bad }' "$MAPS"
[ "$status" -eq 0 ]
dup=$(awk -F '\t' '!/^#/ && NF {print $1}' "$MAPS" | sort | uniq -d)
[ -z "$dup" ]
plan_map "$MAPS"
[ "$status" -eq 0 ]
grep -qi "operator's map" "$EXC"
}
@test "a map that names one folder twice stops the plan" {
printf '{"tvdb:series:78850": %s}\n' \
'{"name": "Naruto", "year": "2002"}' > "$CACHE"
{
printf 'Naruto Complete Series\t78850\tthe dub pack\n'
printf 'Naruto Complete Series\t331753\ta paste of another row\n'
} > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" 5000
title_row "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" \
"Naruto Complete Series" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
[ "$status" -eq 2 ]
[[ "$output" == *"named twice"* ]]
}