Merge remote-tracking branch 'origin/main' into feat/jellyfin-provider-keys
# Conflicts: # AGENTS.md # serverAppConfigs-docs/Dankserver/Media Metadata View.md # serverAppConfigs-docs/index.md
This commit is contained in:
commit
14f340370c
70 files changed
+1898
-4941
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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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[@]}"
|
|
||||||
@@ -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"
|
|
||||||
@@ -156,6 +156,15 @@ Unit requirements:
|
|||||||
(`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by
|
(`<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
|
the quadlet test seam. The host-side unit is generated state; the repo file
|
||||||
is the source of truth.
|
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>` →
|
Before declaring a service deployed: `systemctl --user is-enabled <name>` →
|
||||||
`enabled`, `is-active` → `active`, and the container answers its health check.
|
`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. |
|
| 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`. |
|
| 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. |
|
| 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 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]].** |
|
||||||
| 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]].** |
|
|
||||||
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
|
| 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. |
|
| 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
|
PublishPort=3923:3923
|
||||||
|
|
||||||
[Service]
|
[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
|
EnvironmentFile=/home/dank/Containers/copyparty/env
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
entropy UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec /etc/entropy nofail,luks
|
||||||
@@ -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
|
||||||
@@ -43,11 +43,10 @@ PublishPort=1900:1900/udp
|
|||||||
Volume=/home/dank/Containers/jellyfin/config:/config
|
Volume=/home/dank/Containers/jellyfin/config:/config
|
||||||
Volume=/home/dank/Containers/jellyfin/cache:/cache
|
Volume=/home/dank/Containers/jellyfin/cache:/cache
|
||||||
# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo /
|
# 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
|
# episode.nfo + artwork) and those bytes belong on the SSD. The gate in [Service]
|
||||||
# fail-closed guard — starting against a plain, unmounted view directory would let
|
# is the guard: started against a plain, unmounted view directory a scan decides
|
||||||
# a scan conclude the library is empty.
|
# the library is empty and deletes what it cannot see.
|
||||||
Volume=/var/lib/media-meta/view:/data
|
Volume=/var/lib/media-meta/view:/data
|
||||||
UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
|
|
||||||
AddDevice=/dev/dri
|
AddDevice=/dev/dri
|
||||||
GroupAdd=keep-groups
|
GroupAdd=keep-groups
|
||||||
HealthCmd=curl -f http://localhost:8096/health
|
HealthCmd=curl -f http://localhost:8096/health
|
||||||
@@ -56,8 +55,19 @@ HealthTimeout=10s
|
|||||||
HealthRetries=3
|
HealthRetries=3
|
||||||
|
|
||||||
[Service]
|
[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
|
Restart=always
|
||||||
RestartSec=5
|
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]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
@@ -16,12 +16,21 @@ Volume=/var/lib/plexmediaserver:/config
|
|||||||
# to the overlay view re-points Plex at the sidecars without touching its DB.
|
# 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.
|
# Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata.
|
||||||
Volume=/var/lib/media-meta/view:/mnt/entropy:ro
|
Volume=/var/lib/media-meta/view:/mnt/entropy:ro
|
||||||
UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
|
|
||||||
PublishPort=32400:32400
|
PublishPort=32400:32400
|
||||||
|
|
||||||
[Service]
|
[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
|
Restart=always
|
||||||
RestartSec=5
|
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]
|
[Install]
|
||||||
WantedBy=default.target
|
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
|
|
||||||
-20
@@ -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
|
|
||||||
-203
@@ -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()
|
|
||||||
@@ -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())
|
|
||||||
@@ -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 "$@"
|
|
||||||
@@ -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[@]}"
|
|
||||||
@@ -1208,14 +1208,51 @@ ensure_media_view() {
|
|||||||
# array below stays read-only and every sidecar Jellyfin writes lands on the
|
# array below stays read-only and every sidecar Jellyfin writes lands on the
|
||||||
# NVMe.
|
# NVMe.
|
||||||
# Raise it BEFORE either container starts — a start against a bare view
|
# 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
|
# directory would let a scan clear the library database. Each unit gates on
|
||||||
# ConditionPathIsMountPoint, so a missing view skips the start rather than
|
# scripts/wait-for-mount.sh, which waits and then refuses the start. A
|
||||||
# damaging anything. A relayout must be followed by `media-view.sh reap`
|
# relayout must be followed by `media-view.sh reap`
|
||||||
# (see the Media Relayout page), which this is not.
|
# (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
|
run_privileged bash scripts/media-view.sh install
|
||||||
bash scripts/media-view.sh verify
|
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() {
|
ensure_provider_keys() {
|
||||||
# Jellyfin's metadata-provider keys live in the container-owned plugin
|
# Jellyfin's metadata-provider keys live in the container-owned plugin
|
||||||
# configuration under the data root, never in git; scripts/jellyfin-providers.sh
|
# configuration under the data root, never in git; scripts/jellyfin-providers.sh
|
||||||
@@ -1237,6 +1274,7 @@ ensure_provider_keys() {
|
|||||||
|
|
||||||
deploy_jellyfin() {
|
deploy_jellyfin() {
|
||||||
ensure_media_view
|
ensure_media_view
|
||||||
|
ensure_user_io_delegate
|
||||||
ensure_provider_keys
|
ensure_provider_keys
|
||||||
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
@@ -1326,6 +1364,7 @@ deploy_copyparty() {
|
|||||||
|
|
||||||
deploy_plex() {
|
deploy_plex() {
|
||||||
ensure_media_view
|
ensure_media_view
|
||||||
|
ensure_user_io_delegate
|
||||||
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
if ! systemctl --user is-active -q plex.service; then
|
if ! systemctl --user is-active -q plex.service; then
|
||||||
|
|||||||
+68
-9
@@ -59,10 +59,18 @@ def journal_default(root):
|
|||||||
|
|
||||||
|
|
||||||
class Journal:
|
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.path, self.writing = path, writing
|
||||||
|
self.batch = max(1, int(batch))
|
||||||
self.records = []
|
self.records = []
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
for line in open(path):
|
for line in open(path):
|
||||||
@@ -70,11 +78,15 @@ class Journal:
|
|||||||
if line:
|
if line:
|
||||||
self.records.append(json.loads(line))
|
self.records.append(json.loads(line))
|
||||||
self._fh = None
|
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):
|
def already(self, dst, src):
|
||||||
"""An intent for this exact (src, dst) was written by an earlier run."""
|
"""An intent for this exact (src, dst) was written by an earlier run."""
|
||||||
return any(r.get("op") == "move" and r.get("dst") == dst
|
return (src, dst) in self._intents
|
||||||
and r.get("src") == src for r in self.records)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rolled_back(self):
|
def rolled_back(self):
|
||||||
@@ -82,16 +94,28 @@ class Journal:
|
|||||||
|
|
||||||
def record(self, **fields):
|
def record(self, **fields):
|
||||||
self.records.append(fields)
|
self.records.append(fields)
|
||||||
|
if fields.get("op") == "move":
|
||||||
|
self._intents.add((fields.get("src"), fields.get("dst")))
|
||||||
if not self.writing:
|
if not self.writing:
|
||||||
return
|
return
|
||||||
if self._fh is None:
|
if self._fh is None:
|
||||||
self._fh = open(self.path, "a")
|
self._fh = open(self.path, "a")
|
||||||
self._fh.write(json.dumps(fields) + "\n")
|
self._fh.write(json.dumps(fields) + "\n")
|
||||||
self._fh.flush()
|
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())
|
os.fsync(self._fh.fileno())
|
||||||
|
self._unsynced = 0
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self._fh:
|
if self._fh:
|
||||||
|
self.sync()
|
||||||
self._fh.close()
|
self._fh.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -177,14 +201,30 @@ def do_apply(args, root, journal, mkdirs, keeps, moves):
|
|||||||
print(f" would move {src} -> {dst}")
|
print(f" would move {src} -> {dst}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
batch = max(1, args.batch)
|
||||||
made = {r.get("dst") for r in journal.records if r.get("op") == "mkdir"}
|
made = {r.get("dst") for r in journal.records if r.get("op") == "mkdir"}
|
||||||
dirs_new = 0
|
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)):
|
for d in sorted(mkdirs, key=lambda q: q.count(os.sep)):
|
||||||
if d in made or os.path.isdir(d):
|
if d in made or os.path.isdir(d):
|
||||||
continue
|
continue
|
||||||
journal.record(op="mkdir", dst=d)
|
journal.record(op="mkdir", dst=d)
|
||||||
os.makedirs(d, exist_ok=True)
|
queued.append(d)
|
||||||
dirs_new += 1
|
if len(queued) >= batch:
|
||||||
|
drain_dirs()
|
||||||
|
drain_dirs()
|
||||||
|
|
||||||
# same-filesystem gate: rename cannot cross a device, and a silent copy of a
|
# same-filesystem gate: rename cannot cross a device, and a silent copy of a
|
||||||
# library this size is not an option anyone would choose.
|
# 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
|
return 2
|
||||||
|
|
||||||
moved = done = 0
|
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:
|
for src, dst, _kind in moves:
|
||||||
if journal.already(dst, src):
|
if journal.already(dst, src):
|
||||||
if os.path.exists(dst) and not os.path.exists(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)
|
st = os.stat(src)
|
||||||
journal.record(op="move", src=src, dst=dst, bytes=st.st_size, mode=st.st_mode,
|
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)
|
mtime=st.st_mtime, ino=st.st_ino, dev=st.st_dev)
|
||||||
os.rename(src, dst)
|
pending.append((src, dst))
|
||||||
moved += 1
|
if len(pending) >= batch:
|
||||||
|
drain_moves()
|
||||||
|
drain_moves()
|
||||||
print(f"applied: {moved} moved, {done} already done in an earlier run,"
|
print(f"applied: {moved} moved, {done} already done in an earlier run,"
|
||||||
f" {dirs_new} folders created, {len(keeps)} files stayed put")
|
f" {dirs_new} folders created, {len(keeps)} files stayed put")
|
||||||
print(f"journal: {journal.path} — undo with: media-apply.py rollback"
|
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,
|
ap.add_argument("--metadata-view", default=METADATA_VIEW,
|
||||||
help="overlay the media servers read through; the sidecar"
|
help="overlay the media servers read through; the sidecar"
|
||||||
" note prints only when this path is a mountpoint")
|
" 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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
root = os.path.abspath(args.root)
|
root = os.path.abspath(args.root)
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
raise Refuse(f"no library at {root}")
|
raise Refuse(f"no library at {root}")
|
||||||
journal_path = args.journal or journal_default(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:
|
try:
|
||||||
if args.cmd in ("rollback", "verify", "sweep") and not os.path.exists(journal_path):
|
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}")
|
raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}")
|
||||||
|
|||||||
+22
-14
@@ -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
|
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):
|
def enumerate_lib(root, out):
|
||||||
"""One row per real file under root: size, extension, path."""
|
"""One row per real file under root: size, extension, path."""
|
||||||
@@ -229,13 +249,7 @@ def probe(enum, out, jobs):
|
|||||||
|
|
||||||
def plex(root, db, out):
|
def plex(root, db, out):
|
||||||
"""Plex's matched titles for files under root, from its DB, read-only."""
|
"""Plex's matched titles for files under root, from its DB, read-only."""
|
||||||
uri = f"file:{db}?mode=ro&immutable=1"
|
con = open_plex_ro(db)
|
||||||
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.")
|
|
||||||
with open(out, "w", newline="") as fh:
|
with open(out, "w", newline="") as fh:
|
||||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||||
w.writerow(PLEX_COLS)
|
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
|
at least one un-indexed chapter, so the relayout that reads this file must
|
||||||
not renumber anything from it.
|
not renumber anything from it.
|
||||||
"""
|
"""
|
||||||
uri = f"file:{db}?mode=ro&immutable=1"
|
con = open_plex_ro(db)
|
||||||
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.")
|
|
||||||
tables = {r[0] for r in con.execute(
|
tables = {r[0] for r in con.execute(
|
||||||
"select name from sqlite_master where type='table'")}
|
"select name from sqlite_master where type='table'")}
|
||||||
missing = {"media_parts", "media_items", "metadata_items"} - tables
|
missing = {"media_parts", "media_items", "metadata_items"} - tables
|
||||||
|
|||||||
@@ -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.
|
Executable
+306
@@ -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
@@ -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]
|
media-shows.py [--root DIR] [--enum TSV] [--titles TSV] [--probed TSV]
|
||||||
[--tmdb-cache JSON] [--tmdb] [--movies DIR] [--park DIR]
|
[--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
|
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
|
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
|
— 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.
|
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
|
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
|
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.
|
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"),
|
default=os.path.expanduser("~/.config/secrets/tvdb"),
|
||||||
help="file with an api_key= line (and a pin= line for a "
|
help="file with an api_key= line (and a pin= line for a "
|
||||||
"user-issued key); mode 600, and never the cache")
|
"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",
|
ARGS.add_argument("--movies", default="/mnt/entropy/Movies",
|
||||||
help="movie library a mis-filed film moves into")
|
help="movie library a mis-filed film moves into")
|
||||||
ARGS.add_argument("--park", default="",
|
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)
|
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*$")
|
SEASON_DIR_RE = re.compile(r"(?i)^\s*(?:season[\s._-]*(\d{1,3})|s(\d{1,2}))\s*$")
|
||||||
BAD_CHARS = re.compile(r'[<>:"/|?*]')
|
BAD_CHARS = re.compile(r'[<>:"/|?*]')
|
||||||
# SxxEyy with an optional second episode, plus the 1x04 form. The second number
|
# SxxEyy with an optional second episode, plus the 1x04 form. Both episode numbers
|
||||||
# is guarded so a resolution never reads as an episode: "S01E01-1080p" is ONE
|
# are guarded so a longer digit run never reads as an episode. A resolution glued
|
||||||
# episode, and the longest-match backtracking of \d{1,3} is what the (?![\dpP])
|
# on stays legal — "S01E01-1080p" is ONE episode — while "S03E2019" and
|
||||||
# after it closes off.
|
# "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._-]*"
|
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"(?:[\s._-]*(?:-|to[\s._-]*|ep)[\s._-]*(?:s\d{1,2}[\s._-]*)?e?"
|
||||||
r"(\d{1,3})(?![\dpP]))?")
|
r"(\d{1,3})(?![\dpP]))?")
|
||||||
NX_RE = re.compile(r"(?i)(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![a-z0-9])")
|
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",
|
"deleted": "deleted scenes", "other": "other", "epilogue": "other",
|
||||||
"opening": "other", "featurette": "featurettes", "short": "shorts"}
|
"opening": "other", "featurette": "featurettes", "short": "shorts"}
|
||||||
SHORT_MAX = 45 * 60 # over this, a file with no episode evidence is film-shaped
|
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):
|
def rel(p):
|
||||||
@@ -404,19 +431,79 @@ def tvdb_search(name):
|
|||||||
return None
|
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):
|
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}"
|
ck = f"tvdb:episodes:{sid}"
|
||||||
if ck in CACHE:
|
if ck not in CACHE:
|
||||||
return CACHE[ck]
|
data = tvdb(f"/series/{sid}/episodes/default")
|
||||||
data = tvdb(f"/series/{sid}/episodes/default")
|
if not data:
|
||||||
if not data:
|
return None
|
||||||
return None
|
rows = data.get("episodes") or []
|
||||||
CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}":
|
CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}":
|
||||||
(e.get("name") or "").strip()
|
(e.get("name") or "").strip() for e in rows}
|
||||||
for e in data.get("episodes") or []}
|
CACHE[f"tvdb:capped:{sid}"] = len(rows) >= EPISODE_PAGE_CAP
|
||||||
return CACHE[ck]
|
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
|
# ------------------------------------------------------------------- the inputs
|
||||||
rows = []
|
rows = []
|
||||||
for line in open(ARGS.enum):
|
for line in open(ARGS.enum):
|
||||||
@@ -551,6 +638,63 @@ def extras_kind(path, extra_type):
|
|||||||
return "extras"
|
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
|
# ----------------------------------------------------------- classify every file
|
||||||
series = {} # norm(name) -> {name, year, tmdb}
|
series = {} # norm(name) -> {name, year, tmdb}
|
||||||
claimants = [] # (row, name, season, [episodes])
|
claimants = [] # (row, name, season, [episodes])
|
||||||
@@ -584,7 +728,7 @@ def as_extra(r, owner, extra_type):
|
|||||||
if not owner:
|
if not owner:
|
||||||
unknown.append((r, "bonus material with no series to own it"))
|
unknown.append((r, "bonus material with no series to own it"))
|
||||||
return
|
return
|
||||||
note(owner)
|
note(owner, tvdb_id=map_id_for(r["path"], ""))
|
||||||
extras.append((r, owner, extras_kind(r["path"], extra_type)))
|
extras.append((r, owner, extras_kind(r["path"], extra_type)))
|
||||||
|
|
||||||
for r in video:
|
for r in video:
|
||||||
@@ -613,18 +757,26 @@ for r in video:
|
|||||||
else:
|
else:
|
||||||
season = fs if fs is not None else num(t["season"])
|
season = fs if fs is not None else num(t["season"])
|
||||||
ep = leading_index(r["base"]) or num(t["episode"])
|
ep = leading_index(r["base"]) or num(t["episode"])
|
||||||
if not 0 < season <= 200 or ep <= 0:
|
if not 0 <= season <= 200 or not 1 <= ep <= EP_MAX:
|
||||||
unknown.append((r, f"a provider row of season {season} episode {ep}"))
|
# Season 0 IS a season: specials live in Season 00 by Jellyfin's own
|
||||||
continue
|
# 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]
|
eps = [ep]
|
||||||
claimants.append((r, owner, season, eps))
|
claimants.append((r, owner, season, eps))
|
||||||
note(owner, t["year"] if t else "", t["tmdb"] if t else "",
|
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
|
continue
|
||||||
owner = series_of_dir(os.path.dirname(p))
|
owner = series_of_dir(os.path.dirname(p))
|
||||||
if fs is not None and owner and leading_index(r["base"]):
|
if fs is not None and owner and leading_index(r["base"]):
|
||||||
claimants.append((r, owner, fs, [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
|
continue
|
||||||
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
|
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
|
||||||
as_extra(r, owner, "")
|
as_extra(r, owner, "")
|
||||||
@@ -705,11 +857,41 @@ def label_of(s):
|
|||||||
return f"{nm} ({yr})"
|
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):
|
def episode_title(r, s, season, ep):
|
||||||
"""The title this episode carries: tvdb's for the id the series resolved to,
|
"""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 ` - `
|
else the title the matched row states, else nothing at all — a dangling ` - ` is
|
||||||
is not a title, and an invented one is worse than none."""
|
not a title, and an invented one is worse than none. A season tvdb's own list
|
||||||
if s["tvdb"]:
|
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()
|
t = ((tvdb_episodes(s["tvdb"]) or {}).get(f"{season}/{ep}") or "").strip()
|
||||||
if t:
|
if t:
|
||||||
return clean(t)
|
return clean(t)
|
||||||
@@ -718,8 +900,17 @@ def episode_title(r, s, season, ep):
|
|||||||
|
|
||||||
|
|
||||||
def series_dir(name):
|
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))
|
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
|
# ------------------------------------------------------------------- the plan
|
||||||
@@ -967,12 +1158,14 @@ def write_plan():
|
|||||||
sized = sum(held[s]["bytes"] for s, _d, _k in moves if s in held)
|
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"
|
print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked"
|
||||||
f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed,"
|
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():
|
def write_exceptions():
|
||||||
no_year = sorted({s["name"] for s in canon.values() if not s["year"]})
|
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")})
|
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:
|
with open(ARGS.exceptions, "w") as fh:
|
||||||
fh.write(f"TV relayout exceptions for {ROOT}\n\n")
|
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")
|
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(" 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(" id (--tvdb) and re-plan; a rename is idempotent.\n")
|
||||||
fh.write(" " + ", ".join(no_tvdb[:300]) + "\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}")
|
print(f"exceptions -> {ARGS.exceptions}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -60,7 +60,7 @@ die() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
usage() {
|
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
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +98,22 @@ require_upper_base() {
|
|||||||
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)"
|
|| 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() {
|
do_plan() {
|
||||||
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
|
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
|
||||||
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
|
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
|
||||||
@@ -234,6 +250,7 @@ do_status() {
|
|||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
plan) do_plan ;;
|
plan) do_plan ;;
|
||||||
verify) do_verify ;;
|
verify) do_verify ;;
|
||||||
|
ensure-base) do_ensure_base ;;
|
||||||
install) do_install ;;
|
install) do_install ;;
|
||||||
reap) do_reap ;;
|
reap) do_reap ;;
|
||||||
status) do_status ;;
|
status) do_status ;;
|
||||||
|
|||||||
Executable
+74
@@ -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
|
hand-off list (`HANDOFFS`) instead of a single host. The guard contract is
|
||||||
untouched: challenge cleared, no authentik `uidField`, status < 500, and every
|
untouched: challenge cleared, no authentik `uidField`, status < 500, and every
|
||||||
host NOT in the list still has to land on its own vhost.
|
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
|
tags (copyparty-ac stops at 1.19.15), pin `:latest@sha256:` and name the
|
||||||
version the config label carries in the comment beside it.
|
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
|
## 5. Rootless Quadlet log retrieval
|
||||||
|
|
||||||
Step 0 — establish ownership: `systemctl --user status <name>.service`
|
Step 0 — establish ownership: `systemctl --user status <name>.service`
|
||||||
|
|||||||
@@ -1,51 +1,68 @@
|
|||||||
---
|
---
|
||||||
type: reference
|
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:
|
tags:
|
||||||
- dankserver
|
- dankserver
|
||||||
timestamp: 2026-08-28
|
timestamp: 2026-10-31
|
||||||
---
|
---
|
||||||
|
|
||||||
# Entropy Volume
|
# Entropy Volume
|
||||||
|
|
||||||
The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at
|
`/mnt/entropy` is the ext4 filesystem on the LUKS container holding `/dev/md0`
|
||||||
`/mnt/entropy`. The command-line helper reuses the host's desktop-integrated
|
(RAID6, seven disks). The media stack's data lives there, so the mount is a boot
|
||||||
`udisks2`, `crypttab`, and `fstab` configuration; it does not store or handle
|
dependency for those containers rather than a convenience.
|
||||||
the passphrase itself.
|
|
||||||
|
|
||||||
## Command-line mount
|
## Chain
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```text
|
```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
|
## Files that own it
|
||||||
helper deliberately does not run `chmod` or `chown`, so it cannot silently
|
|
||||||
change those permissions.
|
|
||||||
|
|
||||||
## 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
|
## Law
|
||||||
/dev/md0 -> LUKS UUID cd86c728-459e-4f3a-bcc2-41ecf84685ec
|
|
||||||
-> ext4 label entropy
|
|
||||||
-> /mnt/entropy
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper checks that `/dev/md0` exists and exits successfully if the volume
|
**`crypttab`'s device field takes the LUKS UUID, never the md array UUID.**
|
||||||
is already mounted. It is intentionally specific to this host and volume:
|
`blkid` prints both on adjacent lines for a RAID-backed container, and the file
|
||||||
that avoids accidentally unlocking or mounting a different disk.
|
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]]
|
**Prove a mount fix with the artifacts systemd generates, not with a live
|
||||||
- [[Dankserver Stack]]
|
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.
|
||||||
@@ -18,7 +18,7 @@ clients.
|
|||||||
public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
|
public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
|
||||||
[[Authentik]] outpost (post-auth route proxies `localhost:8096`).
|
[[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 |
|
| Item | Value |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
@@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
|
|||||||
| Image | `ghcr.io/jellyfin/jellyfin:latest` |
|
| Image | `ghcr.io/jellyfin/jellyfin:latest` |
|
||||||
| Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp |
|
| 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` |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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/config -> /config (JELLYFIN_DATA_DIR)
|
||||||
/home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME)
|
/home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME)
|
||||||
/var/lib/media-meta/view -> /data (git: the overlay view)
|
/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
|
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
|
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
|
sees is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover:
|
||||||
array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31).
|
`/var/lib/media-meta/view:/data`, read-write.
|
||||||
|
|
||||||
`Local metadata` — save metadata to media folders — is enabled, and it is the
|
`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
|
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
|
written once on the NVMe upper layer are files [[Plex]] can read too — and since
|
||||||
`/data` is bound read-only every item update logs the error below and no sidecar
|
the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only
|
||||||
is written; Jellyfin becomes the fleet's only metadata writer with the deploy
|
metadata writer, and every sidecar it saves is an SSD byte the array never sees.
|
||||||
that raises the view.
|
|
||||||
|
**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 :
|
**Failure signature:** `System.IO.IOException: Read-only file system :
|
||||||
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array
|
'/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
|
instead of the view — always a fault on this host since the 2026-10-31 cutover.
|
||||||
never been installed (`scripts/media-view.sh status`). Once it is deployed, the
|
`scripts/media-view.sh status` says which: the quadlet drifted back to the raw
|
||||||
same line is a fault: the quadlet drifted or the mount died.
|
array, or the mount died.
|
||||||
|
|
||||||
## Ports & access
|
## 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
|
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
|
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
|
||||||
later `204` is accepted and then discarded — the status code stops meaning
|
later `204` is accepted and then discarded — the status code stops meaning
|
||||||
anything. The fix is `systemctl --user restart jellyfin.service` (never
|
anything. The fix is `systemctl --user restart jellyfin.service`, one POST after
|
||||||
`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after
|
|
||||||
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
||||||
response code.
|
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
|
merged door are one directory — and so the mount table grows no new `/mnt` entry
|
||||||
next to the array.
|
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`,
|
`/var/lib/media-meta/view` (`lowerdir=/mnt/entropy`,
|
||||||
`upperdir=/var/lib/media-meta/upper`), and the installed quadlets under
|
`upperdir=/var/lib/media-meta/upper`), and the installed quadlets under
|
||||||
`~/.config/containers/systemd/` carry the view binds:
|
`~/.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
|
order: `scripts/media-view.sh verify` (the deploy gate), then
|
||||||
`grep -n Volume ~/.config/containers/systemd/{jellyfin,plex}.container`.
|
`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
|
The cutover itself is an ordinary deploy: `deploy.sh dankserver --service
|
||||||
jellyfin|plex` runs `ensure_media_view` (`install` then `verify`) before it
|
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
|
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` |
|
| 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 |
|
| Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it |
|
||||||
|
|
||||||
Both consumers guard the view with
|
Both consumers gate their start on the view with
|
||||||
`UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view`, and
|
`ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh` (it waits
|
||||||
`scripts/deploy.sh dankserver --service jellyfin|plex` calls `media-view.sh
|
for the mount, then refuses), and `scripts/deploy.sh dankserver --service
|
||||||
install` + `verify` before it restarts either container.
|
jellyfin|plex` calls `media-view.sh install` + `verify` before it restarts
|
||||||
|
either container.
|
||||||
|
|
||||||
| Consumer | Bind | Mode | Why that path |
|
| 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
|
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
|
whose upper is the spinners is the original problem with extra steps, and it
|
||||||
silently turns every metadata write back into a seek.
|
silently turns every metadata write back into a seek.
|
||||||
- **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy`
|
- **A container that binds the array must refuse to start until the mount
|
||||||
plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir`
|
answers.** Live 2026-10-31: the array's mount and the media containers raced at
|
||||||
presents an *empty library* as truth, and a media scan would then clear the
|
boot, podman resolved the bind against the pre-mount directory, and both
|
||||||
database of everything. systemd does not create a mount point, so `install`
|
servers came up blind — Jellyfin read an empty library and began
|
||||||
creates `view` itself; without it the mount simply fails.
|
`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
|
- **Never delete through the view.** A delete writes an overlayfs whiteout
|
||||||
(`.wh.<name>`) in the upper layer, which hides the array's copy without
|
(`.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
|
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 |
|
| 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`. |
|
| 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`. |
|
| 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. |
|
| `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. |
|
| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. |
|
||||||
|
|
||||||
## Rollback
|
## 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]]
|
||||||
@@ -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` reads the Plex metadata cache (`--db` defaults to the path the
|
||||||
[[Plex]] container bind-mounts at `/var/lib/plexmediaserver`) with
|
[[Plex]] container bind-mounts at `/var/lib/plexmediaserver`) with plain
|
||||||
`mode=ro&immutable=1`: a live server is never locked or written.
|
`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
|
Then plan — this reads only the three TSVs, so it costs seconds and can run
|
||||||
anywhere, including a laptop:
|
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
|
hundreds of them at the library root — which is what `sweep` removes, dry-run
|
||||||
first, from the same journal.
|
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
|
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
|
[[Jellyfin]] own the exact requests; two laws hold at this step: `204` is
|
||||||
acceptance and anything else queued nothing — re-issue the identical request
|
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
|
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.
|
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
|
```bash
|
||||||
scripts/media-verify.py --journal /mnt/entropy/media-relayout-music.jsonl \
|
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
|
--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
|
"No row at the destination" is not one verdict but four. **never indexed**: the
|
||||||
fault; a row still naming its `src` means the scan has not followed the rename
|
server indexes no file of that extension (its own extension census says so) — a
|
||||||
yet, so re-run the check after the scan, never before.
|
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
|
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
|
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
|
- 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.
|
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
|
- 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
|
movie share once carried a second copy of a whole box set — same episode list,
|
||||||
both sides, the same episode list, every byte count identical, 31.5 GiB each
|
every byte count identical. The mover leaves such a series in place and says
|
||||||
way, and head+tail blake2b identical on a 6-episode sample. The mover leaves
|
so; a duplicate is deleted or kept by Frank, never copied into one library by
|
||||||
such a series in place and says so; a duplicate is deleted or kept by Frank,
|
a tool.
|
||||||
never copied into one library by a tool.
|
|
||||||
|
|
||||||
A sidecar that shares an episode's own name (`.srt`, `.nfo`) moves with it.
|
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
|
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
|
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
|
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
|
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
|
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**:
|
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
|
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.
|
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:
|
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
|
- 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;
|
it**: `Season 19/01 Charles Barkley, Nirvana.avi` is S19E01;
|
||||||
- the **provider row**: which episode the file IS, and the numbering of a file
|
- the **provider row**: which episode the file IS, and the numbering of a file
|
||||||
whose own name says nothing;
|
whose own name says nothing;
|
||||||
- the **row's tvdb id, else its TMDb id carried through TMDb's external ids, else
|
- 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
|
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
|
- a **bonus folder** anywhere above the file, or Jellyfin's `ExtraType` → the
|
||||||
series' canonical extra folder;
|
series' canonical extra folder;
|
||||||
- a runtime over `SHORT_MAX` makes it film-shaped, and it leaves for `--movies` only
|
- 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
|
for the word "russia", *Special Edition I* for a file saying *Edition III*. Else it
|
||||||
parks.
|
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
|
- **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
|
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
|
- **A scraped row does not outrank the filesystem.** Jellyfin numbers
|
||||||
multi-season packs badly: every file under `SNL 16-20/Season 16..19` carries
|
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
|
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
|
the file's own leading number, win.
|
||||||
parks its file instead of creating `Season 1989`.
|
- **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
|
**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
|
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
|
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.
|
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
|
`scripts/media-shows-tvdb.py` makes the sheet the map is built from. It reads a
|
||||||
tagged almost all of it. `scripts/media-music.py` re-files it into the shape
|
plan's `mkdir` rows, asks tvdb about every destination folder carrying no
|
||||||
Jellyfin's music scanner reads —
|
`[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
|
Each is a page of its own, because each reads a different authority and refuses
|
||||||
the file.
|
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
|
||||||
```bash
|
executor, the one-journal-per-arm law, the sweep and the rollback.
|
||||||
# 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.
|
|
||||||
|
|
||||||
## Laws
|
## Laws
|
||||||
|
|
||||||
@@ -442,10 +422,10 @@ journal, and then refreshes the Plex section.
|
|||||||
scope, so the next sweep finishes what a permission problem stopped.
|
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
|
- **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
|
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
|
`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
|
never reaching the five-episode size that calls something a series — a whole
|
||||||
box set was filed as 124 movies in 124 folders. The threshold still earns its
|
box set filed as one folder per episode. The threshold still earns its keep:
|
||||||
keep: a lone film named `Part 2` stays a film. Episodes of a series are parked
|
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 —
|
in `_TV needs a TV library - <series>` for a human to move to a TV source —
|
||||||
the movie tool never files TV.
|
the movie tool never files TV.
|
||||||
- **One journal is one undo.** The TV move runs on its own journal, and with
|
- **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
|
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.
|
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
|
## See Also
|
||||||
|
|
||||||
[[Jellyfin]], [[Plex]], [[Media Metadata View]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]]
|
[[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 |
|
| 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 |
|
| 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**) |
|
| 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
|
## See Also
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
| [[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. |
|
| [[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. |
|
| [[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. |
|
| [[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. |
|
| [[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 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 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. |
|
| [[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 |
|
| [[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. |
|
| [[redis]] | The Redis cache and task queue for RomM: quadlet unit, network alias topology, and the backup path. |
|
||||||
|
|||||||
@@ -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]}" ]
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
Executable
+96
@@ -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; }
|
||||||
|
}
|
||||||
@@ -106,3 +106,24 @@ load helpers
|
|||||||
# env file, not an Environment= line.
|
# env file, not an Environment= line.
|
||||||
! grep -qE '^Environment=CUSTOM_(USER|PASSWORD)=' "$unit" || return 1
|
! 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
|
||||||
|
}
|
||||||
@@ -28,15 +28,16 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
|||||||
! grep -q 'frank_npu\|frank-npu' "$REMOTE_DEPLOY"
|
! 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
|
# scripts/chat-template.jinja existed only for the ik_llama.cpp runtime
|
||||||
# (retired 2026-09-16 in favour of halogen-flash-server again). The
|
# (retired 2026-09-16 in favour of halogen-flash-server again). The
|
||||||
# llama.cpp frank-zealot runtime serves the template the GGUF itself
|
# llama.cpp frank-zealot runtime serves the template the GGUF itself
|
||||||
# carries, which is the one with reasoning_effort, preserve_thinking and
|
# carries, which is the one with reasoning_effort, preserve_thinking and
|
||||||
# the tool-call grammar. Editing the archive must deploy nothing -- and the
|
# the tool-call grammar. The planner's rule is a PATH rule, so an edit at
|
||||||
# archive must still be present, so this cannot pass by the file having
|
# that path deploys nothing no matter where the bytes currently sit: the seam
|
||||||
# vanished.
|
# deliberately does not assert the archive exists. Trash is a purgeable hold
|
||||||
[ -f "$REPO_ROOT/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp" ]
|
# (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
|
run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[ -z "$output" ]
|
[ -z "$output" ]
|
||||||
|
|||||||
@@ -228,6 +228,51 @@ PY
|
|||||||
[ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 1 ] # header only
|
[ "$(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
|
# -------------------------------------------------------------------- the plan
|
||||||
|
|
||||||
@test "a matched chapter files under its narrator and its book, name untouched" {
|
@test "a matched chapter files under its narrator and its book, name untouched" {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
# 4. both media units bind the VIEW and refuse to start without it;
|
# 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
|
# 5. only those two units touch the view — the LLM and library tenants keep the
|
||||||
# raw volume, so the blast radius is two containers;
|
# 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.
|
# 7. the planner routes a change to these files to both media services.
|
||||||
|
|
||||||
load helpers
|
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" {
|
@test "both media servers bind the view and refuse to start without it" {
|
||||||
for unit in "$JELLY" "$PLEX"; do
|
for unit in "$JELLY" "$PLEX"; do
|
||||||
grep -q "^Volume=$MOUNT:" "$unit" || return 1
|
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
|
# The raw array must no longer be a container source: that bind is what
|
||||||
# made sidecar sharing impossible (read-only) and metadata slow (seeks).
|
# made sidecar sharing impossible (read-only) and metadata slow (seeks).
|
||||||
! grep -q '^Volume=/mnt/entropy' "$unit"
|
! grep -q '^Volume=/mnt/entropy' "$unit"
|
||||||
@@ -107,6 +111,30 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
|
|||||||
done
|
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" {
|
@test "the planner routes the view files to both media services" {
|
||||||
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
|
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
|
||||||
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount
|
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount
|
||||||
|
|||||||
@@ -494,6 +494,65 @@ for r in moves:
|
|||||||
PY
|
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" {
|
@test "rollback puts every file back where the census found it" {
|
||||||
plan
|
plan
|
||||||
apply_plan
|
apply_plan
|
||||||
|
|||||||
+298
-6
@@ -11,7 +11,11 @@
|
|||||||
# the name caches are input files, so no test touches the network.
|
# the name caches are input files, so no test touches the network.
|
||||||
#
|
#
|
||||||
# The target contract is tvdb-grade naming:
|
# 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
|
# 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.
|
# 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
|
# 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"
|
"${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 SRC — the destination the plan gives SRC, or nothing when it does not move.
|
||||||
dst() {
|
dst() {
|
||||||
awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $3}' "$OUT"
|
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" ]
|
= "$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"
|
printf '{"tvdb:series:78850": {"name": "Show Box", "year": "2016"}}\n' > "$CACHE"
|
||||||
plan
|
plan
|
||||||
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \
|
want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01"
|
||||||
= "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ]
|
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" {
|
@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.
|
# and `Acapulco (2021) (2021)` is a folder neither matcher nor human reuses.
|
||||||
printf '{"tvdb:series:78850": {"name": "Show Box (2016)", "year": "2016"}}\n' > "$CACHE"
|
printf '{"tvdb:series:78850": {"name": "Show Box (2016)", "year": "2016"}}\n' > "$CACHE"
|
||||||
plan
|
plan
|
||||||
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \
|
want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01"
|
||||||
= "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ]
|
want+="/Show Box (2016) - S01E01 - Pilot.mkv"
|
||||||
|
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" = "$want" ]
|
||||||
run grep -F '(2016) (2016)' "$OUT"
|
run grep -F '(2016) (2016)' "$OUT"
|
||||||
[ -z "$output" ]
|
[ -z "$output" ]
|
||||||
}
|
}
|
||||||
@@ -457,3 +472,280 @@ PY
|
|||||||
plan
|
plan
|
||||||
[ -z "$(awk -F '\t' '$1 == "move" {print $2}' "$OUT")" ]
|
[ -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"* ]]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user