frank-primary: swap Dankserver to llama.cpp Qwen3.8-Flash-Next Q8_0

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.
This commit is contained in:
Frank
2026-09-26 19:45:28 -07:00
parent d89813363e
commit 8500600d09
25 changed files with 856 additions and 544 deletions
+75 -81
View File
@@ -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). merge absorbed the former `vllmScripts` repo, history preserved).
- **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty, - **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 - **Dankclaw** (192.168.0.143) — AI/search/dev/observability stack (SearXNG
via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Loki, via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Loki,
Alloy, Pi-hole) plus TWO LLM services: `frank-large` (stock llama.cpp on 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 | | 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-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 | | 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 dankclaw
./scripts/deploy.sh dankserver ./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng ./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 ./scripts/deploy.sh dankclaw --dry-run
``` ```
The planner maps changed paths to services; the remote runner restarts only The planner maps changed paths to services; the remote runner restarts only
the selected service and records its revision outside the checkout. the selected service and records its revision outside the checkout.
Documentation-only changes are a no-op. LLM services are selected only by 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 their own files — every endpoint (`frank-large`/`frank-small`/`frank-primary`)
`scripts/`, `frank-medium` by its Quadlet unit, entrypoint and healthcheck under by its unit/env/serve trio in `scripts/` — so
`dankserver/frank-medium/` — so
unrelated unrelated
container changes cannot interrupt inference. Set `FRANK_SUDO_PW` for headless container changes cannot interrupt inference. Set `FRANK_SUDO_PW` for headless
runs on a host without passwordless sudo; the value never enters the repo. 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` `sudo systemctl stop frank-large && sudo systemctl mask frank-large`
- Dankclaw `frank-small` (root unit, llama.cpp on the 3090): - Dankclaw `frank-small` (root unit, llama.cpp on the 3090):
`sudo systemctl stop frank-small && sudo systemctl mask frank-small` `sudo systemctl stop frank-small && sudo systemctl mask frank-small`
- Dankserver `frank-medium` (**rootless Quadlet since 2026-09-20** — the - Dankserver `frank-primary` (root unit, llama.cpp on the 2x R9700):
system unit is retired, so `sudo systemctl` sees nothing): `sudo systemctl stop frank-primary && sudo systemctl mask frank-primary`
`systemctl --user stop frank-medium && systemctl --user mask frank-medium`
2. Bench. 2. Bench.
3. Unmask and start; verify `curl http://192.168.0.<host>:7770/health`. 3. Unmask and start; verify `curl http://192.168.0.<host>: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 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 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 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.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`. `modelRoles`.
Resolution rules: the default model resolves as a `defaultProvider` + 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 provider block, model `id`, `contextWindow`, `maxTokens`, `input` in
`models.yml`, then fix every `modelRoles` entry that named the old endpoint — `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 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 of 2026-09-26 both `frank-small` and `frank-primary` accept images
(`input: [text, image]`) — frank-medium's checkpoint serves vision through the (`input: [text, image]`) — frank-primary loads the BF16 mmproj explicitly
Gemma4 merge path with no separate mmproj, verified on held-out synthetic (this build never autoloads projectors); `frank-large` remains text-only
images; `frank-large` remains text-only because its stock llama.cpp deployment loads no because its stock llama.cpp deployment loads no
vision projector (the mmproj is not loaded). Note also that vision projector (the mmproj is not loaded). Note also that
the reasoning channel differs by runtime: llama.cpp endpoints emit 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** There is **no audio**
input anywhere in the fleet: that capability left with the FLM NPU endpoint on input anywhere in the fleet except frank-small: that capability left with the
2026-09-11. 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) ### 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`. Runbook: `serverAppConfigs-docs/LLM/frank-small-gemma4-12b.md`.
Rollback: [[frank-small-qwen38-gsq-rco]]. 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** unsloth llama.cpp **b11030-mix prebuilt** (ROCm gfx120X, commit `6ba30d05b`,
`dankserver/frank-medium/quadlet/frank-medium.container`) serving bundled ROCm runtime at `/home/dank/GitPackages/unsloth-llama-b11030/`, **root
`just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq` (MXFP4/quark W4A8, 19.37 systemd unit** `scripts/frank-primary.service`, `User=dank`) serving
GiB) with the **z-lab DFlash2 external drafter** (`num_speculative_tokens: 7`, `unsloth/Qwen3.8-Flash-Next-GGUF` at **Q_8** — 175.30 GiB across six split
live mean accept 4.24/7) in **one tensor-parallel process across both parts under `/home/dank/models/Qwen3.8-Flash-Next-GGUF/Q8_0/`, fetched by
R9700s** (`--tensor-parallel-size 2`). 262,144 ctx, `--max-num-seqs 8`, chunk `scripts/download-frank-primary.sh` (per-file sha256 + byte-count verified) —
8192, **unquantized bf16 KV** (`KV_DTYPE=auto`, cut 2026-09-21 from the on **both** Radeon AI PRO R9700s. Replaced the Lucebox DS4.1 quadlet (hours
FP8/fp8-KV shape to test whether fp8 KV had been degrading W4A8 output old) on 2026-09-26, which had replaced radiance frank-medium the same day.
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).
**The entrypoint is quantization-aware.** `radiance-entrypoint.sh` reads **Placement: compute the experts in RAM, never stream them.** Layer split
`quantization_config.quant_method` from the checkpoint's `config.json` and `-ts 1,1` with `-ngl 999`; the routed experts and the 51B-param n-gram
serves the plain FP8 path (no MXFP4 patches, `--quantization fp8`, (PLE) table are overridden to CPU (`-ot
`ROCM_AITER_UNIFIED_ATTN`, the DFlash2 drafter) or the MXFP4 patch-and-serve exps=CPU,ngram_embeddings=CPU,per_layer_token_embd.weight=CPU`) and computed
path (the DFlash2 external drafter). A checkpoint swap is therefore four quadlet from the mmap page cache — the x4-pair law (streaming over PCIe loses at
env vars (`SNAP`, `SPEC_METHOD`, `SPEC`, `KV_MEM`) — the deploy handler reads 3.7 t/s; computing in RAM does not care). `per_layer_token_embd.weight=CPU`
the serving shape from the quadlet and gates on the right assets. The MXFP4 is REQUIRED by the shared MTP head's borrow path (a GPU-resident borrow
checkpoint (`just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq` + the z-lab crashes the draft load with `invalid vector subscript`). Dense weights,
DFlash2-FP8 drafter) remains on disk as the rollback target. Full swap attention, the BF16 projector and the MTP head sit on the cards (~29 GiB);
process + toolchain: [[frank-medium-model-swap]]. 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 **Spec chain `draft-mtp,ngram-mod`, depth 2.** The shared Q_8 head
B650-E) put card #2 behind chipset bridges advertising `AtomicOpsCap: Routing-` (`MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf`, 2,786,568,256 B — unsloth's
and no collective could be dispatched there. The ProArt B850-Creator WiFi NEO recommended pairing) loads via `--model-draft` and borrows the target's
routes both cards to CPU GPP root ports (`00:01.1 → 03:00.0`, embedding tensors; `ngram-mod` chains behind it (draftless, so legal).
`00:01.3 → 07:00.0`, Gen5 x8, every hop `Routing+`, both endpoints `ReqEn+`). The dankclaw UD-IQ4_XS deployment of the same model family ran this head
The collective in use is vLLM's **R4D one-shot allreduce**, which never touches at depth 2 with 0.83–0.92 acceptance. ngram-mod prints no startup banner —
RCCL (TheRock RCCL has no gfx1201 device code; the Ubuntu RCCL 2.27 build does, identify it behaviourally (`draft_n` > 0 where text repeats).
and that is what `scripts/build-llama-rdna4.sh GGML_HIP_RCCL=ON` links).
**Measured, not assumed** (`scripts/bench-conversation.py`, 10 real prompts, **Vision explicit, reasoning deepseek-format.** `--mmproj` names the BF16
max_tokens 512, greedy, salted per arm, service stopped between arms): projector (this build never autoloads one — a server without the flag comes
radiance TP=2 95.8 t/s decode geomean / 0.21 s TTFT / 259.4 t/s burst-4, vs the up healthy and text-only and answers image requests 500). `--jinja` with the
llama.cpp layer split's 45.7 / 0.72 / 71.7 and llama.cpp `--split-mode tensor`'s GGUF's embedded template; `--reasoning-format deepseek` fills
30.5 / 0.71 / 54.3. Honest caveat: the arms also differ in quantization (MXFP4 `reasoning_content`. The Qwen3.8 template gates `reasoning_effort` to
vs Q8_0), so part of the margin is the quant, not the parallelism. Re-measured `{xhigh, medium, low}` — folded by `reasoningEffortMap` in `models.yml`.
against production after deploy: 92.4 t/s, 0.22 s, 248.9. 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`, Files: `scripts/frank-primary.service` (root unit, relinked to
`dankserver/frank-medium/radiance-entrypoint.sh` (quantization-aware), `/etc/systemd/system/`), `scripts/serve-frank-primary.sh` (validates binary
`dankserver/frank-medium/frank-medium-healthcheck.py`. Swap toolchain: + all 6 parts + drafter + mmproj, then `exec`s llama-server),
`scripts/hf-manifest.py` (generate a `<filename> <size> <sha256>` manifest `scripts/frank-primary.env` (tunables; the bind address is `BIND_HOST` —
from the live repo tree), `scripts/hf-fetch.sh` (resumable, byte + sha256 never a bare `HOST`, HIP reads it). The deployer's `deploy_llm_primary`
verified, idempotent fetch), `scripts/swap-frank-medium.sh` (generate gates every artifact at its declared byte count (`stat -c %s`, never `wc -c`),
manifest → download → detect quantization → edit the quadlet → deploy). The retires the lucebox quadlet to `~/.AGENTTRASH/20260926-llamacpp-cutover/`,
llama.cpp layer-split trio (`scripts/frank-medium.service`, `.env`, waits out port 7770, relinks/enables/restarts the root unit and waits up to
`serve-frank-medium.sh`) is archived in `scripts/.AGENTTRASH/ 1800 s for `/health` (cold start page-ins ~175 GiB).
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`.
Runbook: `serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md` (serving Runbook: `serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md`.
details); swap process + toolchain: `serverAppConfigs-docs/LLM/ Rollback: the lucebox DS4.1 material on host (image
frank-medium-model-swap.md`. `localhost/lucebox-rocm:gfx1201` + the 377 GiB payload + the archived
Rollback: the MXFP4 checkpoint on disk (`SPEC_METHOD=dflash`, `SPEC=7`); quadlet files in `dankserver/.AGENTTRASH/20260926-llamacpp-cutover/`) — see
before that, [[frank-medium-llamacpp-dflash2]] (SUPERSEDED 2026-09-20). [[frank-primary-lucebox-ds4]]; the Qwen payload stays fetched, so a
forward re-swap is a redeploy.
## Key Lessons Learned ## Key Lessons Learned
Durable operational lessons from the fleet's history. Full detail in the 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-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-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-primary (llama.cpp Qwen3.8-Flash-Next Q8_0 + shared MTP, Dankserver) | `serverAppConfigs-docs/LLM/frank-primary-llamacpp-qwen38-flash-next-q8.md` |
| frank-medium model swap (toolchain + process) | `serverAppConfigs-docs/LLM/frank-medium-model-swap.md` |
| Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` | | Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.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` | | 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` | | 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` | | 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` | | 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` | | 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` |
@@ -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
@@ -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)
@@ -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
@@ -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
+12 -7
View File
@@ -371,13 +371,11 @@ def services_for_path(path: str, target: str) -> set[str]:
if name == "node-exporter": if name == "node-exporter":
_add(services, "node-exporter") _add(services, "node-exporter")
return services return services
if len(parts) >= 2 and parts[1] == "frank-primary": # frank-primary's quadlet-era directory left the tree on 2026-09-26
# frank-primary is the Lucebox DeepSeek V4 Quadlet (2026-09-26 # (the Lucebox DS4.1 unit was retired for the llama.cpp root unit —
# cutover, replacing frank-medium on :7770): its entrypoint and # the trio in scripts/ now maps it). Nothing lives under
# healthcheck live under this directory and the container mounts # dankserver/frank-primary/ except dated trash, and trash plans
# them straight out of this checkout, so any file here changes # nothing (the retire rule above the service blocks).
# deployed state.
_add(services, "frank-primary")
return services return services
if parts[0] == "frankbook-pro": 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 # what the live frank-large endpoint renders, so it plans
# frank-large. # frank-large.
_add(services, "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 # Deployment/test/benchmark scripts and other repo tooling do not
# restart a runtime service. # restart a runtime service.
return services return services
+87 -41
View File
@@ -1178,52 +1178,98 @@ deploy_llm_small() {
wait_for_health frank-small http://127.0.0.1:7771/health 900 wait_for_health frank-small http://127.0.0.1:7771/health 900
} }
# deploy_llm_primary — frank-primary is the Lucebox DeepSeek V4 Quadlet # deploy_llm_primary — frank-primary is unsloth llama.cpp (the b11030-mix
# (the 2026-09-26 cutover that replaces frank-medium on :7770). The model # prebuilt, ROCm gfx120X, bundled runtime) serving Qwen3.8-Flash-Next at
# is a 377 GiB GGUF and the image is a local build, so the gates are: # Q8_0 on the two R9700s, as a ROOT unit — the same shape as frank-large on
# image present, model file present at its declared byte count (a partial # Dankclaw: a host binary, no container image, no generated unit.
# download must never be served). # 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() { deploy_llm_primary() {
local missing=0 # Refuse rather than restart-loop. A unit whose runtime or payload is
local model="/home/dank/models/DeepSeek-V4.1-Flash/DeepSeek-V4.1-Flash-ROCMFP23.gguf" # missing never becomes healthy, and a green deploy would leave the
local want_size=405349859328 # endpoint dark with nothing in the deploy log to say why.
if ! podman image exists localhost/lucebox-rocm:gfx1201; then local missing=0 i part want_size got_size
echo "ERROR: lucebox image absent (localhost/lucebox-rocm:gfx1201)" >&2 if [[ ! -x "$LLM_PRIMARY_RUNTIME" ]]; then
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 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 missing=1
fi fi
# stat -c %s, never `wc -c < FILE`. wc falls back to reading the whole for i in 1 2 3 4 5 6; do
# stream whenever its fstat shortcut does not apply, and this payload is part="$LLM_PRIMARY_ROOT/Q8_0/Qwen3.8-Flash-Next-Q8_0-0000$i-of-00006.gguf"
# 377 GiB: on 2026-09-26 a deploy sat in wc for minutes (30 s of CPU, want_size="${LLM_PRIMARY_PART_SIZES[$((i-1))]}"
# NVMe-bound) before it could even reach the cutover. got_size="$(stat -c %s "$part" 2>/dev/null || echo 0)"
got_size="$(stat -c %s "$model" 2>/dev/null || echo 0)" if [[ "$got_size" != "$want_size" ]]; then
if [[ "$got_size" != "$want_size" ]]; then echo "ERROR: part $i absent or incomplete at $part (have $got_size, want $want_size)" >&2
echo "ERROR: model absent or incomplete at $model (have $got_size, want $want_size)" >&2 missing=1
missing=1 fi
fi done
[[ "$missing" -eq 0 ]] || return 1 for part in "$LLM_PRIMARY_DRAFT:$LLM_PRIMARY_DRAFT_SIZE" \
"$LLM_PRIMARY_MMPROJ:$LLM_PRIMARY_MMPROJ_SIZE"; do
# Cutover COMPLETED 2026-09-26: frank-primary took :7770 and the radiance local file="${part%%:*}" want="${part##*:}"
# unit is retired. The host-side unit, its mask and the repo files were all got_size="$(stat -c %s "$file" 2>/dev/null || echo 0)"
# moved to trash by the parent session, so there is nothing left to stop or if [[ "$got_size" != "$want" ]]; then
# mask here — masking a name that no longer exists would only resurrect it echo "ERROR: artifact absent or incomplete at $file (have $got_size, want $want)" >&2
# in the unit search path. The port gate stays, because 7770 is a missing=1
# host-network bind: nothing new can be created until the holder released it. fi
# 7770 is a host-network bind: nothing new can be created until the old done
# holder released it (the same gate as the 2026-09-16 cutover). [[ "$missing" -eq 0 ]] || {
sleep 2 echo "frank-primary: run scripts/download-frank-primary.sh on this host" >&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
return 1 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() { wait_for_health() {
+78
View File
@@ -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
# <tensor-pattern>=<backend> 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
+21
View File
@@ -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
+134
View File
@@ -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 <pattern>=<backend> pairs -> repeated --override-tensor.
IFS=',' read -ra _ots <<< "$OT_OVERRIDES"
for ot in "${_ots[@]}"; do
ARGS+=(--override-tensor "$ot")
done
exec "$SERVER_BIN" "${ARGS[@]}"
+8 -8
View File
@@ -187,16 +187,16 @@ for path in "${CHANGED[@]}"; do
# as additions, with "no focused-test mapping". # as additions, with "no focused-test mapping".
.AGENTTRASH/*|*/.AGENTTRASH/*) .AGENTTRASH/*|*/.AGENTTRASH/*)
;; ;;
# frank-primary is the lucebox DS4.1 Quadlet (2026-09-26 cutover on # frank-primary is llama.cpp (the unsloth b11030-mix prebuilt) on the
# :7770): its entrypoint and healthcheck live under # two R9700s as a ROOT unit — the 2026-09-26 swap that replaced the
# dankserver/frank-primary/ and the container mounts them straight out # Lucebox DS4.1 quadlet on :7770. Its env, unit and serve script all
# of the checkout, so any file here changes deployed state. The # live in scripts/, which is why this case has to precede the generic
# radiance quadlet that held this endpoint before that date is archive # scripts/*.service and scripts/* fallbacks below.
# material in dankserver/.AGENTTRASH/ and maps to nothing. scripts/frank-primary.*|scripts/serve-frank-primary.sh)
dankserver/frank-primary/*)
add_test tests/frank_primary_unit.bats add_test tests/frank_primary_unit.bats
add_test tests/llm_units.bats
add_test tests/deployment_scoping.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 # frank-large's chat template lives in scripts/ (moved there on
# 2026-09-26 when its old home was retired): a template edit changes # 2026-09-26 when its old home was retired): a template edit changes
@@ -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]`).
@@ -1,7 +1,7 @@
--- ---
type: configuration type: configuration
title: frank-primary Lucebox DeepSeek V4 Runbook 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: tags:
- frank-primary - frank-primary
- dankserver - dankserver
@@ -18,14 +18,22 @@ timestamp: 2026-09-26
# frank-primary Lucebox DeepSeek V4 Runbook # 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](https://github.com/Luce-Org/lucebox) runtime serving
`Lucebox/DeepSeek-V4.1-Flash-ROCMFP23-GGUF` (377.2 GiB, 405,349,859,328 `Lucebox/DeepSeek-V4.1-Flash-ROCMFP23-GGUF` (377.2 GiB, 405,349,859,328
bytes, LFS oid bytes, LFS oid
`2daea6ca1715bc987aae201e63445d3f7abdc2337c05f5f569f5e50237ce8be2`) on a `2daea6ca1715bc987aae201e63445d3f7abdc2337c05f5f569f5e50237ce8be2`) on a
single Radeon AI PRO R9700 (gfx1201). It replaces single Radeon AI PRO R9700 (gfx1201). It replaced
[[frank-medium-radiance-mxfp4]] (vllm-radiance TP=2), retired on [[frank-medium-radiance-mxfp4]] (vllm-radiance TP=2), retired earlier the
2026-09-26. same day.
## Why this shape ## Why this shape
+40 -27
View File
@@ -1,14 +1,14 @@
# LLM Fleet — Knowledge Base # LLM Fleet — Knowledge Base
Documentation for the LLM inference fleet on the two hosts (2026-09-21): Documentation for the LLM inference fleet on the two hosts (2026-09-26):
**vllm-radiance on Dankserver** serving frank-medium as ONE tensor-parallel **llama.cpp on all three endpoints** — Dankserver runs unsloth llama.cpp
process across both Radeon AI PRO R9700 cards (just1moremodel (the b11030-mix prebuilt, ROCm gfx120X) serving frank-primary
Qwen3.8-27B-Uncensored-MXFP4 + the z-lab DFlash2 drafter, bf16 KV, TP=2 over the (unsloth Qwen3.8-Flash-Next Q8_0 + the shared Q8_0 MTP head + ngram-mod +
R4D one-shot allreduce), and TWO endpoints on Dankclaw — stock llama.cpp vision) across both Radeon AI PRO R9700s on :7770, and Dankclaw runs two
(unsloth b11030, ROCm gfx1151; frank-large, unsloth Qwen3.8-27B UD-Q8_K_XL + endpoints — stock llama.cpp (unsloth b11030, ROCm gfx1151; frank-large,
the z-lab DFlash2 Q4_K_M drafter on the gfx1151 iGPU, port 7770) and mainline unsloth Qwen3.8-27B UD-Q8_K_XL + the z-lab DFlash2 Q4_K_M drafter on the
llama.cpp (frank-small, HauhauCS Gemma4-12B QAT Q4_K_M with MTP and vision on gfx1151 iGPU, port 7770) and mainline llama.cpp (frank-small, HauhauCS
the RTX 3090, port 7771). 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 > 2026-08-22: the former `vllmScripts` repository is merged into the
> serverAppConfigs repository. This knowledge base moved from the old repo's > serverAppConfigs repository. This knowledge base moved from the old repo's
@@ -22,11 +22,10 @@ graph LR
subgraph Dankserver[AMD Dankserver - Ubuntu 26.04] subgraph Dankserver[AMD Dankserver - Ubuntu 26.04]
A[2x Radeon AI PRO R9700 A[2x Radeon AI PRO R9700
gfx1201, 32 GiB each] gfx1201, 32 GiB each]
B2[vllm-radiance 0.9.3, TP=2 B2[unsloth llama.cpp b11030-mix, ROCm gfx120X
frank-medium, port 7770] frank-primary, port 7770]
C[Qwen3.8-27B MXFP4 + DFlash2 drafter C[Qwen3.8-Flash-Next Q8_0 + shared-Q8_0 MTP head
ONE process on both cards, 262144 ctx, up to 8 seqs] layer split on both cards, 262144 ctx / 1 slot, ngram-mod, vision]
A --> B2 --> C
end end
subgraph Dankclaw[AMD Strix Halo - Ubuntu 26.04] subgraph Dankclaw[AMD Strix Halo - Ubuntu 26.04]
@@ -53,20 +52,34 @@ frank-small, port 7771]
| Server | GPU | VRAM / Memory | OS | | 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 | | [[Dankclaw System Specs\|Dankclaw]] | AMD Radeon 8060S (Strix Halo) | gfx1151 unified memory | Ubuntu 26.04 |
## Active Deployment ## Active Deployment
- [[frank-medium-radiance-mxfp4]] — the ACTIVE frank-medium runbook: - [[frank-primary-llamacpp-qwen38-flash-next-q8]] — the ACTIVE
vllm-radiance (stilldeadcode/vllm-radiance:0.9.3) serving frank-primary runbook (2026-09-26, root systemd unit, port 7770):
just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq (MXFP4/quark W4A8) with the unsloth llama.cpp (b11030-mix prebuilt, ROCm gfx120X, bundled runtime)
z-lab DFlash2-FP8 drafter as ONE tensor-parallel process (TP=2) behind serving unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0 across both R9700s —
:7770, now on unquantized bf16 KV (`KV_DTYPE=auto`, cut 2026-09-21 to test routed experts and the 51B-param n-gram table computed in host RAM
whether the endpoint's historical fp8 KV had been degrading W4A8 quality). (`-ot exps=CPU,ngram_embeddings=CPU`), the shared Q8_0 MTP head
The FP8 checkpoint (orcarouter/Qwen3.8-27B-Uncensored-FP8, the 09-20→09-21 (depth 2) chained behind ngram-mod, BF16 vision, one full 262,144-token
shape) is the on-disk rollback. The three-arm battery that chose TP=2, the conversation, `reasoning_content`, the placement law, the cutover and
auto-sized KV pool, and verified capabilities (tools, reasoning) 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: - [[frank-medium-model-swap]] — how to swap the checkpoint behind frank-medium:
the streamlining toolchain (hf-manifest.py, hf-fetch.sh, the streamlining toolchain (hf-manifest.py, hf-fetch.sh,
swap-frank-medium.sh), the quantization-aware entrypoint contract, the 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) on Dankclaw (superseded 2026-08-24 by the kingjones ROCmFP4 model)
- [[kairic-edge]] — the retired Kairic Edge IU4 experiment - [[kairic-edge]] — the retired Kairic Edge IU4 experiment
(deployed and reverted 2026-08-22) (deployed and reverted 2026-08-22)
- [[Dankserver System Specs]] — the 2x R9700 box (frank-medium) - [[Dankserver System Specs]] — the 2x R9700 box (frank-primary)
- [[Dankclaw System Specs]] — the Strix Halo gfx1151 box (frank-large on the - [[Dankclaw System Specs]] — the Strix Halo gfx1151 box (frank-large on
Kairic Edge runtime since 2026-09-21, frank-small on the RTX 3090) 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 - [[frank-npu]] — RETIRED 2026-09-11: the FLM NPU sight+sound endpoint
on Dankclaw (Gemma4 E4B vision+audio pack, XDNA2, port 7771), kept as on Dankclaw (Gemma4 E4B vision+audio pack, XDNA2, port 7771), kept as
the retirement record — the IOMMU/SVA err=-19 root cause and where the retirement record — the IOMMU/SVA err=-19 root cause and where
+12 -6
View File
@@ -124,12 +124,18 @@ The LLM inference fleet on the two hosts. Full index:
- [[deployment]] — unified deploy and update process for both servers - [[deployment]] — unified deploy and update process for both servers
- [[frank-redo-2026-08-17]] — the 2026-08-17 topology change - [[frank-redo-2026-08-17]] — the 2026-08-17 topology change
- [[frank-primary-lucebox-ds4]] — the ACTIVE frank-primary runbook: - [[frank-primary-llamacpp-qwen38-flash-next-q8]] — the ACTIVE
the Lucebox runtime serving DeepSeek-V4.1-Flash-ROCMFP23 (377.2 GiB frank-primary runbook: unsloth llama.cpp (b11030-mix prebuilt, ROCm
GGUF, 43-layer MoE, 256 routed experts top-6) on Dankserver's R9700 gfx120X, root unit) serving unsloth Qwen3.8-Flash-Next at Q8_0 (175.3
with the routed experts computed on CPU from the mmapped page cache — GiB) on Dankserver's both R9700s — the shared Q8_0 MTP head chained
replaces frank-medium on :7770 (cutover 2026-09-26) behind ngram-mod, BF16 vision, the experts and the 51B n-gram table
- [[frank-medium-radiance-mxfp4]] — the ACTIVE frank-medium runbook: 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 vllm-radiance as ONE tensor-parallel process (TP=2 over the R4D one-shot
allreduce) serving just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq (MXFP4 allreduce) serving just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq (MXFP4
W4A8, DFlash2 drafter, unquantized bf16 KV since 2026-09-21) on Dankserver's W4A8, DFlash2 drafter, unquantized bf16 KV since 2026-09-21) on Dankserver's
@@ -1,7 +1,7 @@
{ {
"uid": "llm-fleet", "uid": "llm-fleet",
"title": "LLM fleet \u2014 capacity and reuse", "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": [ "tags": [
"llm", "llm",
"capacity" "capacity"
@@ -96,7 +96,7 @@
}, },
{ {
"expr": "job:llm_spec_accept_length:rate5m{job=~\"$job\"}", "expr": "job:llm_spec_accept_length:rate5m{job=~\"$job\"}",
"legendFormat": "{{job}} mean accepted length (vLLM)", "legendFormat": "{{job}} mean accepted length",
"refId": "B" "refId": "B"
}, },
{ {
@@ -110,7 +110,7 @@
"id": 3, "id": 3,
"type": "timeseries", "type": "timeseries",
"title": "Requests waiting (queue depth)", "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": { "gridPos": {
"h": 8, "h": 8,
"w": 12, "w": 12,
@@ -138,7 +138,7 @@
"id": 4, "id": 4,
"type": "timeseries", "type": "timeseries",
"title": "Decode and prefill throughput", "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": { "gridPos": {
"h": 8, "h": 8,
"w": 12, "w": 12,
@@ -166,7 +166,7 @@
"id": 5, "id": 5,
"type": "timeseries", "type": "timeseries",
"title": "Decode steps and batch size", "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": { "gridPos": {
"h": 8, "h": 8,
"w": 12, "w": 12,
+7 -7
View File
@@ -36,8 +36,8 @@
Pick a model in the chat's model selector — the three models the fleet runs are Pick a model in the chat's model selector — the three models the fleet runs are
described <a href="#models">below</a>. Open WebUI keeps your chat history; the raw described <a href="#models">below</a>. Open WebUI keeps your chat history; the raw
model endpoints do not.</p> model endpoints do not.</p>
<p>There is no audio input anywhere on the fleet. Text in, and — on <p>Images go to <code>frank-primary</code> or <code>frank-small</code>
<code>frank-small</code> only — images too.</p> (both see them). Audio (WAV) goes to <code>frank-small</code> only.</p>
</section> </section>
<section id="models"> <section id="models">
@@ -54,12 +54,12 @@
<li><strong>Not for:</strong> images. It cannot see them.</li> <li><strong>Not for:</strong> images. It cannot see them.</li>
</ul> </ul>
<h2>frank-medium — the primary model</h2> <h2>frank-primary — the primary model</h2>
<ul> <ul>
<li><strong>Model:</strong> Qwen3.8-27B-Uncensored (MXFP4/W4A8, 19.4 GiB).</li> <li><strong>Model:</strong> Qwen3.8-Flash-Next (Q8_0, 175.3 GiB) with MTP + n-gram speculative drafting on both R9700s.</li>
<li><strong>Capacity:</strong> 262k-token context shared by up to eight conversations at once; sees images.</li> <li><strong>Capacity:</strong> 262k-token context, exactly one conversation at a time; sees images.</li>
<li><strong>Inputs:</strong> text, image.</li> <li><strong>Inputs:</strong> text, image.</li>
<li><strong>Best for:</strong> software engineering, system administration, and instruction tasks. Beats Claude Sonnet 5 per Artificial Analysis.</li> <li><strong>Best for:</strong> software engineering, system administration, and instruction tasks.</li>
</ul> </ul>
<h2>frank-small — the fast utility model</h2> <h2>frank-small — the fast utility model</h2>
@@ -72,7 +72,7 @@
<p class="foot-note">Which one to pick? Small utility work, RAG, search, and <p class="foot-note">Which one to pick? Small utility work, RAG, search, and
screenshots: <code>frank-small</code>. Software engineering, sysadmin, and screenshots: <code>frank-small</code>. Software engineering, sysadmin, and
instruction tasks: <code>frank-medium</code>. Cybersecurity and debugging: instruction tasks: <code>frank-primary</code>. Cybersecurity and debugging:
<code>frank-large</code> (with Frank's permission).</p> <code>frank-large</code> (with Frank's permission).</p>
</section> </section>
+2 -2
View File
@@ -281,12 +281,12 @@
"showLink": false "showLink": false
}, },
{ {
"name": "frank-medium", "name": "frank-primary",
"category": "user", "category": "user",
"section": "AI", "section": "AI",
"details": { "details": {
"role": "Primary model", "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"], "inputs": ["text", "image"],
"use": "Software engineering, system administration, and instruction tasks." "use": "Software engineering, system administration, and instruction tasks."
}, },
+23 -12
View File
@@ -79,25 +79,29 @@ scrape_configs:
# this container, not the guarded vhosts: these are machine clients and # this container, not the guarded vhosts: these are machine clients and
# Prometheus cannot solve an Anubis challenge. # Prometheus cannot solve an Anubis challenge.
# #
# One metric namespace: llamacpp:* — frank-large and frank-small, both # One metric namespace: llamacpp:* — frank-large, frank-small and
# llama.cpp with --metrics and both verified 2026-09-20 to export # 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 # requests_deferred (the queue depth, the only form llama.cpp shows the
# capacity wall in), spec_decode_num_drafts_total, tokens_predicted_total, # capacity wall in), spec_decode_num_drafts_total, tokens_predicted_total,
# and the prompt_tokens_cached_total / prompt_tokens_total reuse pair. # and the prompt_tokens_cached_total / prompt_tokens_total reuse pair.
# #
# frank-large is scraped over host.containers.internal, not its own LAN address: # frank-large and frank-small are scraped over host.containers.internal,
# this Prometheus is a bridge-networked container (Network=dankclaw_default), # not their own LAN addresses: this Prometheus is a bridge-networked
# and on this netavark host a container can only reach the host's LAN IP at a # container (Network=dankclaw_default), and on this netavark host a
# PUBLISHED port — a host-process bind like llama-server's 0.0.0.0:7770 is # container can only reach the host's LAN IP at a PUBLISHED port — a
# refused at 192.168.0.143:7770 while it answers at 169.254.1.2:7770. That is # 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, # 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 # "dial tcp 192.168.0.143:7771") even though the endpoint was healthy and
# exporting: the target address was unreachable by construction, not the # exporting: the target address was unreachable by construction, not the
# service down. Same host, same rule, so both local endpoints use the gateway. # service down. Same host, same rule, so both local endpoints use the
# (2026-09-26) frank-medium is replaced by frank-primary (lucebox DS4.1), # gateway.
# 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; # (2026-09-26) frank-primary is llama.cpp again (the unsloth b11030-mix
# the documented remedy is a blackbox exporter, which is not deployed. # 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" - job_name: "frank-small"
metrics_path: "/metrics" metrics_path: "/metrics"
static_configs: static_configs:
@@ -111,3 +115,10 @@ scrape_configs:
- targets: ["host.containers.internal:7770"] - targets: ["host.containers.internal:7770"]
labels: labels:
service: frank-large service: frank-large
- job_name: "frank-primary"
metrics_path: "/metrics"
static_configs:
- targets: ["192.168.0.235:7770"]
labels:
service: frank-primary
+8 -10
View File
@@ -1,11 +1,10 @@
# Alerting and recording rules for the LLM fleet. # Alerting and recording rules for the LLM fleet.
# #
# One metric namespace: llamacpp:* — frank-small and frank-large, both # One metric namespace: llamacpp:* — frank-small, frank-large and
# llama.cpp with --metrics (the family verified from /metrics on 2026-09-16 # frank-primary, all llama.cpp with /metrics (the family verified from
# and 2026-09-20). frank-primary (Dankserver, the Lucebox DS4.1 engine since # /metrics on 2026-09-16 and 2026-09-20). frank-primary is scraped across
# the 2026-09-26 cutover) answers 404 on /metrics, so nothing here selects # the LAN at 192.168.0.235:7770 (the netavark published-port quirk that
# it; its liveness is covered by the homepage probe, and the documented # forces the gateway for the Dankclaw endpoints is same-host only).
# remedy is a blackbox exporter (not deployed).
# #
# Every expression is keyed to a series that EXISTS. Nothing matches on # Every expression is keyed to a series that EXISTS. Nothing matches on
# absence: when an endpoint's runtime leaves the fleet, its selectors are # absence: when an endpoint's runtime leaves the fleet, its selectors are
@@ -111,7 +110,6 @@ groups:
description: >- description: >-
Prometheus cannot reach the LLM endpoint. frank-small and Prometheus cannot reach the LLM endpoint. frank-small and
frank-large are root units on Dankclaw: `systemctl is-active frank-large are root units on Dankclaw: `systemctl is-active
frank-small` / `frank-large`. Both scraped endpoints export the frank-small` / `frank-large`. frank-primary is a root unit on
llamacpp:* family. frank-primary (Dankserver) is not scraped — Dankserver: `systemctl is-active frank-primary`. All three
its runtime exports no metrics; check the homepage probe or scraped endpoints export the llamacpp:* family.
`systemctl --user is-active frank-primary` on Dankserver.
+15 -8
View File
@@ -21,21 +21,28 @@ load helpers
[[ "$output" != *"tests/homepage_pages.bats"* ]] [[ "$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 \ 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 ] [ "$status" -eq 0 ]
[[ "$output" == *"tests/frank_primary_unit.bats"* ]] [[ "$output" == *"tests/frank_primary_unit.bats"* ]]
[[ "$output" == *"tests/llm_units.bats"* ]]
[[ "$output" == *"tests/deployment_scoping.bats"* ]] [[ "$output" == *"tests/deployment_scoping.bats"* ]]
[[ "$output" == *"tests/docs_completeness.bats"* ]]
[[ "$output" != *"tests/homepage_pages.bats"* ]] [[ "$output" != *"tests/homepage_pages.bats"* ]]
# The retired frank-medium tree must no longer select any LLM seam: the # The retired trees must no longer select any LLM seam: the mappings
# mappings were removed with the service, and a stale case would keep # were removed with the services, and a stale case would keep firing
# firing tests for an archived runtime. Both archive shapes — the radiance # tests for archived runtimes. All three archive shapes — the radiance
# Quadlet era and the llama.cpp layersplit trio before it — map to nothing. # 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 \ run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
dankserver/.AGENTTRASH/20260926-frank-medium-radiance/frank-medium.container \ 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 ] [ "$status" -eq 0 ]
[[ "$output" != *"tests/frank_medium_unit.bats"* ]] [[ "$output" != *"tests/frank_medium_unit.bats"* ]]
[[ "$output" != *"tests/frank_primary_unit.bats"* ]] [[ "$output" != *"tests/frank_primary_unit.bats"* ]]
+14 -4
View File
@@ -63,13 +63,23 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
[ -z "$output" ] [ -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 \ run python3 "$PLANNER" --target dankserver --paths \
dankserver/frank-primary/quadlet/frank-primary.container \ scripts/frank-primary.env \
dankserver/frank-primary/frank-primary-entrypoint.sh \ scripts/frank-primary.service \
dankserver/frank-primary/frank-primary-healthcheck.py scripts/serve-frank-primary.sh
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[ "$output" = "frank-primary" ] [ "$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" { @test "the small endpoint is Dankclaw's again, and Dankserver's copy stays retired" {
+143 -69
View File
@@ -1,94 +1,168 @@
#!/usr/bin/env bats #!/usr/bin/env bats
# Seam: frank-primary — the Dankserver LLM endpoint on :7770. # Seam: frank-primary — the Dankserver LLM endpoint on :7770.
# #
# This file asserts the LUCEBOX shape (the 2026-09-26 cutover that replaced # This file asserts the llama.cpp ROOT-UNIT shape (the 2026-09-26 swap that
# frank-medium): a rootless Quadlet running the lucebox DS4.1 engine on the # replaced the Lucebox DS4.1 Quadlet): the unsloth b11030-mix prebuilt on
# two R9700s, with the model mmapped out of /home/dank/models and the # the two R9700s (gfx1201) serving unsloth/Qwen3.8-Flash-Next-GGUF at Q8_0
# entrypoint/healthcheck mounted straight out of this checkout. # 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 load helpers
UNIT="$REPO_ROOT/dankserver/frank-primary/quadlet/frank-primary.container" UNIT="$REPO_ROOT/scripts/frank-primary.service"
EP="$REPO_ROOT/dankserver/frank-primary/frank-primary-entrypoint.sh" SERVE="$REPO_ROOT/scripts/serve-frank-primary.sh"
HC="$REPO_ROOT/dankserver/frank-primary/frank-primary-healthcheck.py" 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" ] [ -f "$UNIT" ]
[ -x "$EP" ] [ -x "$SERVE" ]
[ -f "$HC" ] [ -f "$ENV_FILE" ]
# The healthcheck is python; a syntax error would leave the container
# permanently unhealthy with the failure hidden inside HealthCmd.
python3 -m py_compile "$HC"
} }
@test "frank-primary is boot-persistent and self-healing" { @test "frank-primary is boot-persistent, self-healing and unprivileged" {
# Rootless Podman does not auto-start bare containers and restart: # Root unit parity with frank-large/frank-small: enable is the [Install]
# unless-stopped does not survive shutdown's explicit stop. The unit must # section, the restart policy survives a crash, and the mmap'd payload
# carry both the boot symlink and a restart policy (2026-08-26 incident: # needs an unbounded memlock.
# a compose-only Caddy never came back after a reboot). grep -q '^WantedBy=multi-user.target$' "$UNIT"
grep -q '^WantedBy=default.target' "$UNIT" grep -q '^Restart=on-failure$' "$UNIT"
grep -qE '^Restart=(always|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" { @test "frank-primary runs the repo's own serve script with the repo's env" {
# luce_server binds 0.0.0.0:7770 directly, so the container takes the grep -q '^ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-primary.sh$' "$UNIT"
# host netns. A PublishPort would be dead config. grep -q 'FRANK_PRIMARY_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-primary.env' "$UNIT"
grep -q -- '--network=host' "$UNIT"
! grep -q '^PublishPort=' "$UNIT"
} }
@test "frank-primary reaches both R9700s and hides the Raphael iGPU" { @test "frank-primary drafts with the shared MTP head chained behind ngram-mod" {
# /dev/kfd + /dev/dri via AddDevice; HIP_VISIBLE_DEVICES=0,1 keeps the # The chain is draft-mtp (context-backed) + ngram-mod (draftless): the
# host's Raphael iGPU (index 2) out of the enumeration so the engine's # only legal way to layer two spec implementations in one process. The
# device picks cannot land on it. # shared Q8_0 head borrows the target's embedding tensors — which is
grep -q '^AddDevice=/dev/kfd' "$UNIT" # why per_layer_token_embd.weight must be CPU-resident (a GPU-resident
grep -q '^AddDevice=/dev/dri' "$UNIT" # borrow crashes the draft load with "invalid vector subscript").
grep -q '^Environment=HIP_VISIBLE_DEVICES=0,1$' "$UNIT" 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" { @test "the bandwidth-huge tensors are computed in host RAM, not streamed" {
# The HIP runtime reads bare TEMP for its JIT cache path (the 2026-08-13 # The x4-pair law: streaming experts over PCIe loses (3.7 tok/s) while
# incident: TEMP=1.0 sent the JIT cache to path "1.0" and segfaulted every # computing them in RAM does not care (20+ tok/s on the same model
# load). HOST and GPU_MAX_HEAP_SIZE are the same class. The unit's # shape). exps=CPU plus the n-gram embedding table on CPU; the PLE
# Environment= lines must not define them; the entrypoint exports # table must NEVER carry a GPU target (the 23 -> 13.4 t/s regression).
# GPU_MAX_HW_QUEUES (explicitly not GPU_MAX_HEAP_SIZE). grep -q '^OT_OVERRIDES=exps=CPU,ngram_embeddings=CPU,per_layer_token_embd.weight=CPU$' "$ENV_FILE"
! grep -qE '^Environment=(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$UNIT" grep -q -- '--override-tensor "$ot"' "$SERVE"
! grep -qE '^(export )?(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$EP" # ...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" { @test "the projector is explicit, never autoloaded" {
# deploy.sh git-pulls this checkout before dispatching, so a Volume # This build never autoloads mmproj from the model directory: without
# reference is how an edit here reaches the container without an image # the flag the server comes up healthy and text-only, answering image
# rebuild. Absolute host paths, read-only. # requests with 500 (the frank-medium 2026-09-17 trap).
grep -q 'Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-entrypoint.sh:/usr/local/bin/frank-primary-entrypoint.sh:ro' "$UNIT" grep -q -- '--mmproj "$MMPROJ_FILE"' "$SERVE"
grep -q 'Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-healthcheck.py:/usr/local/bin/frank-primary-healthcheck.py:ro' "$UNIT" grep -q 'MMPROJ_FILE="$MODEL_ROOT/mmproj-BF16.gguf"' "$SERVE"
} }
@test "first-start patience is configured on both sides of the health check" { @test "frank-primary is vision-serving but holds the single fleet port" {
# Cold start page-ins the dense weights and builds the graphs; the grep -q '^PORT=7770$' "$ENV_FILE"
# container must not be killed as unhealthy while that happens, and the grep -q '^BIND_HOST=0.0.0.0$' "$ENV_FILE"
# deployer must wait the same order of magnitude. grep -q -- '--alias frank-primary' "$SERVE"
grep -q '^HealthStartPeriod=1800s' "$UNIT" grep -q -- '--host "$BIND_HOST"' "$SERVE"
grep -q '^TimeoutStartSec=1800$' "$UNIT" grep -q -- '--port "$PORT"' "$SERVE"
grep -q 'wait_container_healthy frank-primary 1800' \
"$REPO_ROOT/scripts/deploy-remote.sh"
} }
@test "the deployer refuses to start without image and complete payload" { @test "one stream, the model's full native window" {
# A 377 GiB partial download must never be served: the handler gates on local ctx parallel
# the image being present and the model file existing at its exact ctx="$(sed -nE 's/^CONTEXT_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
# declared byte count (stat -c %s, never wc -c — wc read the whole stream parallel="$(sed -nE 's/^PARALLEL_SLOTS=([0-9]+)$/\1/p' "$ENV_FILE")"
# for minutes on 2026-09-26). [ -n "$ctx" ] && [ -n "$parallel" ]
grep -q 'deploy_llm_primary() {' "$REPO_ROOT/scripts/deploy-remote.sh" [ "$ctx" -eq 262144 ]
grep -q 'podman image exists localhost/lucebox-rocm:gfx1201' \ [ "$parallel" -eq 1 ]
"$REPO_ROOT/scripts/deploy-remote.sh" grep -q -- '--ctx-size "$CONTEXT_SIZE"' "$SERVE"
grep -q 'want_size=405349859328' "$REPO_ROOT/scripts/deploy-remote.sh" grep -q -- '--parallel "$PARALLEL_SLOTS"' "$SERVE"
grep -q 'stat -c %s' "$REPO_ROOT/scripts/deploy-remote.sh"
} }
@test "the deployer gates on the port actually being free" { @test "reasoning is extracted to reasoning_content and tools are legal" {
# 7770 is a host-network bind: the previous holder must release it before # The harness contract: llama.cpp endpoints emit reasoning_content
# the new container can be created, or pasta's bind fails at create time # (the deepseek format is the one that fills it), and tools require
# and the failure hides behind Restart=. # --jinja (the ik_llama 500 lesson). The Qwen3.8 template gates
grep -q 'wait_port_free 7770' "$REPO_ROOT/scripts/deploy-remote.sh" # 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"
} }
+8 -7
View File
@@ -61,17 +61,18 @@ PY
@test "the LLM status cards track the fleet, not retired service names" { @test "the LLM status cards track the fleet, not retired service names" {
# This drifted twice: the card probing Dankserver's :7770 was still named # This drifted twice: the card probing Dankserver's :7770 was still named
# frank-small long after frank-medium took that endpoint, and Dankclaw's # frank-small long after frank-medium took that endpoint, and the endpoint
# second endpoint now exists again as a real service on its own port. The # itself has since been frank-medium (radiance), frank-primary (lucebox)
# probe target is what matters; the name has to follow it. # 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' python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
import json, sys import json, sys
services = json.load(open(sys.argv[1])) services = json.load(open(sys.argv[1]))
by_probe = {s["probe"]: s["name"] for s in services} by_probe = {s["probe"]: s["name"] for s in services}
medium = by_probe.get("192.168.0.235:7770") primary = by_probe.get("192.168.0.235:7770")
assert medium == "frank-medium", f"Dankserver :7770 card is named {medium!r}" assert primary == "frank-primary", f"Dankserver :7770 card is named {primary!r}"
# Dankclaw serves two endpoints on two ports: frank-large (halogen, 7770) and # Dankclaw serves two endpoints on two ports: frank-large (unsloth llama.cpp
# frank-small (llama.cpp on the RTX 3090, 7771). # 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: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") assert by_probe.get("127.0.0.1:7771") == "frank-small", by_probe.get("127.0.0.1:7771")
PY PY
+15 -10
View File
@@ -315,24 +315,29 @@ PAIRS
@test "prometheus scrapes every LLM endpoint that exports metrics" { @test "prometheus scrapes every LLM endpoint that exports metrics" {
# The LLM endpoints are the services whose behaviour this fleet argues about # The LLM endpoints are the services whose behaviour this fleet argues about
# most (KV pressure, prefix-cache reuse, queue depth). Only the two Dankclaw # most (KV pressure, prefix-cache reuse, queue depth). All three export
# llama.cpp endpoints export metrics today: frank-primary (Dankserver, the # the llamacpp:* family since 2026-09-26, when frank-primary (Dankserver)
# Lucebox DS4.1 engine since the 2026-09-26 cutover) answers 404 on # went back to llama.cpp — the unsloth b11030-mix prebuilt serving
# /metrics, so a job for it would be a permanently-down target — no job, # Qwen3.8-Flash-Next Q8_0 — after two days of the Lucebox DS4.1 engine,
# and its LAN address must not sneak back in with one. # which exported nothing (404) and had no job.
# The two scraped endpoints are HOST processes reached through # The two Dankclaw endpoints are HOST processes reached through
# host.containers.internal: this Prometheus is a bridge-networked # host.containers.internal: this Prometheus is a bridge-networked
# container, and on this netavark host a container can only reach the # 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 # 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 # 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 # 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" 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:7771"' "$cfg" || { echo "frank-small not scraped"; return 1; }
grep -q '"host.containers.internal:7770"' "$cfg" || { echo "frank-large 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; } grep -q "job_name: \"$job\"" "$cfg" || { echo "no scrape job for $job"; return 1; }
done done
# None of them is reached through a guarded vhost: Prometheus cannot solve # None of them is reached through a guarded vhost: Prometheus cannot solve