Compare commits

..
Author SHA1 Message Date
dank 3d10a4ca4f Merge pull request 'docs+fix(frank-zealot): the adapter's measured effect, and a unit description that stopped lying' (#37) from feat/zealot-uncensored-lora into main 2026-10-19 14:47:59 -07:00
Frank e5b731216c fix(frank-zealot): the unit description still described the 27B era
`systemctl status` said `2x262144 slots over one 524288 pool, unquantized f16
K+V, MTP off` — three payload eras and two reslices out of date, and still
wrong about the thing that decides whether the endpoint can be trusted (the KV
dtype and whether the drafter is armed). It now quotes the env: 3x200704 over
one 602112 pool, f16 K + q8_0 V, MTP armed, adapter, Q8_0 vision.

Touching the unit selects the service in the planner, so this deliberately does
NOT come with a deploy: the hub is serving live conversations. The revision
marker stays behind until the next real frank-zealot change.
2026-10-19 14:47:30 -07:00
Frank ce8d82d4a7 docs(frank-zealot): record what the adapter did, and where provenance is read
Before/after on the two probe prompts (both opened `I cannot provide…`; neither
now carries a marker — one ran to its 2048-token cap, the other finished at
1930), the residual `Important Disclaimer` heading named for what it is (style,
not the circuit), and the same-boot proof that chat/tools/vision still answer.
Provenance now points at the boot ledger: the fork prints every
`prism.abliterate.*` key at each start, so the PTQ1_0-vs-PQ2_0 mismatch is
visible to anyone who boots the service — the page links that instrument
instead of restating it.
2026-10-19 14:47:30 -07:00
dank 02d04bc1f6 Merge pull request 'feat(frank-zealot): apply the refusal-direction adapter (rank-1 LoRA) at load' (#36) from feat/zealot-uncensored-lora into main 2026-10-19 14:43:26 -07:00
Frank 428ac22736 Merge remote-tracking branch 'origin/main' into feat/zealot-uncensored-lora 2026-10-19 14:43:06 -07:00
Frank 5d5cac4da3 docs(frank-zealot): the adapter is a payload row, a shape line, and two laws
- Shape table: an Adapter row (bytes, sha, what it edits, and the PTQ1_0
  provenance asymmetry), the fetch line now names both fetchers, the flag list
  carries --lora-scaled.
- One paragraph: the adapter changes behaviour, not the budget equation, and the
  probe that proves it must budget >= 2048 tokens because the thinking channel
  eats a short budget whole and returns empty content with finish_reason=length.
- Eras: the new era's rollback is one env line on a file already loaded.
- Laws: provenance is metadata, not a gate (tensor names, not hashes; an
  embedding rank would crash, which is why the publisher left token_embd alone);
  the fork pin now has a third reason, since older builds refuse the adapter with
  `unsupported prism.hadamard.version: 2`. Base-alignment law updated: the 9B
  refusal escape hatch was a fetch, and it has now been taken.
- AGENTS.md: topology payload cell and the zealot operator block.
2026-10-19 14:42:56 -07:00
Frank 3eaba57925 feat(frank-zealot): apply the refusal-direction adapter at load
Frank ordered the adapter; this adds it to the shape as one knob and one flag.

- `LORA_FILE` / `LORA_SCALE` in the env, `--lora-scaled <file>:<scale>` as ONE
  argv token so a scale can never reach the engine without its adapter. The
  rollback follows the mmproj convention exactly: `LORA_FILE=` (explicitly
  empty) drops the flag entirely, no rebuild, and `LORA_SCALE=0` is the card's
  own "inert but present" value.
- New fetcher `scripts/download-frank-zealot-uncensored-lora.sh`: `-f`, byte
  count, sha256, `--verify-only`, idempotent. The file is NOT LFS (a plain git
  object), so the pinned sha is the content hash, not an LFS oid.
- The deployer gates on the adapter beside the pack and the projector, and its
  error names the ADAPTER's fetcher — sending an operator to the bonsai9 script
  for a 3 MB file is how a payload ships without it.
- Seam: path+scale pinned as one token, the scale knob including 0, absence on
  the empty opt-out, a refusal to boot on a missing adapter, and the env ↔
  deployer anti-drift pair extended to LORA_FILE/LLM_ZEALOT_LORA. 454/454.

Provenance, stated where it will be read: the adapter's own metadata names
`Ternary-Bonsai-2-9B-PTQ1_0.gguf` (3561df7a…) as the pack the direction was
fitted against. This endpoint serves the PQ2_0+MTP+Q8_0 packing (c79fcdfa…),
which is not that file and is not on this host. The loader matches tensor names,
not hashes, so it applies — and build 10777 is the build the card verified on —
but the strength is calibrated on a sibling quantization. The proof is therefore
the refusal probe against the live endpoint, captured before and after, and not
the card's table.
2026-10-19 14:42:56 -07:00
dank 9b9c32005f Merge pull request 'docs(frank-tts): the engine computes on the gfx1151 now; the NPU question is closed' (#35) from docs/frank-tts-igpu-followup into main 2026-10-19 14:38:33 -07:00
Frank ea2fc6440b docs(frank-tts): record the iGPU cutover, close the NPU question
The cutover PR merged the runtime; this carries the surface that has to be
true afterwards.

- Engine page: the shape is now `--device Vulkan0` on the gfx1151 as a
  co-tenant beside frank-dragoon; the registry/thread/streaming laws gain a
  fourth — the device law — whose numbers stay in the verdict report, and the
  deploy record gets the cutover steps including the `glslc` configure gate.
  The CPU-era arm/rollback prose keeps the qualitative argument and drops the
  dead numbers per the purpose test.
- Endpoint page: "Where the engine computes" now says iGPU + co-tenancy, and
  states the NPU verdict in one paragraph instead of a research tour.
- Quadlet comment: it claimed supplementary groups are what grant render
  access. On this host the udev rule world-writes the render nodes and the
  container's `id` shows no groups at all, so keep-groups is belt-and-braces;
  the RSS note now matches the device shape instead of the CPU trial.
- Verdict report: gate 2 (contention) recorded as measured after the cutover,
  and the build-stage prediction corrected — the package is `glslc`, not
  `libvulkan-dev glslang-dev`.
- index.md regenerated (one description changed).
2026-10-19 14:37:29 -07:00
dank 6248f02ca2 Merge pull request 'feat(frank-tts-engine): synthesize on the gfx1151 iGPU (Vulkan), CPU stays as the rollback' (#34) from feat/frank-tts-vulkan into main 2026-10-19 14:27:11 -07:00
Frank 25df9d05a2 feat(frank-tts-engine): synthesize on the gfx1151 iGPU via Vulkan
The NPU path is a dead end for this payload (verdict:
report/tts-npu-spike-verdict-2026-10-19.md); the iGPU is the accelerator that
exists on this box today, and it is already shared with frank-dragoon.

- Containerfile.engine: -DGGML_VULKAN=ON, plus the three packages the door
  needs to configure — libvulkan-dev, spirv-headers, and glslc, which Ubuntu's
  glslang-tools does NOT ship (find_package(Vulkan COMPONENTS glslc REQUIRED)
  fails without it) — and libvulkan1 + mesa-vulkan-drivers (the RADV ICD) at
  runtime. No NVIDIA ICD on purpose: one Vulkan device in the enumeration is
  what makes the NAME Vulkan0 safe to write down.
- tts-engine-start.sh: QWENTTS_DEVICE -> --device, default Vulkan0. A value
  that is set but EMPTY passes no flag at all, which is the CPU rollback; that
  is why the default is written as an -v test and not with :=, because := also
  fires on an empty value and would silently undo the rollback.
- quadlet: AddDevice=/dev/dri + GroupAdd=keep-groups (a rootless container that
  drops its supplementary groups enumerates no render device). The node is not
  named: this host is hybrid, renderD128 is the NVIDIA card and renderD129 the
  amdgpu iGPU.
- env: QWENTTS_DEVICE=Vulkan0 plus the device law in the header, including the
  rollback line.
- tests: the two CPU-only assertions now assert the Vulkan shape, and a new one
  pins the rollback (empty device => no --device in argv). 451/451 green.

Measured before writing any of it: the configure in the digest-pinned base
answers "Found Vulkan: ... found components: glslc" and "Including Vulkan
backend"; gate 1 (RADV GFX1151 visible to a rootless container off this base)
is recorded in the verdict report.
2026-10-19 14:26:58 -07:00
dank 7601edfe28 Merge pull request 'docs(LLM): the decode stall a prefill causes, and the knobs that turn it' (#27) from docs/radiance-prefill-decode-census into main 2026-10-19 14:21:45 -07:00
17 changed files with 527 additions and 109 deletions

No files matched your search

+6 -2
View File
@@ -27,7 +27,7 @@ merge absorbed the former `vllmScripts` repo, history preserved).
|--------|---------|---------|---------|------| |--------|---------|---------|---------|------|
| Dankserver | `frank-archon` | radiance, digest-pinned, root unit, TP=2 | publisher `qwen3.8-next-flash-fp8-iq4r-moe.rad` | 7770 | | Dankserver | `frank-archon` | radiance, digest-pinned, root unit, TP=2 | publisher `qwen3.8-next-flash-fp8-iq4r-moe.rad` | 7770 |
| Dankclaw | `frank-dragoon` | halogen-flash-server 0.16.2, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 | | Dankclaw | `frank-dragoon` | halogen-flash-server 0.16.2, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 |
| Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 10777, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj | 7771 | | Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 10777, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj + rank-1 refusal adapter | 7771 |
Payload identity, shape, eras, laws and rollback live in ONE page each: Payload identity, shape, eras, laws and rollback live in ONE page each:
[[frank-archon]], [[frank-dragoon]], [[frank-zealot]]. `scripts/<service>.env` [[frank-archon]], [[frank-dragoon]], [[frank-zealot]]. `scripts/<service>.env`
@@ -440,7 +440,11 @@ disagrees with it is the bug.
`prism.hadamard.*` dequant metadata — stock llama.cpp refuses it, and a `prism.hadamard.*` dequant metadata — stock llama.cpp refuses it, and a
loader ignoring that metadata dequantizes wrong); payload loader ignoring that metadata dequantizes wrong); payload
`Bonsai2-9B-MTP/…PQ2_0.gguf` + the publisher's **Q8_0** mmproj (the BF16 `Bonsai2-9B-MTP/…PQ2_0.gguf` + the publisher's **Q8_0** mmproj (the BF16
tower can crash the server with concurrent image requests). tower can crash the server with concurrent image requests) + the rank-1
refusal-direction adapter applied at load by `--lora-scaled …:1`. The
adapter's rollback is `LORA_FILE=` (empty) in the env — one line, no rebuild
— and build 10777 is also the gate that lets it load: older forks refuse it
with `unsupported prism.hadamard.version: 2`.
- Shape: `--ctx-size 602112 --parallel 3` = **three conversations at - Shape: `--ctx-size 602112 --parallel 3` = **three conversations at
200,704 each**, `CACHE_TYPE_K=f16` + `CACHE_TYPE_V=q8_0` (asymmetric: 25,088 200,704 each**, `CACHE_TYPE_K=f16` + `CACHE_TYPE_V=q8_0` (asymmetric: 25,088
B/token, 14,406 MiB of pool), batch 6144 / ubatch 1024, B/token, 14,406 MiB of pool), batch 6144 / ubatch 1024,
+44 -6
View File
@@ -19,11 +19,14 @@
# is recorded in the parent tree, so cloning the parent commit recursively is # is recorded in the parent tree, so cloning the parent commit recursively is
# reproducible without a second pin. # reproducible without a second pin.
# #
# THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim: # THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim — with the Vulkan door
# open since 2026-10-19. That configure was run in this exact base image and
# answers `Found Vulkan: /usr/lib/x86_64-linux-gnu/libvulkan.so (found version
# "1.3.275") found components: glslc` then `Including Vulkan backend`:
# #
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \ # cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \
# -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \ # -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \
# -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF \ # -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=ON \
# -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \ # -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \
# && cmake --build build -j 8 # && cmake --build build -j 8
# #
@@ -42,8 +45,12 @@
# defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS # defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS
# host — correct while build host == run host, wrong if anyone ever builds # host — correct while build host == run host, wrong if anyone ever builds
# this image elsewhere, which is why the fleet builds it on Dankclaw. # this image elsewhere, which is why the fleet builds it on Dankclaw.
# CUDA/HIP/VULKAN/METAL/SYCL/RPC all OFF: this is a CPU endpoint (the trial # CUDA/HIP/METAL/SYCL/RPC stay OFF, VULKAN is ON: the endpoint computes on the
# ran on CPU; the box's GPUs belong to the text endpoints). # gfx1151 iGPU (decision and co-tenancy gates:
# report/tts-npu-spike-verdict-2026-10-19.md, shape in [[Frank TTS Engine]]).
# Nothing else is available to this image: no ROCm userland, no NVIDIA
# userland, no macOS, no oneAPI, and RPC would add a second process to
# supervise on a box that already has one engine per card.
# #
# BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so # BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so
# tts-server links libggml*.so rather than swallowing them; the runtime stage # tts-server links libggml*.so rather than swallowing them; the runtime stage
@@ -67,9 +74,28 @@ ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02f
# ------------------------------------------------------------------ build # ------------------------------------------------------------------ build
FROM ${BASE_IMAGE} AS build FROM ${BASE_IMAGE} AS build
# The three Vulkan build deps are not optional and not interchangeable:
# libvulkan-dev vulkan/vulkan.h + the VulkanHeaders/VulkanLoader CMake
# configs, i.e. what find_package(Vulkan) resolves to.
# glslc ggml compiles its shaders with glslc, not glslangValidator.
# Ubuntu's glslang-tools ships glslangValidator and NOT glslc,
# so without this package `find_package(Vulkan COMPONENTS
# glslc REQUIRED)` fails and there is no Vulkan backend.
# The archive's glslc is old (2023.8): it compiles
# GL_KHR_cooperative_matrix — the path ggml's quantized
# matmul needs — but reports cooperative_matrix2,
# integer_dot_product, bfloat16 and the micro-float
# extensions as unsupported, so those shader families are
# compiled out. If a Vulkan prefill ever reads as
# inexplicably slow, suspect this compiler before the device;
# the fix is a pinned glslang build passed as
# -DVulkan_GLSLC_EXECUTABLE=<path>, not a different runtime.
# spirv-headers find_package(SPIRV-Headers CONFIG REQUIRED); the distro
# package ships /usr/share/cmake/SPIRV-Headers/.
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \ && apt-get install -y -qq --no-install-recommends \
ca-certificates cmake g++ git make \ ca-certificates cmake g++ git make \
libvulkan-dev glslc spirv-headers \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Checked out detached at a full SHA, then the recorded submodule. No branch # Checked out detached at a full SHA, then the recorded submodule. No branch
@@ -90,7 +116,7 @@ RUN cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DGGML_BLAS=OFF \ -DGGML_BLAS=OFF \
-DGGML_CUDA=OFF \ -DGGML_CUDA=OFF \
-DGGML_HIP=OFF \ -DGGML_HIP=OFF \
-DGGML_VULKAN=OFF \ -DGGML_VULKAN=ON \
-DGGML_METAL=OFF \ -DGGML_METAL=OFF \
-DGGML_SYCL=OFF \ -DGGML_SYCL=OFF \
-DGGML_RPC=OFF \ -DGGML_RPC=OFF \
@@ -102,9 +128,21 @@ FROM ${BASE_IMAGE}
# libgomp1: the OpenMP runtime the build above links against. curl: the unit's # libgomp1: the OpenMP runtime the build above links against. curl: the unit's
# HealthCmd. ca-certificates: so that curl can speak https if anyone ever # HealthCmd. ca-certificates: so that curl can speak https if anyone ever
# points a probe at a URL with a scheme this host terminates elsewhere. # points a probe at a URL with a scheme this host terminates elsewhere.
#
# libvulkan1 + mesa-vulkan-drivers are what make `--device Vulkan0` mean
# anything at runtime: libvulkan1 is the loader libggml-vulkan.so dlopens, and
# mesa-vulkan-drivers is the RADV ICD that claims the gfx1151. Loader without an
# ICD enumerates ZERO devices, and tts-server then dies in device lookup AFTER
# both GGUFs have been read — the journal reads as a model fault, not a missing
# driver. Enumerated from this base on 2026-10-19 with `--device /dev/dri
# --group-add keep-groups` plus the host's ComfyUI udev rule: "Radeon 8060S
# Graphics (RADV GFX1151)", API 1.4.318 (the heap figure is owned by
# [[Frank TTS Engine]]; it is the GTT-backed aperture, not the ~2 GiB DRM
# carve-out). No NVIDIA ICD is installed on purpose: one Vulkan device in the
# enumeration is what makes the NAME Vulkan0 safe to write down.
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \ && apt-get install -y -qq --no-install-recommends \
ca-certificates curl libgomp1 \ ca-certificates curl libgomp1 libvulkan1 mesa-vulkan-drivers \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Same unprivileged uid as the frank-tts façade (10000): the voice packs and the # Same unprivileged uid as the frank-tts façade (10000): the voice packs and the
@@ -40,12 +40,28 @@
# speaks to the server from inside its own namespace, which is where the address # speaks to the server from inside its own namespace, which is where the address
# is valid. # is valid.
# #
# MEMORY / NO GPU. Peak RSS measured 5.7 GiB with both GGUFs resident # MEMORY / THE RENDER NODE. Host RSS is ~1.7 GiB, not the size of the GGUFs:
# (2026-10-18 trial); no memory limit is set because the box has the room and a # the Vulkan backend copies both tensors into device memory at load, so they
# limit would convert a busy moment into an OOM kill mid-utterance. No # stop counting in the process's RSS (measured after the 2026-10-19 cutover —
# /dev/kfd, no /dev/dri, no keep-groups: every GPU backend is compiled out of # `podman stats` / `ps -o rss`). No memory limit is set: the box has the room
# the image, the endpoint is CPU-only by decision, and the GPUs on this host # and a limit would convert a busy moment into an OOM kill mid-utterance.
# belong to the text endpoints. #
# `AddDevice=/dev/dri` is what makes the iGPU visible; without it the Vulkan
# ICD enumerates nothing and the server dies on device lookup after it has
# already read both GGUFs. `GroupAdd=keep-groups` is belt-and-braces, not the
# thing that works here: the units podman spawns carry no supplementary groups
# at all, and this host grants render access a different way — the udev rule
# that makes the render nodes world-writable (same rule ComfyUI rides, see
# dankclaw/comfyui). Measured: `id` inside this container lists only the
# container's own group, and `tts-server` still holds renderD129.
# The node is NOT named: this host is hybrid, renderD128 is the NVIDIA card and
# renderD129 the amdgpu iGPU, so a node number in the unit is a guess that a
# re-probe can invalidate, while the directory hands the container every render
# node and the driver picks by name.
#
# /dev/kfd stays absent on purpose: that is the ROCm compute interface, owned by
# the engines that use HIP. A Vulkan endpoint has no use for it, and handing a
# container a second way onto the GPU is surface for nothing.
# #
# THE VOICE MOUNT IS READ-ONLY AND IS FOR THE WARMER, NOT THE ENGINE. A voice # THE VOICE MOUNT IS READ-ONLY AND IS FOR THE WARMER, NOT THE ENGINE. A voice
# becomes known to the engine only through POST /v1/audio/voices, and the # becomes known to the engine only through POST /v1/audio/voices, and the
@@ -98,6 +114,9 @@ Network=frank-tts-engine.network
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts-engine.env EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts-engine.env
Volume=/home/dank/models/Qwen3-TTS-GGUF:/models:ro Volume=/home/dank/models/Qwen3-TTS-GGUF:/models:ro
Volume=/home/dank/voices:/voices:ro Volume=/home/dank/voices:/voices:ro
# Render access for the iGPU (see MEMORY / THE RENDER NODE above).
AddDevice=/dev/dri
GroupAdd=keep-groups
HealthCmd=curl -fsS http://127.0.0.1:8880/health HealthCmd=curl -fsS http://127.0.0.1:8880/health
HealthInterval=30s HealthInterval=30s
HealthTimeout=10s HealthTimeout=10s
+54 -19
View File
@@ -7,7 +7,7 @@
# supervision, no retries, no logging of its own. PID 1 stays the server, so # supervision, no retries, no logging of its own. PID 1 stays the server, so
# `podman stop` and the unit's Restart=always drive it directly. # `podman stop` and the unit's Restart=always drive it directly.
# #
# THE FLAGS THAT ARE NOT HERE, and must stay absent: # THE FLAG THAT MUST STAY ABSENT:
# #
# --threads / -t does not exist in this build. src/backend.h computes the # --threads / -t does not exist in this build. src/backend.h computes the
# CPU thread count as std::thread::hardware_concurrency()/2 # CPU thread count as std::thread::hardware_concurrency()/2
@@ -18,13 +18,19 @@
# placement — pinning is deliberately not done anywhere in # placement — pinning is deliberately not done anywhere in
# this stack. See the env file header for the same warning # this stack. See the env file header for the same warning
# aimed at the operator. # aimed at the operator.
# --device left at its default. Every GPU backend is compiled out of
# the image, so the default device pick can only land on
# CPU; naming a device would add a way to be wrong.
# #
# Everything defaults to the shape the 2026-10-18 trial measured, so an empty # --device IS here, but conditionally: it reaches the binary only when
# environment still boots the decided configuration: 1.7 B talker Q4_K_M with # QWENTTS_DEVICE is non-empty, and empty is the CPU rollback — the binary then
# the Q8_0 tokenizer, one request at a time, on the private bridge. # takes its own default device, which is CPU because the CPU backend is always
# built. One line in scripts/frank-tts-engine.env moves the endpoint between the
# two shapes, with no rebuild. What must never reach argv is a device NAME the
# loader does not enumerate: that is not refused by the argument parser, it dies
# during device lookup with the loader's own error, so the journal reads as a
# load that failed rather than a configuration refusal.
#
# Everything defaults to the shape this endpoint runs: 1.7 B talker Q4_K_M with
# the Q8_0 tokenizer, one request at a time, on the private bridge, on the
# gfx1151 iGPU. An empty environment still boots that shape.
set -euo pipefail set -euo pipefail
# The binary path. Overridable for one reason: the bats seam runs a stub # The binary path. Overridable for one reason: the bats seam runs a stub
@@ -60,13 +66,33 @@ set -euo pipefail
: "${QWENTTS_LANG:=auto}" : "${QWENTTS_LANG:=auto}"
# One request at a time. The server-side queue is a serialised FIFO at this # One request at a time. The server-side queue is a serialised FIFO at this
# setting, which is what keeps the measured per-voice timings true; batching # setting, which is what keeps the per-voice timings predictable. A second
# on a CPU build splits the same cores between two syntheses and the warm RTF # concurrent synthesis shares the device with the first — and on this box that
# margin (0.55-0.67 against real time 1.0) disappears. The engine's own # device is also carrying frank-dragoon — so it buys no throughput and it
# default is also 1; it is written here because it is a decision, not an # doubles the wait for the caller behind it. The engine's own default is also 1;
# accident. # written here because it is a decision, not an accident.
: "${QWENTTS_MAX_BATCH:=1}" : "${QWENTTS_MAX_BATCH:=1}"
# The compute device, NAMED and not numbered: `Vulkan0` is how ggml enumerates
# the gfx1151 iGPU on this host. An index would follow whatever order the
# loader's device list returns; a name says which device this endpoint means.
# The container sees exactly one Vulkan device because the image carries the
# Mesa ICD and no NVIDIA one, so the 3090 is not in the enumeration at all.
#
# EMPTY IS THE CPU ROLLBACK: a value that is set but EMPTY passes no `--device`,
# the binary picks its own default, and the CPU backend is always built — which
# is why moving this endpoint between the iGPU and the CPU is one line in
# scripts/frank-tts-engine.env and not a rebuild.
#
# That is also why the default below is NOT `${QWENTTS_DEVICE:=Vulkan0}`: `:=`
# fires on a set-but-empty variable as well as an unset one, so the rollback line
# `QWENTTS_DEVICE=` would be rewritten back to Vulkan0 and the endpoint would sit
# on the shared GPU with no way out short of a rebuild. Only UNSET takes the
# default.
if [[ ! -v QWENTTS_DEVICE ]]; then
QWENTTS_DEVICE=Vulkan0
fi
# Fail loudly BEFORE binding, so the journal says which artifact is missing # Fail loudly BEFORE binding, so the journal says which artifact is missing
# instead of the unit reporting a container that starts and dies. This is the # instead of the unit reporting a container that starts and dies. This is the
# self-healing path: Restart=always re-runs it every RestartSec, and the same # self-healing path: Restart=always re-runs it every RestartSec, and the same
@@ -79,11 +105,20 @@ for artifact in "$QWENTTS_MODEL" "$QWENTTS_CODEC"; do
fi fi
done done
exec "$QWENTTS_BIN" \ ARGS=(
--model "$QWENTTS_MODEL" \ --model "$QWENTTS_MODEL"
--codec "$QWENTTS_CODEC" \ --codec "$QWENTTS_CODEC"
--alias "$QWENTTS_ALIAS" \ --alias "$QWENTTS_ALIAS"
--host "$QWENTTS_HOST" \ --host "$QWENTTS_HOST"
--port "$QWENTTS_PORT" \ --port "$QWENTTS_PORT"
--lang "$QWENTTS_LANG" \ --lang "$QWENTTS_LANG"
--max-batch "$QWENTTS_MAX_BATCH" --max-batch "$QWENTTS_MAX_BATCH"
)
# Only when a device is named. An empty QWENTTS_DEVICE has to produce NO flag,
# because "the rollback shape" is argv identical to the pre-iGPU endpoint.
if [[ -n "$QWENTTS_DEVICE" ]]; then
ARGS+=(--device "$QWENTTS_DEVICE")
fi
exec "$QWENTTS_BIN" "${ARGS[@]}"
+28 -17
View File
@@ -136,25 +136,36 @@ TTS engine today|3.84 GiB RSS, CPU|its device-resident replacement lands in that
ICD**, so Vulkan device 0 is the iGPU here. Do not trust that ordering — select by ICD**, so Vulkan device 0 is the iGPU here. Do not trust that ordering — select by
device *name*, or an image that grows an `nvidia_icd.json` silently points TTS at the device *name*, or an image that grows an `nvidia_icd.json` silently points TTS at the
3090 slot. 3090 slot.
2. **Contention gate.** `frank-dragoon` is the endpoint the operator session runs on, so the 2. **Contention gate — PASSED, measured 2026-10-19 after the cutover.** With a 16.9 s
honest measurement with both loaded is dragoon's own TTFT and decode with a synthesis in synthesis occupying the device, `frank-dragoon` answered a streamed 124-token request at
flight — one request each, no ladder. Expect compute-unit sharing rather than memory **17 ms to first token and ~35 tok/s decode**, against 17 ms and ~33 tok/s for the same
pressure: the aperture has room, the shaders do not. request with the card idle. Compute-unit sharing showed up as nothing. One request per
arm, no ladder: these are the gate's evidence, not a throughput record, and the engine's
own shape lives in [[Frank TTS Engine]].
**Change surface** — the whole edit, so nobody re-derives it: **Change surface — as landed** (PR #34, `6248f02`; the CPU shape stays reachable as the
rollback):
1. `Containerfile.engine` build stage: add `libvulkan-dev glslang-dev`. It installs only 1. `Containerfile.engine` build stage: add **`libvulkan-dev glslc spirv-headers`**. The
`ca-certificates cmake g++ git make` today, and ggml's Vulkan backend compiles its prediction above (`libvulkan-dev glslang-dev`) is wrong on two counts and the configure
shaders at build time — the flag alone fails at configure. says so: ggml looks for `glslc`, which Ubuntu's `glslang-tools` does **not** ship (it
2. `-DGGML_VULKAN=OFF` → `ON` in the same cmake block; the flag list is written out ships `glslangValidator`), and `find_package(SPIRV-Headers CONFIG REQUIRED)` needs the
explicitly, so this is a one-word diff. distro's `spirv-headers`, which does carry the CMake config. `glslc` is its own source
3. Runtime stage: add `mesa-vulkan-drivers`. Without a Vulkan **ICD inside the image** the package on noble, at 2023.8 — old enough that only `GL_KHR_cooperative_matrix` compiles,
device node buys nothing — there is no `radeon_icd.json` in the container today. so the coopmat2 / integer-dot / bf16 / micro-float shader families are built out. Suspect
4. `frank-tts-engine.container`: `AddDevice=/dev/dri` + `GroupAdd=keep-groups` — **not** that compiler before the device if a Vulkan prefill ever reads as slow.
`renderD128`. On this hybrid box that node is the NVIDIA card; the iGPU is 2. `-DGGML_VULKAN=OFF` → `ON`: one word in the written-out flag list.
`renderD129` (`readlink -f /sys/class/drm/renderD129/device/driver` → `amdgpu`). 3. Runtime stage: `mesa-vulkan-drivers` **and** `libvulkan1` — the ICD and its loader. An
5. Engine argv: `--device Vulkan0` — qwentts.cpp's tools take `--device <name>` at the image with a loader and no ICD enumerates zero devices.
pinned commit. 4. `frank-tts-engine.container`: `AddDevice=/dev/dri` + `GroupAdd=keep-groups` — the
DIRECTORY, never the node: on this hybrid box `renderD128` is the NVIDIA card and
`renderD129` the amdgpu iGPU (`readlink -f /sys/class/drm/renderD129/device/driver` →
`amdgpu`). On this host the udev rule already makes the nodes world-writable, so the
group is belt-and-braces, not the gate.
5. Engine argv: `--device Vulkan0`, reached through `QWENTTS_DEVICE` in
`scripts/frank-tts-engine.env`. **An empty value passes no flag at all** — that is the
CPU rollback, and it is why the entrypoint's default is an `-v` test rather than `:=`,
which also fires on a set-but-empty value and would silently undo the rollback.
Gate 1 recipe, already run and **PASSED**: `mesa-vulkan-drivers` + `vulkan-tools` in a Gate 1 recipe, already run and **PASSED**: `mesa-vulkan-drivers` + `vulkan-tools` in a
throwaway container off the same base, `--device /dev/dri`, then `vulkaninfo --text` and read throwaway container off the same base, `--device /dev/dri`, then `vulkaninfo --text` and read
+13
View File
@@ -1463,6 +1463,12 @@ LLM_ZEALOT_RUNTIME="${LLM_ZEALOT_RUNTIME:-/home/dank/GitPackages/llama-prism-b10
# 868.32 MiB worst-case VRAM (595.29 model + 248.10 compute in the boot ledger) # 868.32 MiB worst-case VRAM (595.29 model + 248.10 compute in the boot ledger)
# lands AFTER the engine's fit check, so it is not inside the q8_0 budget. # lands AFTER the engine's fit check, so it is not inside the q8_0 budget.
LLM_ZEALOT_MMPROJ="${LLM_ZEALOT_MMPROJ:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}" LLM_ZEALOT_MMPROJ="${LLM_ZEALOT_MMPROJ:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}"
# The refusal adapter is part of the payload identity now, so the deployer
# checks it beside the other two files — but it has its OWN fetcher, so a
# missing adapter must not send the operator to the bonsai9 script. Integrity
# (bytes + sha256) is the fetcher's job at download time; this gate only asks
# whether the file the env names exists, exactly like the two above.
LLM_ZEALOT_LORA="${LLM_ZEALOT_LORA:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf}"
deploy_llm_zealot() { deploy_llm_zealot() {
# Refuse rather than restart-loop: a unit whose runtime or payload is # Refuse rather than restart-loop: a unit whose runtime or payload is
@@ -1484,6 +1490,13 @@ deploy_llm_zealot() {
return 1 return 1
fi fi
done done
if [[ ! -f "$LLM_ZEALOT_LORA" ]]; then
echo "ERROR: frank-zealot refusal adapter absent at $LLM_ZEALOT_LORA" >&2
echo "frank-zealot: run scripts/download-frank-zealot-uncensored-lora.sh first;" >&2
echo "frank-zealot: the env names the adapter, so the unit would refuse to boot" >&2
echo "frank-zealot: without it — starting the unit anyway is a crash loop." >&2
return 1
fi
relink_unit frank-zealot relink_unit frank-zealot
run_privileged systemctl daemon-reload run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-zealot.service run_privileged systemctl enable frank-zealot.service
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Fetch the refusal-direction adapter that frank-zealot applies at load:
# AtomicChat/Ternary-Bonsai-2-9B-Uncensored-LoRA-GGUF — a **3.2 MB rank-1 LoRA**
# for prism-ml/Ternary-Bonsai-2-9B, not a checkpoint. Public repo: no auth
# header, and never a token on the command line (a Bearer token is
# world-readable in /proc/<pid>/cmdline).
#
# IT IS AN EDIT INSIDE THE COMPUTE GRAPH, NOT A SECOND MODEL. The card: B = -r
# (the refusal direction, row 23 of its own probe matrix), A = P^T(s *
# H(r^T W_fold)) built from the base pack's folded input basis, applied at every
# transformer layer (`target_layers = all`, `sites = 64`), with the embedding
# deliberately untouched because an embedding LoRA crashes this fork's graph.
# `--lora-scaled <file>:<scale>` is the flag pair: scale 1 is the card's
# "held-out refusals 0 %" point, scale 0 is the plain model again.
#
# THE RUNTIME IS A GATE, NOT A PREFERENCE. The card verified the adapter on
# `prism-b10777-e3de715` — the exact build frank-zealot runs — and says older
# forks (e.g. `prism-b10709`) refuse these packs with `unsupported
# prism.hadamard.version: 2`. The fork is already pinned for the payload's
# custom tensor type 142; this adapter adds a second reason it cannot move.
#
# PROVENANCE ASYMMETRY, STATED HONESTLY. The GGUF's own metadata names the pack
# the direction was fitted against:
# prism.abliterate.base_gguf = Ternary-Bonsai-2-9B-PTQ1_0.gguf
# prism.abliterate.base_sha256 = 3561df7ac7d26a71ffa991ac2db740b10841c3a350aa2676ccd3ff5d428b4e8b
# frank-zealot serves the PQ2_0 + MTP + Q8_0-embedding packing (its sha256 is
# c79fcdfa…, see download-frank-zealot-bonsai9.sh), which is NOT that file and is
# not on this host. The adapter still applies — same tensor names, and the
# metadata is provenance rather than a checksum gate — but its strength was
# calibrated on a sibling quantization. What proves the effect here is the
# behaviour of the endpoint (a refusal probe before and after the swap), not the
# metadata and not the card's table, which was measured on PTQ1_0.
#
# Fresh downloads, no resume; -f/--fail-with-body so an HTTP error is not a
# success, then byte count and sha256 (this repo is NOT LFS — the file is a
# plain git object, so the sha below is the content hash of the Hub's bytes as
# fetched, not an LFS oid). Idempotent: a complete file is skipped.
#
# Usage:
# scripts/download-frank-zealot-uncensored-lora.sh [DEST_DIR] [--verify-only]
set -euo pipefail
DEST=""
VERIFY_ONLY=0
for arg in "$@"; do
case "$arg" in
--verify-only) VERIFY_ONLY=1 ;;
*) DEST="$arg" ;;
esac
done
DEST="${DEST:-/home/dank/models/Bonsai2-9B-MTP}"
REPO="AtomicChat/Ternary-Bonsai-2-9B-Uncensored-LoRA-GGUF"
# name | bytes | sha256 of the Hub's bytes
FILES=(
"Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf|3155424|861b9c099fb5cd81438ba85bad30590847c777b1e18112586be7f4f9ae5fb07b"
)
mkdir -p "$DEST"
status=0
for entry in "${FILES[@]}"; do
IFS='|' read -r name size sha <<<"$entry"
url="https://huggingface.co/${REPO}/resolve/main/${name}"
f="$DEST/$name"
have=0
if [[ -f "$f" ]] && [[ "$(stat -c%s "$f")" == "$size" ]]; then
if sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
have=1
fi
fi
if (( have )); then
echo "OK $name"
continue
fi
if (( VERIFY_ONLY )); then
echo "MISSING $name"
status=1
continue
fi
echo "FETCHING $name (from $REPO)"
tmp="$f.part"
curl -fsSL --fail-with-body -o "$tmp" "$url"
mv "$tmp" "$f"
if [[ "$(stat -c%s "$f")" != "$size" ]]; then
echo "SIZE FAIL $name ($(stat -c%s "$f") != $size)"
status=1
continue
fi
if ! sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
echo "SHA FAIL $name"
status=1
continue
fi
echo "VERIFIED $name"
done
if (( status == 0 )); then
echo "DOWNLOAD_DONE $DEST"
else
echo "DOWNLOAD_FAILED $DEST" >&2
fi
exit "$status"
+21
View File
@@ -27,6 +27,16 @@
# below stop describing the process you are running. Nothing in this fleet # below stop describing the process you are running. Nothing in this fleet
# pins this container; that is a decision, not an oversight. # pins this container; that is a decision, not an oversight.
# #
# THE DEVICE LAW (since the 2026-10-19 Vulkan cutover). Synthesis computes on
# the gfx1151 iGPU — the SAME card frank-dragoon serves. It is a shared device,
# never a dedicated one, so two numbers matter and neither is what `rocm-smi`
# prints first: the memory the container may use is the RADV heap inside the
# GTT-backed aperture (the ~2 GiB DRM carve-out is not the working set), and the
# timing that matters is dragoon's, measured WITH a synthesis in flight. The
# rollback is one line: set QWENTTS_DEVICE empty, and the entrypoint passes no
# --device at all — the pre-iGPU CPU shape, no rebuild, no restart of anything
# else on the box.
#
# THE REGISTRY LAW (why the unit has an ExecStartPost at all). Registrations live # THE REGISTRY LAW (why the unit has an ExecStartPost at all). Registrations live
# in memory only, so a restart loses them, and re-registration alone does NOT warm # in memory only, so a restart loses them, and re-registration alone does NOT warm
# a voice — the warm pass has to synthesize something. Without it the façade # a voice — the warm pass has to synthesize something. Without it the façade
@@ -103,6 +113,17 @@ QWENTTS_LANG=auto
# round-trip outside it) is what absorbs the queue. # round-trip outside it) is what absorbs the queue.
QWENTTS_MAX_BATCH=1 QWENTTS_MAX_BATCH=1
# The compute device, by ggml device NAME. `Vulkan0` is the gfx1151 iGPU: the
# image carries the Mesa (RADV) ICD and no NVIDIA ICD, so this container's
# Vulkan enumeration has exactly one entry and it is the iGPU. The unit hands
# the container /dev/dri and keeps the render group; on this hybrid host the
# iGPU's render node is renderD129 and renderD128 is the NVIDIA card, which is
# why the unit passes the directory and lets the driver resolve the node.
#
# ROLLBACK: an empty value means no --device flag at all — the CPU shape this
# endpoint shipped with until 2026-10-19. One line, no rebuild.
QWENTTS_DEVICE=Vulkan0
# --- the boot warmer (dankclaw/voiceservice/tts-engine-warm.sh) -------------- # --- the boot warmer (dankclaw/voiceservice/tts-engine-warm.sh) --------------
# These three are read by the warm hook, not by tts-server. They live here # These three are read by the warm hook, not by tts-server. They live here
# because the hook is exec-ed into this container and therefore inherits this # because the hook is exec-ed into this container and therefore inherits this
+13 -1
View File
@@ -1,5 +1,6 @@
# frank-zealot — llama.cpp (PrismML fork build 10777, CUDA) on the RTX 3090, # frank-zealot — llama.cpp (PrismML fork build 10777, CUDA) on the RTX 3090,
# :7771. Payload: Ternary-Bonsai-2-9B PQ2_0 with the MTP head in the file. # :7771. Payload: Ternary-Bonsai-2-9B PQ2_0 with the MTP head in the file, plus
# the refusal-direction adapter applied at load.
# #
# ONE OWNER: shape, payload identity, eras, laws and every measured number # ONE OWNER: shape, payload identity, eras, laws and every measured number
# live in serverAppConfigs-docs/LLM/frank-zealot.md. This file carries the # live in serverAppConfigs-docs/LLM/frank-zealot.md. This file carries the
@@ -13,6 +14,17 @@ MODEL_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-PQ2_0-MTP-Q8_0.g
# Q8_0 IS MANDATORY: the publisher states the BF16 tower can crash llama-server # Q8_0 IS MANDATORY: the publisher states the BF16 tower can crash llama-server
# under concurrent image requests, which is exactly --parallel 3. # under concurrent image requests, which is exactly --parallel 3.
MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf
# Refusal-direction adapter (a rank-1 LoRA, 3.2 MB) applied at load; it edits
# the compute graph, it is not a second model. Scale 1 is the card's "held-out
# refusals 0 %" point. LORA_FILE="" is the documented opt-out — the plain pack
# again, no rebuild — and LORA_SCALE=0 is the same result through the flag.
# PROVENANCE ASYMMETRY: the adapter's own prism.abliterate.base_gguf names
# Ternary-Bonsai-2-9B-PTQ1_0.gguf as the pack its direction was fitted against,
# which is NOT this packing and is not on this host. The tensor names match, so
# it applies; the strength was calibrated on a sibling quantization, so the
# effect is proven by the endpoint's behaviour, never by the metadata.
LORA_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf
LORA_SCALE=1
# Served id — the harness/family name, not a fact about the payload. # Served id — the harness/family name, not a fact about the payload.
# Id-keyed consumers: OpenWebUI (model row, access_grant, api_configs), models.yml. # Id-keyed consumers: OpenWebUI (model row, access_grant, api_configs), models.yml.
ALIAS=frank-zealot ALIAS=frank-zealot
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=llama.cpp (PrismML build 10777) Ternary-Bonsai-2-9B PQ2_0-MTP GGUF (qwen35 hybrid, 2x262144 slots over one 524288 pool, unquantized f16 K+V, MTP off, mmproj Q8_0 vision) on the RTX 3090 (dankclaw, frank-zealot) Description=llama.cpp (PrismML build 10777) Ternary-Bonsai-2-9B PQ2_0-MTP GGUF + refusal adapter (qwen35 hybrid, 3x200704 slots over one 602112 pool, f16 K + q8_0 V, MTP armed, mmproj Q8_0 vision) on the RTX 3090 (dankclaw, frank-zealot)
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
+31
View File
@@ -33,6 +33,19 @@
# what gives this endpoint eyes; MMPROJ_FILE="" is the documented text-only # what gives this endpoint eyes; MMPROJ_FILE="" is the documented text-only
# opt-out. # opt-out.
# #
# A refusal-direction adapter rides on top of the pack (Frank, 2026-10-19):
# AtomicChat's rank-1 LoRA, applied at load by --lora-scaled. It is an edit
# inside the compute graph, not a second model — B = -r (the refusal direction),
# A built from the base pack's folded input basis, every transformer layer, the
# embedding deliberately untouched because an embedding LoRA crashes this fork's
# graph. The card verified it on prism-b10777-e3de715 — the build pinned here —
# and says older forks refuse the pack outright (`unsupported
# prism.hadamard.version: 2`), which makes the runtime lock doubly load-bearing.
# Its own metadata names the PTQ1_0 packing as the pack the direction was fitted
# against, so what proves the effect is a refusal probe against the live
# endpoint, not the metadata and not the card's table. LORA_FILE="" drops the
# flag and the endpoint is the plain pack again.
#
# Speculative decoding ships ON (Frank, 2026-10-19). The head is IN the file and # Speculative decoding ships ON (Frank, 2026-10-19). The head is IN the file and
# accepts well (0.798/0.617 per position, mean length 2.41, measured 2026-10-18); # accepts well (0.798/0.617 per position, mean length 2.41, measured 2026-10-18);
# what made it lose that evening was the bill, not the drafts — the draft context # what made it lose that evening was the bill, not the drafts — the draft context
@@ -136,6 +149,13 @@ fi
# Vision projector. No colon on purpose: an explicitly empty value means # Vision projector. No colon on purpose: an explicitly empty value means
# "text-only, on purpose" and must NOT be replaced by the default path. # "text-only, on purpose" and must NOT be replaced by the default path.
: "${MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}" : "${MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}"
# Refusal adapter. No colon on purpose, the same convention as MMPROJ_FILE: an
# explicitly empty LORA_FILE is the documented opt-out (the plain pack again)
# and must NOT be replaced by the default path. The scale travels INSIDE the
# same argv token as the file, so LORA_SCALE only matters while LORA_FILE names
# something; 0 is the card's "switch it off without touching the file" value.
: "${LORA_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf}"
: "${LORA_SCALE:=1}"
# Fail loudly rather than half-loading: a missing file is a download that # Fail loudly rather than half-loading: a missing file is a download that
# has not finished, and Restart=always would turn it into a crash loop that # has not finished, and Restart=always would turn it into a crash loop that
@@ -149,6 +169,17 @@ if [[ -n "$MMPROJ_FILE" ]]; then
EXTRA_ARGS+=(--mmproj "$MMPROJ_FILE") EXTRA_ARGS+=(--mmproj "$MMPROJ_FILE")
fi fi
# The adapter reaches the engine as ONE token — --lora-scaled <file>:<scale> —
# so a scale can never arrive without its file. It is applied once, at load;
# nothing here hot-swaps it (--lora-init-without-apply plus POST /lora-adapters
# is that shape, and a process holding three conversations is not the place to
# re-parameterise weights mid-flight). A missing file is a fetch that never
# finished, so refuse rather than let Restart=always loop on it.
if [[ -n "$LORA_FILE" ]]; then
[[ -e "$LORA_FILE" ]] || { echo "frank-zealot: missing $LORA_FILE" >&2; exit 1; }
EXTRA_ARGS+=(--lora-scaled "${LORA_FILE}:${LORA_SCALE}")
fi
# Prometheus scraping is opt-IN in llama.cpp: without --metrics the server # Prometheus scraping is opt-IN in llama.cpp: without --metrics the server
# answers 501 on /metrics, and the fleet scrape then reports the endpoint DOWN. # answers 501 on /metrics, and the fleet scrape then reports the endpoint DOWN.
if [[ "$METRICS" = "1" ]]; then if [[ "$METRICS" = "1" ]]; then
@@ -1,35 +1,36 @@
--- ---
type: reference type: reference
description: "The engine behind the fleet's speech endpoint: its runtime, private bridge, and the surface it publishes." description: "The engine behind the fleet's speech endpoint: its Vulkan build on the iGPU, its private bridge, and what it publishes."
tags: tags:
- dankclaw - dankclaw
- tts - tts
- voice - voice
- quadlet - quadlet
- cpu - vulkan
timestamp: 2026-10-18 timestamp: 2026-10-19
--- ---
# Frank TTS Engine # Frank TTS Engine
`frank-tts-engine` is the **arm A text-to-speech engine** on Dankclaw: [qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp)'s resident `tts-server` HTTP process, holding the Qwen3-TTS **1.7 B base talker (Q4_K_M)** and the **12 Hz tokenizer (Q8_0)** in RAM once and synthesizing from there. It listens on container port **8880** and is **deliberately not a fleet endpoint** — the endpoint consumers address is `frank-tts` on **7772**, which resolves voice names, re-encodes to mp3 and owns the timeout budget. This page is the engine runbook; the endpoint's contract lives with its own page. `frank-tts-engine` is the **arm A text-to-speech engine** on Dankclaw: [qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp)'s resident `tts-server` HTTP process, holding the Qwen3-TTS **1.7 B base talker (Q4_K_M)** and the **12 Hz tokenizer (Q8_0)** loaded once and synthesizing from there — **on the gfx1151 iGPU** (ggml's Vulkan backend, `--device Vulkan0`) since 2026-10-19, and in host RAM before that. It listens on container port **8880** and is **deliberately not a fleet endpoint** — the endpoint consumers address is `frank-tts` on **7772**, which resolves voice names, re-encodes to mp3 when asked, and owns the timeout budget. This page is the engine runbook; the endpoint's contract lives with its own page.
**Deployed 2026-10-18.** The chain ran in the order at the bottom of this page — fetch (1.4 GiB, byte + LFS oid verified, 18 s), image build against the pinned commit, unit + network install, container-health gate, warm pass — and the live state still reads the way this page describes it: unit `active` with no drop-ins, container healthy 31 s after the build, **4.0 GiB** resident on a host that kept 17 GiB available, `3 voice(s) registered and warm` in the journal, and no published port anywhere. Two things the first real boot taught: the warm hook's registration body must reach curl through a **file**, not an argument (Troubleshooting), and `STEP 3/13 RUN useradd` is the first honest proof of the image's non-root user. **Deployed 2026-10-18, cut over to the iGPU on 2026-10-19.** The first chain ran in the order at the bottom of this page — fetch (1.4 GiB, byte + LFS oid verified), image build against the pinned commit, unit + network install, container-health gate, warm pass — and the live state still reads the way this page describes it: unit `active` with no drop-ins, container healthy shortly after the build, `3 voice(s) registered and warm` in the journal, and no published port anywhere. Two things the first real boot taught: the warm hook's registration body must reach curl through a **file**, not an argument (Troubleshooting), and `STEP 3/13 RUN useradd` is the first honest proof of the image's non-root user.
**Host:** Dankclaw (`192.168.0.143`), rootless Podman, user `dank` — **Port:** 8880, **never published** — **Network:** the private `systemd-frank-tts-engine` bridge (10.89.5.0/24) and no other — **Deployer:** `frank-tts-engine` — **Consumers:** `frank-tts` (7772) and, through it, every voice request in the fleet including [[Open Notebook]]'s podcast renders. **Host:** Dankclaw (`192.168.0.143`), rootless Podman, user `dank` — **Port:** 8880, **never published** — **Network:** the private `systemd-frank-tts-engine` bridge (10.89.5.0/24) and no other — **Deployer:** `frank-tts-engine` — **Consumers:** `frank-tts` (7772) and, through it, every voice request in the fleet including [[Open Notebook]]'s podcast renders.
## Facts (verified 2026-10-18 against the pinned upstream commit, the Hub, and the CPU trial on this host) ## Facts (verified 2026-10-18 against the pinned upstream commit, the Hub and the CPU trial; the device rows re-verified on the live iGPU shape 2026-10-19)
| Item | Value | | Item | Value |
|------|-------| |------|-------|
| Engine | `tts-server` from qwentts.cpp, pinned at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (`dankclaw/voiceservice/Containerfile.engine`). The source is **not vendored**: the image clones that commit and its recorded `ggml` submodule, so the build needs outbound HTTPS and the `cfhash` label is what keeps an unchanged re-deploy offline | | Engine | `tts-server` from qwentts.cpp, pinned at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (`dankclaw/voiceservice/Containerfile.engine`). The source is **not vendored**: the image clones that commit and its recorded `ggml` submodule, so the build needs outbound HTTPS and the `cfhash` label is what keeps an unchanged re-deploy offline |
| Build line | `cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF && cmake --build build -j 8` — the exact line the 2026-10-18 trial ran on this box. `GGML_BLAS=OFF` is **forced**: Dankclaw has `libopenblas.so.0` and no BLAS dev headers, so upstream's own `Dockerfile` and `buildcpu.sh` (both of which pass BLAS on) cannot be copied. No `-DGGML_NATIVE` is passed, and ggml defaults it on, so the binary is `-march=native` for the machine that built it: **build this image on the host that runs it** | | Build line | `cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=ON -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF && cmake --build build -j 8`. **`GGML_VULKAN=ON` since 2026-10-19**, and that door needs three packages in the build stage: `libvulkan-dev`, `spirv-headers`, and **`glslc`** — which Ubuntu's `glslang-tools` does NOT ship (it ships `glslangValidator`, and `find_package(Vulkan COMPONENTS glslc REQUIRED)` fails without the package). The archive's `glslc` is 2023.8: `GL_KHR_cooperative_matrix` compiles, the coopmat2 / integer-dot / bf16 / micro-float shader families do not. `GGML_BLAS=OFF` is **forced**: Dankclaw has `libopenblas.so.0` and no BLAS dev headers, so upstream's own `Dockerfile` and `buildcpu.sh` (both pass BLAS on) cannot be copied. No `-DGGML_NATIVE` is passed, and ggml defaults it on, so the binary is `-march=native` for the machine that built it: **build this image on the host that runs it** |
| Image | built locally as `franksdank/frank-tts-engine:latest`, two stages from one digest-pinned base `docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac` (index digest resolved 2026-10-18). Runtime stage: `libgomp1` (OpenMP is on), `curl` (the unit's `HealthCmd` runs as `/bin/sh -c`, and the warmer speaks HTTP), uid **10000**, `LD_LIBRARY_PATH=/app` because ggml builds shared by default and the link-time RPATH names the build tree | | Image | built locally as `franksdank/frank-tts-engine:latest`, two stages from one digest-pinned base `docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac` (index digest resolved 2026-10-18). Runtime stage: `libgomp1` (OpenMP is on), `curl` (the unit's `HealthCmd` runs as `/bin/sh -c`, and the warmer speaks HTTP), `libvulkan1` + `mesa-vulkan-drivers` (the loader and the RADV ICD — a loader with no ICD enumerates ZERO devices, and the server then dies in device lookup after both GGUFs have been read), uid **10000**, `LD_LIBRARY_PATH=/app` because ggml builds shared by default and the link-time RPATH names the build tree. **No NVIDIA ICD on purpose**: exactly one Vulkan device in the enumeration is what makes the name `Vulkan0` safe to write down |
| Talker | `qwen-talker-1.7b-base-Q4_K_M.gguf`, **1 219 245 248 B**, sha256 `ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d` (the Hub's LFS pointer oid) | | Talker | `qwen-talker-1.7b-base-Q4_K_M.gguf`, **1 219 245 248 B**, sha256 `ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d` (the Hub's LFS pointer oid) |
| Tokenizer | `qwen-tokenizer-12hz-Q8_0.gguf`, **291 150 624 B**, sha256 `1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94` | | Tokenizer | `qwen-tokenizer-12hz-Q8_0.gguf`, **291 150 624 B**, sha256 `1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94` |
| Source of both | `https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF`, fetched by `scripts/download-frank-tts-engine-qwentts.sh` into `/home/dank/models/Qwen3-TTS-GGUF`, mounted read-only at `/models`. It has to be the **base** variant: the engine refuses every voice registration unless the model reports type `base`, so on `customvoice`/`voicedesign` the cloned-voice product does not exist. A 0.6 B talker (628 905 056 B) exists and is **not** the decided artifact | | Source of both | `https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF`, fetched by `scripts/download-frank-tts-engine-qwentts.sh` into `/home/dank/models/Qwen3-TTS-GGUF`, mounted read-only at `/models`. It has to be the **base** variant: the engine refuses every voice registration unless the model reports type `base`, so on `customvoice`/`voicedesign` the cloned-voice product does not exist. A 0.6 B talker (628 905 056 B) exists and is **not** the decided artifact |
| Memory | **5.7 GiB peak RSS** with both files resident (2026-10-18 trial). No `LimitMEMORY`/`MemoryMax`: a limit would turn a busy moment into an OOM kill in the middle of an utterance | | Device | `--device Vulkan0`: the gfx1151 iGPU, **shared with [[frank-dragoon]] as a co-tenant, never a handover**. Proven on the live pair, not inferred: `fuser -v /dev/dri/renderD129` lists `tts-server` beside `flash_serve`. Both gates (the RADV heap and dragoon's contention) are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`, which also carries the change surface. **Rollback: `QWENTTS_DEVICE=` (empty) in `scripts/frank-tts-engine.env`, redeploy this one service** — the entrypoint then passes no `--device` at all, which is the CPU shape, no rebuild, nothing else restarts |
| Concurrency | `--max-batch 1`: a serialised FIFO. Warm RTF on this box is 0.55–0.67 against real time 1.0, and that margin is the entire headroom a live stream has; two concurrent syntheses split the same cores and the second caller's first byte additionally waits for the first utterance to finish | | Memory | **1.7 GiB host RSS** since the iGPU cutover — the weights ride the device. No `LimitMEMORY`/`MemoryMax`: a limit would turn a busy moment into an OOM kill in the middle of an utterance |
| Concurrency | `--max-batch 1`: a serialised FIFO. Warm RTF on the iGPU measures **0.20** (11.04 s of audio in 2.23 s) with `frank-dragoon` live on the same card, so a live stream has room to spare; a second concurrent synthesis would share the device with the first and make the caller behind it wait out the whole first utterance, so the FIFO stays a decision, not an accident |
| Network | `systemd-frank-tts-engine` (10.89.5.0/24, `dankclaw/voiceservice/quadlet/frank-tts-engine.network`) and **nothing else**, with **no `PublishPort` at all**. `frank-tts` joins the same bridge and dials `http://frank-tts-engine:8880` | | Network | `systemd-frank-tts-engine` (10.89.5.0/24, `dankclaw/voiceservice/quadlet/frank-tts-engine.network`) and **nothing else**, with **no `PublishPort` at all**. `frank-tts` joins the same bridge and dials `http://frank-tts-engine:8880` |
| Voices | `/home/dank/voices` mounted read-only at `/voices`, and it is the **warmer** that reads it, not the server: `tts-server` receives reference audio as base64 in a registration body and keeps its registry in memory only | | Voices | `/home/dank/voices` mounted read-only at `/voices`, and it is the **warmer** that reads it, not the server: `tts-server` receives reference audio as base64 in a registration body and keeps its registry in memory only |
| Health | `GET /health` → `{"status":"ok"}`, served by the HTTP thread rather than the synthesis queue, so a busy engine still reads healthy. Probed by the unit (`HealthCmd`, 30 s interval, 120 s start period) **inside the container's namespace**, because there is no published port to probe from outside | | Health | `GET /health` → `{"status":"ok"}`, served by the HTTP thread rather than the synthesis queue, so a busy engine still reads healthy. Probed by the unit (`HealthCmd`, 30 s interval, 120 s start period) **inside the container's namespace**, because there is no published port to probe from outside |
@@ -37,18 +38,11 @@ timestamp: 2026-10-18
## Why this engine, and not the alternatives ## Why this engine, and not the alternatives
The criterion Frank set on 2026-10-18 was **"whatever is better for live output"**, and live output is decided by time-to-first-audio and by whether the stream stays ahead of real time — not by benchmark throughput. RTF only has to stay under 1.0 with margin; what separates the arms is first-byte latency and **spread**. Measured on this host the same day (CPU; the full A/B is the section *"Decision: arm A for live output (2026-10-18)"* in , which is the source of these numbers): The criterion Frank set on 2026-10-18 was **"whatever is better for live output"**, and live output is decided by time-to-first-audio and by whether the stream stays ahead of real time — not by benchmark throughput. On that criterion the resident `tts-server` (arm A) beat the mainline per-request CLI path (arm B) on this box the same day, and the deciding property was **spread**: the resident server repeats the same first-audio latency every run, while the CLI path varies enough that some runs fall behind real time and a live listener hears the stream stall mid-sentence. That A/B was a CPU measurement and the endpoint has since moved to the iGPU, so the shape of the argument is what is kept here; no page carries its numbers any more, and they are not worth re-deriving.
| Measure | Resident `tts-server` (arm A, chosen) | Mainline per-request CLI path (arm B) | The rollback to the other backend stays in the repo untouched — `TTS_BACKEND=llama-tts-cli` in `scripts/frank-tts.env` still selects the per-request CLI engine, and that file's header documents what it costs.
|---|---|---|
| first audio byte | **86–451 ms** end to end; **94–112 ms** server-side | model load, per request |
| warm real-time factor while streaming | **0.55–0.67** | 0.93–0.99 |
| run-to-run spread | **±0.01** | ±0.25 |
| HTTP surface | serves `/v1/audio/speech` itself | none — façade CLI only |
The spread is the whole argument: at ±0.01 the engine finishes synthesizing 1.5–1.8× faster than the audio plays, every run; at ±0.25 some runs fall behind real time and a live listener hears the stream stall mid-sentence. The rollback path stays in the repo untouched — `TTS_BACKEND=llama-tts-cli` in `scripts/frank-tts.env` still selects the per-request CLI engine, and that file's header documents what it costs. ## Four laws this service lives under
## Three laws this service lives under
**1. The registry law → the unit warms at boot.** The voice registry is **memory only**: a restart forgets every voice, and re-registering one does **not** warm it — only synthesizing does. A boot that registers nothing leaves the façade answering 400 for every name, which is why `frank-tts-engine.container` carries **1. The registry law → the unit warms at boot.** The voice registry is **memory only**: a restart forgets every voice, and re-registering one does **not** warm it — only synthesizing does. A boot that registers nothing leaves the façade answering 400 for every name, which is why `frank-tts-engine.container` carries
@@ -64,10 +58,12 @@ frank-tts-engine-warm: voice narrator1 cold=1402ms warm=1812ms
frank-tts-engine-warm: 3 voice(s) registered and warm frank-tts-engine-warm: 3 voice(s) registered and warm
``` ```
**2. The thread law → nothing is ever pinned.** `tts-server` in this build has **no `-t`/`--threads` flag**: `src/backend.h` computes the CPU thread count as `std::thread::hardware_concurrency() / 2` (its comment: GEMM shares SIMD units across hyperthreads). Thread count therefore follows what the kernel *reports*, not what a cgroup *allows* — so `CPUQuota=`, `CPUAffinity=`, `taskset` or a slice silently changes the engine's GEMM tiling and its timings, with no log line. Nothing in this fleet pins this container. If a neighbour starves it, fix the neighbour. **2. The thread law → nothing is ever pinned.** `tts-server` in this build has **no `-t`/`--threads` flag**: `src/backend.h` computes the CPU thread count as `std::thread::hardware_concurrency() / 2` (its comment: GEMM shares SIMD units across hyperthreads). Thread count therefore follows what the kernel *reports*, not what a cgroup *allows* — so `CPUQuota=`, `CPUAffinity=`, `taskset` or a slice silently changes the engine's pool sizes and its timings, with no log line. The rule did not retire with the iGPU cutover: the binary still sizes those pools from what the kernel says, whatever the device ends up doing with the matmuls. Nothing in this fleet pins this container. If a neighbour starves it, fix the neighbour.
**3. Streaming is not buffered.** At the same seed, `response_format=pcm` (chunked, codec chunks pushed as they decode) and `response_format=wav` (buffered) differ in **92.2 % of samples**, −41.6 dB RMS. Nothing may compare the two paths, cache one against the other, or assume a seed pins output across `response_format`. `frank-tts` always asks for `wav`; the warmer asks for `wav`; the test seam is forbidden from pinning either. **3. Streaming is not buffered.** At the same seed, `response_format=pcm` (chunked, codec chunks pushed as they decode) and `response_format=wav` (buffered) differ in **92.2 % of samples**, −41.6 dB RMS. Nothing may compare the two paths, cache one against the other, or assume a seed pins output across `response_format`. `frank-tts` always asks for `wav`; the warmer asks for `wav`; the test seam is forbidden from pinning either.
**4. The device law → the card is shared, and the rollback is one line.** Synthesis computes on the gfx1151 **beside** [[frank-dragoon]], so a change to either tenant is a change to both. Two gates decide that pair, and both passed on 2026-10-19 with the other tenant live: the RADV heap a rootless container tenant is actually handed (not the ~2 GiB DRM carve-out), and dragoon's own first-token and decode rates with a synthesis in flight — unchanged. Their numbers, the recipe that measures them, and the full change surface are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`; do not re-derive them here. What belongs on this page is the move: `QWENTTS_DEVICE=` (empty) in `scripts/frank-tts-engine.env`, re-deploy this one service, and synthesis is back on CPU — no rebuild, and dragoon never restarts.
## The voice registry contract ## The voice registry contract
A voice becomes known to the engine through `POST /v1/audio/voices`, and only these shapes are accepted: A voice becomes known to the engine through `POST /v1/audio/voices`, and only these shapes are accepted:
@@ -97,9 +93,9 @@ The **façade** on 7772 is the pair's public surface and carries the probe. Note
| `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts | | `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts |
| `dankclaw/voiceservice/tts-engine-start.sh` | image entrypoint (`/app/start.sh`): env → flags, refuses to boot without weights | | `dankclaw/voiceservice/tts-engine-start.sh` | image entrypoint (`/app/start.sh`): env → flags, refuses to boot without weights |
| `dankclaw/voiceservice/tts-engine-warm.sh` | boot warmer (`/app/warm-voices.sh`): register from `reference.wav` (body via a temp file), speak a fixed sentence twice, report cold/warm | | `dankclaw/voiceservice/tts-engine-warm.sh` | boot warmer (`/app/warm-voices.sh`): register from `reference.wav` (body via a temp file), speak a fixed sentence twice, report cold/warm |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.container` | the unit: bridge only, no publish, env file, two read-only mounts, health probe, warm hook, `Restart=always` | | `dankclaw/voiceservice/quadlet/frank-tts-engine.container` | the unit: bridge only, no publish, env file, two read-only mounts, render access (`AddDevice=/dev/dri` + `GroupAdd=keep-groups`), health probe, warm hook, `Restart=always` |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the private 10.89.5.0/24 bridge, with the subnet registry | | `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the private 10.89.5.0/24 bridge, with the subnet registry |
| `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law and the registry law in its header | | `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law, the registry law and the device law in its header |
| `scripts/download-frank-tts-engine-qwentts.sh` | weight fetcher: no resume, byte count + LFS oid check, `--verify-only` | | `scripts/download-frank-tts-engine-qwentts.sh` | weight fetcher: no resume, byte count + LFS oid check, `--verify-only` |
| `scripts/deploy-remote.sh` → `deploy_frank_tts_engine` | weights gate → build → quadlet → restart → container health → warm | | `scripts/deploy-remote.sh` → `deploy_frank_tts_engine` | weights gate → build → quadlet → restart → container health → warm |
| `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps | | `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps |
@@ -118,6 +114,25 @@ still open.
6. **Then the façade on 7772** (its own page), after the engine answered — that order is the whole point of step 6: publishing the façade in front of a dead engine would make every consumer fail at the first voice line. 6. **Then the façade on 7772** (its own page), after the engine answered — that order is the whole point of step 6: publishing the façade in front of a dead engine would make every consumer fail at the first voice line.
7. **Open:** [[Open Notebook]] is still not installed, so nothing renders a podcast yet; the voice leg it waits on now exists. 7. **Open:** [[Open Notebook]] is still not installed, so nothing renders a podcast yet; the voice leg it waits on now exists.
### 2026-10-19 — the iGPU cutover (PR #34, `6248f02`)
1. **Configure gate before touching anything** — the flag set, configured inside the
digest-pinned base on Dankclaw: `Found Vulkan: … found components: glslc` then
`Including Vulkan backend`. That probe is what caught `glslc` missing from
`glslang-tools`; the Containerfile's flag comment carries the finding.
2. **Deploy** — `./scripts/deploy.sh dankclaw --service frank-tts-engine`: image rebuild
(the `cfhash` moved), unit reinstall, health gate, warm pass
(`3 voice(s) registered and warm`), Playwright e2e 33/33.
3. **Prove the device, not the flag** — `sudo fuser -v /dev/dri/renderD129` lists
`tts-server` next to dragoon's `flash_serve`. Do this instead of reading the log: the
engine prints nothing about which backend it chose, so argv (`--device Vulkan0`) proves
intent and the render-node holder list proves use.
4. **Consumer path** — one `/v1/audio/speech` per voice through the façade on 7772; all
three answered with audio. The RTF figure in the Facts table comes from one engine-direct
WAV, where the duration is exact.
5. **Co-tenancy gate** — §5 of the verdict report: dragoon's first token and decode
unchanged with a 16.9 s synthesis occupying the card.
## Troubleshooting ## Troubleshooting
| Symptom | Meaning | Move | | Symptom | Meaning | Move |
@@ -131,3 +146,5 @@ still open.
| `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | re-deploy: since 2026-10-18 `build_compose_image` hashes every file the image `COPY`s, not only `Containerfile.engine`, so editing the warmer rebuilds it — see | | `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | re-deploy: since 2026-10-18 `build_compose_image` hashes every file the image `COPY`s, not only `Containerfile.engine`, so editing the warmer rebuilds it — see |
| The warm pass logs `registered, but the warm synthesis failed` for EVERY voice, while a hand-run `/app/warm-voices.sh` succeeds | `WARM_TEXT` arrived **with its quotes**. podman's `--env-file` does no shell parsing — it takes the literal remainder of the line — so the env-file reader got `"Warm up, one two three."` where `source` gives `Warm up, one two three.`; the quotes plus a backslash then make the synthesis body invalid JSON | the hook strips one matched pair and escapes what is left; prove the value with `podman exec frank-tts-engine env \| grep WARM_TEXT`. The seam pins both readers, because a test that only sources the env file cannot see this | | The warm pass logs `registered, but the warm synthesis failed` for EVERY voice, while a hand-run `/app/warm-voices.sh` succeeds | `WARM_TEXT` arrived **with its quotes**. podman's `--env-file` does no shell parsing — it takes the literal remainder of the line — so the env-file reader got `"Warm up, one two three."` where `source` gives `Warm up, one two three.`; the quotes plus a backslash then make the synthesis body invalid JSON | the hook strips one matched pair and escapes what is left; prove the value with `podman exec frank-tts-engine env \| grep WARM_TEXT`. The seam pins both readers, because a test that only sources the env file cannot see this |
| A `502` for `/v1/voices` where the engine answers `200` on `/health` from the same boot | the engine restarted and its in-memory registry is empty again — `/health` is model-loaded, NOT voices-registered | re-run `podman exec frank-tts-engine /app/warm-voices.sh`; that is the same distinction the `frank-tts` healthcheck enforces | | A `502` for `/v1/voices` where the engine answers `200` on `/health` from the same boot | the engine restarted and its in-memory registry is empty again — `/health` is model-loaded, NOT voices-registered | re-run `podman exec frank-tts-engine /app/warm-voices.sh`; that is the same distinction the `frank-tts` healthcheck enforces |
| Unit restarts every 10 s and the log ends on a **device** error *after* both GGUFs were read | the image has the Vulkan loader but no ICD, or the container has no render node — the device name then resolves to nothing | `podman exec frank-tts-engine ls /dev/dri` (it must see `renderD129`) and `podman exec frank-tts-engine dpkg -l \| grep -E 'mesa-vulkan\|libvulkan1'`; re-deploy to rebuild the image, or set `QWENTTS_DEVICE=` and re-deploy for the CPU shape |
| `frank-dragoon` slows while TTS speaks | the contention premise broke — a Mesa bump, a third GPU tenant, a bigger synthesis | `QWENTTS_DEVICE=` + re-deploy this service, then re-run the gate recipe in §5 of `report/tts-npu-spike-verdict-2026-10-19.md` before putting it back |
+12 -11
View File
@@ -113,17 +113,18 @@ shows up as a failed synthesis, never as a down probe.
## Where the engine computes ## Where the engine computes
CPU, and only CPU is compiled in: `Containerfile.engine` builds `qwentts.cpp` with The **gfx1151 iGPU**, as a co-tenant beside `frank-dragoon`: the Vulkan build of
`-DGGML_VULKAN=OFF` written out explicitly, so the container carries `libggml-cpu` and no `qwentts.cpp` with `--device Vulkan0`, and `/dev/dri` handed to the engine container. That is
other backend. The realistic accelerator is the gfx1151 iGPU behind that flag — plus the shape since 2026-10-19. The flag, the device name, the render-node trap on this hybrid box
`/dev/dri` passthrough, the ComfyUI pattern — on the same payload and the same pinned and the one-line CPU rollback are owned by [[Frank TTS Engine]] — read that page before
commit, and the intended shape is **co-tenancy**: the engine resident on the card beside touching either tenant, because a change to one is a change to both. Co-tenancy was gated with
`frank-dragoon`, not a handover. Memory is not what decides that; shader contention is, and `frank-dragoon` live on the card (its first token and decode rates unchanged with a synthesis
the census plus both gates are in `report/tts-npu-spike-verdict-2026-10-19.md` §5. in flight); both gate recipes are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`.
**The Strix Halo NPU is not a door at all**: XDNA2 executes only precompiled AIE
partition programs, and AMD's own Linux runtime opens `/dev/accel/accel0`, succeeds at the **The Strix Halo NPU is not a door at all**: XDNA2 executes only precompiled AIE partition
ioctls, then declines this model family's graph without emitting a single log line. The programs, and AMD's own Linux runtime opens `/dev/accel/accel0`, succeeds at the ioctls, then
measured record and the model-side contract live in `report/` at the repo root declines this model family's graph without emitting a single log line. The measured record and
the model-side contract live in `report/` at the repo root
(`tts-npu-spike-verdict-2026-10-19.md`). (`tts-npu-spike-verdict-2026-10-19.md`).
## Files ## Files
+35 -9
View File
@@ -1,6 +1,6 @@
--- ---
type: reference type: reference
title: "frank-zealot on Dankclaw — llama.cpp (PrismML fork 10777), Ternary-Bonsai-2-9B PQ2_0-MTP, 3 × 200704, f16 K + q8_0 V, MTP armed" title: "frank-zealot on Dankclaw — llama.cpp (PrismML fork 10777), Ternary-Bonsai-2-9B PQ2_0-MTP + refusal adapter, 3 × 200704, f16 K + q8_0 V, MTP armed"
description: "The RTX 3090 endpoint: live shape exactly as the env ships it, every era with its rollback path, and its card laws." description: "The RTX 3090 endpoint: live shape exactly as the env ships it, every era with its rollback path, and its card laws."
tags: tags:
- llm - llm
@@ -10,10 +10,10 @@ tags:
- gguf - gguf
- rtx3090 - rtx3090
- endpoint - endpoint
timestamp: 2026-10-18 timestamp: 2026-10-19
--- ---
# frank-zealot — llama.cpp (PrismML) Bonsai-2-9B PQ2_0-MTP, 3 × 200,704, f16 K + q8_0 V, MTP armed # frank-zealot — llama.cpp (PrismML) Bonsai-2-9B PQ2_0-MTP + refusal adapter, 3 × 200,704, f16 K + q8_0 V, MTP armed
Dankclaw's RTX 3090 endpoint — `http://192.168.0.143:7771`, wire id `frank-zealot`, the fleet's **compaction hub** Dankclaw's RTX 3090 endpoint — `http://192.168.0.143:7771`, wire id `frank-zealot`, the fleet's **compaction hub**
(three conversations at 200,704 each, the window the other two endpoints also cap at). One page (three conversations at 200,704 each, the window the other two endpoints also cap at). One page
@@ -29,11 +29,12 @@ is the bug.
|---|---| |---|---|
| Checkpoint | `/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-PQ2_0-MTP-Q8_0.gguf` — 2,675,410,720 B, LFS sha256 `c79fcdfaf6b7bf47…` | | Checkpoint | `/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-PQ2_0-MTP-Q8_0.gguf` — 2,675,410,720 B, LFS sha256 `c79fcdfaf6b7bf47…` |
| Projector | `…/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf` — 624,229,664 B, sha256 `e0cb6f6988cea323…` — **Q8_0 is mandatory**, not a preference (see Contract) | | Projector | `…/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf` — 624,229,664 B, sha256 `e0cb6f6988cea323…` — **Q8_0 is mandatory**, not a preference (see Contract) |
| Fetch | `scripts/download-frank-zealot-bonsai9.sh [DEST]` (`--verify-only`; `-f`, byte count, LFS sha256 per file) | | Adapter | `…/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf` — **3,155,424 B**, sha256 `861b9c099fb5cd81…` — a rank-1 refusal-direction LoRA (`adapter.type lora`, 128 tensors, every transformer layer, embedding untouched), applied at load by `--lora-scaled <file>:1`. Its metadata names `Ternary-Bonsai-2-9B-PTQ1_0.gguf` (`3561df7a…`) as the pack it was fitted against — **not this packing, and not on this host**; see Laws |
| Fetch | `scripts/download-frank-zealot-bonsai9.sh [DEST]` (payload + projector) · `scripts/download-frank-zealot-uncensored-lora.sh [DEST]` (the adapter) — both `--verify-only`-able, `-f`, byte count and sha256 per file |
| Runtime | `/home/dank/GitPackages/llama-prism-b10777/llama-server` — PrismML **fork**, `0.2.0-dev (build 10777, commit e3de71522)`; the only loader for this payload | | Runtime | `/home/dank/GitPackages/llama-prism-b10777/llama-server` — PrismML **fork**, `0.2.0-dev (build 10777, commit e3de71522)`; the only loader for this payload |
| Trio | `scripts/frank-zealot.service` (root, relinked, `User=dank`, `Restart=on-failure`, `RestartSec=10`, `LimitMEMLOCK=infinity`, `TimeoutStopSec=180`) · `scripts/frank-zealot.env` · `scripts/serve-frank-zealot.sh` | | Trio | `scripts/frank-zealot.service` (root, relinked, `User=dank`, `Restart=on-failure`, `RestartSec=10`, `LimitMEMLOCK=infinity`, `TimeoutStopSec=180`) · `scripts/frank-zealot.env` · `scripts/serve-frank-zealot.sh` |
| Address / alias | `BIND_ADDR=0.0.0.0`, `LISTEN_PORT=7771`, `ALIAS=frank-zealot` (a flag, not a fact) | | Address / alias | `BIND_ADDR=0.0.0.0`, `LISTEN_PORT=7771`, `ALIAS=frank-zealot` (a flag, not a fact) |
| Flags | `--ctx-size 602112 --parallel 3` ⇒ 200,704 per slot · `--cache-type-k f16 --cache-type-v q8_0` (asymmetric, **14,406.00 MiB**) · `--flash-attn on` · `--batch-size 6144 --ubatch-size 1024` · `--n-gpu-layers 99` · `--jinja --reasoning auto --reasoning-format deepseek` · `--metrics` · `--mmproj` · `--log-verbosity 4` · `--presence-penalty 1.0` · **`--spec-type draft-mtp`** with `--spec-draft-n-max 2 --spec-draft-n-min 0 --spec-draft-p-min 0` | | Flags | `--ctx-size 602112 --parallel 3` ⇒ 200,704 per slot · `--cache-type-k f16 --cache-type-v q8_0` (asymmetric, **14,406.00 MiB**) · `--flash-attn on` · `--batch-size 6144 --ubatch-size 1024` · `--n-gpu-layers 99` · `--jinja --reasoning auto --reasoning-format deepseek` · `--metrics` · `--mmproj` · **`--lora-scaled Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf:1`** · `--log-verbosity 4` · `--presence-penalty 1.0` · **`--spec-type draft-mtp`** with `--spec-draft-n-max 2 --spec-draft-n-min 0 --spec-draft-p-min 0` |
**Why this shape.** `general.architecture qwen35`: 33 blocks (32 trunk + `blk.32` = the MTP block) **Why this shape.** `general.architecture qwen35`: 33 blocks (32 trunk + `blk.32` = the MTP block)
`full_attention_interval 4` ⇒ 8 full-attention + 24 Gated DeltaNet, 4 KV heads × 256 dim, `nextn_predict_layers 1` `full_attention_interval 4` ⇒ 8 full-attention + 24 Gated DeltaNet, 4 KV heads × 256 dim, `nextn_predict_layers 1`
@@ -57,6 +58,28 @@ and `UBATCH_SIZE=2048` beside an armed drafter is still the shape that halved de
`--spec-draft-model` — the draft rides the main model's weights, so a draft file would load the trunk twice. `--spec-draft-model` — the draft rides the main model's weights, so a draft file would load the trunk twice.
`SPEC_TYPE=none` is the rollback and strips the whole set. `SPEC_TYPE=none` is the rollback and strips the whole set.
**The adapter is behaviour, not shape.** It folds 3.2 MB of rank-1 tensors into the trunk at
load, so it moves nothing in the budget equation above — no KV, no slot, no window. What it
changes is what the endpoint answers, and the only instrument for that is a refusal probe:
the same two prompts the plain pack answered `I cannot provide…` (captured on the live
endpoint before the swap, `temperature 0.1`), re-asked after it. Budget **≥ 2 048 tokens** for
that probe on this payload — the thinking channel swallows a 600-token request whole and
`content` comes back empty with `finish_reason: length`, which reads as a refusal that never
happened. The adapter's own card table is **not** evidence here: it was measured on the PTQ1_0
packing. Rollback is `LORA_FILE=` (empty) in the env and one redeploy — no rebuild, no payload
move; `LORA_SCALE=0` is the same result through the flag.
**Measured on the cutover boot (2026-10-19, `02d04bc`).** Before: both probe prompts
opened `I cannot provide…`. After: neither carries a refusal marker — one ran to its
2 048-token cap, the other finished at 1 930 — so the direction is out. It still
volunteers a `⚠️ Important Disclaimer` heading before answering, which is style, not the
circuit: the marker sentence is gone, and a leading hedge is not an escape hatch. Chat,
tools (`tool_calls` with valid JSON) and vision ("Red", 281 prompt tokens) answered from
the same boot, and `DropInPaths` came back empty. Read the ledger, not this paragraph,
for provenance: the fork prints the adapter's `prism.abliterate.*` keys — including the
`base_gguf = Ternary-Bonsai-2-9B-PTQ1_0.gguf` line — at every start, so the packing
mismatch is visible in the journal to anyone who boots it.
**Harness contract** (`~/.omp/agent/models.yml`, provider `llamacpp (dankclaw-3090)`, id `frank-zealot`): **Harness contract** (`~/.omp/agent/models.yml`, provider `llamacpp (dankclaw-3090)`, id `frank-zealot`):
`contextWindow: 200704` — **the per-slot window**, because llama.cpp divides `--ctx-size` across slots and answers a `contextWindow: 200704` — **the per-slot window**, because llama.cpp divides `--ctx-size` across slots and answers a
longer prompt with a 400, never a truncation — plus `maxTokens: 16384`, `input: [text, image]` longer prompt with a 400, never a truncation — plus `maxTokens: 16384`, `input: [text, image]`
@@ -113,6 +136,7 @@ with 448 completion tokens (at `max_tokens: 64` the same request returns EMPTY c
| Window | Payload | Runtime | Shape | Replaced because | Rollback | | Window | Payload | Runtime | Shape | Replaced because | Rollback |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 2026-10-19 → | the same 9B MTP file and projector, **plus** `Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf` (3,155,424 B `861b9c09…`) applied by `--lora-scaled …:1` | PrismML fork 10777 (its verified build) | — (shape unchanged; the adapter is behaviour) | Frank ordered the refusal direction removed on this endpoint | one env line — `LORA_FILE=` (or `LORA_SCALE=0`) — and redeploy; the plain pack is the same file it already loads |
| 2026-10-18 21:06 → 2026-10-19 | the same 9B MTP file and projector | PrismML fork 10777 | — | Frank ordered the third conversation back AND the drafter armed, and exact V funds neither: it bills 18,816 MiB at the pool that pays for the third slot, leaving nothing for the draft's 2,352 | one line each — `CTX_SIZE=524288 PARALLEL=2 CACHE_TYPE_V=f16 BATCH_SIZE=4096 UBATCH_SIZE=2048 SPEC_TYPE=none` — and redeploy; no payload move | | 2026-10-18 21:06 → 2026-10-19 | the same 9B MTP file and projector | PrismML fork 10777 | — | Frank ordered the third conversation back AND the drafter armed, and exact V funds neither: it bills 18,816 MiB at the pool that pays for the third slot, leaving nothing for the draft's 2,352 | one line each — `CTX_SIZE=524288 PARALLEL=2 CACHE_TYPE_V=f16 BATCH_SIZE=4096 UBATCH_SIZE=2048 SPEC_TYPE=none` — and redeploy; no payload move |
| 2026-10-18 13:19 → same evening (the boot `68afc6d` replaced) | the same 9B MTP file and projector | PrismML fork 10777 | — | Frank ordered unquantized KV, and that pool cannot survive it: 24,576 MiB of f16 cache > 24,123 addressable | one env line — `CTX_SIZE=786432 PARALLEL=3 CACHE_TYPE_K/V=q8_0` — and redeploy; no payload move | | 2026-10-18 13:19 → same evening (the boot `68afc6d` replaced) | the same 9B MTP file and projector | PrismML fork 10777 | — | Frank ordered unquantized KV, and that pool cannot survive it: 24,576 MiB of f16 cache > 24,123 addressable | one env line — `CTX_SIZE=786432 PARALLEL=3 CACHE_TYPE_K/V=q8_0` — and redeploy; no payload move |
| 2026-10-18 03:14 → 13:19 (arm under the unit, then the leftover drop-in) | `Bonsai2-PTQ1_0/Ternary-Bonsai-2-27B-PTQ1_0.gguf` 5,946,648,928 B + hardlinked Q8 mmproj | PrismML fork 10777 | arm: 2 × 200,704, batch/ubatch 3072/1536. The leftover `frank-zealot.service.d/ptq1.conf` then made the 13:16 cutover deploy boot THIS payload at 3 × 133,888 (`new slot, n_ctx = 133888` x3, journal read 2026-10-19) until the 13:19 boot | — | `/home/dank/models/Bonsai2-PTQ1_0/` + `scripts/download-frank-zealot-bonsai2-ptq1.sh` | | 2026-10-18 03:14 → 13:19 (arm under the unit, then the leftover drop-in) | `Bonsai2-PTQ1_0/Ternary-Bonsai-2-27B-PTQ1_0.gguf` 5,946,648,928 B + hardlinked Q8 mmproj | PrismML fork 10777 | arm: 2 × 200,704, batch/ubatch 3072/1536. The leftover `frank-zealot.service.d/ptq1.conf` then made the 13:16 cutover deploy boot THIS payload at 3 × 133,888 (`new slot, n_ctx = 133888` x3, journal read 2026-10-19) until the 13:19 boot | — | `/home/dank/models/Bonsai2-PTQ1_0/` + `scripts/download-frank-zealot-bonsai2-ptq1.sh` |
@@ -169,13 +193,15 @@ with 448 completion tokens (at `max_tokens: 64` the same request returns EMPTY c
- MTP is a property of the **file**: the plain PQ2_0 sibling could not draft at any flag value; only the `-MTP` export carries `blk.N.nextn.*`. - MTP is a property of the **file**: the plain PQ2_0 sibling could not draft at any flag value; only the `-MTP` export carries `blk.N.nextn.*`.
- **The format is the runtime lock.** PQ2_0 is `general.file_type 141` over 202 tensors of custom GGML type **142** plus `prism.hadamard.*` metadata: stock llama.cpp refuses the type, and a loader ignoring the Hadamard transform would dequant **wrong, silently** (same lock class as ROCmFPX). Build ≥ b10776 is additionally the floor that pairs MTP with a quantized V cache. - **The format is the runtime lock.** PQ2_0 is `general.file_type 141` over 202 tensors of custom GGML type **142** plus `prism.hadamard.*` metadata: stock llama.cpp refuses the type, and a loader ignoring the Hadamard transform would dequant **wrong, silently** (same lock class as ROCmFPX). Build ≥ b10776 is additionally the floor that pairs MTP with a quantized V cache.
- Check arch support in the source tree: `grep -c` on the packed binary returns 0 for every arch, including the one it serves. - Check arch support in the source tree: `grep -c` on the packed binary returns 0 for every arch, including the one it serves.
- **Base alignment.** Bonsai-2 is the censored base lineage. `/home/dank/models/Bonsai2-CRACK/` matches the **27B** geometry (a one-line `MODEL_FILE` swap there, identical tensor census); no dealigned 9B exists, so for this payload the refusal escape hatch is a fetch, not a revert. - **Base alignment.** Bonsai-2 is the censored base lineage. `/home/dank/models/Bonsai2-CRACK/` matches the **27B** geometry (a one-line `MODEL_FILE` swap there, identical tensor census); no dealigned 9B exists, so for this payload the refusal escape hatch is a fetch, not a revert — which is exactly what was taken on 2026-10-19: the rank-1 adapter in the Adapter row above, behind `LORA_FILE`.
- **A stale bench drop-in hijacks the production boot and no health check sees it.** An `Environment=FRANK_ZEALOT_ENV_FILE=/tmp/<arm>.env` in `frank-zealot.service.d/` booted a 27B PTQ1_0 payload while `/health` answered 200, `/v1/models` echoed the alias flag and the e2e gate passed 33/33 — none of them read the payload. - **A stale bench drop-in hijacks the production boot and no health check sees it.** An `Environment=FRANK_ZEALOT_ENV_FILE=/tmp/<arm>.env` in `frank-zealot.service.d/` booted a 27B PTQ1_0 payload while `/health` answered 200, `/v1/models` echoed the alias flag and the e2e gate passed 33/33 — none of them read the payload.
- After any bench window: `systemctl show -p DropInPaths` → `tr '\0' ' ' </proc/$pid/cmdline` → ledger, in that order; drop-ins retire to `~/.AGENTTRASH/<date>-<topic>/`, and payload decisions land on this page the moment they are made, or an approved arm becomes indistinguishable from a rogue one a day later. - After any bench window: `systemctl show -p DropInPaths` → `tr '\0' ' ' </proc/$pid/cmdline` → ledger, in that order; drop-ins retire to `~/.AGENTTRASH/<date>-<topic>/`, and payload decisions land on this page the moment they are made, or an approved arm becomes indistinguishable from a rogue one a day later.
- **An arm env must use the serve script's own variable names** (`CACHE_TYPE_K`/`CACHE_TYPE_V`, `BIND_ADDR`, `NGL`): an invented `KV_CACHE_TYPE` is swallowed by `: "${CACHE_TYPE_K:=f16}"` and the arm boots f16 — an OOM that reads like a model fault. - **An arm env must use the serve script's own variable names** (`CACHE_TYPE_K`/`CACHE_TYPE_V`, `BIND_ADDR`, `NGL`): an invented `KV_CACHE_TYPE` is swallowed by `: "${CACHE_TYPE_K:=f16}"` and the arm boots f16 — an OOM that reads like a model fault.
- **Stop, shebang, GPU node.** llama.cpp rides out the default stop timeout (`TimeoutStopSec=180` is on the unit for that reason) and a hand-booted arm keeps answering 7771 behind a `failed` unit — kill it **by PID**, never `pkill -f llama-server`. - **Stop, shebang, GPU node.** llama.cpp rides out the default stop timeout (`TimeoutStopSec=180` is on the unit for that reason) and a hand-booted arm keeps answering 7771 behind a `failed` unit — kill it **by PID**, never `pkill -f llama-server`.
- `serve-frank-zealot.sh` is a direct `ExecStart`, so a missing shebang is a `203/EXEC` boot loop at `Mem peak: 1.9M` with the GPU untouched: `head -1` is a deploy check. - `serve-frank-zealot.sh` is a direct `ExecStart`, so a missing shebang is a `203/EXEC` boot loop at `Mem peak: 1.9M` with the GPU untouched: `head -1` is a deploy check.
- `No CUDA GPUs are available` ⇒ `nvidia-modprobe -u -c=0`; KV spilled into unified memory halves decode silently, so re-read `nvidia-smi` after any shape change. - `No CUDA GPUs are available` ⇒ `nvidia-modprobe -u -c=0`; KV spilled into unified memory halves decode silently, so re-read `nvidia-smi` after any shape change.
- **An adapter's provenance is metadata, not a gate.** Its GGUF carries `prism.abliterate.base_gguf = Ternary-Bonsai-2-9B-PTQ1_0.gguf` and that pack's sha256; the endpoint applies the file to the PQ2_0+MTP packing anyway, because the loader matches **tensor names, not hashes**. So an adapter's effect is only ever proven behaviourally (a refusal probe against the live endpoint), never by the card, and an adapter whose rank landed on `token_embd` would be a crash rather than a degradation — the publisher leaves the embedding untouched for exactly that reason.
- **The fork pin now has a third reason.** The payload's custom type 142, the b10776 floor that pairs MTP with a quantized V, and now this: older PrismML builds (e.g. `prism-b10709`) refuse the adapter outright with `unsupported prism.hadamard.version: 2`, so a runtime that can load the pack can still be unable to carry the adapter.
## Contract ## Contract
@@ -210,12 +236,12 @@ with 448 completion tokens (at `max_tokens: 64` the same request returns EMPTY c
boot ledger and the KV derivation all live on this page now; the header used to hold them, and two of its boot ledger and the KV derivation all live on this page now; the header used to hold them, and two of its
rung figures were wrong (`CACHE_TYPE_V=q8_0` and `CTX_SIZE=401408` both free **3,840 MiB** at the 524,288 pool, rung figures were wrong (`CACHE_TYPE_V=q8_0` and `CTX_SIZE=401408` both free **3,840 MiB** at the 524,288 pool,
not 8,192 and not ~6.7 GiB — the Fallback rungs table above is the corrected statement). not 8,192 and not ~6.7 GiB — the Fallback rungs table above is the corrected statement).
- `scripts/serve-frank-zealot.sh` — the exec line and its rationale comments; honors `FRANK_ZEALOT_ENV_FILE` for arm boots; exits 1 naming a missing binary, GGUF or projector rather than half-loading (a restart policy would turn an unfinished download into a crash loop that reads like a runtime fault). - `scripts/serve-frank-zealot.sh` — the exec line and its rationale comments; honors `FRANK_ZEALOT_ENV_FILE` for arm boots; exits 1 naming a missing binary, GGUF, projector or adapter rather than half-loading (a restart policy would turn an unfinished download into a crash loop that reads like a runtime fault).
- `scripts/frank-zealot.service` — root unit, relinked into `/etc/systemd/system/`. - `scripts/frank-zealot.service` — root unit, relinked into `/etc/systemd/system/`.
- `scripts/download-frank-zealot-bonsai9.sh` (active) · `…-bonsai2.sh` · `…-bonsai2-ptq1.sh` · - `scripts/download-frank-zealot-bonsai9.sh` (active payload + projector) · `scripts/download-frank-zealot-uncensored-lora.sh` (the refusal adapter; its header is the provenance record) · `…-bonsai2.sh` · `…-bonsai2-ptq1.sh` ·
`scripts/download-frank-zealot.sh` (master-era GGUFs) · `old/download-frank-zealot-sglang-int8.sh` (SGLang). `scripts/download-frank-zealot.sh` (master-era GGUFs) · `old/download-frank-zealot-sglang-int8.sh` (SGLang).
- `tests/frank_zealot_unit.bats` — stub-argv seam plus the VRAM fit gate. - `tests/frank_zealot_unit.bats` — stub-argv seam plus the VRAM fit gate.
- `scripts/deploy-remote.sh` — `deploy_llm_zealot` (payload-path gates, MainPID-hardened health); - `scripts/deploy-remote.sh` — `deploy_llm_zealot` (payload-path gates including the adapter, MainPID-hardened health);
- `shared/prometheus/prometheus.yml`, `shared/prometheus/rules/llm-fleet.yml` - `shared/prometheus/prometheus.yml`, `shared/prometheus/rules/llm-fleet.yml`
`shared/grafana/provisioning/dashboards/llm-fleet.json` — the scrape, the alert, the dashboard. `shared/grafana/provisioning/dashboards/llm-fleet.json` — the scrape, the alert, the dashboard.
- Live state, not repo files: the OpenWebUI picker row (back up `webui.db` before re-keying) and `~/.omp/agent/models.yml`. - Live state, not repo files: the OpenWebUI picker row (back up `webui.db` before re-keying) and `~/.omp/agent/models.yml`.
+1 -1
View File
@@ -23,7 +23,7 @@ is a locator: one row, one clause, no numbers.
| [[ComfyUI Qwen-Image Workflows]] | Proven Qwen-Image text-to-image workflows on Dankclaw: the recipes, the API call shape, and which models to add next. | | [[ComfyUI Qwen-Image Workflows]] | Proven Qwen-Image text-to-image workflows on Dankclaw: the recipes, the API call shape, and which models to add next. |
| [[Dankclaw Stack]] | Service inventory and routing overview for the Dankclaw AI, development, and observability host. | | [[Dankclaw Stack]] | Service inventory and routing overview for the Dankclaw AI, development, and observability host. |
| [[Frank TTS]] | The fleet's cloned-voice speech endpoint and the one voice surface consumers address; wired as the harness speech role. | | [[Frank TTS]] | The fleet's cloned-voice speech endpoint and the one voice surface consumers address; wired as the harness speech role. |
| [[Frank TTS Engine]] | The engine behind the fleet's speech endpoint: its runtime, private bridge, and the surface it publishes. | | [[Frank TTS Engine]] | The engine behind the fleet's speech endpoint: its Vulkan build on the iGPU, its private bridge, and what it publishes. |
| [[Gitea]] | Gitea deployment on Dankclaw with its web UI reverse-proxied by the canonical Caddy service. | | [[Gitea]] | Gitea deployment on Dankclaw with its web UI reverse-proxied by the canonical Caddy service. |
| [[Gluetun]] | Gluetun is a lightweight VPN client container. | | [[Gluetun]] | Gluetun is a lightweight VPN client container. |
| [[Hermes]] | Hermes is the self-improving AI agent from Nous Research. | | [[Hermes]] | Hermes is the self-improving AI agent from Nous Research. |
+40 -7
View File
@@ -331,13 +331,40 @@ FAKE
[ "$(argv --port)" = "8880" ] [ "$(argv --port)" = "8880" ]
[ "$(argv --lang)" = "auto" ] [ "$(argv --lang)" = "auto" ]
[ "$(argv --max-batch)" = "1" ] [ "$(argv --max-batch)" = "1" ]
# The two flags that must never appear: there is no thread count to set (it # The device the env names has to reach argv BY NAME: the image ships the
# comes from hardware_concurrency()/2), and naming a device on an image with # Mesa ICD and no NVIDIA one, so Vulkan0 is the gfx1151 and cannot drift onto
# every GPU backend compiled out is only a new way to be wrong. # another card the way an index would.
! grep -qx -- '--device' "$ARGS_FILE" [ "$(argv --device)" = "Vulkan0" ]
# The flag that must never appear: there is no thread count to set in this
# build (it comes from hardware_concurrency()/2), so naming one would only
# disagree with what the binary sized its pools from.
! grep -qE -- '^--?t$|^--threads$' "$ARGS_FILE" ! grep -qE -- '^--?t$|^--threads$' "$ARGS_FILE"
} }
@test "an empty device value is the CPU rollback: no --device reaches argv" {
# The same entrypoint with one key cleared, which is the rollback the env
# header documents. A script-level default that survived an explicitly empty
# value would leave the endpoint on the iGPU with no way back except a
# rebuild.
cat > "$TEST_TMP/fake-tts-server" <<'FAKE'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$ARGS_FILE"
FAKE
chmod +x "$TEST_TMP/fake-tts-server"
mkdir -p "$TEST_TMP/models"
: > "$TEST_TMP/models/$TALKER"
: > "$TEST_TMP/models/$CODEC"
export QWENTTS_MODEL="$TEST_TMP/models/$TALKER"
export QWENTTS_CODEC="$TEST_TMP/models/$CODEC"
export QWENTTS_BIN="$TEST_TMP/fake-tts-server"
export ARGS_FILE="$TEST_TMP/argv.txt"
export QWENTTS_DEVICE=""
run bash "$START"
[ "$status" -eq 0 ] || { echo "$output"; false; }
! grep -qx -- '--device' "$ARGS_FILE"
}
@test "missing weights are a refusal that names the fetcher, before the port is bound" { @test "missing weights are a refusal that names the fetcher, before the port is bound" {
cat > "$TEST_TMP/fake-tts-server" <<'FAKE' cat > "$TEST_TMP/fake-tts-server" <<'FAKE'
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -627,17 +654,23 @@ PY
@test "the image build is the pinned commit with the proven cmake line" { @test "the image build is the pinned commit with the proven cmake line" {
# GGML_BLAS=OFF is forced by the host (libopenblas.so.0 present, no dev # GGML_BLAS=OFF is forced by the host (libopenblas.so.0 present, no dev
# headers) and upstream's own Dockerfile passes it ON, so this file is not a # headers) and upstream's own Dockerfile passes it ON, so this file is not a
# copy of upstream on purpose. -j 8 and the OFF-for-every-GPU-backend list # copy of upstream on purpose. -j 8 is what the 2026-10-18 trial ran;
# are what the 2026-10-18 trial ran. # GGML_VULKAN=ON is the 2026-10-19 iGPU cutover, and the two greps below are
# the packages that door needs at build time and at run time.
grep -qE '^ARG QWENTTS_COMMIT=[0-9a-f]{40}$' "$CF" grep -qE '^ARG QWENTTS_COMMIT=[0-9a-f]{40}$' "$CF"
grep -qF '5916bf7c79dbb4e66fc059cdc507a79a23a28bb4' "$CF" grep -qF '5916bf7c79dbb4e66fc059cdc507a79a23a28bb4' "$CF"
grep -qE 'git -C /src checkout --detach' "$CF" grep -qE 'git -C /src checkout --detach' "$CF"
grep -qE 'submodule update --init' "$CF" # ggml is a submodule; without it the build cannot configure grep -qE 'submodule update --init' "$CF" # ggml is a submodule; without it the build cannot configure
for flag in -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF \ for flag in -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF \
-DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF \ -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF \
-DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF; do -DGGML_VULKAN=ON -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF; do
grep -qF -- "$flag" "$CF" || { echo "build line lost $flag"; false; } grep -qF -- "$flag" "$CF" || { echo "build line lost $flag"; false; }
done done
# glslc is not in glslang-tools on this distro, and the configure fails
# without it; an image with the loader and no RADV ICD enumerates zero
# devices and dies in device lookup after the weights load.
grep -qE 'libvulkan-dev glslc spirv-headers' "$CF"
grep -qE 'libvulkan1 mesa-vulkan-drivers' "$CF"
grep -qE 'cmake --build build -j 8' "$CF" grep -qE 'cmake --build build -j 8' "$CF"
# OpenMP on means the runtime stage needs the OpenMP runtime, and the probe # OpenMP on means the runtime stage needs the OpenMP runtime, and the probe
# runs as /bin/sh -c, so the runtime stage needs curl. # runs as /bin/sh -c, so the runtime stage needs curl.
+62 -6
View File
@@ -12,7 +12,10 @@
# pool (4,096 B/position, 2,352.00 MiB here), and the 786,432 pool this shape # pool (4,096 B/position, 2,352.00 MiB here), and the 786,432 pool this shape
# replaced cannot carry a drafter at any dtype inside the 24,123 MiB the driver # replaced cannot carry a drafter at any dtype inside the 24,123 MiB the driver
# leaves addressable. The publisher's own Q8_0 vision projector rides along (the # leaves addressable. The publisher's own Q8_0 vision projector rides along (the
# BF16 one can crash on concurrent images). Log # BF16 one can crash on concurrent images). The payload also carries a
# REFUSAL-DIRECTION ADAPTER: a rank-1 LoRA applied at load, so the seam pins the
# --lora-scaled token (path and scale together), the scale knob, and the
# empty-LORA_FILE opt-out.
# This seam runs the serve script against a stub engine and asserts on the # This seam runs the serve script against a stub engine and asserts on the
# argv it actually execs, so what is pinned is the command the operator gets. # argv it actually execs, so what is pinned is the command the operator gets.
@@ -39,8 +42,9 @@ STUB
# (the env) instead of silently forking the test's filename constants. # (the env) instead of silently forking the test's filename constants.
MODEL_STUB="$(basename "$(sed -nE 's/^MODEL_FILE=(.+)$/\1/p' "$ENV_FILE")")" MODEL_STUB="$(basename "$(sed -nE 's/^MODEL_FILE=(.+)$/\1/p' "$ENV_FILE")")"
MM_STUB="$(basename "$(sed -nE 's/^MMPROJ_FILE=(.+)$/\1/p' "$ENV_FILE")")" MM_STUB="$(basename "$(sed -nE 's/^MMPROJ_FILE=(.+)$/\1/p' "$ENV_FILE")")"
LORA_STUB="$(basename "$(sed -nE 's/^LORA_FILE=(.+)$/\1/p' "$ENV_FILE")")"
DRAFT_STUB="draft-assistant.gguf" DRAFT_STUB="draft-assistant.gguf"
touch "$MODEL_DIR/$MODEL_STUB" "$MODEL_DIR/$MM_STUB" "$MODEL_DIR/$DRAFT_STUB" touch "$MODEL_DIR/$MODEL_STUB" "$MODEL_DIR/$MM_STUB" "$MODEL_DIR/$LORA_STUB" "$MODEL_DIR/$DRAFT_STUB"
} }
teardown() { teardown() {
@@ -69,6 +73,8 @@ SPEC_DRAFT_N_MAX=$(sed -nE 's/^SPEC_DRAFT_N_MAX=([0-9]+)$/\1/p' "$ENV_FILE")
VERBOSITY=$(sed -nE 's/^VERBOSITY=([0-9]+)$/\1/p' "$ENV_FILE") VERBOSITY=$(sed -nE 's/^VERBOSITY=([0-9]+)$/\1/p' "$ENV_FILE")
METRICS=$(sed -nE 's/^METRICS=([0-9]+)$/\1/p' "$ENV_FILE") METRICS=$(sed -nE 's/^METRICS=([0-9]+)$/\1/p' "$ENV_FILE")
NGL=$(sed -nE 's/^NGL=([0-9]+)$/\1/p' "$ENV_FILE") NGL=$(sed -nE 's/^NGL=([0-9]+)$/\1/p' "$ENV_FILE")
LORA_FILE=$MODEL_DIR/$LORA_STUB
LORA_SCALE=$(sed -nE 's/^LORA_SCALE=(.+)$/\1/p' "$ENV_FILE")
EOF EOF
run env FRANK_ZEALOT_ENV_FILE="$envf" nvidia-modprobe=true bash "$SERVE" run env FRANK_ZEALOT_ENV_FILE="$envf" nvidia-modprobe=true bash "$SERVE"
} }
@@ -117,7 +123,7 @@ run_stage() {
grep -q 'FRANK_ZEALOT_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-zealot.env' "$UNIT" grep -q 'FRANK_ZEALOT_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-zealot.env' "$UNIT"
} }
@test "the shipped env points at the fork runtime and the Bonsai-2 9B MTP pair" { @test "the shipped env points at the fork runtime, the MTP pair and the refusal adapter" {
# The payload path IS the endpoint's identity, and the runtime is part of # The payload path IS the endpoint's identity, and the runtime is part of
# it here: the file's custom tensor type 142 + prism.hadamard.* metadata # it here: the file's custom tensor type 142 + prism.hadamard.* metadata
# make the PrismML build the only loader, and this build must be >= b10776 # make the PrismML build the only loader, and this build must be >= b10776
@@ -127,6 +133,7 @@ run_stage() {
grep -qE '^LLAMA_BIN=/home/dank/GitPackages/llama-prism-b10777/llama-server$' "$ENV_FILE" grep -qE '^LLAMA_BIN=/home/dank/GitPackages/llama-prism-b10777/llama-server$' "$ENV_FILE"
grep -qE '^MODEL_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-PQ2_0-MTP-Q8_0\.gguf$' "$ENV_FILE" grep -qE '^MODEL_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-PQ2_0-MTP-Q8_0\.gguf$' "$ENV_FILE"
grep -qE '^MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0\.gguf$' "$ENV_FILE" grep -qE '^MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0\.gguf$' "$ENV_FILE"
grep -qE '^LORA_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA\.gguf$' "$ENV_FILE"
} }
@test "the deployer gates on exactly the payload paths the env file ships" { @test "the deployer gates on exactly the payload paths the env file ships" {
@@ -135,7 +142,7 @@ run_stage() {
# would be gating on a file nobody reads — and a missing projector would # would be gating on a file nobody reads — and a missing projector would
# ship a blind endpoint. # ship a blind endpoint.
local env_v dep_v path pair local env_v dep_v path pair
for pair in "MODEL_FILE LLM_ZEALOT_MODEL_FILE" "MMPROJ_FILE LLM_ZEALOT_MMPROJ"; do for pair in "MODEL_FILE LLM_ZEALOT_MODEL_FILE" "MMPROJ_FILE LLM_ZEALOT_MMPROJ" "LORA_FILE LLM_ZEALOT_LORA"; do
env_v="${pair%% *}" env_v="${pair%% *}"
dep_v="${pair##* }" dep_v="${pair##* }"
path="$(sed -nE "s/^${env_v}=(.+)$/\1/p" "$ENV_FILE")" path="$(sed -nE "s/^${env_v}=(.+)$/\1/p" "$ENV_FILE")"
@@ -367,6 +374,45 @@ run_stage() {
! grep -qxF -- '--mmproj' <<<"$output" ! grep -qxF -- '--mmproj' <<<"$output"
} }
@test "the refusal adapter reaches the engine as path and scale in ONE token" {
# --lora-scaled takes FNAME:SCALE as a single argument, so the seam pins the
# whole token. An adapter that reaches the engine without its scale — or a
# scale without its adapter — is a different payload than the one the env
# file describes, and nothing else would notice.
serve_argv
[ "$status" -eq 0 ]
local scale
scale="$(sed -nE 's/^LORA_SCALE=(.+)$/\1/p' "$ENV_FILE")"
[ "$(argv_value --lora-scaled)" = "$MODEL_DIR/$LORA_STUB:$scale" ]
}
@test "the scale knob rides inside the token, including the card's off value" {
# Distinctive values first: proof the flag is wired to the env and not
# merely to the script's default. Scale 0 is the card's second mode — the
# direction present but inert — so the endpoint can be neutered without
# deleting a file or rebuilding anything.
serve_argv
{ echo 'LORA_SCALE=0'; } >> "$STAGE/test.env"
run_stage
[ "$status" -eq 0 ]
[ "$(argv_value --lora-scaled)" = "$MODEL_DIR/$LORA_STUB:0" ]
sed -i 's|^LORA_SCALE=.*|LORA_SCALE=0.75|' "$STAGE/test.env"
run_stage
[ "$status" -eq 0 ]
[ "$(argv_value --lora-scaled)" = "$MODEL_DIR/$LORA_STUB:0.75" ]
}
@test "LORA_FILE empty opts the adapter out and leaves no trace in the argv" {
# The rollback is ABSENCE of the flag: an empty argument would have the
# engine open "" and die at load, which is the failure this seam forbids.
serve_argv
sed -i 's|^LORA_FILE=.*|LORA_FILE=|' "$STAGE/test.env"
run_stage
[ "$status" -eq 0 ]
! grep -qxF -- '--lora-scaled' <<<"$output"
}
@test "the card sampler's only non-metadata term reaches the argv" { @test "the card sampler's only non-metadata term reaches the argv" {
# general.sampling.* (temperature / top_p / top_k / min_p / repeat_penalty) # general.sampling.* (temperature / top_p / top_k / min_p / repeat_penalty)
# rides in the checkpoint and the engine reads it itself; presence penalty # rides in the checkpoint and the engine reads it itself; presence penalty
@@ -406,23 +452,33 @@ run_stage() {
grep -qxF -- '--jinja' <<<"$output" grep -qxF -- '--jinja' <<<"$output"
} }
@test "the serve script refuses a missing runtime, GGUF or projector" { @test "the serve script refuses a missing runtime, GGUF, projector or adapter" {
rm -f "$MODEL_DIR/$MM_STUB" rm -f "$MODEL_DIR/$MM_STUB"
serve_argv serve_argv
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"frank-zealot: missing"* ]] [[ "$output" == *"frank-zealot: missing"* ]]
# The adapter is payload: a fetch that stopped short must not boot a
# silently-plain endpoint, and Restart=always would loop on the missing
# file until somebody reads the name in the log.
touch "$MODEL_DIR/$MM_STUB"
rm -f "$MODEL_DIR/$LORA_STUB"
serve_argv
[ "$status" -ne 0 ]
[[ "$output" == *"frank-zealot: missing $MODEL_DIR/$LORA_STUB"* ]]
rm -f "$RUNTIME_DIR/bin/llama-server" rm -f "$RUNTIME_DIR/bin/llama-server"
serve_argv serve_argv
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"frank-zealot: missing"* ]] [[ "$output" == *"frank-zealot: missing"* ]]
} }
@test "the deploy handler gates on binary, GGUF and projector, then MainPID-hardens health" { @test "the deploy handler gates on binary, GGUF, projector and adapter, then MainPID-hardens health" {
grep -q 'deploy_llm_zealot' "$REMOTE_DEPLOY" grep -q 'deploy_llm_zealot' "$REMOTE_DEPLOY"
grep -q 'LLM_ZEALOT_RUNTIME' "$REMOTE_DEPLOY" grep -q 'LLM_ZEALOT_RUNTIME' "$REMOTE_DEPLOY"
grep -q 'LLM_ZEALOT_MODEL_FILE' "$REMOTE_DEPLOY" grep -q 'LLM_ZEALOT_MODEL_FILE' "$REMOTE_DEPLOY"
grep -q 'LLM_ZEALOT_MMPROJ' "$REMOTE_DEPLOY" grep -q 'LLM_ZEALOT_MMPROJ' "$REMOTE_DEPLOY"
grep -q 'LLM_ZEALOT_LORA' "$REMOTE_DEPLOY"
grep -q 'relink_unit frank-zealot' "$REMOTE_DEPLOY" grep -q 'relink_unit frank-zealot' "$REMOTE_DEPLOY"
grep -q 'systemctl enable frank-zealot.service' "$REMOTE_DEPLOY" grep -q 'systemctl enable frank-zealot.service' "$REMOTE_DEPLOY"
grep -q '127.0.0.1:7771/health' "$REMOTE_DEPLOY" grep -q '127.0.0.1:7771/health' "$REMOTE_DEPLOY"