From 8500600d09d0ac393d65abe9af9ac87f3e9d6999 Mon Sep 17 00:00:00 2001
From: Frank
Date: Sat, 26 Sep 2026 19:45:28 -0700
Subject: [PATCH] frank-primary: swap Dankserver to llama.cpp
Qwen3.8-Flash-Next Q8_0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 2026-09-26 same-day cutover that ended the Lucebox DS4.1 detour:
Dankserver's :7770 endpoint is now unsloth llama.cpp (the b11030-mix
prebuilt, ROCm gfx120X, bundled runtime) serving
unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0 — 175.30 GiB across six sha256-
verified split parts — on both Radeon AI PRO R9700s, as a ROOT unit (the
frank-large shape: no container, no generated unit).
The deployed contract:
- Placement law: layer split -ts 1,1 with -ngl 999; the routed experts
and the 51B-param n-gram (PLE) table are COMPUTED in host RAM via
-ot exps=CPU,ngram_embeddings=CPU (the x4-pair measurement: streaming
over PCIe loses at 3.7 t/s, computing in RAM does not care).
per_layer_token_embd.weight=CPU is required by the shared MTP head's
borrow path (a GPU-resident borrow crashes the draft load).
mmap stays on; --cache-ram 16384.
- Spec chain draft-mtp,ngram-mod: the shared Q8_0 MTP head
(mtp-...-shared-Q8_0.gguf, 2,786,568,256 B) at depth 2, with the
draftless ngram-mod matcher chained behind it — the only legal way to
layer two spec implementations in one process.
- Vision explicit: --mmproj names the BF16 projector (this build never
autoloads; a server without it comes up healthy and text-only).
- Reasoning: --reasoning-format deepseek → reasoning_content; the
Qwen3.8 template's {xhigh, medium, low} reasoning_effort gate folded
by reasoningEffortMap in models.yml.
- One stream, the model's full 262,144 context; greedy server default
with Qwen-recommended sampling riding compat.extraBody.
The deployer's deploy_llm_primary is rewritten for the root unit: it
gates every artifact at its declared byte count (stat -c %s, never wc -c
— which read the whole 377 GiB lucebox file for minutes on 2026-09-26),
retires the lucebox user quadlet to dated home trash, waits out port
7770, relinks/enables/restarts the root unit and waits up to 1800 s for
/health. The quadlet-era tree under dankserver/frank-primary/ is
archived (dankserver/.AGENTTRASH/20260926-llamacpp-cutover/ on this
box; git history everywhere else) and its planner mapping replaced by
the scripts/ trio.
Observability flips back to the llamacpp namespace: a frank-primary
Prometheus job at 192.168.0.235:7770 (cross-host LAN — the netavark
published-port quirk that forces host.containers.internal for the two
Dankclaw endpoints is same-host only), the LlmEndpointDown runbook
text, and the dashboard description for three llama.cpp endpoints.
Docs: the new runbook (frank-primary-llamacpp-qwen38-flash-next-q8.md),
RETIRED banners on the lucebox and radiance runbooks and their index
entries, the AGENTS.md fleet section rewritten for the root-unit shape,
and the homepage frank-medium card renamed to frank-primary with the
Q8_0 Flash-Next spec (docs.html #models and the modality note updated:
images on frank-primary and frank-small, audio on frank-small only).
models.yml: the dankserver provider is "llamacpp (dankserver)" serving
frank-primary, contextWindow 262144, maxTokens 65536 (compaction-gap
law: 183,501 + 65,536 <= 262,144), input [text, image].
Suite: 346 green (frank_primary_unit.bats rewritten for the root unit —
16 tests pinning the placement law, the shared-head requirement, the
explicit projector, HIP env hygiene (BIND_HOST, never bare HOST), and
the deployer's byte-count gates; the quadlet-era planner and
changed-tests mappings rewritten for the trio; the observability scrape
test asserts three llamacpp jobs again).
Rollback: the lucebox image (localhost/lucebox-rocm:gfx1201) and the
378 GiB payload stay on the host; the Qwen payload stays fetched, so a
re-swap in either direction is a redeploy, not a refetch.
---
AGENTS.md | 156 +++++++------
.../frank-primary/frank-primary-entrypoint.sh | 90 --------
.../frank-primary-healthcheck.py | 14 --
.../frank-primary/lucebox-gfx1201.patch | 40 ----
.../quadlet/frank-primary.container | 90 --------
scripts/deploy-plan.py | 19 +-
scripts/deploy-remote.sh | 128 +++++++----
scripts/frank-primary.env | 78 +++++++
scripts/frank-primary.service | 21 ++
scripts/serve-frank-primary.sh | 134 +++++++++++
scripts/test-changed.sh | 16 +-
...k-primary-llamacpp-qwen38-flash-next-q8.md | 135 +++++++++++
.../LLM/frank-primary-lucebox-ds4.md | 18 +-
serverAppConfigs-docs/LLM/index.md | 67 +++---
serverAppConfigs-docs/index.md | 18 +-
.../provisioning/dashboards/llm-fleet.json | 10 +-
shared/homepage/docs.html | 14 +-
shared/homepage/services.json | 4 +-
shared/prometheus/prometheus.yml | 37 +--
shared/prometheus/rules/llm-fleet.yml | 18 +-
tests/changed_tests.bats | 23 +-
tests/deployment_scoping.bats | 18 +-
tests/frank_primary_unit.bats | 212 ++++++++++++------
tests/homepage_config.bats | 15 +-
tests/observability_quadlet.bats | 25 ++-
25 files changed, 856 insertions(+), 544 deletions(-)
delete mode 100755 dankserver/frank-primary/frank-primary-entrypoint.sh
delete mode 100755 dankserver/frank-primary/frank-primary-healthcheck.py
delete mode 100644 dankserver/frank-primary/lucebox-gfx1201.patch
delete mode 100644 dankserver/frank-primary/quadlet/frank-primary.container
create mode 100644 scripts/frank-primary.env
create mode 100644 scripts/frank-primary.service
create mode 100755 scripts/serve-frank-primary.sh
create mode 100644 serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md
diff --git a/AGENTS.md b/AGENTS.md
index 84544d3..3e0a6a5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,7 @@ services run from `/home/dank/serverAppConfigs` on each host (the 2026-08-22
merge absorbed the former `vllmScripts` repo, history preserved).
- **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty,
- Calibre-Web, Jellyfin, Plex) plus the `frank-medium` LLM service.
+ Calibre-Web, Jellyfin, Plex) plus the `frank-primary` LLM service.
- **Dankclaw** (192.168.0.143) — AI/search/dev/observability stack (SearXNG
via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Loki,
Alloy, Pi-hole) plus TWO LLM services: `frank-large` (stock llama.cpp on
@@ -24,7 +24,7 @@ merge absorbed the former `vllmScripts` repo, history preserved).
| Target | Service | Runtime | Model | Port |
|--------|---------|---------|-------|------|
-| Dankserver | `frank-medium` | vllm-radiance 0.9.3 (rootless Quadlet, image `localhost/radiance-vllm:0.9.3`, gfx1201) | just1moremodel Qwen3.8-27B-Uncensored-MXFP4-awq (MXFP4/quark W4A8, 19.37 GiB) with the z-lab DFlash2 external drafter (depth 7), 2x Radeon AI PRO R9700 in ONE tensor-parallel process (TP=2 over the R4D one-shot allreduce — no RCCL collective; both cards on CPU root ports at Gen5 x8 since the 2026-09-20 ProArt B850-Creator swap), 262144 ctx over up to 8 conversations, UNQUANTIZED bf16 KV (`KV_DTYPE=auto`, cut 2026-09-21 from the FP8/fp8-KV shape to test whether fp8 KV had been degrading W4A8 output quality; auto-sized 486,464-token pool = 1.86x the window), prefix caching, vision | 7770 |
+| Dankserver | `frank-primary` | unsloth llama.cpp b11030-mix prebuilt (ROCm gfx120X, bundled runtime, root systemd unit) | unsloth Qwen3.8-Flash-Next-GGUF Q_8 (175.30 GiB, 6 split parts) with the shared Q_8 MTP head (depth 2) chained behind `ngram-mod`, BF16 vision (mmproj), layer split `-ts 1,1` across both R9700s with the routed experts + 51B-param n-gram table computed in host RAM (`-ot exps=CPU,ngram_embeddings=CPU,per_layer_token_embd.weight=CPU` — the shared-head borrow path needs the last), 262144 ctx single stream (1 slot), mmap on, `--cache-ram 16384` | 7770 |
| Dankclaw | `frank-large` | unsloth llama.cpp b11030 prebuilt (ROCm gfx1151, root systemd unit) | unsloth Qwen3.8-27B-GGUF:UD-Q8_K_XL (27B dense, 31.5 GiB) with the z-lab DFlash2 Q4_K_M drafter (depth 4) on the gfx1151 iGPU (Strix Halo LPDDR, 114 GiB GTT), 262144 ctx single stream (1 slot), q8_0 KV, text only | 7770 |
| Dankclaw | `frank-small` | mainline llama.cpp master 37b53fd (root systemd unit) | HauhauCS Gemma4-12B-QAT-Uncensored-Balanced Q4_K_M (BF16 mmproj + MTP assistant sidecar, `draft-mtp,ngram-mod`) on the RTX 3090, 1048576 pool / 4 slots of 262,144, q8_0 KV, the fleet's fastest vision endpoint | 7771 |
@@ -61,16 +61,15 @@ All edits flow through git. Never edit files on a remote machine directly.
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
-./scripts/deploy.sh dankserver --service frank-medium
+./scripts/deploy.sh dankserver --service frank-primary
./scripts/deploy.sh dankclaw --dry-run
```
The planner maps changed paths to services; the remote runner restarts only
the selected service and records its revision outside the checkout.
Documentation-only changes are a no-op. LLM services are selected only by
-their own files — `frank-large`/`frank-small` by their unit/env/serve trio in
-`scripts/`, `frank-medium` by its Quadlet unit, entrypoint and healthcheck under
-`dankserver/frank-medium/` — so
+their own files — every endpoint (`frank-large`/`frank-small`/`frank-primary`)
+by its unit/env/serve trio in `scripts/` — so
unrelated
container changes cannot interrupt inference. Set `FRANK_SUDO_PW` for headless
runs on a host without passwordless sudo; the value never enters the repo.
@@ -232,9 +231,8 @@ merely wedged kernel usually still answers ARP.
`sudo systemctl stop frank-large && sudo systemctl mask frank-large`
- Dankclaw `frank-small` (root unit, llama.cpp on the 3090):
`sudo systemctl stop frank-small && sudo systemctl mask frank-small`
- - Dankserver `frank-medium` (**rootless Quadlet since 2026-09-20** — the
- system unit is retired, so `sudo systemctl` sees nothing):
- `systemctl --user stop frank-medium && systemctl --user mask frank-medium`
+ - Dankserver `frank-primary` (root unit, llama.cpp on the 2x R9700):
+ `sudo systemctl stop frank-primary && sudo systemctl mask frank-primary`
2. Bench.
3. Unmask and start; verify `curl http://192.168.0.:7770/health`.
@@ -247,9 +245,8 @@ deletes the unit file from `/etc/systemd/system/`, not just the symlink.
The harness reaches the fleet through `.omp/agent/models.yml` in the
operator's home — `/home/blank/.omp/agent/models.yml` on the dev box, which is
where the harness runs; there is no copy on the servers. Each provider is one
-HOST:PORT; each model `id` is the served alias: `frank-medium` →
+HOST:PORT; each model `id` is the served alias: `frank-primary` →
`192.168.0.235:7770`, `frank-large` → `192.168.0.143:7770`, `frank-small` →
-`192.168.0.143:7771`. Per-role routing lives beside it in `config.yml` under
`modelRoles`.
Resolution rules: the default model resolves as a `defaultProvider` +
@@ -262,16 +259,19 @@ When renaming a service, retiring an endpoint, or moving a model: update the
provider block, model `id`, `contextWindow`, `maxTokens`, `input` in
`models.yml`, then fix every `modelRoles` entry that named the old endpoint —
a role pointing at a provider the file does not define fails at call time. As
-of 2026-09-20 both `frank-small` and `frank-medium` accept images
-(`input: [text, image]`) — frank-medium's checkpoint serves vision through the
-Gemma4 merge path with no separate mmproj, verified on held-out synthetic
-images; `frank-large` remains text-only because its stock llama.cpp deployment loads no
+of 2026-09-26 both `frank-small` and `frank-primary` accept images
+(`input: [text, image]`) — frank-primary loads the BF16 mmproj explicitly
+(this build never autoloads projectors); `frank-large` remains text-only
+because its stock llama.cpp deployment loads no
vision projector (the mmproj is not loaded). Note also that
the reasoning channel differs by runtime: llama.cpp endpoints emit
-`reasoning_content`, vLLM emits `reasoning`.
+`reasoning_content`, vLLM emits `reasoning` — academic since the
+2026-09-26 swap, as every endpoint in the fleet is llama.cpp now.
There is **no audio**
-input anywhere in the fleet: that capability left with the FLM NPU endpoint on
-2026-09-11.
+input anywhere in the fleet except frank-small: that capability left with the
+FLM NPU endpoint on 2026-09-11 and returned on 2026-09-17 with the Gemma4
+projector's audio encoder (frank-small transcribes WAV verbatim even though
+`audio` is omitted from its `input` list — OMP's schema rejects it).
### frank-large — stock llama.cpp Qwen3.8-27B UD-Q8_K_XL (Dankclaw, ACTIVE)
@@ -341,73 +341,66 @@ ubatch 2048 OOMs the draft's pp buffer, and why draft depth 4 + poll
Runbook: `serverAppConfigs-docs/LLM/frank-small-gemma4-12b.md`.
Rollback: [[frank-small-qwen38-gsq-rco]].
-### frank-medium — radiance TP=2, DFlash2 drafter (Dankserver, ACTIVE)
+### frank-primary — llama.cpp Qwen3.8-Flash-Next Q8_0 (Dankserver, ACTIVE)
-vllm-radiance **0.9.3** (`localhost/radiance-vllm:0.9.3`, rootless **Quadlet**
-`dankserver/frank-medium/quadlet/frank-medium.container`) serving
-`just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq` (MXFP4/quark W4A8, 19.37
-GiB) with the **z-lab DFlash2 external drafter** (`num_speculative_tokens: 7`,
-live mean accept 4.24/7) in **one tensor-parallel process across both
-R9700s** (`--tensor-parallel-size 2`). 262,144 ctx, `--max-num-seqs 8`, chunk
-8192, **unquantized bf16 KV** (`KV_DTYPE=auto`, cut 2026-09-21 from the
-FP8/fp8-KV shape to test whether fp8 KV had been degrading W4A8 output
-quality), prefix caching, GPU fill 0.98, **no KV-memory pin** — the pool
-auto-sized to 486,464 tokens (1.86× the full window). DFlash2 was A/B tested
-against MTP self-speculation on this checkpoint and wins on decode (100.0 vs
-85.9 t/s) — see the runbook. Tools via `qwen3_coder`, reasoning via `qwen3`
-(the field is `reasoning`, not llama.cpp's `reasoning_content`), vision through
-the Gemma4 merge path, chat template Qwen-Sharp v22.4.0 (froggeric,
-repo-tracked at `dankserver/frank-medium/`, terse system prompt by default).
+unsloth llama.cpp **b11030-mix prebuilt** (ROCm gfx120X, commit `6ba30d05b`,
+bundled ROCm runtime at `/home/dank/GitPackages/unsloth-llama-b11030/`, **root
+systemd unit** `scripts/frank-primary.service`, `User=dank`) serving
+`unsloth/Qwen3.8-Flash-Next-GGUF` at **Q_8** — 175.30 GiB across six split
+parts under `/home/dank/models/Qwen3.8-Flash-Next-GGUF/Q8_0/`, fetched by
+`scripts/download-frank-primary.sh` (per-file sha256 + byte-count verified) —
+on **both** Radeon AI PRO R9700s. Replaced the Lucebox DS4.1 quadlet (hours
+old) on 2026-09-26, which had replaced radiance frank-medium the same day.
-**The entrypoint is quantization-aware.** `radiance-entrypoint.sh` reads
-`quantization_config.quant_method` from the checkpoint's `config.json` and
-serves the plain FP8 path (no MXFP4 patches, `--quantization fp8`,
-`ROCM_AITER_UNIFIED_ATTN`, the DFlash2 drafter) or the MXFP4 patch-and-serve
-path (the DFlash2 external drafter). A checkpoint swap is therefore four quadlet
-env vars (`SNAP`, `SPEC_METHOD`, `SPEC`, `KV_MEM`) — the deploy handler reads
-the serving shape from the quadlet and gates on the right assets. The MXFP4
-checkpoint (`just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq` + the z-lab
-DFlash2-FP8 drafter) remains on disk as the rollback target. Full swap
-process + toolchain: [[frank-medium-model-swap]].
+**Placement: compute the experts in RAM, never stream them.** Layer split
+`-ts 1,1` with `-ngl 999`; the routed experts and the 51B-param n-gram
+(PLE) table are overridden to CPU (`-ot
+exps=CPU,ngram_embeddings=CPU,per_layer_token_embd.weight=CPU`) and computed
+from the mmap page cache — the x4-pair law (streaming over PCIe loses at
+3.7 t/s; computing in RAM does not care). `per_layer_token_embd.weight=CPU`
+is REQUIRED by the shared MTP head's borrow path (a GPU-resident borrow
+crashes the draft load with `invalid vector subscript`). Dense weights,
+attention, the BF16 projector and the MTP head sit on the cards (~29 GiB);
+mmap must stay on (`--no-mmap` forces the table into anonymous memory →
+silent cgroup kill) and the PLE tensor must never carry a GPU target
+(23 → 13.4 t/s when tried). `--cache-ram 16384`; KV is the hybrid
+DeltaNet/QSA pattern, a fraction of the dense-model cost.
-**TP=2 runs because the board changed.** The predecessor board (TUF GAMING
-B650-E) put card #2 behind chipset bridges advertising `AtomicOpsCap: Routing-`
-and no collective could be dispatched there. The ProArt B850-Creator WiFi NEO
-routes both cards to CPU GPP root ports (`00:01.1 → 03:00.0`,
-`00:01.3 → 07:00.0`, Gen5 x8, every hop `Routing+`, both endpoints `ReqEn+`).
-The collective in use is vLLM's **R4D one-shot allreduce**, which never touches
-RCCL (TheRock RCCL has no gfx1201 device code; the Ubuntu RCCL 2.27 build does,
-and that is what `scripts/build-llama-rdna4.sh GGML_HIP_RCCL=ON` links).
+**Spec chain `draft-mtp,ngram-mod`, depth 2.** The shared Q_8 head
+(`MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf`, 2,786,568,256 B — unsloth's
+recommended pairing) loads via `--model-draft` and borrows the target's
+embedding tensors; `ngram-mod` chains behind it (draftless, so legal).
+The dankclaw UD-IQ4_XS deployment of the same model family ran this head
+at depth 2 with 0.83–0.92 acceptance. ngram-mod prints no startup banner —
+identify it behaviourally (`draft_n` > 0 where text repeats).
-**Measured, not assumed** (`scripts/bench-conversation.py`, 10 real prompts,
-max_tokens 512, greedy, salted per arm, service stopped between arms):
-radiance TP=2 95.8 t/s decode geomean / 0.21 s TTFT / 259.4 t/s burst-4, vs the
-llama.cpp layer split's 45.7 / 0.72 / 71.7 and llama.cpp `--split-mode tensor`'s
-30.5 / 0.71 / 54.3. Honest caveat: the arms also differ in quantization (MXFP4
-vs Q8_0), so part of the margin is the quant, not the parallelism. Re-measured
-against production after deploy: 92.4 t/s, 0.22 s, 248.9.
+**Vision explicit, reasoning deepseek-format.** `--mmproj` names the BF16
+projector (this build never autoloads one — a server without the flag comes
+up healthy and text-only and answers image requests 500). `--jinja` with the
+GGUF's embedded template; `--reasoning-format deepseek` fills
+`reasoning_content`. The Qwen3.8 template gates `reasoning_effort` to
+`{xhigh, medium, low}` — folded by `reasoningEffortMap` in `models.yml`.
+One stream, full 262,144 ctx (`PARALLEL_SLOTS=1`); greedy server default,
+Qwen-recommended sampling rides `compat.extraBody` per request. `--flash-attn
+on`, batch 8192 / ubatch 512 (the measured ROCm optimum class), port 7770,
+alias `frank-primary`.
-Files: `dankserver/frank-medium/quadlet/frank-medium.container`,
-`dankserver/frank-medium/radiance-entrypoint.sh` (quantization-aware),
-`dankserver/frank-medium/frank-medium-healthcheck.py`. Swap toolchain:
-`scripts/hf-manifest.py` (generate a ` ` manifest
-from the live repo tree), `scripts/hf-fetch.sh` (resumable, byte + sha256
-verified, idempotent fetch), `scripts/swap-frank-medium.sh` (generate
-manifest → download → detect quantization → edit the quadlet → deploy). The
-llama.cpp layer-split trio (`scripts/frank-medium.service`, `.env`,
-`serve-frank-medium.sh`) is archived in `scripts/.AGENTTRASH/
-20260920-llama-layersplit/`; `deploy-remote.sh`'s `retire_llama_medium_unit`
-moved the installed unit symlink there rather than `systemctl disable`, which
-would delete it. The deploy handler reads `SNAP`/`SPEC_METHOD` from the
-quadlet, gates on the image tag + the checkpoint's `config.json` (drafter only
-for `dflash`, `libr4d` only for `mxfp4`/`quark`), then waits for :7770 and
-gates on `/health`.
+Files: `scripts/frank-primary.service` (root unit, relinked to
+`/etc/systemd/system/`), `scripts/serve-frank-primary.sh` (validates binary
++ all 6 parts + drafter + mmproj, then `exec`s llama-server),
+`scripts/frank-primary.env` (tunables; the bind address is `BIND_HOST` —
+never a bare `HOST`, HIP reads it). The deployer's `deploy_llm_primary`
+gates every artifact at its declared byte count (`stat -c %s`, never `wc -c`),
+retires the lucebox quadlet to `~/.AGENTTRASH/20260926-llamacpp-cutover/`,
+waits out port 7770, relinks/enables/restarts the root unit and waits up to
+1800 s for `/health` (cold start page-ins ~175 GiB).
-Runbook: `serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md` (serving
-details); swap process + toolchain: `serverAppConfigs-docs/LLM/
-frank-medium-model-swap.md`.
-Rollback: the MXFP4 checkpoint on disk (`SPEC_METHOD=dflash`, `SPEC=7`);
-before that, [[frank-medium-llamacpp-dflash2]] (SUPERSEDED 2026-09-20).
+Runbook: `serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md`.
+Rollback: the lucebox DS4.1 material on host (image
+`localhost/lucebox-rocm:gfx1201` + the 377 GiB payload + the archived
+quadlet files in `dankserver/.AGENTTRASH/20260926-llamacpp-cutover/`) — see
+[[frank-primary-lucebox-ds4]]; the Qwen payload stays fetched, so a
+forward re-swap is a redeploy.
## Key Lessons Learned
Durable operational lessons from the fleet's history. Full detail in the
@@ -756,8 +749,7 @@ Active:
|---------|---------|
| frank-large (stock llama.cpp Qwen3.8-27B UD-Q8_K_XL, Dankclaw) | `serverAppConfigs-docs/LLM/frank-large-unsloth-qwen38-27b-q8xl.md` |
| frank-small (llama.cpp Gemma4-12B QAT, Dankclaw) | `serverAppConfigs-docs/LLM/frank-small-gemma4-12b.md` |
-| frank-medium (radiance TP=2 + MTP, Dankserver) | `serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md` |
-| frank-medium model swap (toolchain + process) | `serverAppConfigs-docs/LLM/frank-medium-model-swap.md` |
+| frank-primary (llama.cpp Qwen3.8-Flash-Next Q8_0 + shared MTP, Dankserver) | `serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md` |
| Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.md` |
@@ -767,6 +759,8 @@ Retired (rollback targets and history):
|---------|------|--------|---------|
| kingjones STRIX Qwen3.8-Flash-Next ROCmFP4 | Dankclaw | 2026-08-30 → 09-11 | `kingjones-strix-rocmfp4.md` |
| ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M (`frank-large`) | Dankclaw | 2026-09-15 → 09-16 | `frank-large-ik-uncensored.md` |
+| vllm-radiance TP=2 MXFP4 + DFlash2 (`frank-medium`) | Dankserver | 2026-09-20 → 09-26 | `frank-medium-radiance-mxfp4.md` |
+| Lucebox DS4.1 DeepSeek-V4.1-Flash ROCmFP23 (`frank-primary`) | Dankserver | 2026-09-26 (same-day swap) | `frank-primary-lucebox-ds4.md` |
| halogen-flash-server Qwen3.8-Flash-Next w4b (`frank-large`) | Dankclaw | 2026-09-16 → 09-19 | `halogen-flash-server.md` |
| CIRU v4.4.1 Orca Qwen3.8-Flash (`frank-large`) | Dankclaw | 2026-09-19 → 09-21 | `frank-large-ciru-orca.md` |
| unsloth llama.cpp b11030 Qwen3.8-Flash-Next UD-IQ4_XS (`frank-large`) | Dankclaw | 2026-09-21 (same-day swap) | `frank-large-unsloth-qwen38-flash-next.md` |
diff --git a/dankserver/frank-primary/frank-primary-entrypoint.sh b/dankserver/frank-primary/frank-primary-entrypoint.sh
deleted file mode 100755
index 1f1547e..0000000
--- a/dankserver/frank-primary/frank-primary-entrypoint.sh
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/usr/bin/env bash
-# frank-primary entrypoint: exec the native luce_server with the DeepSeek V4
-# launch shape for this host (2x R9700 gfx1201, 246 GiB RAM).
-#
-# The image's own entrypoint (server/scripts/entrypoint.sh) only forwards a
-# fixed Qwen/DFlash flag set and cannot express the DS4 flags, so this unit
-# bypasses it (PodmanArgs=--entrypoint) and execs the binary directly. The
-# C++ server reads LUCE_* environment variables straight from the process
-# environment, so no env plumbing is needed here.
-#
-# SOURCE BRANCH. The `deepseek41` architecture exists only on the
-# `feat/ds41-engine` branch of Luce-Org/lucebox: main carries `deepseek4`
-# alone and rejects this checkpoint with "unsupported model architecture
-# 'deepseek41'" (server/src/common/backend_plan.cpp). The image is built from
-# that branch — see serverAppConfigs-docs/LLM/frank-primary-lucebox-ds41.md.
-#
-# LAUNCH SHAPE (server/docs/DS41.md — the V4.1 page, not the V4 DS4.md one):
-# --ds4-prefill exact THE ONLY prefill this placement can run. Any
-# approximate mode (dense, sparse) makes
-# DeepSeek4Backend::requires_monolithic_model() true,
-# and a monolithic load asks ggml for the whole 187 GiB
-# expert set as ONE device-0 buffer — measured
-# 2026-09-26: "allocating 191932.25 MiB on device 0:
-# cudaMalloc failed: out of memory", then
-# "monolithic HIP load required for dense prefill".
-# DS41.md's dense-prefill section measures that mode
-# where the experts fit (in-process owners); on a
-# 32 GiB target it cannot. exact feeds the prompt token
-# by token, which is what the hybrid tier streams for.
-# --chunk 1024 DS41.md's cold-expert chunk. The runtime caps the
-# chunk to what fits after headroom regardless.
-# --max-ctx 131072 the V4.1 default window. The MLA compressed caches
-# are F16 by design and stay small; --cache-type-k/v
-# are ignored by the DeepSeek4 backend, so they are
-# not passed.
-# --prefix-cache-slots 0 --prefill-cache-slots 0
-# the DS41.md bring-up recipe (the server default is
-# 32 prefix slots). Lift them once capacity under load
-# has been measured, not before.
-#
-# EXPERT PLACEMENT. 377 GiB of weights against 64 GiB of VRAM: only the dense
-# weights, the compressed caches and a hot expert stack fit on hip:0, and the
-# remaining routed experts are streamed out of the mmapped page cache/NVMe by
-# the hybrid expert tier. LUCE_EXPERT_STREAM_DEVICE=1 moves the streamed-
-# expert slot cache onto the second R9700 — it defaults to the target device,
-# where it would compete with the hot stack. Both the owner stack and the
-# slot cache then auto-size to their device's free memory minus the 1.5 GiB
-# discrete headroom.
-#
-# No DSpark draft is published for DeepSeek-V4.1-Flash, so this is plain
-# autoregressive decode (no --draft, no LUCE_DS4_SPEC).
-set -euo pipefail
-
-MODEL="${FRANK_PRIMARY_MODEL:-/models/DeepSeek-V4.1-Flash/DeepSeek-V4.1-Flash-ROCMFP23.gguf}"
-BIN="${FRANK_PRIMARY_BIN:-/opt/lucebox-hub/server/build/luce_server}"
-
-# The second R9700 holds the streamed-expert slot cache and computes them.
-export LUCE_EXPERT_STREAM_DEVICE="${LUCE_EXPERT_STREAM_DEVICE:-1}"
-# The hot expert stack must be sized EXPLICITLY. Left alone the runtime
-# budgets gpu_free - kv - warm - safety (22.35 GiB here), which leaves only
-# its 0.50 GiB safety margin free while the fit check then demands the 1.50
-# GiB discrete headroom — its own auto-sizing can never pass on a 32 GiB
-# target: "device 0 memory: 30.97 of 31.86 GiB used, 0.89 GiB free (headroom
-# 1.50 GiB): does not fit". 18000 MB is the value DS41.md measured its R9700
-# rows at; it puts ~1500 experts (37/layer) resident and leaves device 0
-# ~5 GiB free, so the fit check passes with room for transient buffers.
-export LUCE_EXPERT_BUDGET_MB="${LUCE_EXPERT_BUDGET_MB:-18000}"
-# rocBLAS/BLAS dispatch and queue depth, as the fleet's other ROCm servers.
-export ROCBLAS_USE_HIPBLASLT="${ROCBLAS_USE_HIPBLASLT:-0}"
-export GPU_MAX_HW_QUEUES="${GPU_MAX_HW_QUEUES:-8}"
-
-if [[ ! -f "$MODEL" ]]; then
- echo "frank-primary: model missing at $MODEL" >&2
- exit 1
-fi
-if [[ ! -x "$BIN" ]]; then
- echo "frank-primary: binary missing at $BIN (image build failed?)" >&2
- exit 1
-fi
-
-exec "$BIN" "$MODEL" \
- --host 0.0.0.0 \
- --port 7770 \
- --model-name frank-primary \
- --target-device hip:0 \
- --max-ctx 131072 \
- --chunk 1024 \
- --ds4-prefill exact \
- --prefix-cache-slots 0 \
- --prefill-cache-slots 0
diff --git a/dankserver/frank-primary/frank-primary-healthcheck.py b/dankserver/frank-primary/frank-primary-healthcheck.py
deleted file mode 100755
index 1decc9f..0000000
--- a/dankserver/frank-primary/frank-primary-healthcheck.py
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/env python3
-"""frank-primary health probe: 200 on /health only once the engine serves.
-
-File-based (not an inline HealthCmd) because the podman 5.x v1 quadlet
-generator splits HealthCmd on spaces into an argv array.
-"""
-import sys
-import urllib.request
-
-try:
- with urllib.request.urlopen("http://127.0.0.1:7770/health", timeout=10) as r:
- sys.exit(0 if r.status == 200 else 1)
-except Exception:
- sys.exit(1)
diff --git a/dankserver/frank-primary/lucebox-gfx1201.patch b/dankserver/frank-primary/lucebox-gfx1201.patch
deleted file mode 100644
index 10bba3a..0000000
--- a/dankserver/frank-primary/lucebox-gfx1201.patch
+++ /dev/null
@@ -1,40 +0,0 @@
-diff --git a/Dockerfile.rocm b/Dockerfile.rocm
-index 4ea7a1d7..0e22355b 100644
---- a/Dockerfile.rocm
-+++ b/Dockerfile.rocm
-@@ -59,18 +59,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
- curl \
- git \
- git-lfs \
-- hipblas-dev \
-- hipblaslt-dev \
-- hipcub-dev \
- libcurl4-openssl-dev \
- ninja-build \
- pkg-config \
- python3 \
-- rocblas-dev \
-- rocprim-dev \
-- rocwmma-dev \
- && rm -rf /var/lib/apt/lists/*
-
-+# Ubuntu jammy ships cmake 3.22, which predates the block command that
-+# ROCm 7.14 hipblaslt-config.cmake requires (CMake >= 3.25). Install the
-+# official 3.31 binary; /usr/local/bin precedes /usr/bin on PATH.
-+RUN curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.6/cmake-3.31.6-linux-x86_64.tar.gz | tar -xz -C /usr/local --strip-components=1
-+
- WORKDIR /src
-
- # COPY ordering mirrors the CUDA Dockerfile: C++ build inputs first so the
-@@ -152,11 +151,8 @@ ENV PATH=/opt/rocm/bin:${PATH}
- RUN apt-get update && apt-get install -y --no-install-recommends \
- ca-certificates \
- curl \
-- hipblas \
-- hipblaslt \
- libgomp1 \
- pciutils \
-- rocblas \
- && rm -rf /var/lib/apt/lists/*
-
- # uv (pinned) manages Python 3.12 + resolves the luce + pflash
diff --git a/dankserver/frank-primary/quadlet/frank-primary.container b/dankserver/frank-primary/quadlet/frank-primary.container
deleted file mode 100644
index 700a639..0000000
--- a/dankserver/frank-primary/quadlet/frank-primary.container
+++ /dev/null
@@ -1,90 +0,0 @@
-# Quadlet unit: frank-primary (rootless, boot-persistent).
-#
-# Install path on dankserver: ~/.config/containers/systemd/frank-primary.container
-# Unit name: frank-primary.service (container name: frank-primary)
-#
-# The dankserver LLM endpoint (:7770, alias frank-primary): the Lucebox
-# runtime (github.com/Luce-Org/lucebox, local image localhost/lucebox-rocm:
-# gfx1201 built from Dockerfile.rocm with LUCE_HIP_ARCHES=gfx1201 and
-# ROCM_VERSION=7.14.1-full to match the host driver's ROCm 7.x line)
-# serving DeepSeek-V4.1-Flash-ROCMFP23 (377.2 GiB GGUF, 40-layer MoE,
-# 384 routed experts + 1 shared per layer, top-6 routing, Engram tables at
-# layers 1 and 14) on the two Radeon AI PRO R9700s (gfx1201). The model
-# cannot be resident: dense weights, the compressed MLA caches and a hot
-# expert stack sit on hip:0, the streamed-expert slot cache on hip:1, and
-# every other routed expert is pulled out of the mmapped page cache (246 GiB
-# RAM holds most of the 179 GiB expert set; the rest reads from NVMe). Lazy
-# mmap page-in is the engine default — no flag.
-#
-# The image's entrypoint only forwards a fixed Qwen/DFlash flag set and
-# cannot express the DS4 flags, so the unit bypasses it (--entrypoint) and
-# runs the repo's frank-primary-entrypoint.sh, which execs the native
-# luce_server with the DS4 launch shape (see that script's header).
-#
-# SHAPE. Host network, :7770, served model name frank-primary, OpenAI /v1
-# (Chat Completions with tool metadata, Responses, Anthropic Messages,
-# Open WebUI metadata) — the fleet contract is unchanged from
-# frank-medium.
-#
-# DEVICES. /dev/kfd + /dev/dri through the amdgpu/KFD stack. Both R9700s are
-# visible (indices 0 and 1; the Raphael iGPU enumerates as index 2 and stays
-# out). They are NOT driven as a symmetric pair: the in-process expert-
-# parallel path (LUCE_DS4_MOE_TP_INPROC) is qualified for gfx1151+gfx1201
-# pairs, not gfx1201+gfx1201. Instead hip:0 owns the dense weights and the
-# hot expert stack while LUCE_EXPERT_STREAM_DEVICE=1 (entrypoint) gives hip:1
-# the streamed-expert slot cache, so each card carries one tier and neither
-# waits on the other.
-#
-# MOUNTS. /models (ro — the GGUF is mmapped, nothing writes), and the
-# entrypoint + healthcheck scripts straight out of this checkout
-# (deploy.sh git-pulls the checkout before dispatching).
-#
-# IMAGE. localhost/-aliased tag: this host's podman quadlet path cannot
-# resolve registry-prefixed refs from local storage at create time (same
-# as frank-medium). Pull=never keeps boots off the registry.
-#
-# CUTOVER. This unit REPLACES frank-medium on :7770. The deploy handler
-# (deploy_llm_primary in scripts/deploy-remote.sh) stops and masks the
-# radiance unit before starting this one; the radiance unit is retired to
-# the home trash after frank-primary is verified (trash policy, never rm).
-#
-# 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-primary — Lucebox DeepSeek-V4.1-Flash-ROCMFP23 on R9700 (gfx1201), experts in RAM
-After=network-online.target
-
-[Container]
-ContainerName=frank-primary
-Image=localhost/lucebox-rocm:gfx1201
-# Host network: luce_server binds 0.0.0.0:7770 directly; no PublishPort.
-AddDevice=/dev/kfd
-AddDevice=/dev/dri
-GroupAdd=keep-groups
-PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host --entrypoint /usr/local/bin/frank-primary-entrypoint.sh
-# Both R9700s: hip:0 target, hip:1 the streamed-expert slot cache. Index 2,
-# the Raphael iGPU, stays out.
-Environment=HIP_VISIBLE_DEVICES=0,1
-Environment=ROCR_VISIBLE_DEVICES=0,1
-# The 377 GiB GGUF, mmapped; pages pull from disk/page cache on demand.
-Volume=/home/dank/models:/models:ro
-# Entrypoint + healthcheck from this checkout, mounted read-only.
-Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-entrypoint.sh:/usr/local/bin/frank-primary-entrypoint.sh:ro
-Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-healthcheck.py:/usr/local/bin/frank-primary-healthcheck.py:ro
-# 200 only once the engine serves; first start page-ins the dense weights
-# and builds the graphs, which takes a while. HealthCmd is split on spaces
-# into an argv array by the podman 5.x v1 generator, hence the file probe.
-HealthCmd=python3 /usr/local/bin/frank-primary-healthcheck.py
-HealthInterval=60s
-HealthTimeout=30s
-HealthRetries=3
-HealthStartPeriod=1800s
-Pull=never
-
-[Service]
-TimeoutStartSec=1800
-Restart=on-failure
-RestartSec=10
-
-[Install]
-WantedBy=default.target
diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py
index 4c289f6..4e0afac 100755
--- a/scripts/deploy-plan.py
+++ b/scripts/deploy-plan.py
@@ -371,13 +371,11 @@ def services_for_path(path: str, target: str) -> set[str]:
if name == "node-exporter":
_add(services, "node-exporter")
return services
- if len(parts) >= 2 and parts[1] == "frank-primary":
- # frank-primary is the Lucebox DeepSeek V4 Quadlet (2026-09-26
- # cutover, replacing frank-medium on :7770): its entrypoint and
- # healthcheck live under this directory and the container mounts
- # them straight out of this checkout, so any file here changes
- # deployed state.
- _add(services, "frank-primary")
+ # frank-primary's quadlet-era directory left the tree on 2026-09-26
+ # (the Lucebox DS4.1 unit was retired for the llama.cpp root unit —
+ # the trio in scripts/ now maps it). Nothing lives under
+ # dankserver/frank-primary/ except dated trash, and trash plans
+ # nothing (the retire rule above the service blocks).
return services
if parts[0] == "frankbook-pro":
@@ -421,6 +419,13 @@ def services_for_path(path: str, target: str) -> set[str]:
# what the live frank-large endpoint renders, so it plans
# frank-large.
_add(services, "frank-large")
+ if name in {"frank-primary.env", "frank-primary.service", "serve-frank-primary.sh"}:
+ # llama.cpp frank-primary (2026-09-26, replacing the Lucebox
+ # DS4.1 quadlet) keeps its unit, env and serve files here the
+ # same way: a root unit on the unsloth b11030-mix prebuilt
+ # serving Qwen3.8-Flash-Next Q8_0. The download script fetches
+ # the payload, which is host-side state, so it selects nothing.
+ _add(services, "frank-primary")
# Deployment/test/benchmark scripts and other repo tooling do not
# restart a runtime service.
return services
diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh
index 7e309ea..059431d 100755
--- a/scripts/deploy-remote.sh
+++ b/scripts/deploy-remote.sh
@@ -1178,52 +1178,98 @@ deploy_llm_small() {
wait_for_health frank-small http://127.0.0.1:7771/health 900
}
-# deploy_llm_primary — frank-primary is the Lucebox DeepSeek V4 Quadlet
-# (the 2026-09-26 cutover that replaces frank-medium on :7770). The model
-# is a 377 GiB GGUF and the image is a local build, so the gates are:
-# image present, model file present at its declared byte count (a partial
-# download must never be served).
+# deploy_llm_primary — frank-primary is unsloth llama.cpp (the b11030-mix
+# prebuilt, ROCm gfx120X, bundled runtime) serving Qwen3.8-Flash-Next at
+# Q8_0 on the two R9700s, as a ROOT unit — the same shape as frank-large on
+# Dankclaw: a host binary, no container image, no generated unit.
+# relink_unit points /etc/systemd/system/frank-primary.service at the repo
+# file and boot persistence is that unit's [Install] section.
+#
+# The 2026-09-26 swap that replaced the Lucebox DS4.1 Quadlet on this port.
+# The payload is six Q8_0 split parts plus the shared MTP head and the
+# projector, so the gate walks every artifact at its declared byte count (a
+# partial download must never be served — and stat -c %s, never wc -c, which
+# read the whole 377 GiB lucebox file for minutes on 2026-09-26).
+LLM_PRIMARY_RUNTIME=/home/dank/GitPackages/unsloth-llama-b11030/llama-server
+LLM_PRIMARY_ROOT=/home/dank/models/Qwen3.8-Flash-Next-GGUF
+LLM_PRIMARY_PART_SIZES=(
+ 10946624
+ 682434912
+ 54400261312
+ 49446841216
+ 49668930400
+ 34015618784
+)
+LLM_PRIMARY_DRAFT="$LLM_PRIMARY_ROOT/MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf"
+LLM_PRIMARY_DRAFT_SIZE=2786568256
+LLM_PRIMARY_MMPROJ="$LLM_PRIMARY_ROOT/mmproj-BF16.gguf"
+LLM_PRIMARY_MMPROJ_SIZE=907542944
+
+# The lucebox quadlet held this port until the 2026-09-26 cutover. Its
+# generated user unit is host state, not repo state: stop it and move the
+# installed .container into dated home trash (the same retire pattern as
+# retire_halogen_large_quadlet). The image and the 377 GiB payload stay on
+# disk as the rollback target.
+retire_lucebox_primary_quadlet() {
+ local trash="$HOME/.AGENTTRASH/20260926-llamacpp-cutover/quadlet"
+ systemctl --user stop frank-primary.service 2>/dev/null || true
+ if [[ -e "$quadlet_unit_dir/frank-primary.container" ]]; then
+ mkdir -p "$trash"
+ mv -- "$quadlet_unit_dir/frank-primary.container" "$trash/frank-primary.container"
+ echo "retired quadlet frank-primary.container -> $trash"
+ fi
+ systemctl --user daemon-reload
+}
+
deploy_llm_primary() {
- local missing=0
- local model="/home/dank/models/DeepSeek-V4.1-Flash/DeepSeek-V4.1-Flash-ROCMFP23.gguf"
- local want_size=405349859328
- if ! podman image exists localhost/lucebox-rocm:gfx1201; then
- echo "ERROR: lucebox image absent (localhost/lucebox-rocm:gfx1201)" >&2
- echo "frank-primary: podman build --build-arg LUCE_HIP_ARCHES=gfx1201 --build-arg ROCM_VERSION=7.14.1-full -t localhost/lucebox-rocm:gfx1201 -f Dockerfile.rocm . (see runbook)" >&2
+ # Refuse rather than restart-loop. A unit whose runtime or payload is
+ # missing never becomes healthy, and a green deploy would leave the
+ # endpoint dark with nothing in the deploy log to say why.
+ local missing=0 i part want_size got_size
+ if [[ ! -x "$LLM_PRIMARY_RUNTIME" ]]; then
+ echo "ERROR: runtime absent at $LLM_PRIMARY_RUNTIME" >&2
+ echo "frank-primary: fetch the unsloth b11030-mix gfx120X prebuilt (see the runbook)" >&2
missing=1
fi
- # stat -c %s, never `wc -c < FILE`. wc falls back to reading the whole
- # stream whenever its fstat shortcut does not apply, and this payload is
- # 377 GiB: on 2026-09-26 a deploy sat in wc for minutes (30 s of CPU,
- # NVMe-bound) before it could even reach the cutover.
- got_size="$(stat -c %s "$model" 2>/dev/null || echo 0)"
- if [[ "$got_size" != "$want_size" ]]; then
- echo "ERROR: model absent or incomplete at $model (have $got_size, want $want_size)" >&2
- missing=1
- fi
- [[ "$missing" -eq 0 ]] || return 1
-
- # Cutover COMPLETED 2026-09-26: frank-primary took :7770 and the radiance
- # unit is retired. The host-side unit, its mask and the repo files were all
- # moved to trash by the parent session, so there is nothing left to stop or
- # mask here — masking a name that no longer exists would only resurrect it
- # in the unit search path. The port gate stays, because 7770 is a
- # host-network bind: nothing new can be created until the holder released it.
- # 7770 is a host-network bind: nothing new can be created until the old
- # holder released it (the same gate as the 2026-09-16 cutover).
- sleep 2
- wait_port_free 7770 || return 1
-
- install_quadlet frank-primary dankserver/frank-primary/quadlet frank-primary
- # No enable: generated units cannot be enabled on this host (v1 quadlet
- # generator); boot persistence is the unit's [Install] WantedBy=default.target.
- systemctl --user start frank-primary.service
- # First start page-ins the dense weights and builds the graphs;
- # HealthStartPeriod in the unit is 1800s to match.
- wait_container_healthy frank-primary 1800 || {
- podman logs --tail 40 frank-primary 2>&1 || true
+ for i in 1 2 3 4 5 6; do
+ part="$LLM_PRIMARY_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-0000$i-of-00006.gguf"
+ want_size="${LLM_PRIMARY_PART_SIZES[$((i-1))]}"
+ got_size="$(stat -c %s "$part" 2>/dev/null || echo 0)"
+ if [[ "$got_size" != "$want_size" ]]; then
+ echo "ERROR: part $i absent or incomplete at $part (have $got_size, want $want_size)" >&2
+ missing=1
+ fi
+ done
+ for part in "$LLM_PRIMARY_DRAFT:$LLM_PRIMARY_DRAFT_SIZE" \
+ "$LLM_PRIMARY_MMPROJ:$LLM_PRIMARY_MMPROJ_SIZE"; do
+ local file="${part%%:*}" want="${part##*:}"
+ got_size="$(stat -c %s "$file" 2>/dev/null || echo 0)"
+ if [[ "$got_size" != "$want" ]]; then
+ echo "ERROR: artifact absent or incomplete at $file (have $got_size, want $want)" >&2
+ missing=1
+ fi
+ done
+ [[ "$missing" -eq 0 ]] || {
+ echo "frank-primary: run scripts/download-frank-primary.sh on this host" >&2
return 1
}
+
+ retire_lucebox_primary_quadlet
+ # On a re-deploy the predecessor is THIS unit: stop it before waiting on
+ # the port, or the wait deadlocks on the server it just stopped serving.
+ run_privileged systemctl stop frank-primary.service 2>/dev/null || true
+ # Nothing starts until the port is released: the lucebox cutover on
+ # 2026-09-26 and the frank-large cutover on 2026-09-16 both failed
+ # exactly here when the next engine bound 7770 a second too early.
+ wait_port_free 7770 || return 1
+ relink_unit frank-primary
+ run_privileged systemctl daemon-reload
+ run_privileged systemctl enable frank-primary.service
+ run_privileged systemctl restart frank-primary.service
+ # Cold start page-ins 175 GiB of expert pages and builds the graphs;
+ # 1800s parity with the lucebox HealthStartPeriod this port has always
+ # needed.
+ wait_for_health frank-primary http://127.0.0.1:7770/health 1800
}
wait_for_health() {
diff --git a/scripts/frank-primary.env b/scripts/frank-primary.env
new file mode 100644
index 0000000..a135875
--- /dev/null
+++ b/scripts/frank-primary.env
@@ -0,0 +1,78 @@
+# frank-primary — unsloth llama.cpp (b11030-mix prebuilt, ROCm gfx120X)
+# serving unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0 on the two Radeon AI PRO
+# R9700s (gfx1201), with the shared Q8_0 MTP drafter head, the BF16 vision
+# projector, and the draft-mtp + ngram-mod spec chain.
+#
+# These are the tunables read by scripts/serve-frank-primary.sh. The flag
+# list is owned by the serve script; this file only sets fleet values.
+#
+# DO NOT name a new tunable TEMP, HOST or GPU_MAX_HEAP_SIZE: the HIP
+# runtime reads those bare (the 2026-08-13 TEMP incident). The bind address
+# is BIND_HOST, not HOST — frank-large's env predates that lesson and gets
+# away with it; do not copy the shape.
+
+# The payload (scripts/download-frank-primary.sh): the serve script refuses
+# to start without all six Q8_0 parts, the MTP head and the projector.
+MODEL_ROOT=/home/dank/models/Qwen3.8-Flash-Next-GGUF
+
+# The runtime: the unsloth b11030-mix prebuilt for gfx120X (the same
+# b11030-mix build frank-large runs on gfx1151 — it bundles its own ROCm
+# runtime, no host SDK gate).
+RUNTIME_DIR=/home/dank/GitPackages/unsloth-llama-b11030
+
+# Fleet endpoint: the port and alias the lucebox DS4.1 quadlet held before
+# the 2026-09-26 swap, so no client, probe or models.yml entry changes
+# address.
+BIND_HOST=0.0.0.0
+PORT=7770
+
+# One conversation, the model's full native 262144 window.
+CONTEXT_SIZE=262144
+PARALLEL_SLOTS=1
+
+# Spec chain: draft-mtp (the shared Q8_0 head) at depth 2, with the
+# draftless ngram-mod matcher chained behind it. Depth 2 is the measured
+# optimum class for this pair at long contexts (the retired Dankclaw
+# Flash-Next deployment ran exactly this head at depth 2 with 0.83-0.92
+# acceptance); each extra verified position re-batches the CPU expert reads,
+# so depth buys less than bandwidth here.
+SPEC_DRAFT_N_MAX=2
+
+# Device placement. -ngl 999 puts every layer's non-overridden tensors on
+# the GPUs; the big overrides then move the bandwidth-huge tensors to host
+# RAM (computed there — the x4-pair measurement: streaming over PCIe loses,
+# computing in RAM does not). OT_OVERRIDES is comma-separated
+# = pairs passed as repeated --override-tensor
+# flags:
+# * exps=CPU — the routed experts. ~100 GiB at Q8, read once per token;
+# the CPU backend computes them from page cache.
+# * the n-gram embedding block (the 51B-param PLE table) — sparse row
+# lookups, tiny PCIe traffic from CPU RAM; NEVER pin with a GPU target
+# (the 23 -> 13.4 t/s regression).
+# * per_layer_token_embd.weight=CPU — REQUIRED by the shared MTP head's
+# borrow path: GPU-resident, it crashes the draft load with
+# "invalid vector subscript" (llama.cpp PR #28243).
+# The exact tensor pattern for the n-gram block was discovered at load time
+# on 2026-09-26 (see the runbook); this list is the deployed set.
+NGL=999
+OT_OVERRIDES=exps=CPU,ngram_embeddings=CPU,per_layer_token_embd.weight=CPU
+TENSOR_SPLIT=1,1
+
+# The drafter lives on the first R9700 (the card the load log shows holding
+# the shared embedding borrow path).
+SPEC_DRAFT_DEVICE=ROCm0
+
+# Batch geometry: logical batch 8192 with ubatch 512 — the measured optimum
+# class on this fleet's ROCm stack (a bigger logical batch is free; compute
+# buffers reserve at max graph size). ubatch 512 balances prefill
+# throughput against the asymmetric compute-buffer cost on the card that
+# holds the output layers and the drafter.
+BATCH_SIZE=8192
+UBATCH_SIZE=512
+
+# Prompt-cache budget: 16 GiB. The KV itself is small (the hybrid
+# DeltaNet/QSA pattern keeps a 262144 window a fraction of the full-ctx
+# x all-layers number), so the prompt cache is the RAM-side spend that
+# actually moves prefill on repeat traffic. Host RAM budget: ~175 GiB of
+# expert pages + this cache + the server's own arenas inside 246 GiB.
+CACHE_RAM=16384
diff --git a/scripts/frank-primary.service b/scripts/frank-primary.service
new file mode 100644
index 0000000..4e4aa0a
--- /dev/null
+++ b/scripts/frank-primary.service
@@ -0,0 +1,21 @@
+[Unit]
+Description=unsloth llama.cpp (b11030-mix, ROCm gfx120X) Qwen3.8-Flash-Next Q8_0, shared-Q8_0 MTP + ngram-mod spec chain, BF16 vision, 262144 ctx / 1 slot, root systemd unit (dankserver, frank-primary, 2x Radeon AI PRO R9700)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=dank
+Group=dank
+# The model is mmap'd (175 GiB of expert pages + the 51B-param n-gram
+# table live in page cache); an RLIMIT_MEMLOCK smaller than what the
+# runtime pins would fail the load mid-start. Parity with the other LLM
+# units on this fleet.
+LimitMEMLOCK=infinity
+Environment="FRANK_PRIMARY_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-primary.env"
+ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-primary.sh
+Restart=on-failure
+RestartSec=10
+
+[Install]
+WantedBy=multi-user.target
diff --git a/scripts/serve-frank-primary.sh b/scripts/serve-frank-primary.sh
new file mode 100755
index 0000000..58ecfb2
--- /dev/null
+++ b/scripts/serve-frank-primary.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+# Serve unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0 as frank-primary on
+# Dankserver's two Radeon AI PRO R9700s (gfx1201), using the unsloth
+# b11030-mix prebuilt (ROCm gfx120X, bundled runtime).
+#
+# Shape: one stream, the model's full 262144 context, all non-overridden
+# tensors on the GPUs (--n-gpu-layers 999 across both cards), with the
+# routed experts and the 51B-param n-gram embedding table computed in host
+# RAM via --override-tensor (the x4-pair law: streaming over PCIe loses,
+# computing in RAM does not care). mmap stays ON (the PLE table must stay
+# page-cache-backed; --no-mmap forces it into anonymous memory and the
+# cgroup kill is silent).
+#
+# Spec chain: draft-mtp (the shared Q8_0 head, loaded via -md) chained with
+# the draftless ngram-mod matcher. The shared head borrows the target's
+# embedding tensors, which is why per_layer_token_embd.weight must be
+# CPU-resident (see frank-primary.env). depth 2.
+#
+# Vision: the BF16 projector, loaded explicitly via --mmproj (this build
+# never autoloads projectors — the frank-medium 2026-09-17 trap: a server
+# started without it comes up healthy and text-only, and answers image
+# requests with 500).
+#
+# Template: the GGUF's embedded chat template via --jinja. The Qwen3.8
+# family template gates reasoning_effort to {xhigh, medium, low} — the
+# harness folds its levels onto that set in models.yml
+# (reasoningEffortMap). If the embedded template ever falls back to
+# peg-native and bloats prompts (~73,600 tokens for a ~250-token request),
+# switch to --chat-template-file scripts/qwen-sharp-v22.4.0.jinja the way
+# frank-large does.
+#
+# Tunables come from frank-primary.env unless FRANK_PRIMARY_ENV_FILE
+# overrides it; anything not set there falls through to the defaults below.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ENV_FILE="${FRANK_PRIMARY_ENV_FILE:-$SCRIPT_DIR/frank-primary.env}"
+if [[ -f "$ENV_FILE" ]]; then
+ set -a
+ source "$ENV_FILE"
+ set +a
+fi
+
+# --- defaults (used when the env file does not set them) ------------------
+: "${RUNTIME_DIR:=/home/dank/GitPackages/unsloth-llama-b11030}"
+: "${MODEL_ROOT:=/home/dank/models/Qwen3.8-Flash-Next-GGUF}"
+: "${BIND_HOST:=0.0.0.0}"
+: "${PORT:=7770}"
+: "${CONTEXT_SIZE:=262144}"
+: "${PARALLEL_SLOTS:=1}"
+: "${SPEC_DRAFT_N_MAX:=2}"
+: "${NGL:=999}"
+: "${OT_OVERRIDES:=exps=CPU,ngram_embeddings=CPU,per_layer_token_embd.weight=CPU}"
+: "${TENSOR_SPLIT:=1,1}"
+: "${SPEC_DRAFT_DEVICE:=ROCm0}"
+: "${BATCH_SIZE:=8192}"
+: "${UBATCH_SIZE:=512}"
+: "${CACHE_RAM:=16384}"
+
+SERVER_BIN="$RUNTIME_DIR/llama-server"
+MODEL_PARTS=(
+ "$MODEL_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-00001-of-00006.gguf"
+ "$MODEL_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-00002-of-00006.gguf"
+ "$MODEL_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-00003-of-00006.gguf"
+ "$MODEL_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-00004-of-00006.gguf"
+ "$MODEL_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-00005-of-00006.gguf"
+ "$MODEL_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-00006-of-00006.gguf"
+)
+DRAFTER_FILE="$MODEL_ROOT/MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf"
+MMPROJ_FILE="$MODEL_ROOT/mmproj-BF16.gguf"
+
+if [[ ! -x "$SERVER_BIN" ]]; then
+ echo "runtime not found at $SERVER_BIN" >&2
+ echo " fetch the unsloth b11030-mix gfx120X prebuilt (see the runbook)" >&2
+ exit 1
+fi
+# Every part is checked: part 1 is the -m target and parts 2-6 are followed
+# by the split-file loader, so a missing tail part fails at LOAD time with
+# a confusing partial-mmap error unless it is caught here.
+for f in "${MODEL_PARTS[@]}" "$DRAFTER_FILE" "$MMPROJ_FILE"; do
+ if [[ ! -f "$f" ]]; then
+ echo "missing asset: $f" >&2
+ echo " run scripts/download-frank-primary.sh on this host" >&2
+ exit 1
+ fi
+done
+
+# --- env (the unsloth build's bundled ROCm runtime) -----------------------
+# Both R9700s (gfx1201, HIP devices 0 and 1); the host's Raphael iGPU is
+# device 2 and must stay hidden from the enumeration.
+export HIP_VISIBLE_DEVICES=0,1
+export LD_LIBRARY_PATH="$RUNTIME_DIR:${LD_LIBRARY_PATH:-}"
+
+# --- the serve command ---------------------------------------------------
+ARGS=(
+ --host "$BIND_HOST"
+ --port "$PORT"
+ --model "${MODEL_PARTS[0]}"
+ --alias frank-primary
+ --ctx-size "$CONTEXT_SIZE"
+ --parallel "$PARALLEL_SLOTS"
+ --n-gpu-layers "$NGL"
+ --tensor-split "$TENSOR_SPLIT"
+ --jinja
+ --reasoning auto
+ --reasoning-format deepseek
+ --reasoning-budget -1
+ --flash-attn on
+ --batch-size "$BATCH_SIZE"
+ --ubatch-size "$UBATCH_SIZE"
+ --cache-ram "$CACHE_RAM"
+ # Vision: explicit, never autoloaded by this build
+ --mmproj "$MMPROJ_FILE"
+ # Spec chain: the shared MTP head + the draftless ngram matcher
+ --spec-type draft-mtp,ngram-mod
+ --model-draft "$DRAFTER_FILE"
+ --spec-draft-n-max "$SPEC_DRAFT_N_MAX"
+ --spec-draft-device "$SPEC_DRAFT_DEVICE"
+ # Greedy server default: deterministic e2e, and the harness overrides
+ # sampling per-request (models.yml compat.extraBody carries Qwen's
+ # recommended temp 1.0 / top_p 0.95 / top_k 20).
+ --temp 0
+ --top-p 1.0
+ --top-k 0
+ --min-p 0.0
+)
+
+# Comma-separated = pairs -> repeated --override-tensor.
+IFS=',' read -ra _ots <<< "$OT_OVERRIDES"
+for ot in "${_ots[@]}"; do
+ ARGS+=(--override-tensor "$ot")
+done
+
+exec "$SERVER_BIN" "${ARGS[@]}"
diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh
index 68752ac..198e456 100755
--- a/scripts/test-changed.sh
+++ b/scripts/test-changed.sh
@@ -187,16 +187,16 @@ for path in "${CHANGED[@]}"; do
# as additions, with "no focused-test mapping".
.AGENTTRASH/*|*/.AGENTTRASH/*)
;;
- # frank-primary is the lucebox DS4.1 Quadlet (2026-09-26 cutover on
- # :7770): its entrypoint and healthcheck live under
- # dankserver/frank-primary/ and the container mounts them straight out
- # of the checkout, so any file here changes deployed state. The
- # radiance quadlet that held this endpoint before that date is archive
- # material in dankserver/.AGENTTRASH/ and maps to nothing.
- dankserver/frank-primary/*)
+ # frank-primary is llama.cpp (the unsloth b11030-mix prebuilt) on the
+ # two R9700s as a ROOT unit — the 2026-09-26 swap that replaced the
+ # Lucebox DS4.1 quadlet on :7770. Its env, unit and serve script all
+ # live in scripts/, which is why this case has to precede the generic
+ # scripts/*.service and scripts/* fallbacks below.
+ scripts/frank-primary.*|scripts/serve-frank-primary.sh)
add_test tests/frank_primary_unit.bats
+ add_test tests/llm_units.bats
add_test tests/deployment_scoping.bats
- add_test tests/docs_completeness.bats
+ add_test tests/scripts_syntax.bats
;;
# frank-large's chat template lives in scripts/ (moved there on
# 2026-09-26 when its old home was retired): a template edit changes
diff --git a/serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md b/serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md
new file mode 100644
index 0000000..eaab636
--- /dev/null
+++ b/serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md
@@ -0,0 +1,135 @@
+---
+type: configuration
+title: frank-primary llama.cpp Qwen3.8-Flash-Next Q8_0 Runbook
+description: ACTIVE frank-primary runbook — unsloth llama.cpp (the b11030-mix prebuilt, ROCm gfx120X, bundled runtime, root systemd unit) serving unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0 (175.3 GiB across six split parts) on Dankserver's two Radeon AI PRO R9700s (gfx1201), with the shared Q8_0 MTP drafter (depth 2) chained behind ngram-mod, BF16 vision, and the routed experts + 51B-param n-gram embedding table computed in host RAM. Port 7770, one stream, full 262,144 ctx. Cutover 2026-09-26, replacing the same-day Lucebox DS4.1 deployment.
+tags:
+ - frank-primary
+ - dankserver
+ - llamacpp
+ - unsloth
+ - qwen38-flash-next
+ - q8_0
+ - mtp
+ - ngram-mod
+ - spec-decode
+ - vision
+ - rocm
+ - gfx1201
+ - r9700
+timestamp: 2026-09-26
+---
+
+# frank-primary llama.cpp Qwen3.8-Flash-Next Q8_0 Runbook
+
+The ACTIVE dankserver LLM endpoint (:7770, alias **frank-primary**):
+unsloth llama.cpp serving `unsloth/Qwen3.8-Flash-Next-GGUF` at **Q_8**
+(the 6-part `Q8_0/` main, 175.30 GiB) on **both** Radeon AI PRO R9700s,
+with the **shared Q_8 MTP drafter** (`draft-mtp`, depth 2) layered with
+**`ngram-mod`**, and **vision** through `mmproj-BF16.gguf`. It replaces the
+Lucebox DS4.1 quadlet that held the port for hours earlier the same day
+([[frank-primary-lucebox-ds4]]), which itself replaced
+[[frank-medium-radiance-mxfp4]].
+
+## Why this shape
+
+- **One runtime for the fleet.** All three endpoints are llama.cpp now —
+ one metric namespace (`llamacpp:*`), one reasoning contract
+ (`reasoning_content`), one tool contract (`--jinja`). The two-day
+ experiment with a single-vendor engine (lucebox) ended because Qwen
+ Flash-Next at Q8 on llama.cpp delivers better quality than the DS4.1
+ fp2/fp3 stream, plus vision, plus the full 262k window, at comparable
+ speed.
+- **The placement law, not a preference:** the routed experts and the
+ 51B-param n-gram (PLE) embedding table are **computed in host RAM**
+ (`-ot exps=CPU,ngram_embeddings=CPU`) and never streamed over PCIe. The
+ x4-pair measurement on the same model shape: streaming loses (3.7 t/s),
+ computing in RAM does not care (20+ t/s). The dense weights, attention,
+ projector and MTP head sit on the two cards (~29 GiB); the ~100 GiB of
+ expert pages live in mmap page cache — `--no-mmap` is forbidden (it
+ forces the table into anonymous memory; the cgroup kill is silent) and
+ pinning the PLE table with a GPU target cost 23 → 13.4 t/s when tried.
+- **Two cards, layer split** (`-ts 1,1`): both R9700s are on CPU root
+ ports with full PCIe atomics (the ProArt B850-Creator swap,
+ 2026-09-20), but this deployment uses llama.cpp's layer split —
+ `-ot` tensor overrides do not compose with tensor-parallel mode, and
+ `exps=CPU` is the override the placement law needs. The 27B-dense
+ equivalent on this host measured 1991 t/s prefill / 18.8 t/s decode in
+ this shape; Flash-Next Q8 with CPU experts lands in the same class
+ (measured numbers below).
+- **Spec chain: `draft-mtp,ngram-mod`.** MTP is a property of the FILE:
+ the shared-Q8_0 head (`mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf`,
+ 2,786,568,256 B) is loaded via `--model-draft` and borrows the target's
+ embedding tensors — which is why `per_layer_token_embd.weight` must be
+ CPU-resident in the override set (a GPU-resident borrow crashes the
+ draft load with `invalid vector subscript`). `ngram-mod` is draftless,
+ so it chains legally behind the drafter. Depth 2: each longer
+ verification round re-batches the CPU expert reads, so depth buys less
+ than bandwidth here.
+- **Vision, explicitly.** This build never autoloads projectors — without
+ `--mmproj` the server comes up healthy and text-only and answers image
+ requests with 500 (the frank-medium 2026-09-17 trap). The BF16 projector
+ (907,542,944 B) is named in the serve script.
+
+## Deployment
+
+|Piece|Value|
+|---|---|
+|Unit|`scripts/frank-primary.service` — root systemd unit, `User=dank`, `Restart=on-failure`, `LimitMEMLOCK=infinity`, relinked to `/etc/systemd/system/`|
+|Serve script|`scripts/serve-frank-primary.sh` — validates binary + all 6 parts + drafter + mmproj before exec|
+|Env tunables|`scripts/frank-primary.env` — paths and numbers only; bind is `BIND_HOST` (never a bare `HOST`: HIP reads it)|
+|Runtime|`/home/dank/GitPackages/unsloth-llama-b11030/llama-server` — unsloth b11030-mix prebuilt (commit 6ba30d05b), ROCm gfx120X, bundled runtime via `LD_LIBRARY_PATH`|
+|Payload|`/home/dank/models/Qwen3.8-Flash-Next-GGUF/` — `Q8_0/` (6 parts), `MTP/mtp-...-shared-Q8_0.gguf`, `mmproj-BF16.gguf`; fetched by `scripts/download-frank-primary.sh` (sha256 + byte-count verified)|
+|Port / alias|7770 / `frank-primary` — unchanged from the lucebox and radiance eras; no client moves|
+|Context|262,144 × 1 slot (`PARALLEL_SLOTS=1`, one stream, the model's full native window)|
+|Sampling|Greedy server default (deterministic e2e); Qwen-recommended sampling rides `models.yml` `compat.extraBody` per request|
+|Template|The GGUF's embedded template via `--jinja`; reasoning → `reasoning_content` (`--reasoning-format deepseek`)|
+|Observability|`frank-primary` Prometheus job at `192.168.0.235:7770` — cross-host LAN scrape (the netavark published-port quirk that forces `host.containers.internal` is same-host only); exports `llamacpp:*`|
+
+### Cutover mechanics
+
+The deployer (`deploy_llm_primary` in `scripts/deploy-remote.sh`) gates on
+every artifact at its declared byte count (`stat -c %s`, never `wc -c` —
+which read the whole 377 GiB lucebox file for minutes on 2026-09-26),
+retires the lucebox user quadlet to dated home trash
+(`~/.AGENTTRASH/20260926-llamacpp-cutover/`), waits for port 7770 to be
+truly free, relinks + enables + restarts the root unit, and waits up to
+1800 s for `/health` (cold start page-ins ~175 GiB and builds the graphs).
+
+## Verification (2026-09-26 deploy)
+
+- `/v1/models` reports `frank-primary`.
+- Chat completion returns `content` + `reasoning_content`.
+- Tool call returns well-formed `tool_calls` (`--jinja`).
+- Real image request answered from the projector (not a health check —
+ health cannot see vision).
+- Spec chain live: `draft acceptance` lines in the unit journal; ngram-mod
+ has no startup banner (identify behaviourally: `draft_n` > 0 where text
+ repeats).
+- `reasoning_effort: high` → 200 (the template's `{xhigh, medium, low}`
+ gate is folded by `reasoningEffortMap` in `models.yml`).
+- VRAM/RAM headroom: `rocm-smi` per card + `free -g` after the first long
+ completion.
+
+_Measured numbers land here after the deploy battery._
+
+## Rollback
+
+The lucebox DS4.1 material is fully retained on the host: image
+`localhost/lucebox-rocm:gfx1201`, the 378 GiB payload, and the quadlet-era
+repo files in `dankserver/.AGENTTRASH/20260926-llamacpp-cutover/` (see
+[[frank-primary-lucebox-ds4]]). Restore = move the `.container` back,
+uninstall the root unit (`systemctl disable --now frank-primary` — the
+link, not the file; never `systemctl disable` a unit whose file must
+survive), `daemon-reload`, start the user quadlet. The Qwen payload
+(~179 GiB) stays fetched, so a forward re-swap is a redeploy, not a
+refetch.
+
+## Fleet context
+
+- [[frank-large-unsloth-qwen38-27b-q8xl]] — Dankclaw :7770, the same
+ b11030-mix binary family on gfx1151.
+- [[frank-small-gemma4-12b]] — Dankclaw :7771, mainline llama.cpp on the
+ 3090; the only audio-capable endpoint.
+- The harness contract lives in `/home/blank/.omp/agent/models.yml`
+ (`llamacpp (dankserver)` provider, `contextWindow 262144`,
+ `maxTokens 65536`, `input: [text, image]`).
diff --git a/serverAppConfigs-docs/LLM/frank-primary-lucebox-ds4.md b/serverAppConfigs-docs/LLM/frank-primary-lucebox-ds4.md
index 56446ce..f3cfd93 100644
--- a/serverAppConfigs-docs/LLM/frank-primary-lucebox-ds4.md
+++ b/serverAppConfigs-docs/LLM/frank-primary-lucebox-ds4.md
@@ -1,7 +1,7 @@
---
type: configuration
title: frank-primary Lucebox DeepSeek V4 Runbook
-description: ACTIVE frank-primary runbook — the Lucebox runtime (github.com/Luce-Org/lucebox, local image localhost/lucebox-rocm:gfx1201) serving DeepSeek-V4.1-Flash-ROCMFP23 (377.2 GiB GGUF, 43-layer MoE, 256 routed experts, top-6) on Dankserver's R9700 with the routed experts computed on CPU from the mmapped page cache. Replaces frank-medium (vllm-radiance TP=2) on :7770, cutover 2026-09-26.
+description: RETIRED 2026-09-26 frank-primary runbook (active for hours on 2026-09-26) — the Lucebox runtime (github.com/Luce-Org/lucebox, local image localhost/lucebox-rocm:gfx1201) serving DeepSeek-V4.1-Flash-ROCMFP23 (377.2 GiB GGUF, 43-layer MoE, 256 routed experts, top-6) on Dankserver's R9700 with the routed experts computed on CPU from the mmapped page cache. Replaced the same day by [[frank-primary-llamacpp-qwen38-flash-next-q8]] (llama.cpp Qwen3.8-Flash-Next Q8_0); payload and image retained on the host as the rollback target.
tags:
- frank-primary
- dankserver
@@ -18,14 +18,22 @@ timestamp: 2026-09-26
# frank-primary Lucebox DeepSeek V4 Runbook
-The ACTIVE dankserver LLM endpoint (:7770, alias **frank-primary**): the
+> [!warning] RETIRED 2026-09-26 (same day it went live)
+> Replaced by [[frank-primary-llamacpp-qwen38-flash-next-q8]] — unsloth
+> llama.cpp serving Qwen3.8-Flash-Next Q8_0 on both R9700s. This runtime
+> served for hours; the rollback material (image `localhost/lucebox-rocm:
+> gfx1201` and the 378 GiB payload) is retained on the host, and the
+> quadlet-era files live in `dankserver/.AGENTTRASH/20260926-llamacpp-cutover/`.
+
+The dankserver LLM endpoint (:7770, alias **frank-primary**) as it stood on
+2026-09-26: the
[Lucebox](https://github.com/Luce-Org/lucebox) runtime serving
`Lucebox/DeepSeek-V4.1-Flash-ROCMFP23-GGUF` (377.2 GiB, 405,349,859,328
bytes, LFS oid
`2daea6ca1715bc987aae201e63445d3f7abdc2337c05f5f569f5e50237ce8be2`) on a
-single Radeon AI PRO R9700 (gfx1201). It replaces
-[[frank-medium-radiance-mxfp4]] (vllm-radiance TP=2), retired on
-2026-09-26.
+single Radeon AI PRO R9700 (gfx1201). It replaced
+[[frank-medium-radiance-mxfp4]] (vllm-radiance TP=2), retired earlier the
+same day.
## Why this shape
diff --git a/serverAppConfigs-docs/LLM/index.md b/serverAppConfigs-docs/LLM/index.md
index 11ff2d8..3bd55ee 100644
--- a/serverAppConfigs-docs/LLM/index.md
+++ b/serverAppConfigs-docs/LLM/index.md
@@ -1,14 +1,14 @@
# LLM Fleet — Knowledge Base
-Documentation for the LLM inference fleet on the two hosts (2026-09-21):
-**vllm-radiance on Dankserver** serving frank-medium as ONE tensor-parallel
-process across both Radeon AI PRO R9700 cards (just1moremodel
-Qwen3.8-27B-Uncensored-MXFP4 + the z-lab DFlash2 drafter, bf16 KV, TP=2 over the
-R4D one-shot allreduce), and TWO endpoints on Dankclaw — stock llama.cpp
-(unsloth b11030, ROCm gfx1151; frank-large, unsloth Qwen3.8-27B UD-Q8_K_XL +
-the z-lab DFlash2 Q4_K_M drafter on the gfx1151 iGPU, port 7770) and mainline
-llama.cpp (frank-small, HauhauCS Gemma4-12B QAT Q4_K_M with MTP and vision on
-the RTX 3090, port 7771).
+Documentation for the LLM inference fleet on the two hosts (2026-09-26):
+**llama.cpp on all three endpoints** — Dankserver runs unsloth llama.cpp
+(the b11030-mix prebuilt, ROCm gfx120X) serving frank-primary
+(unsloth Qwen3.8-Flash-Next Q8_0 + the shared Q8_0 MTP head + ngram-mod +
+vision) across both Radeon AI PRO R9700s on :7770, and Dankclaw runs two
+endpoints — stock llama.cpp (unsloth b11030, ROCm gfx1151; frank-large,
+unsloth Qwen3.8-27B UD-Q8_K_XL + the z-lab DFlash2 Q4_K_M drafter on the
+gfx1151 iGPU, port 7770) and mainline llama.cpp (frank-small, HauhauCS
+Gemma4-12B QAT Q4_K_M with MTP and vision on the RTX 3090, port 7771).
> 2026-08-22: the former `vllmScripts` repository is merged into the
> serverAppConfigs repository. This knowledge base moved from the old repo's
@@ -22,11 +22,10 @@ graph LR
subgraph Dankserver[AMD Dankserver - Ubuntu 26.04]
A[2x Radeon AI PRO R9700
gfx1201, 32 GiB each]
- B2[vllm-radiance 0.9.3, TP=2
-frank-medium, port 7770]
- C[Qwen3.8-27B MXFP4 + DFlash2 drafter
-ONE process on both cards, 262144 ctx, up to 8 seqs]
- A --> B2 --> C
+ B2[unsloth llama.cpp b11030-mix, ROCm gfx120X
+frank-primary, port 7770]
+ C[Qwen3.8-Flash-Next Q8_0 + shared-Q8_0 MTP head
+layer split on both cards, 262144 ctx / 1 slot, ngram-mod, vision]
end
subgraph Dankclaw[AMD Strix Halo - Ubuntu 26.04]
@@ -53,20 +52,34 @@ frank-small, port 7771]
| Server | GPU | VRAM / Memory | OS |
| --------------------------------------- | ----------------------------- | -------------------- | ------------ |
-| [[Dankserver System Specs\|Dankserver]] | 2x AMD Radeon AI PRO R9700 | 2x 32 GB VRAM, 60 GB RAM | Ubuntu 26.04 |
+| [[Dankserver System Specs\|Dankserver]] | 2x AMD Radeon AI PRO R9700 | 2x 32 GB VRAM, 246 GB RAM | Ubuntu 26.04 |
| [[Dankclaw System Specs\|Dankclaw]] | AMD Radeon 8060S (Strix Halo) | gfx1151 unified memory | Ubuntu 26.04 |
## Active Deployment
-- [[frank-medium-radiance-mxfp4]] — the ACTIVE frank-medium runbook:
- vllm-radiance (stilldeadcode/vllm-radiance:0.9.3) serving
- just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq (MXFP4/quark W4A8) with the
- z-lab DFlash2-FP8 drafter as ONE tensor-parallel process (TP=2) behind
- :7770, now on unquantized bf16 KV (`KV_DTYPE=auto`, cut 2026-09-21 to test
- whether the endpoint's historical fp8 KV had been degrading W4A8 quality).
- The FP8 checkpoint (orcarouter/Qwen3.8-27B-Uncensored-FP8, the 09-20→09-21
- shape) is the on-disk rollback. The three-arm battery that chose TP=2, the
- auto-sized KV pool, and verified capabilities (tools, reasoning)
+- [[frank-primary-llamacpp-qwen38-flash-next-q8]] — the ACTIVE
+ frank-primary runbook (2026-09-26, root systemd unit, port 7770):
+ unsloth llama.cpp (b11030-mix prebuilt, ROCm gfx120X, bundled runtime)
+ serving unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0 across both R9700s —
+ routed experts and the 51B-param n-gram table computed in host RAM
+ (`-ot exps=CPU,ngram_embeddings=CPU`), the shared Q8_0 MTP head
+ (depth 2) chained behind ngram-mod, BF16 vision, one full 262,144-token
+ conversation, `reasoning_content`, the placement law, the cutover and
+ rollback
+- [[frank-primary-lucebox-ds4]] — RETIRED 2026-09-26 (same-day swap,
+ rollback target; active for hours): the Lucebox DS4.1 engine
+ (localhost/lucebox-rocm:gfx1201) serving
+ Lucebox/DeepSeek-V4.1-Flash-ROCMFP23 (377.2 GiB) with streamed experts —
+ the fleet's only non-llama.cpp runtime detour
+- [[frank-medium-radiance-mxfp4]] — RETIRED 2026-09-26 (replaced by
+ [[frank-primary-lucebox-ds4]], then
+ [[frank-primary-llamacpp-qwen38-flash-next-q8]]): vllm-radiance 0.9.3
+ serving just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq (MXFP4/quark
+ W4A8) with the z-lab DFlash2-FP8 drafter as ONE tensor-parallel process
+ (TP=2) behind :7770, on unquantized bf16 KV (`KV_DTYPE=auto`, cut
+ 2026-09-21). The FP8 checkpoint is the on-disk rollback. The three-arm
+ battery that chose TP=2, the auto-sized KV pool, and the verified
+ capability battery (tools, reasoning)
- [[frank-medium-model-swap]] — how to swap the checkpoint behind frank-medium:
the streamlining toolchain (hf-manifest.py, hf-fetch.sh,
swap-frank-medium.sh), the quantization-aware entrypoint contract, the
@@ -181,9 +194,9 @@ frank-small, port 7771]
on Dankclaw (superseded 2026-08-24 by the kingjones ROCmFP4 model)
- [[kairic-edge]] — the retired Kairic Edge IU4 experiment
(deployed and reverted 2026-08-22)
-- [[Dankserver System Specs]] — the 2x R9700 box (frank-medium)
-- [[Dankclaw System Specs]] — the Strix Halo gfx1151 box (frank-large on the
- Kairic Edge runtime since 2026-09-21, frank-small on the RTX 3090)
+- [[Dankserver System Specs]] — the 2x R9700 box (frank-primary)
+- [[Dankclaw System Specs]] — the Strix Halo gfx1151 box (frank-large on
+ the unsloth b11030 runtime since 2026-09-21, frank-small on the RTX 3090)
- [[frank-npu]] — RETIRED 2026-09-11: the FLM NPU sight+sound endpoint
on Dankclaw (Gemma4 E4B vision+audio pack, XDNA2, port 7771), kept as
the retirement record — the IOMMU/SVA err=-19 root cause and where
diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md
index 7d4baa0..2d62143 100644
--- a/serverAppConfigs-docs/index.md
+++ b/serverAppConfigs-docs/index.md
@@ -124,12 +124,18 @@ The LLM inference fleet on the two hosts. Full index:
- [[deployment]] — unified deploy and update process for both servers
- [[frank-redo-2026-08-17]] — the 2026-08-17 topology change
-- [[frank-primary-lucebox-ds4]] — the ACTIVE frank-primary runbook:
- the Lucebox runtime serving DeepSeek-V4.1-Flash-ROCMFP23 (377.2 GiB
- GGUF, 43-layer MoE, 256 routed experts top-6) on Dankserver's R9700
- with the routed experts computed on CPU from the mmapped page cache —
- replaces frank-medium on :7770 (cutover 2026-09-26)
-- [[frank-medium-radiance-mxfp4]] — the ACTIVE frank-medium runbook:
+- [[frank-primary-llamacpp-qwen38-flash-next-q8]] — the ACTIVE
+ frank-primary runbook: unsloth llama.cpp (b11030-mix prebuilt, ROCm
+ gfx120X, root unit) serving unsloth Qwen3.8-Flash-Next at Q8_0 (175.3
+ GiB) on Dankserver's both R9700s — the shared Q8_0 MTP head chained
+ behind ngram-mod, BF16 vision, the experts and the 51B n-gram table
+ computed in host RAM, the placement law, the cutover, and the rollback
+- [[frank-primary-lucebox-ds4]] — RETIRED 2026-09-26 (same-day swap;
+ rollback target): the Lucebox runtime serving
+ DeepSeek-V4.1-Flash-ROCMFP23 (377.2 GiB GGUF, 43-layer MoE, 256 routed
+ experts top-6) on Dankserver's R9700 with the routed experts computed
+ on CPU from the mmapped page cache — held :7770 for hours
+- [[frank-medium-radiance-mxfp4]] — RETIRED 2026-09-26:
vllm-radiance as ONE tensor-parallel process (TP=2 over the R4D one-shot
allreduce) serving just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq (MXFP4
W4A8, DFlash2 drafter, unquantized bf16 KV since 2026-09-21) on Dankserver's
diff --git a/shared/grafana/provisioning/dashboards/llm-fleet.json b/shared/grafana/provisioning/dashboards/llm-fleet.json
index d5319e3..7411d87 100644
--- a/shared/grafana/provisioning/dashboards/llm-fleet.json
+++ b/shared/grafana/provisioning/dashboards/llm-fleet.json
@@ -1,7 +1,7 @@
{
"uid": "llm-fleet",
"title": "LLM fleet \u2014 capacity and reuse",
- "description": "The signals that explain the LLM endpoints under load. One metric namespace: llamacpp:* for frank-small (Dankclaw, RTX 3090) and frank-large (Dankclaw, gfx1151 iGPU). frank-primary (Dankserver, the Lucebox DS4.1 engine since the 2026-09-26 cutover) exports no metrics and has no panels here. The endpoint picker selects between them. See serverAppConfigs-docs/Infrastructure/Observability.md for the namespace split and the addressability rules.",
+ "description": "The signals that explain the LLM endpoints under load. One metric namespace: llamacpp:* across all three endpoints — frank-small (Dankclaw, RTX 3090), frank-large (Dankclaw, gfx1151 iGPU) and frank-primary (Dankserver, 2x R9700, llama.cpp again since the 2026-09-26 swap back from Lucebox DS4.1). The endpoint picker selects between them. See serverAppConfigs-docs/Infrastructure/Observability.md for the namespace split and the addressability rules.",
"tags": [
"llm",
"capacity"
@@ -96,7 +96,7 @@
},
{
"expr": "job:llm_spec_accept_length:rate5m{job=~\"$job\"}",
- "legendFormat": "{{job}} mean accepted length (vLLM)",
+ "legendFormat": "{{job}} mean accepted length",
"refId": "B"
},
{
@@ -110,7 +110,7 @@
"id": 3,
"type": "timeseries",
"title": "Requests waiting (queue depth)",
- "description": "llama.cpp: anything above zero means the engine is serialising (with --parallel 1 a second concurrent request always shows up here). vLLM: the scheduler's own waiting/running gauges against MAXSEQS=8.",
+ "description": "llama.cpp: anything above zero means the engine is serialising (with --parallel 1 a second concurrent request always shows up here). All three endpoints are llama.cpp since the 2026-09-26 frank-primary swap.",
"gridPos": {
"h": 8,
"w": 12,
@@ -138,7 +138,7 @@
"id": 4,
"type": "timeseries",
"title": "Decode and prefill throughput",
- "description": "llama.cpp: llamacpp:predicted_tokens_seconds is decode, llamacpp:prompt_tokens_seconds is prompt. vLLM: the token counters, rate'd. The series carry the job label of their scrape, so the endpoint picker selects between the runtimes.",
+ "description": "llamacpp:predicted_tokens_seconds is decode, llamacpp:prompt_tokens_seconds is prompt. The series carry the job label of their scrape, so the endpoint picker selects between the endpoints.",
"gridPos": {
"h": 8,
"w": 12,
@@ -166,7 +166,7 @@
"id": 5,
"type": "timeseries",
"title": "Decode steps and batch size",
- "description": "llama.cpp-only signal \u2014 vLLM does not publish per-step counters. Steps per second and the busy-slot count per step (the effective batch).",
+ "description": "Per-step counters: steps per second and the busy-slot count per step (the effective batch). With frank-primary's --parallel 1 the slot count stays at 0 or 1 and says nothing about the card; with frank-small's 4 slots it tracks the agent-fleet burst shape.",
"gridPos": {
"h": 8,
"w": 12,
diff --git a/shared/homepage/docs.html b/shared/homepage/docs.html
index 1168713..455549d 100644
--- a/shared/homepage/docs.html
+++ b/shared/homepage/docs.html
@@ -36,8 +36,8 @@
Pick a model in the chat's model selector — the three models the fleet runs are
described below. Open WebUI keeps your chat history; the raw
model endpoints do not.
- There is no audio input anywhere on the fleet. Text in, and — on
- frank-small only — images too.
+ Images go to frank-primary or frank-small
+ (both see them). Audio (WAV) goes to frank-small only.
@@ -54,12 +54,12 @@
Not for: images. It cannot see them.
- frank-medium — the primary model
+ frank-primary — the primary model
- - Model: Qwen3.8-27B-Uncensored (MXFP4/W4A8, 19.4 GiB).
- - Capacity: 262k-token context shared by up to eight conversations at once; sees images.
+ - Model: Qwen3.8-Flash-Next (Q8_0, 175.3 GiB) with MTP + n-gram speculative drafting on both R9700s.
+ - Capacity: 262k-token context, exactly one conversation at a time; sees images.
- Inputs: text, image.
- - Best for: software engineering, system administration, and instruction tasks. Beats Claude Sonnet 5 per Artificial Analysis.
+ - Best for: software engineering, system administration, and instruction tasks.
frank-small — the fast utility model
@@ -72,7 +72,7 @@
diff --git a/shared/homepage/services.json b/shared/homepage/services.json
index 10bae49..434a452 100644
--- a/shared/homepage/services.json
+++ b/shared/homepage/services.json
@@ -281,12 +281,12 @@
"showLink": false
},
{
- "name": "frank-medium",
+ "name": "frank-primary",
"category": "user",
"section": "AI",
"details": {
"role": "Primary model",
- "spec": "Qwen3.8-27B-Uncensored (MXFP4/W4A8, 19.4 GiB) — 262k-token context across up to eight conversations; sees images. Beats Claude Sonnet 5 per Artificial Analysis.",
+ "spec": "Qwen3.8-Flash-Next (Q8_0, 175.3 GiB) — full 262k-token context, one conversation; sees images. Spec-drafted (MTP + n-gram) on both R9700s.",
"inputs": ["text", "image"],
"use": "Software engineering, system administration, and instruction tasks."
},
diff --git a/shared/prometheus/prometheus.yml b/shared/prometheus/prometheus.yml
index 94540ed..ed644ef 100644
--- a/shared/prometheus/prometheus.yml
+++ b/shared/prometheus/prometheus.yml
@@ -79,35 +79,46 @@ scrape_configs:
# this container, not the guarded vhosts: these are machine clients and
# Prometheus cannot solve an Anubis challenge.
#
- # One metric namespace: llamacpp:* — frank-large and frank-small, both
- # llama.cpp with --metrics and both verified 2026-09-20 to export
+ # One metric namespace: llamacpp:* — frank-large, frank-small and
+ # frank-primary, all llama.cpp with /metrics (the family verified from
+ # /metrics on 2026-09-16 and 2026-09-20) and all exporting
# requests_deferred (the queue depth, the only form llama.cpp shows the
# capacity wall in), spec_decode_num_drafts_total, tokens_predicted_total,
# and the prompt_tokens_cached_total / prompt_tokens_total reuse pair.
#
- # frank-large is scraped over host.containers.internal, not its own LAN address:
- # this Prometheus is a bridge-networked container (Network=dankclaw_default),
- # and on this netavark host a container can only reach the host's LAN IP at a
- # PUBLISHED port — a host-process bind like llama-server's 0.0.0.0:7770 is
- # refused at 192.168.0.143:7770 while it answers at 169.254.1.2:7770. That is
+ # frank-large and frank-small are scraped over host.containers.internal,
+ # not their own LAN addresses: this Prometheus is a bridge-networked
+ # container (Network=dankclaw_default), and on this netavark host a
+ # container can only reach the host's LAN IP at a PUBLISHED port — a
+ # host-process bind like llama-server's 0.0.0.0:7770 is refused at
+ # 192.168.0.143:7770 while it answers at 169.254.1.2:7770. That is
# why frank-small was red here for days (up{job="frank-small"} == 0,
# "dial tcp 192.168.0.143:7771") even though the endpoint was healthy and
# exporting: the target address was unreachable by construction, not the
- # service down. Same host, same rule, so both local endpoints use the gateway.
- # (2026-09-26) frank-medium is replaced by frank-primary (lucebox DS4.1),
- # which exposes no metrics endpoint (/metrics -> 404), so there is no job
- # for it here. The endpoint's liveness is covered by the homepage probe;
- # the documented remedy is a blackbox exporter, which is not deployed.
+ # service down. Same host, same rule, so both local endpoints use the
+ # gateway.
+ #
+ # (2026-09-26) frank-primary is llama.cpp again (the unsloth b11030-mix
+ # prebuilt serving Qwen3.8-Flash-Next Q8_0) on Dankserver, a DIFFERENT
+ # machine: the netavark published-port quirk above is same-host only, so
+ # plain LAN routing reaches 192.168.0.235:7770 and the job returns.
- job_name: "frank-small"
metrics_path: "/metrics"
static_configs:
- targets: ["host.containers.internal:7771"]
labels:
service: frank-small
-
+
- job_name: "frank-large"
metrics_path: "/metrics"
static_configs:
- targets: ["host.containers.internal:7770"]
labels:
service: frank-large
+
+ - job_name: "frank-primary"
+ metrics_path: "/metrics"
+ static_configs:
+ - targets: ["192.168.0.235:7770"]
+ labels:
+ service: frank-primary
diff --git a/shared/prometheus/rules/llm-fleet.yml b/shared/prometheus/rules/llm-fleet.yml
index 54a9932..d042842 100644
--- a/shared/prometheus/rules/llm-fleet.yml
+++ b/shared/prometheus/rules/llm-fleet.yml
@@ -1,11 +1,10 @@
# Alerting and recording rules for the LLM fleet.
#
-# One metric namespace: llamacpp:* — frank-small and frank-large, both
-# llama.cpp with --metrics (the family verified from /metrics on 2026-09-16
-# and 2026-09-20). frank-primary (Dankserver, the Lucebox DS4.1 engine since
-# the 2026-09-26 cutover) answers 404 on /metrics, so nothing here selects
-# it; its liveness is covered by the homepage probe, and the documented
-# remedy is a blackbox exporter (not deployed).
+# One metric namespace: llamacpp:* — frank-small, frank-large and
+# frank-primary, all llama.cpp with /metrics (the family verified from
+# /metrics on 2026-09-16 and 2026-09-20). frank-primary is scraped across
+# the LAN at 192.168.0.235:7770 (the netavark published-port quirk that
+# forces the gateway for the Dankclaw endpoints is same-host only).
#
# Every expression is keyed to a series that EXISTS. Nothing matches on
# absence: when an endpoint's runtime leaves the fleet, its selectors are
@@ -111,7 +110,6 @@ groups:
description: >-
Prometheus cannot reach the LLM endpoint. frank-small and
frank-large are root units on Dankclaw: `systemctl is-active
- frank-small` / `frank-large`. Both scraped endpoints export the
- llamacpp:* family. frank-primary (Dankserver) is not scraped —
- its runtime exports no metrics; check the homepage probe or
- `systemctl --user is-active frank-primary` on Dankserver.
+ frank-small` / `frank-large`. frank-primary is a root unit on
+ Dankserver: `systemctl is-active frank-primary`. All three
+ scraped endpoints export the llamacpp:* family.
diff --git a/tests/changed_tests.bats b/tests/changed_tests.bats
index e3a2291..731bb82 100644
--- a/tests/changed_tests.bats
+++ b/tests/changed_tests.bats
@@ -21,21 +21,28 @@ load helpers
[[ "$output" != *"tests/homepage_pages.bats"* ]]
}
-@test "changed-test wrapper maps the frank-primary dir to its focused tests" {
+@test "changed-test wrapper maps the frank-primary trio to its focused tests" {
+ # The root-unit shape since the 2026-09-26 swap: the trio lives in
+ # scripts/ and must select the unit seam before the generic
+ # scripts/*.service fallbacks can swallow it.
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
- dankserver/frank-primary/quadlet/frank-primary.container
+ scripts/frank-primary.env \
+ scripts/frank-primary.service \
+ scripts/serve-frank-primary.sh
[ "$status" -eq 0 ]
[[ "$output" == *"tests/frank_primary_unit.bats"* ]]
+ [[ "$output" == *"tests/llm_units.bats"* ]]
[[ "$output" == *"tests/deployment_scoping.bats"* ]]
- [[ "$output" == *"tests/docs_completeness.bats"* ]]
[[ "$output" != *"tests/homepage_pages.bats"* ]]
- # The retired frank-medium tree must no longer select any LLM seam: the
- # mappings were removed with the service, and a stale case would keep
- # firing tests for an archived runtime. Both archive shapes — the radiance
- # Quadlet era and the llama.cpp layersplit trio before it — map to nothing.
+ # The retired trees must no longer select any LLM seam: the mappings
+ # were removed with the services, and a stale case would keep firing
+ # tests for archived runtimes. All three archive shapes — the radiance
+ # Quadlet era, the llama.cpp layersplit trio, and the lucebox quadlet
+ # dir — map to nothing.
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
dankserver/.AGENTTRASH/20260926-frank-medium-radiance/frank-medium.container \
- scripts/.AGENTTRASH/20260920-llama-layersplit/serve-frank-medium.sh.llamacpp-layersplit
+ scripts/.AGENTTRASH/20260920-llama-layersplit/serve-frank-medium.sh.llamacpp-layersplit \
+ dankserver/.AGENTTRASH/20260926-llamacpp-cutover/frank-primary.container
[ "$status" -eq 0 ]
[[ "$output" != *"tests/frank_medium_unit.bats"* ]]
[[ "$output" != *"tests/frank_primary_unit.bats"* ]]
diff --git a/tests/deployment_scoping.bats b/tests/deployment_scoping.bats
index 9458ac3..b8df710 100644
--- a/tests/deployment_scoping.bats
+++ b/tests/deployment_scoping.bats
@@ -63,13 +63,23 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
[ -z "$output" ]
}
-@test "deployment planner maps the frank-primary container dir to frank-primary only" {
+@test "deployment planner maps the frank-primary trio to frank-primary only" {
+ # The root-unit shape since the 2026-09-26 swap: unit, env and serve
+ # script live in scripts/ and select exactly frank-primary. The
+ # download script fetches host-side state and must select nothing, and
+ # the lucebox quadlet-era tree in dated trash plans nothing either.
run python3 "$PLANNER" --target dankserver --paths \
- dankserver/frank-primary/quadlet/frank-primary.container \
- dankserver/frank-primary/frank-primary-entrypoint.sh \
- dankserver/frank-primary/frank-primary-healthcheck.py
+ scripts/frank-primary.env \
+ scripts/frank-primary.service \
+ scripts/serve-frank-primary.sh
[ "$status" -eq 0 ]
[ "$output" = "frank-primary" ]
+
+ run python3 "$PLANNER" --target dankserver --paths \
+ scripts/download-frank-primary.sh \
+ dankserver/.AGENTTRASH/20260926-llamacpp-cutover/frank-primary.container
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
}
@test "the small endpoint is Dankclaw's again, and Dankserver's copy stays retired" {
diff --git a/tests/frank_primary_unit.bats b/tests/frank_primary_unit.bats
index 3746526..a19a446 100755
--- a/tests/frank_primary_unit.bats
+++ b/tests/frank_primary_unit.bats
@@ -1,94 +1,168 @@
#!/usr/bin/env bats
# Seam: frank-primary — the Dankserver LLM endpoint on :7770.
#
-# This file asserts the LUCEBOX shape (the 2026-09-26 cutover that replaced
-# frank-medium): a rootless Quadlet running the lucebox DS4.1 engine on the
-# two R9700s, with the model mmapped out of /home/dank/models and the
-# entrypoint/healthcheck mounted straight out of this checkout.
+# This file asserts the llama.cpp ROOT-UNIT shape (the 2026-09-26 swap that
+# replaced the Lucebox DS4.1 Quadlet): the unsloth b11030-mix prebuilt on
+# the two R9700s (gfx1201) serving unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0
+# with the shared Q8_0 MTP head, the BF16 projector, and the
+# draft-mtp + ngram-mod chain. The seam pins what the flags alone do not
+# say: the CPU-override placement law (experts and the 51B n-gram table
+# are COMPUTED in host RAM, never streamed), mmap staying on, the
+# shared-head borrow requirement (per_layer_token_embd on CPU), the
+# explicit projector (never autoloaded by this build), HIP env hygiene,
+# and the deployer's byte-count gates over a six-part payload.
load helpers
-UNIT="$REPO_ROOT/dankserver/frank-primary/quadlet/frank-primary.container"
-EP="$REPO_ROOT/dankserver/frank-primary/frank-primary-entrypoint.sh"
-HC="$REPO_ROOT/dankserver/frank-primary/frank-primary-healthcheck.py"
+UNIT="$REPO_ROOT/scripts/frank-primary.service"
+SERVE="$REPO_ROOT/scripts/serve-frank-primary.sh"
+ENV_FILE="$REPO_ROOT/scripts/frank-primary.env"
+REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
-@test "the frank-primary quadlet file and its mounted scripts exist" {
+@test "frank-primary unit, serve script and env exist in scripts/" {
[ -f "$UNIT" ]
- [ -x "$EP" ]
- [ -f "$HC" ]
- # The healthcheck is python; a syntax error would leave the container
- # permanently unhealthy with the failure hidden inside HealthCmd.
- python3 -m py_compile "$HC"
+ [ -x "$SERVE" ]
+ [ -f "$ENV_FILE" ]
}
-@test "frank-primary is boot-persistent and self-healing" {
- # Rootless Podman does not auto-start bare containers and restart:
- # unless-stopped does not survive shutdown's explicit stop. The unit must
- # carry both the boot symlink and a restart policy (2026-08-26 incident:
- # a compose-only Caddy never came back after a reboot).
- grep -q '^WantedBy=default.target' "$UNIT"
- grep -qE '^Restart=(always|on-failure)' "$UNIT"
+@test "frank-primary is boot-persistent, self-healing and unprivileged" {
+ # Root unit parity with frank-large/frank-small: enable is the [Install]
+ # section, the restart policy survives a crash, and the mmap'd payload
+ # needs an unbounded memlock.
+ grep -q '^WantedBy=multi-user.target$' "$UNIT"
+ grep -q '^Restart=on-failure$' "$UNIT"
+ grep -q '^RestartSec=' "$UNIT"
+ grep -q '^User=dank$' "$UNIT"
+ grep -q '^LimitMEMLOCK=infinity$' "$UNIT"
}
-@test "frank-primary binds the host network and needs no published port" {
- # luce_server binds 0.0.0.0:7770 directly, so the container takes the
- # host netns. A PublishPort would be dead config.
- grep -q -- '--network=host' "$UNIT"
- ! grep -q '^PublishPort=' "$UNIT"
+@test "frank-primary runs the repo's own serve script with the repo's env" {
+ grep -q '^ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-primary.sh$' "$UNIT"
+ grep -q 'FRANK_PRIMARY_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-primary.env' "$UNIT"
}
-@test "frank-primary reaches both R9700s and hides the Raphael iGPU" {
- # /dev/kfd + /dev/dri via AddDevice; HIP_VISIBLE_DEVICES=0,1 keeps the
- # host's Raphael iGPU (index 2) out of the enumeration so the engine's
- # device picks cannot land on it.
- grep -q '^AddDevice=/dev/kfd' "$UNIT"
- grep -q '^AddDevice=/dev/dri' "$UNIT"
- grep -q '^Environment=HIP_VISIBLE_DEVICES=0,1$' "$UNIT"
+@test "frank-primary drafts with the shared MTP head chained behind ngram-mod" {
+ # The chain is draft-mtp (context-backed) + ngram-mod (draftless): the
+ # only legal way to layer two spec implementations in one process. The
+ # shared Q8_0 head borrows the target's embedding tensors — which is
+ # why per_layer_token_embd.weight must be CPU-resident (a GPU-resident
+ # borrow crashes the draft load with "invalid vector subscript").
+ grep -q -- '--spec-type draft-mtp,ngram-mod' "$SERVE"
+ grep -q '^SPEC_DRAFT_N_MAX=2$' "$ENV_FILE"
+ grep -q -- '--spec-draft-n-max "$SPEC_DRAFT_N_MAX"' "$SERVE"
+ grep -q 'DRAFTER_FILE="$MODEL_ROOT/MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf"' "$SERVE"
+ grep -q -- '--spec-draft-device "$SPEC_DRAFT_DEVICE"' "$SERVE"
}
-@test "frank-primary never names a bare HIP-reserved env var" {
- # The HIP runtime reads bare TEMP for its JIT cache path (the 2026-08-13
- # incident: TEMP=1.0 sent the JIT cache to path "1.0" and segfaulted every
- # load). HOST and GPU_MAX_HEAP_SIZE are the same class. The unit's
- # Environment= lines must not define them; the entrypoint exports
- # GPU_MAX_HW_QUEUES (explicitly not GPU_MAX_HEAP_SIZE).
- ! grep -qE '^Environment=(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$UNIT"
- ! grep -qE '^(export )?(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$EP"
+@test "the bandwidth-huge tensors are computed in host RAM, not streamed" {
+ # The x4-pair law: streaming experts over PCIe loses (3.7 tok/s) while
+ # computing them in RAM does not care (20+ tok/s on the same model
+ # shape). exps=CPU plus the n-gram embedding table on CPU; the PLE
+ # table must NEVER carry a GPU target (the 23 -> 13.4 t/s regression).
+ grep -q '^OT_OVERRIDES=exps=CPU,ngram_embeddings=CPU,per_layer_token_embd.weight=CPU$' "$ENV_FILE"
+ grep -q -- '--override-tensor "$ot"' "$SERVE"
+ # ...and mmap must stay on: --no-mmap forces the 51B-param table into
+ # anonymous memory and the cgroup kill is silent.
+ ! grep -q -- '--no-mmap' "$SERVE"
+ ! grep -q '^MMPROJ_ON_GPU' "$ENV_FILE"
}
-@test "the unit mounts the entrypoint and healthcheck from the checkout" {
- # deploy.sh git-pulls this checkout before dispatching, so a Volume
- # reference is how an edit here reaches the container without an image
- # rebuild. Absolute host paths, read-only.
- grep -q 'Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-entrypoint.sh:/usr/local/bin/frank-primary-entrypoint.sh:ro' "$UNIT"
- grep -q 'Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-healthcheck.py:/usr/local/bin/frank-primary-healthcheck.py:ro' "$UNIT"
+@test "the projector is explicit, never autoloaded" {
+ # This build never autoloads mmproj from the model directory: without
+ # the flag the server comes up healthy and text-only, answering image
+ # requests with 500 (the frank-medium 2026-09-17 trap).
+ grep -q -- '--mmproj "$MMPROJ_FILE"' "$SERVE"
+ grep -q 'MMPROJ_FILE="$MODEL_ROOT/mmproj-BF16.gguf"' "$SERVE"
}
-@test "first-start patience is configured on both sides of the health check" {
- # Cold start page-ins the dense weights and builds the graphs; the
- # container must not be killed as unhealthy while that happens, and the
- # deployer must wait the same order of magnitude.
- grep -q '^HealthStartPeriod=1800s' "$UNIT"
- grep -q '^TimeoutStartSec=1800$' "$UNIT"
- grep -q 'wait_container_healthy frank-primary 1800' \
- "$REPO_ROOT/scripts/deploy-remote.sh"
+@test "frank-primary is vision-serving but holds the single fleet port" {
+ grep -q '^PORT=7770$' "$ENV_FILE"
+ grep -q '^BIND_HOST=0.0.0.0$' "$ENV_FILE"
+ grep -q -- '--alias frank-primary' "$SERVE"
+ grep -q -- '--host "$BIND_HOST"' "$SERVE"
+ grep -q -- '--port "$PORT"' "$SERVE"
}
-@test "the deployer refuses to start without image and complete payload" {
- # A 377 GiB partial download must never be served: the handler gates on
- # the image being present and the model file existing at its exact
- # declared byte count (stat -c %s, never wc -c — wc read the whole stream
- # for minutes on 2026-09-26).
- grep -q 'deploy_llm_primary() {' "$REPO_ROOT/scripts/deploy-remote.sh"
- grep -q 'podman image exists localhost/lucebox-rocm:gfx1201' \
- "$REPO_ROOT/scripts/deploy-remote.sh"
- grep -q 'want_size=405349859328' "$REPO_ROOT/scripts/deploy-remote.sh"
- grep -q 'stat -c %s' "$REPO_ROOT/scripts/deploy-remote.sh"
+@test "one stream, the model's full native window" {
+ local ctx parallel
+ ctx="$(sed -nE 's/^CONTEXT_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
+ parallel="$(sed -nE 's/^PARALLEL_SLOTS=([0-9]+)$/\1/p' "$ENV_FILE")"
+ [ -n "$ctx" ] && [ -n "$parallel" ]
+ [ "$ctx" -eq 262144 ]
+ [ "$parallel" -eq 1 ]
+ grep -q -- '--ctx-size "$CONTEXT_SIZE"' "$SERVE"
+ grep -q -- '--parallel "$PARALLEL_SLOTS"' "$SERVE"
}
-@test "the deployer gates on the port actually being free" {
- # 7770 is a host-network bind: the previous holder must release it before
- # the new container can be created, or pasta's bind fails at create time
- # and the failure hides behind Restart=.
- grep -q 'wait_port_free 7770' "$REPO_ROOT/scripts/deploy-remote.sh"
+@test "reasoning is extracted to reasoning_content and tools are legal" {
+ # The harness contract: llama.cpp endpoints emit reasoning_content
+ # (the deepseek format is the one that fills it), and tools require
+ # --jinja (the ik_llama 500 lesson). The Qwen3.8 template gates
+ # reasoning_effort to {xhigh, medium, low} — folded in models.yml.
+ grep -q -- '--jinja' "$SERVE"
+ grep -q -- '--reasoning auto' "$SERVE"
+ grep -q -- '--reasoning-format deepseek' "$SERVE"
+ grep -q -- '--reasoning-budget -1' "$SERVE"
+}
+
+@test "the greedy server default is explicit" {
+ # Deterministic e2e probes; the harness overrides sampling per request.
+ grep -q -- '--temp 0' "$SERVE"
+ grep -q -- '--top-p 1.0' "$SERVE"
+ grep -q -- '--top-k 0' "$SERVE"
+ grep -q -- '--min-p 0.0' "$SERVE"
+}
+
+@test "both R9700s are visible and the Raphael iGPU is not" {
+ # The host has a third (Raphael) GPU the server must never place
+ # tensors on; the bundled ROCm runtime is the only one linked.
+ grep -q 'export HIP_VISIBLE_DEVICES=0,1$' "$SERVE"
+ grep -q 'export LD_LIBRARY_PATH="$RUNTIME_DIR' "$SERVE"
+}
+
+@test "no bare HIP-reserved env var is defined" {
+ # HIP reads bare TEMP for its JIT cache path (2026-08-13: TEMP=1.0
+ # segfaulted every load), and HOST/GPU_MAX_HEAP_SIZE are the same
+ # class. The bind address is BIND_HOST, not HOST.
+ ! grep -qE '^(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$ENV_FILE"
+ grep -q '^BIND_HOST=' "$ENV_FILE"
+ ! grep -qE '^(export )?(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$SERVE"
+}
+
+@test "the serve script refuses to start on a half-finished download" {
+ # Six split parts plus the head and the projector: a missing tail part
+ # fails at LOAD time with a confusing partial-mmap error, so every
+ # artifact is checked before the server execs.
+ grep -q 'missing asset' "$SERVE"
+ grep -q 'download-frank-primary.sh' "$SERVE"
+}
+
+@test "the prompt-cache cap is set below the RAM the host can spare" {
+ grep -q '^CACHE_RAM=16384$' "$ENV_FILE"
+ grep -q -- '--cache-ram "$CACHE_RAM"' "$SERVE"
+}
+
+@test "the deployer retires the lucebox quadlet, gates every part and waits out the port" {
+ # The cutover order: the user-scope quadlet is stopped and its
+ # generated .container moved to dated home trash BEFORE the root unit
+ # binds 7770 (nothing starts until the port is released — the 2026-09-26
+ # and 2026-09-16 cutover failures were both here).
+ grep -q 'retire_lucebox_primary_quadlet' "$REMOTE_DEPLOY"
+ grep -q 'systemctl --user stop frank-primary.service' "$REMOTE_DEPLOY"
+ grep -q 'relink_unit frank-primary' "$REMOTE_DEPLOY"
+ grep -q 'systemctl enable frank-primary.service' "$REMOTE_DEPLOY"
+ grep -q 'systemctl restart frank-primary.service' "$REMOTE_DEPLOY"
+ grep -q 'wait_port_free 7770' "$REMOTE_DEPLOY"
+ grep -q '127.0.0.1:7770/health' "$REMOTE_DEPLOY"
+}
+
+@test "the deployer gates the six-part payload at declared byte counts" {
+ # stat -c %s, never wc -c — wc read the whole 377 GiB lucebox file for
+ # minutes on 2026-09-26 before it could reach the cutover. A partial
+ # download must never be served.
+ grep -q 'LLM_PRIMARY_PART_SIZES' "$REMOTE_DEPLOY"
+ grep -q 'stat -c %s' "$REMOTE_DEPLOY"
+ grep -q '2786568256' "$REMOTE_DEPLOY"
+ grep -q '907542944' "$REMOTE_DEPLOY"
+ grep -q 'wait_for_health frank-primary http://127.0.0.1:7770/health 1800' "$REMOTE_DEPLOY"
}
diff --git a/tests/homepage_config.bats b/tests/homepage_config.bats
index 93033a8..2c3baeb 100644
--- a/tests/homepage_config.bats
+++ b/tests/homepage_config.bats
@@ -61,17 +61,18 @@ PY
@test "the LLM status cards track the fleet, not retired service names" {
# This drifted twice: the card probing Dankserver's :7770 was still named
- # frank-small long after frank-medium took that endpoint, and Dankclaw's
- # second endpoint now exists again as a real service on its own port. The
- # probe target is what matters; the name has to follow it.
+ # frank-small long after frank-medium took that endpoint, and the endpoint
+ # itself has since been frank-medium (radiance), frank-primary (lucebox)
+ # and frank-primary (llama.cpp). The probe target is what matters; the
+ # name has to follow it.
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
import json, sys
services = json.load(open(sys.argv[1]))
by_probe = {s["probe"]: s["name"] for s in services}
-medium = by_probe.get("192.168.0.235:7770")
-assert medium == "frank-medium", f"Dankserver :7770 card is named {medium!r}"
-# Dankclaw serves two endpoints on two ports: frank-large (halogen, 7770) and
-# frank-small (llama.cpp on the RTX 3090, 7771).
+primary = by_probe.get("192.168.0.235:7770")
+assert primary == "frank-primary", f"Dankserver :7770 card is named {primary!r}"
+# Dankclaw serves two endpoints on two ports: frank-large (unsloth llama.cpp
+# on the iGPU, 7770) and frank-small (llama.cpp on the RTX 3090, 7771).
assert by_probe.get("127.0.0.1:7770") == "frank-large", by_probe.get("127.0.0.1:7770")
assert by_probe.get("127.0.0.1:7771") == "frank-small", by_probe.get("127.0.0.1:7771")
PY
diff --git a/tests/observability_quadlet.bats b/tests/observability_quadlet.bats
index ea8d27a..bbff296 100755
--- a/tests/observability_quadlet.bats
+++ b/tests/observability_quadlet.bats
@@ -315,24 +315,29 @@ PAIRS
@test "prometheus scrapes every LLM endpoint that exports metrics" {
# The LLM endpoints are the services whose behaviour this fleet argues about
- # most (KV pressure, prefix-cache reuse, queue depth). Only the two Dankclaw
- # llama.cpp endpoints export metrics today: frank-primary (Dankserver, the
- # Lucebox DS4.1 engine since the 2026-09-26 cutover) answers 404 on
- # /metrics, so a job for it would be a permanently-down target — no job,
- # and its LAN address must not sneak back in with one.
- # The two scraped endpoints are HOST processes reached through
+ # most (KV pressure, prefix-cache reuse, queue depth). All three export
+ # the llamacpp:* family since 2026-09-26, when frank-primary (Dankserver)
+ # went back to llama.cpp — the unsloth b11030-mix prebuilt serving
+ # Qwen3.8-Flash-Next Q8_0 — after two days of the Lucebox DS4.1 engine,
+ # which exported nothing (404) and had no job.
+ # The two Dankclaw endpoints are HOST processes reached through
# host.containers.internal: this Prometheus is a bridge-networked
# container, and on this netavark host a container can only reach the
# host's LAN IP at a PUBLISHED port — a host-process bind like
# llama-server's 0.0.0.0:7770 is refused at 192.168.0.143:7770 while it
# answers at 169.254.1.2:7770 (why frank-small stayed red by construction
- # for days until the 2026-09-20 /targets audit).
+ # for days until the 2026-09-20 /targets audit). frank-primary is a
+ # DIFFERENT machine, so that quirk does not apply: plain LAN routing
+ # reaches 192.168.0.235:7770, and the job must say so (a gateway target
+ # would point at Dankclaw's own :7770 — frank-large — and double-scrape
+ # it under the wrong label).
local cfg="$REPO_ROOT/shared/prometheus/prometheus.yml"
- ! grep -q '"192.168.0.235:7770"' "$cfg" || { echo "frank-primary is scraped but exports no metrics"; return 1; }
-
grep -q '"host.containers.internal:7771"' "$cfg" || { echo "frank-small not scraped"; return 1; }
grep -q '"host.containers.internal:7770"' "$cfg" || { echo "frank-large not scraped"; return 1; }
- for job in frank-small frank-large; do
+ grep -q '"192.168.0.235:7770"' "$cfg" || { echo "frank-primary not scraped"; return 1; }
+ grep -q '"host.containers.internal:7770,192.168.0.235:7770"' "$cfg" \
+ && { echo "frank-primary folded into a Dankclaw gateway target"; return 1; }
+ for job in frank-small frank-large frank-primary; do
grep -q "job_name: \"$job\"" "$cfg" || { echo "no scrape job for $job"; return 1; }
done
# None of them is reached through a guarded vhost: Prometheus cannot solve